Skip to content

Commit 998faf3

Browse files
committed
feat(execution): expose async-work-finished execution hook
Expose an execution hook that runs once tracked async work has finished, including work that can outlive a returned execution result. The hook is available through `hooks.asyncWorkFinished` on execution args. An `execute` wrapper that waits for all tracked async work can be written as: ```ts function executeAndWaitForAsyncWorkFinished( args: ExecutionArgs, ): PromiseOrValue<ExecutionResult> { let hookHasFired = false; const { promise: hookFinished, resolve: resolveHookFinished } = Promise.withResolvers<void>(); const userAsyncWorkFinishedHook = args.hooks?.asyncWorkFinished; const result = execute({ ...args, hooks: { ...args.hooks, asyncWorkFinished(info) { try { userAsyncWorkFinishedHook?.(info); } finally { hookHasFired = true; resolveHookFinished(); } }, }, }); return hookHasFired ? result : hookFinished.then(() => result); } ``` To ensure resolver-side async work is also tracked and awaited, use `info.getAsyncHelpers().track(...)` or `info.getAsyncHelpers().promiseAll(...)`. `promiseAll(...)` is optimized for the common case where the returned promise is awaited (or returned) from the resolver. It only starts tracking on rejection, and does so as a side-effect. Un-awaited async side effects are an anti-pattern: ```ts resolve(_source, _args, _context, info) { const { promiseAll } = info.getAsyncHelpers(); promiseAll([Promise.reject(new Error('bad')), pendingCleanup]).catch( () => undefined, ); return 'ok'; } ``` In that anti-pattern, tracking starts only after rejection (on a later microtask), so this work is not guaranteed to delay `hooks.asyncWorkFinished`. Use `track(...)` for un-awaited async side effects: ```ts resolve(_source, _args, _context, info) { const { track } = info.getAsyncHelpers(); track([doCleanupAsync().catch(() => undefined)]); return 'ok'; } ```
1 parent 2e97665 commit 998faf3

File tree

13 files changed

+736
-10
lines changed

13 files changed

+736
-10
lines changed

src/execution/AsyncWorkTracker.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ export class AsyncWorkTracker {
3030
}
3131
}
3232

33+
wait(): Promise<void> | void {
34+
// wait can complete synchronously when there is no tracked async work,
35+
// which allows synchronous execution paths to remain synchronous.
36+
if (this.pendingAsyncWork.size === 0) {
37+
return;
38+
}
39+
return this.waitForPendingAsyncWork();
40+
}
41+
3342
promiseAllTrackOnReject<T>(
3443
values: ReadonlyArray<PromiseOrValue<T>>,
3544
): Promise<Array<T>> {
@@ -39,4 +48,11 @@ export class AsyncWorkTracker {
3948
});
4049
return promise;
4150
}
51+
52+
private async waitForPendingAsyncWork(): Promise<void> {
53+
while (this.pendingAsyncWork.size > 0) {
54+
// eslint-disable-next-line no-await-in-loop
55+
await Promise.allSettled(Array.from(this.pendingAsyncWork));
56+
}
57+
}
4258
}

src/execution/Executor.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ import { createSharedExecutionContext } from './createSharedExecutionContext.js'
6161
import { buildResolveInfo } from './execute.js';
6262
import type { StreamUsage } from './getStreamUsage.js';
6363
import { getStreamUsage as _getStreamUsage } from './getStreamUsage.js';
64+
import type { ExecutionHooks } from './hooks.js';
65+
import { runAsyncWorkFinishedHook } from './hooks.js';
6466
import { returnIteratorCatchingErrors } from './returnIteratorCatchingErrors.js';
6567
import type { VariableValues } from './values.js';
6668
import { getArgumentValues } from './values.js';
@@ -116,6 +118,7 @@ export interface ValidatedExecutionArgs {
116118
errorPropagation: boolean;
117119
externalAbortSignal: AbortSignal | undefined;
118120
enableEarlyExecution: boolean;
121+
hooks: ExecutionHooks | undefined;
119122
}
120123

121124
/**
@@ -370,17 +373,31 @@ export class Executor<
370373
this.aborted = true;
371374
}
372375

376+
finishSharedExecution(): void {
377+
this.resolverAbortController?.abort();
378+
const asyncWorkFinishedHook =
379+
this.validatedExecutionArgs.hooks?.asyncWorkFinished;
380+
if (asyncWorkFinishedHook === undefined) {
381+
return;
382+
}
383+
runAsyncWorkFinishedHook(
384+
this.validatedExecutionArgs,
385+
this.sharedExecutionContext,
386+
asyncWorkFinishedHook,
387+
);
388+
}
389+
373390
/**
374391
* Given a completed execution context and data, build the `{ errors, data }`
375392
* response defined by the "Response" section of the GraphQL specification.
376393
*/
377394
buildResponse(
378395
data: ObjMap<unknown> | null,
379396
): ExecutionResult | TAlternativeInitialResponse {
397+
this.finishSharedExecution();
380398
this.finish();
381399
const errors = this.collectedErrors.errors;
382400
const result = errors.length ? { errors, data } : { data };
383-
this.resolverAbortController?.abort();
384401
return result;
385402
}
386403

