Skip to content

Commit f8b148f

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

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
@@ -1313,4 +1313,130 @@ describe('Execute: Handles basic execution tasks', () => {
13131313
expect(result).to.deep.equal({ data: { foo: { bar: 'bar' } } });
13141314
expect(possibleTypes).to.deep.equal([fooObject]);
13151315
});
1316+
1317+
describe('Abort execution', () => {
1318+
it('stops execution and throws an error when signal is aborted', async () => {
1319+
/**
1320+
* This test has 3 resolvers nested in each other.
1321+
* Every resolve function waits 200ms before returning data.
1322+
*
1323+
* The test waits for the first resolver and half of the 2nd resolver execution time (200ms + 100ms)
1324+
* and then aborts the execution.
1325+
*
1326+
* 2nd resolver execution finishes, and we then expect to not execute the 3rd resolver
1327+
* and to get an error about aborted operation.
1328+
*/
1329+
1330+
const WAIT_MS_BEFORE_RESOLVING = 200;
1331+
const ABORT_IN_MS_AFTER_STARTING_EXECUTION =
1332+
WAIT_MS_BEFORE_RESOLVING + WAIT_MS_BEFORE_RESOLVING / 2;
1333+
1334+
const schema = new GraphQLSchema({
1335+
query: new GraphQLObjectType({
1336+
name: 'Query',
1337+
fields: {
1338+
resolvesIn500ms: {
1339+
type: new GraphQLObjectType({
1340+
name: 'ResolvesIn500ms',
1341+
fields: {
1342+
resolvesIn400ms: {
1343+
type: new GraphQLObjectType({
1344+
name: 'ResolvesIn400ms',
1345+
fields: {
1346+
shouldNotBeResolved: {
1347+
type: GraphQLString,
1348+
resolve: () => {
1349+
throw new Error('This should not be executed!');
1350+
},
1351+
},
1352+
},
1353+
}),
1354+
resolve: () =>
1355+
new Promise((resolve) => {
1356+
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
1357+
}),
1358+
},
1359+
},
1360+
}),
1361+
resolve: () =>
1362+
new Promise((resolve) => {
1363+
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
1364+
}),
1365+
},
1366+
},
1367+
}),
1368+
});
1369+
const document = parse(`
1370+
query {
1371+
resolvesIn500ms {
1372+
resolvesIn400ms {
1373+
shouldNotBeResolved
1374+
}
1375+
}
1376+
}
1377+
`);
1378+
1379+
const abortController = new AbortController();
1380+
const executionPromise = execute({
1381+
schema,
1382+
document,
1383+
signal: abortController.signal,
1384+
});
1385+
1386+
setTimeout(
1387+
() => abortController.abort(),
1388+
ABORT_IN_MS_AFTER_STARTING_EXECUTION,
1389+
);
1390+
1391+
const result = await executionPromise;
1392+
expect(result.errors?.[0].message).to.eq(
1393+
'Execution aborted. Reason: AbortError: This operation was aborted',
1394+
);
1395+
expect(result.data).to.eql({
1396+
resolvesIn500ms: { resolvesIn400ms: null },
1397+
});
1398+
});
1399+
1400+
const abortMessageTestInputs = [
1401+
{ message: 'Aborted from somewhere', reason: 'Aborted from somewhere' },
1402+
{ message: undefined, reason: 'AbortError: This operation was aborted' },
1403+
];
1404+
1405+
for (const { message, reason } of abortMessageTestInputs) {
1406+
it('aborts with "Reason:" in the error message', async () => {
1407+
const schema = new GraphQLSchema({
1408+
query: new GraphQLObjectType({
1409+
name: 'Query',
1410+
fields: {
1411+
a: {
1412+
type: GraphQLString,
1413+
resolve: () =>
1414+
new Promise((resolve) => {
1415+
setTimeout(() => resolve({}), 100);
1416+
}),
1417+
},
1418+
},
1419+
}),
1420+
});
1421+
1422+
const document = parse(`
1423+
query { a }
1424+
`);
1425+
1426+
const abortController = new AbortController();
1427+
const executionPromise = execute({
1428+
schema,
1429+
document,
1430+
signal: abortController.signal,
1431+
});
1432+
1433+
abortController.abort(message);
1434+
1435+
const { errors } = await executionPromise;
1436+
expect(errors?.[0].message).to.eq(
1437+
`Execution aborted. Reason: ${reason}`,
1438+
);
1439+
});
1440+
}
1441+
});
13161442
});

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_EXPERIMENTAL_DIRECTIVES =
@@ -337,9 +342,29 @@ export function experimentalExecuteIncrementally(
337342
return executeImpl(exeContext);
338343
}
339344

