Skip to content

Commit 56d1315

Browse files
author
igor.luckenkov
committed
accept abortSignal in execute method and check if signal is aborted when resolving every field
1 parent 6b5c8af commit 56d1315

File tree

2 files changed

+169
-0
lines changed

2 files changed

+169
-0
lines changed

src/execution/__tests__/executor-test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,4 +1275,130 @@ describe('Execute: Handles basic execution tasks', () => {
12751275
expect(result).to.deep.equal({ data: { foo: { bar: 'bar' } } });
12761276
expect(possibleTypes).to.deep.equal([fooObject]);
12771277
});
1278+
1279+
describe('Abort execution', () => {
1280+
it('stops execution and throws an error when signal is aborted', async () => {
1281+
/**
1282+
* This test has 3 resolvers nested in each other.
1283+
* Every resolve function waits 200ms before returning data.
1284+
*
1285+
* The test waits for the first resolver and half of the 2nd resolver execution time (200ms + 100ms)
1286+
* and then aborts the execution.
1287+
*
1288+
* 2nd resolver execution finishes, and we then expect to not execute the 3rd resolver
1289+
* and to get an error about aborted operation.
1290+
*/
1291+
1292+
const WAIT_MS_BEFORE_RESOLVING = 200;
1293+
const ABORT_IN_MS_AFTER_STARTING_EXECUTION =
1294+
WAIT_MS_BEFORE_RESOLVING + WAIT_MS_BEFORE_RESOLVING / 2;
1295+
1296+
const schema = new GraphQLSchema({
1297+
query: new GraphQLObjectType({
1298+
name: 'Query',
1299+
fields: {
1300+
resolvesIn500ms: {
1301+
type: new GraphQLObjectType({
1302+
name: 'ResolvesIn500ms',
1303+
fields: {
1304+
resolvesIn400ms: {
1305+
type: new GraphQLObjectType({
1306+
name: 'ResolvesIn400ms',
1307+
fields: {
1308+
shouldNotBeResolved: {
1309+
type: GraphQLString,
1310+
resolve: () => {
1311+
throw new Error('This should not be executed!');
1312+
},
1313+
},
1314+
},
1315+
}),
1316+
resolve: () =>
1317+
new Promise((resolve) => {
1318+
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
1319+
}),
1320+
},
1321+
},
1322+
}),
1323+
resolve: () =>
1324+
new Promise((resolve) => {
1325+
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
1326+
}),
1327+
},
1328+
},
1329+
}),
1330+
});
1331+
const document = parse(`
1332+
query {
1333+
resolvesIn500ms {
1334+
resolvesIn400ms {
1335+
shouldNotBeResolved
1336+
}
1337+
}
1338+
}
1339+
`);
1340+
1341+
const abortController = new AbortController();
1342+
const executionPromise = execute({
1343+
schema,
1344+
document,
1345+
signal: abortController.signal,
1346+
});
1347+
1348+
setTimeout(
1349+
() => abortController.abort(),
1350+
ABORT_IN_MS_AFTER_STARTING_EXECUTION,
1351+
);
1352+
1353+
const result = await executionPromise;
1354+
expect(result.errors?.[0].message).to.eq(
1355+
'Execution aborted. Reason: AbortError: This operation was aborted',
1356+
);
1357+
expect(result.data).to.eql({
1358+
resolvesIn500ms: { resolvesIn400ms: null },
1359+
});
1360+
});
1361+
1362+
const abortMessageTestInputs = [
1363+
{ message: 'Aborted from somewhere', reason: 'Aborted from somewhere' },
1364+
{ message: undefined, reason: 'AbortError: This operation was aborted' },
1365+
];
1366+
1367+
for (const { message, reason } of abortMessageTestInputs) {
1368+
it('aborts with "Reason:" in the error message', async () => {
1369+
const schema = new GraphQLSchema({
1370+
query: new GraphQLObjectType({
1371+
name: 'Query',
1372+
fields: {
1373+
a: {
1374+
type: GraphQLString,
1375+
resolve: () =>
1376+
new Promise((resolve) => {
1377+
setTimeout(() => resolve({}), 100);
1378+
}),
1379+
},
1380+
},
1381+
}),
1382+
});
1383+
1384+
const document = parse(`
1385+
query { a }
1386+
`);
1387+
1388+
const abortController = new AbortController();
1389+
const executionPromise = execute({
1390+
schema,
1391+
document,
1392+
signal: abortController.signal,
1393+
});
1394+
1395+
abortController.abort(message);
1396+
1397+
const { errors } = await executionPromise;
1398+
expect(errors?.[0].message).to.eq(
1399+
`Execution aborted. Reason: ${reason}`,
1400+
);
1401+
});
1402+
}
1403+
});
12781404
});