src/execution/__tests__/AsyncWorkTracker-test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,83 @@ describe('promiseAllTrackOnReject', () => {
103103
expect(unhandledRejection).to.equal(null);
104104
});
105105
});
106+
107+
describe('wait', () => {
108+
it('returns synchronously when there is no pending async work', () => {
109+
const tracker = new AsyncWorkTracker();
110+
expect(tracker.wait()).to.equal(undefined);
111+
});
112+
113+
it('waits when tracked async work is present', async () => {
114+
const tracker = new AsyncWorkTracker();
115+
116+
const delayed = promiseWithResolvers<undefined>();
117+
tracker.add(delayed.promise);
118+
119+
let settled = false;
120+
const maybeWait = tracker.wait();
121+
expect(maybeWait).to.be.instanceOf(Promise);
122+
const wait = Promise.resolve(maybeWait).then(() => {
123+
settled = true;
124+
});
125+
await resolveOnNextTick();
126+
expect(settled).to.equal(false);
127+
128+
delayed.resolve(undefined);
129+
await wait;
130+
expect(settled).to.equal(true);
131+
});
132+
133+
it('keeps waiting when tracked async work is followed by more tracked async work', async () => {
134+
const tracker = new AsyncWorkTracker();
135+
136+
const delayed = promiseWithResolvers<undefined>();
137+
tracker.add(delayed.promise);
138+
139+
let settled = false;
140+
const maybeWait = tracker.wait();
141+
expect(maybeWait).to.be.instanceOf(Promise);
142+
const wait = Promise.resolve(maybeWait).then(() => {
143+
settled = true;
144+
});
145+
await resolveOnNextTick();
146+
expect(settled).to.equal(false);
147+
148+
delayed.resolve(undefined);
149+
150+
const anotherDelayed = promiseWithResolvers<undefined>();
151+
tracker.add(anotherDelayed.promise);
152+
await resolveOnNextTick();
153+
expect(settled).to.equal(false);
154+
155+
anotherDelayed.resolve(undefined);
156+
157+
await wait;
158+
expect(settled).to.equal(true);
159+
});
160+
161+
it('does not wait for side-effect promiseAll tracking added on next microtask', async () => {
162+
const tracker = new AsyncWorkTracker();
163+
const delayed = promiseWithResolvers<undefined>();
164+
165+
tracker
166+
.promiseAllTrackOnReject([
167+
Promise.reject(new Error('bad')),
168+
delayed.promise,
169+
])
170+
.catch(() => undefined);
171+
172+
const maybeWait = tracker.wait();
173+
expect(maybeWait).to.equal(undefined);
174+
175+
await resolveOnNextTick();
176+
expect(tracker.pendingAsyncWork.size).to.equal(0);
177+
await resolveOnNextTick();
178+
expect(tracker.pendingAsyncWork.size).to.be.greaterThan(0);
179+
180+
delayed.resolve(undefined);
181+
await Promise.allSettled(Array.from(tracker.pendingAsyncWork));
182+
await resolveOnNextTick();
183+
expect(tracker.pendingAsyncWork.size).to.equal(0);
184+
});
185+
});

src/execution/__tests__/executor-test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ describe('Execute: Handles basic execution tasks', () => {
261261
'getAsyncHelpers',
262262
);
263263
const asyncHelpers = resolvedInfo?.getAsyncHelpers();
264-
expect(asyncHelpers).to.have.all.keys('track');
264+
expect(asyncHelpers).to.have.all.keys('promiseAll', 'track');
265265

266266
const operation = document.definitions[0];
267267
assert(operation.kind === Kind.OPERATION_DEFINITION);
@@ -300,6 +300,10 @@ describe('Execute: Handles basic execution tasks', () => {
300300

301301
expect(resolvedInfo?.getAsyncHelpers()).to.equal(asyncHelpers);
302302

303+
const promiseAll = asyncHelpers?.promiseAll;
304+
expect(promiseAll).to.be.a('function');
305+
expect(resolvedInfo?.getAsyncHelpers().promiseAll).to.equal(promiseAll);
306+
303307
const track = asyncHelpers?.track;
304308
expect(track).to.be.a('function');
305309
expect(resolvedInfo?.getAsyncHelpers().track).to.equal(track);

0 commit comments

Comments
 (0)