Skip to content

Commit 98fa9e8

Browse files
AbortController support (graphql#4250)
This adds support for aborting execution from the outside or resolvers, this adds a few tests and tries to make the support as easy as possible. Do we want to support having abort support on subscriptions, I guess it makes sense for server-sent events. I've chosen 2 places to place these interrupts - `executeFieldsSerially` - every time we start a new mutation we check whether the runtime has interrupted - `executeFields` - every time we start executing a new field we check whether the runtime has interrupted - inside of the catch block as well so we return a singular error, all though this doesn't really matter as the consumer would not receive anything - this here should also take care of deferred fields When comparing this to `graphql-tools/execute` I am not sure whether we want to match this behavior, this throws a DomException which would be a whole new exception that gets thrown while normally during execution we wrap everything with GraphQLErrors. Supersedes graphql#3791 Resolves graphql#3764 Co-authored-by: yaacovCR <[email protected]>
1 parent 26e1b2f commit 98fa9e8

File tree

6 files changed

+295
-8
lines changed

6 files changed

+295
-8
lines changed

integrationTests/ts/tsconfig.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
{
22
"compilerOptions": {
33
"module": "commonjs",
4-
"lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"],
4+
"lib": [
5+
"es2019",
6+
"es2020.promise",
7+
"es2020.bigint",
8+
"es2020.string",
9+
"DOM"
10+
],
511
"noEmit": true,
612
"types": [],
713
"strict": true,

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"devDependencies": {
5656
"@types/chai": "4.3.19",
5757
"@types/mocha": "10.0.7",
58-
"@types/node": "22.5.4",
58+
"@types/node": "22.7.7",
5959
"@typescript-eslint/eslint-plugin": "8.4.0",
6060
"@typescript-eslint/parser": "8.4.0",
6161
"c8": "10.1.2",
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { expectJSON } from '../../__testUtils__/expectJSON.js';
5+
6+
import { parse } from '../../language/parser.js';
7+
8+
import { buildSchema } from '../../utilities/buildASTSchema.js';
9+
10+
import { execute } from '../execute.js';
11+
12+
const schema = buildSchema(`
13+
type Todo {
14+
id: ID
15+
text: String
16+
author: User
17+
}
18+
19+
type User {
20+
id: ID
21+
name: String
22+
}
23+
24+
type Query {
25+
todo: Todo
26+
}
27+
28+
type Mutation {
29+
foo: String
30+
bar: String
31+
}
32+
`);
33+
34+
describe('Execute: Cancellation', () => {
35+
it('should stop the execution when aborted during object field completion', async () => {
36+
const abortController = new AbortController();
37+
const document = parse(`
38+
query {
39+
todo {
40+
id
41+
author {
42+
id
43+
}
44+
}
45+
}
46+
`);
47+
48+
const resultPromise = execute({
49+
document,
50+
schema,
51+
abortSignal: abortController.signal,
52+
rootValue: {
53+
todo: async () =>
54+
Promise.resolve({
55+
id: '1',
56+
text: 'Hello, World!',
57+
/* c8 ignore next */
58+
author: () => expect.fail('Should not be called'),
59+
}),
60+
},
61+
});
62+
63+
abortController.abort('Aborted');
64+
65+
const result = await resultPromise;
66+
67+
expectJSON(result).toDeepEqual({
68+
data: {
69+
todo: null,
70+
},
71+
errors: [
72+
{
73+
message: 'Aborted',
74+
path: ['todo', 'id'],
75+
locations: [{ line: 4, column: 11 }],
76+
},
77+
],
78+
});
79+
});
80+
81+
it('should stop the execution when aborted during nested object field completion', async () => {
82+
const abortController = new AbortController();
83+
const document = parse(`
84+
query {
85+
todo {
86+
id
87+
author {
88+
id
89+
}
90+
}
91+
}
92+
`);
93+
94+
const resultPromise = execute({
95+
document,
96+
schema,
97+
abortSignal: abortController.signal,
98+
rootValue: {
99+
todo: {
100+
id: '1',
101+
text: 'Hello, World!',
102+
/* c8 ignore next 3 */
103+
author: async () =>
104+
Promise.resolve(() => expect.fail('Should not be called')),
105+
},
106+
},
107+
});
108+
109+
abortController.abort('Aborted');
110+
111+
const result = await resultPromise;
112+
113+
expectJSON(result).toDeepEqual({
114+
data: {
115+
todo: {
116+
id: '1',
117+
author: null,
118+
},
119+
},
120+
errors: [
121+
{
122+
message: 'Aborted',
123+
path: ['todo', 'author', 'id'],
124+
locations: [{ line: 6, column: 13 }],
125+
},
126+
],
127+
});
128+
});
129+
130+
it('should stop deferred execution when aborted', async () => {
131+
const abortController = new AbortController();
132+
const document = parse(`
133+
query {
134+
todo {
135+
id
136+
... on Todo @defer {
137+
text
138+
author {
139+
id
140+
}
141+
}
142+
}
143+
}
144+
`);
145+
146+
const resultPromise = execute({
147+
document,
148+
schema,
149+
rootValue: {
150+
todo: async () =>
151+
Promise.resolve({
152+
id: '1',
153+
text: 'hello world',
154+
/* c8 ignore next */
155+
author: () => expect.fail('Should not be called'),
156+
}),
157+
},
158+
abortSignal: abortController.signal,
159+
});
160+
161+
abortController.abort('Aborted');
162+
163+
const result = await resultPromise;
164+
165+
expectJSON(result).toDeepEqual({
166+
data: {
167+
todo: null,
168+
},
169+
errors: [
170+
{
171+
message: 'Aborted',
172+
path: ['todo', 'id'],
173+
locations: [{ line: 4, column: 11 }],
174+
},
175+
],
176+
});
177+
});
178+
179+
it('should stop the execution when aborted mid-mutation', async () => {
180+
const abortController = new AbortController();
181+
const document = parse(`
182+
mutation {
183+
foo
184+
bar
185+
}
186+
`);
187+
188+
const resultPromise = execute({
189+
document,
190+
schema,
191+
abortSignal: abortController.signal,
192+
rootValue: {
193+
foo: async () => Promise.resolve('baz'),
194+
/* c8 ignore next */
195+
bar: () => expect.fail('Should not be called'),
196+
},
197+
});
198+
199+
abortController.abort('Aborted');
200+
201+
const result = await resultPromise;
202+
203+
expectJSON(result).toDeepEqual({
204+
data: {
205+
foo: 'baz',
206+
bar: null,
207+
},
208+
errors: [
209+
{
210+
message: 'Aborted',
211+
path: ['bar'],
212+
locations: [{ line: 4, column: 9 }],
213+
},
214+
],
215+
});
216+
});
217+
218+
it('should stop the execution when aborted pre-execute', async () => {
219+
const abortController = new AbortController();
220+
const document = parse(`
221+
query {
222+
todo {
223+
id
224+
author {
225+
id
226+
}
227+
}
228+
}
229+
`);
230+
abortController.abort('Aborted');
231+
const result = await execute({
232+
document,
233+
schema,
234+
abortSignal: abortController.signal,
235+
rootValue: {
236+
/* c8 ignore next */
237+
todo: () => expect.fail('Should not be called'),
238+
},
239+
});
240+
241+
expectJSON(result).toDeepEqual({
242+
errors: [
243+
{
244+
message: 'Aborted',
245+
},
246+
],
247+
});
248+
});
249+
});

src/execution/execute.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export interface ValidatedExecutionArgs {
137137
validatedExecutionArgs: ValidatedExecutionArgs,
138138
) => PromiseOrValue<ExecutionResult>;
139139
hideSuggestions: boolean;
140+
abortSignal: AbortSignal | undefined;
140141
}
141142

142143
export interface ExecutionContext {
@@ -185,6 +186,7 @@ export interface ExecutionArgs {
185186
) => PromiseOrValue<ExecutionResult>
186187
>;
187188
hideSuggestions?: Maybe<boolean>;
189+
abortSignal?: Maybe<AbortSignal>;
188190
/** Additional execution options. */
189191
options?: {
190192
/** Set the maximum number of errors allowed for coercing (defaults to 50). */
@@ -337,9 +339,14 @@ export function validateExecutionArgs(
337339
typeResolver,
338340
subscribeFieldResolver,
339341
perEventExecutor,
342+
abortSignal,
340343
options,
341344
} = args;
342345

346+
if (abortSignal?.aborted) {
347+
return [locatedError(new Error(abortSignal.reason), undefined)];
348+
}
349+
343350
// If the schema used for execution is invalid, throw an error.
344351
assertValidSchema(schema);
345352

@@ -418,6 +425,7 @@ export function validateExecutionArgs(
418425
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
419426
perEventExecutor: perEventExecutor ?? executeSubscriptionEvent,
420427
hideSuggestions,
428+
abortSignal: args.abortSignal ?? undefined,
421429
};
422430
}
423431

@@ -473,6 +481,19 @@ function executeFieldsSerially(
473481
groupedFieldSet,
474482
(results, [responseName, fieldDetailsList]) => {
475483
const fieldPath = addPath(path, responseName, parentType.name);
484+
const abortSignal = exeContext.validatedExecutionArgs.abortSignal;
485+
if (abortSignal?.aborted) {
486+
handleFieldError(
487+
new Error(abortSignal.reason),
488+
exeContext,
489+
parentType,
490+
fieldDetailsList,
491+
fieldPath,
492+
);
493+
results[responseName] = null;
494+
return results;
495+
}
496+
476497
const result = executeField(
477498
exeContext,
478499
parentType,
@@ -513,6 +534,15 @@ function executeFields(
513534
try {
514535
for (const [responseName, fieldDetailsList] of groupedFieldSet) {
515536
const fieldPath = addPath(path, responseName, parentType.name);
537+
const abortSignal = exeContext.validatedExecutionArgs.abortSignal;
538+
if (abortSignal?.aborted) {
539+
throw locatedError(
540+
new Error(abortSignal.reason),
541+
toNodes(fieldDetailsList),
542+
pathToArray(fieldPath),
543+
);
544+
}
545+
516546
const result = executeField(
517547
exeContext,
518548
parentType,
@@ -1120,8 +1150,9 @@ function completeLeafValue(
11201150
const coerced = returnType.coerceOutputValue(result);
11211151
if (coerced == null) {
11221152
throw new Error(
1123-
`Expected \`${inspect(returnType)}.coerceOutputValue(${inspect(result)})\` to ` +
1124-
`return non-nullable value, returned: ${inspect(coerced)}`,
1153+
`Expected \`${inspect(returnType)}.coerceOutputValue(${inspect(
1154+
result,
1155+
)})\` to return non-nullable value, returned: ${inspect(coerced)}`,
11251156
);
11261157
}
11271158
return coerced;

src/graphql.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export interface GraphQLArgs {
6666
operationName?: Maybe<string>;
6767
fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
6868
typeResolver?: Maybe<GraphQLTypeResolver<any, any>>;
69+
abortSignal?: Maybe<AbortSignal>;
6970
}
7071

7172
export function graphql(args: GraphQLArgs): Promise<ExecutionResult> {

0 commit comments

Comments
 (0)