src/execution/execute.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ export interface ExecutionContext {
122122
subscribeFieldResolver: GraphQLFieldResolver<any, any>;
123123
errors: Array<GraphQLError>;
124124
subsequentPayloads: Set<AsyncPayloadRecord>;
125+
signal: Maybe<{
126+
isAborted: boolean;
127+
instance: AbortSignal;
128+
}>;
125129
}
126130

127131
/**
@@ -261,6 +265,7 @@ export interface ExecutionArgs {
261265
fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
262266
typeResolver?: Maybe<GraphQLTypeResolver<any, any>>;
263267
subscribeFieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
268+
signal?: AbortSignal;
264269
}
265270

266271
const UNEXPECTED_MULTIPLE_PAYLOADS =
@@ -328,9 +333,29 @@ export function experimentalExecuteIncrementally(
328333
return executeImpl(exeContext);
329334
}
330335

336+
function subscribeToAbortSignal(exeContext: ExecutionContext): {
337+
unsubscribeFromAbortSignal: () => void;
338+
} {
339+
const onAbort = () => {
340+
if ('signal' in exeContext && exeContext.signal) {
341+
exeContext.signal.isAborted = true;
342+
}
343+
};
344+
345+
exeContext.signal?.instance.addEventListener('abort', onAbort);
346+
347+
return {
348+
unsubscribeFromAbortSignal: () => {
349+
exeContext.signal?.instance.removeEventListener('abort', onAbort);
350+
},
351+
};
352+
}
353+
331354
function executeImpl(
332355
exeContext: ExecutionContext,
333356
): PromiseOrValue<ExecutionResult | ExperimentalIncrementalExecutionResults> {
357+
const { unsubscribeFromAbortSignal } = subscribeToAbortSignal(exeContext);
358+
334359
// Return a Promise that will eventually resolve to the data described by
335360
// The "Response" section of the GraphQL specification.
336361
//
@@ -347,6 +372,8 @@ function executeImpl(
347372
if (isPromise(result)) {
348373
return result.then(
349374
(data) => {
375+
unsubscribeFromAbortSignal();
376+
350377
const initialResult = buildResponse(data, exeContext.errors);
351378
if (exeContext.subsequentPayloads.size > 0) {
352379
return {
@@ -360,11 +387,16 @@ function executeImpl(
360387
return initialResult;
361388
},
362389
(error) => {
390+
unsubscribeFromAbortSignal();
391+
363392
exeContext.errors.push(error);
364393
return buildResponse(null, exeContext.errors);
365394
},
366395
);
367396
}
397+
398+
unsubscribeFromAbortSignal();
399+
368400
const initialResult = buildResponse(result, exeContext.errors);
369401
if (exeContext.subsequentPayloads.size > 0) {
370402
return {
@@ -377,6 +409,7 @@ function executeImpl(
377409
}
378410
return initialResult;
379411
} catch (error) {
412+
unsubscribeFromAbortSignal();
380413
exeContext.errors.push(error);
381414
return buildResponse(null, exeContext.errors);
382415
}
@@ -431,6 +464,7 @@ export function buildExecutionContext(
431464
fieldResolver,
432465
typeResolver,
433466
subscribeFieldResolver,
467+
signal,
434468
} = args;
435469

436470
// If the schema used for execution is invalid, throw an error.
@@ -496,6 +530,7 @@ export function buildExecutionContext(
496530
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
497531
subsequentPayloads: new Set(),
498532
errors: [],
533+
signal: signal ? { instance: signal, isAborted: false } : null,
499534
};
500535
}
501536

@@ -832,6 +867,14 @@ function completeValue(
832867
result: unknown,
833868
asyncPayloadRecord?: AsyncPayloadRecord,
834869
): PromiseOrValue<unknown> {
870+
if (exeContext.signal?.isAborted) {
871+
throw new GraphQLError(
872+
`Execution aborted. Reason: ${
873+
exeContext.signal.instance.reason ?? 'Unknown.'
874+
}`,
875+
);
876+
}
877+
835878
// If result is an Error, throw a located error.
836879
if (result instanceof Error) {
837880
throw result;

0 commit comments

Comments
 (0)