345+
function subscribeToAbortSignal(exeContext: ExecutionContext): {
346+
unsubscribeFromAbortSignal: () => void;
347+
} {
348+
const onAbort = () => {
349+
if ('signal' in exeContext && exeContext.signal) {
350+
exeContext.signal.isAborted = true;
351+
}
352+
};
353+
354+
exeContext.signal?.instance.addEventListener('abort', onAbort);
355+
356+
return {
357+
unsubscribeFromAbortSignal: () => {
358+
exeContext.signal?.instance.removeEventListener('abort', onAbort);
359+
},
360+
};
361+
}
362+
340363
function executeImpl(
341364
exeContext: ExecutionContext,
342365
): PromiseOrValue<ExecutionResult | ExperimentalIncrementalExecutionResults> {
366+
const { unsubscribeFromAbortSignal } = subscribeToAbortSignal(exeContext);
367+
343368
// Return a Promise that will eventually resolve to the data described by
344369
// The "Response" section of the GraphQL specification.
345370
//
@@ -356,6 +381,8 @@ function executeImpl(
356381
if (isPromise(result)) {
357382
return result.then(
358383
(data) => {
384+
unsubscribeFromAbortSignal();
385+
359386
const initialResult = buildResponse(data, exeContext.errors);
360387
if (exeContext.subsequentPayloads.size > 0) {
361388
return {
@@ -369,11 +396,16 @@ function executeImpl(
369396
return initialResult;
370397
},
371398
(error) => {
399+
unsubscribeFromAbortSignal();
400+
372401
exeContext.errors.push(error);
373402
return buildResponse(null, exeContext.errors);
374403
},
375404
);
376405
}
406+
407+
unsubscribeFromAbortSignal();
408+
377409
const initialResult = buildResponse(result, exeContext.errors);
378410
if (exeContext.subsequentPayloads.size > 0) {
379411
return {
@@ -386,6 +418,7 @@ function executeImpl(
386418
}
387419
return initialResult;
388420
} catch (error) {
421+
unsubscribeFromAbortSignal();
389422
exeContext.errors.push(error);
390423
return buildResponse(null, exeContext.errors);
391424
}
@@ -440,6 +473,7 @@ export function buildExecutionContext(
440473
fieldResolver,
441474
typeResolver,
442475
subscribeFieldResolver,
476+
signal,
443477
} = args;
444478

445479
// If the schema used for execution is invalid, throw an error.
@@ -505,6 +539,7 @@ export function buildExecutionContext(
505539
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
506540
subsequentPayloads: new Set(),
507541
errors: [],
542+
signal: signal ? { instance: signal, isAborted: false } : null,
508543
};
509544
}
510545

@@ -838,6 +873,14 @@ function completeValue(
838873
result: unknown,
839874
asyncPayloadRecord?: AsyncPayloadRecord,
840875
): PromiseOrValue<unknown> {
876+
if (exeContext.signal?.isAborted) {
877+
throw new GraphQLError(
878+
`Execution aborted. Reason: ${
879+
exeContext.signal.instance.reason ?? 'Unknown.'
880+
}`,
881+
);
882+
}
883+
841884
// If result is an Error, throw a located error.
842885
if (result instanceof Error) {
843886
throw result;

0 commit comments

Comments
 (0)