Skip to content

Commit b8bf9e9

Browse files
committed
use single phase of resolver abortSignal cancellation (#4554)
Previously, we used a lazily created abortSignal per resolver so as to shift triggering the abortSignal for a resolver that returned a streamed asyncIterable from the original executor to the stream. This is expensive and difficult to reason about and has been replaced by a single phase of cancellation when the entire execution finishes. Note: when executing a subscription, we are still able to cancel resolver abortSignals after execution of each subscription event. Additional note: this is labelled "bug fix" rather than "polish" because it is technically observable.
1 parent 50501af commit b8bf9e9

File tree

1 file changed

+26
-62
lines changed

1 file changed

+26
-62
lines changed

src/execution/Executor.ts

Lines changed: 26 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -199,33 +199,35 @@ export interface FormattedExecutionResult<
199199
export class Executor {
200200
validatedExecutionArgs: ValidatedExecutionArgs;
201201
finished: boolean;
202-
resolverAbortControllers: Map<Path, AbortController>;
203202
collectedErrors: CollectedErrors;
203+
internalAbortController: AbortController;
204+
resolverAbortController: AbortController | undefined;
205+
sharedResolverAbortSignal: AbortSignal;
204206

205207
constructor(validatedExecutionArgs: ValidatedExecutionArgs) {
206208
this.validatedExecutionArgs = validatedExecutionArgs;
207209
this.finished = false;
208-
this.resolverAbortControllers = new Map();
209210
this.collectedErrors = new CollectedErrors();
211+
this.internalAbortController = new AbortController();
212+
213+
this.resolverAbortController = new AbortController();
214+
this.sharedResolverAbortSignal = this.resolverAbortController.signal;
210215
}
211216

212217
executeQueryOrMutationOrSubscriptionEvent(): PromiseOrValue<ExecutionResult> {
213-
const validatedExecutionArgs = this.validatedExecutionArgs;
214-
const externalAbortSignal = validatedExecutionArgs.externalAbortSignal;
215-
let removeAbortListener: (() => void) | undefined;
218+
const externalAbortSignal = this.validatedExecutionArgs.externalAbortSignal;
219+
let removeExternalAbortListener: (() => void) | undefined;
216220
if (externalAbortSignal) {
217-
if (externalAbortSignal.aborted) {
218-
throw new Error(externalAbortSignal.reason);
219-
}
221+
externalAbortSignal.throwIfAborted();
220222
const onExternalAbort = () => this.cancel(externalAbortSignal.reason);
221-
removeAbortListener = () =>
223+
removeExternalAbortListener = () =>
222224
externalAbortSignal.removeEventListener('abort', onExternalAbort);
223225
externalAbortSignal.addEventListener('abort', onExternalAbort);
224226
}
225227

226228
const onFinish = () => {
227-
removeAbortListener?.();
228229
this.finish();
230+
removeExternalAbortListener?.();
229231
};
230232

231233
try {
@@ -236,7 +238,7 @@ export class Executor {
236238
operation,
237239
variableValues,
238240
hideSuggestions,
239-
} = validatedExecutionArgs;
241+
} = this.validatedExecutionArgs;
240242

241243
const { operation: operationType, selectionSet } = operation;
242244

@@ -276,59 +278,38 @@ export class Executor {
276278
return this.buildResponse(null);
277279
},
278280
);
279-
return externalAbortSignal
280-
? cancellablePromise(promise, externalAbortSignal)
281-
: promise;
281+
return cancellablePromise(promise, this.internalAbortController.signal);
282282
}
283283
onFinish();
284284
return this.buildResponse(result);
285285
} catch (error) {
286-
this.collectedErrors.add(error as GraphQLError, undefined);
287286
onFinish();
287+
this.collectedErrors.add(error as GraphQLError, undefined);
288288
return this.buildResponse(null);
289289
}
290290
}
291291

292292
cancel(reason?: unknown): void {
293293
if (!this.finished) {
294-
this.finish(reason);
294+
this.finish();
295+
this.internalAbortController.abort(reason);
296+
this.resolverAbortController?.abort(reason);
295297
}
296298
}
297299

298-
finish(reason?: unknown): void {
300+
finish(): void {
299301
if (!this.finished) {
300302
this.finished = true;
301-
this.triggerResolverAbortSignals(reason);
302303
}
303-
}
304-
305-
triggerResolverAbortSignals(reason?: unknown): void {
306-
const { resolverAbortControllers } = this;
307-
const finishReason =
308-
reason ?? new Error('Execution has already completed.');
309-
for (const abortController of resolverAbortControllers.values()) {
310-
abortController.abort(finishReason);
311-
}
312-
}
313-
314-
getAbortSignal(path: Path): AbortSignal {
315-
const resolverAbortSignal = this.resolverAbortControllers.get(path)?.signal;
316-
if (resolverAbortSignal !== undefined) {
317-
return resolverAbortSignal;
318-
}
319-
const abortController = new AbortController();
320-
this.resolverAbortControllers.set(path, abortController);
321-
if (this.finished) {
322-
abortController.abort(new Error('Execution has already completed.'));
323-
}
324-
return abortController.signal;
304+
this.internalAbortController.signal.throwIfAborted();
325305
}
326306

327307
/**
328308
* Given a completed execution context and data, build the `{ errors, data }`
329309
* response defined by the "Response" section of the GraphQL specification.
330310
*/
331311
buildResponse(data: ObjMap<unknown> | null): ExecutionResult {
312+
this.resolverAbortController?.abort();
332313
const errors = this.collectedErrors.errors;
333314
return errors.length ? { errors, data } : { data };
334315
}
@@ -489,7 +470,7 @@ export class Executor {
489470
toNodes(fieldDetailsList),
490471
parentType,
491472
path,
492-
() => this.getAbortSignal(path),
473+
() => this.sharedResolverAbortSignal,
493474
);
494475

495476
// Get the resolve function, regardless of if its result is normal or abrupt (error).
@@ -517,7 +498,6 @@ export class Executor {
517498
info,
518499
path,
519500
result,
520-
true,
521501
);
522502
}
523503

@@ -532,22 +512,13 @@ export class Executor {
532512
if (isPromise(completed)) {
533513
// Note: we don't rely on a `catch` method, but we do expect "thenable"
534514
// to take a second callback for the error case.
535-
return completed.then(
536-
(resolved) => {
537-
this.resolverAbortControllers.delete(path);
538-
return resolved;
539-
},
540-
(rawError: unknown) => {
541-
this.resolverAbortControllers.delete(path);
542-
this.handleFieldError(rawError, returnType, fieldDetailsList, path);
543-
return null;
544-
},
545-
);
515+
return completed.then(undefined, (rawError: unknown) => {
516+
this.handleFieldError(rawError, returnType, fieldDetailsList, path);
517+
return null;
518+
});
546519
}
547-
this.resolverAbortControllers.delete(path);
548520
return completed;
549521
} catch (rawError) {
550-
this.resolverAbortControllers.delete(path);
551522
this.handleFieldError(rawError, returnType, fieldDetailsList, path);
552523
return null;
553524
}
@@ -694,7 +665,6 @@ export class Executor {
694665
info: GraphQLResolveInfo,
695666
path: Path,
696667
result: Promise<unknown>,
697-
isFieldValue?: boolean,
698668
): Promise<unknown> {
699669
try {
700670
const resolved = await result;
@@ -711,14 +681,8 @@ export class Executor {
711681
if (isPromise(completed)) {
712682
completed = await completed;
713683
}
714-
if (isFieldValue) {
715-
this.resolverAbortControllers.delete(path);
716-
}
717684
return completed;
718685
} catch (rawError) {
719-
if (isFieldValue) {
720-
this.resolverAbortControllers.delete(path);
721-
}
722686
this.handleFieldError(rawError, returnType, fieldDetailsList, path);
723687
return null;
724688
}

0 commit comments

Comments
 (0)