Skip to content

Commit 419725a

Browse files
committed
feat(execution): expose async-work-finished execution hook
access finalization of all tracked async work beyond that included in the result via experimental hook Access the asyncWorkFinished hook via `experimentalHooks` on execution args: ```ts const result = execute({ schema, document: parse('{ test }'), experimentalHooks: { queryOrMutationOrSubscriptionEventAsyncWorkFinished({ validatedExecutionArgs, }) { const operationName = validatedExecutionArgs.operationName ?? '<anonymous>'; console.log(`async work finished for operation: ${operationName}`); }, }, }); ``` `execute(...)` can return synchronously (for example after a synchronous bubbled error) while tracked async work is still pending. To always get a Promise that resolves only after this hook is called: ```ts const args: ExecutionArgs = { schema, document: parse('{ test }'), }; let hookCalled = false; let resolveAsyncWorkFinished: (() => void) | undefined; const asyncWorkFinished = new Promise<void>((resolve) => { resolveAsyncWorkFinished = resolve; }); const existingHook = args.experimentalHooks ?.queryOrMutationOrSubscriptionEventAsyncWorkFinished; const result = execute({ ...args, experimentalHooks: { ...(args.experimentalHooks ?? {}), queryOrMutationOrSubscriptionEventAsyncWorkFinished(info) { try { existingHook?.(info); } finally { hookCalled = true; resolveAsyncWorkFinished?.(); } }, }, }); const resultAfterAsyncWorkFinished: Promise<ExecutionResult> = Promise.resolve(result).then((executionResult) => hookCalled ? executionResult : asyncWorkFinished.then(() => executionResult), ); ``` This is safe whether `result` is sync or async, and whether the hook callback is reached immediately or later. Async work started inside resolvers is not automatically guaranteed to be tracked. For example, `Promise.all([...])` rejects on the first rejection, so sibling promises may still be pending after `execute(...)` has already produced a result. Use `info.getAsyncHelpers()` to include that work in async tracking: ```ts resolve(_source, _args, _context, info) { const { promiseAll } = info.getAsyncHelpers(); return promiseAll([ fetchFromA(), fetchFromB(), fetchFromC(), ]); } ``` `promiseAny` and `promiseRace` similarly track non-winning/non-settled promises, and `trackPromise` can be used for any additional Promise you want included: ```ts resolve(_source, _args, _context, info) { const { trackPromise } = info.getAsyncHelpers(); trackPromise(cleanupAfterResponse()); return 'ok'; } ```
1 parent 9dab56e commit 419725a

File tree

10 files changed

+499
-3
lines changed

10 files changed

+499
-3
lines changed

src/execution/AsyncWorkTracker.ts

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

33+
async wait(): Promise<void> {
34+
await Promise.resolve();
35+
while (this.pendingAsyncWork.size > 0) {
36+
// eslint-disable-next-line no-await-in-loop
37+
await Promise.allSettled(Array.from(this.pendingAsyncWork));
38+
}
39+
}
40+
3341
promiseAllTrackOnReject<T>(
3442
values: ReadonlyArray<PromiseOrValue<T>>,
3543
): Promise<Array<T>> {

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
/**
@@ -372,17 +375,31 @@ export class Executor<
372375
this.aborted = true;
373376
}
374377

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

src/execution/__tests__/AsyncWorkTracker-test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,106 @@ describe('promiseCombinatorWithTracking', () => {
144144
await resolveOnNextTick();
145145
expect(tracker.pendingAsyncWork.size).to.equal(0);
146146
});
147+
148+
describe('wait', () => {
149+
it('returns immediately with no pending promises', async () => {
150+
const tracker = new AsyncWorkTracker();
151+
await tracker.wait();
152+
});
153+
154+
it('waits when tracked async work is present', async () => {
155+
const tracker = new AsyncWorkTracker();
156+
157+
const delayed = promiseWithResolvers<undefined>();
158+
tracker.add(delayed.promise);
159+
160+
let settled = false;
161+
const wait = tracker.wait().then(() => {
162+
settled = true;
163+
});
164+
await resolveOnNextTick();
165+
expect(settled).to.equal(false);
166+
167+
delayed.resolve(undefined);
168+
await wait;
169+
expect(settled).to.equal(true);
170+
});
171+
172+
it('keeps waiting when tracked async work is followed by more tracked async work', async () => {
173+
const tracker = new AsyncWorkTracker();
174+
175+
const delayed = promiseWithResolvers<undefined>();
176+
tracker.add(delayed.promise);
177+
178+
let settled = false;
179+
const wait = tracker.wait().then(() => {
180+
settled = true;
181+
});
182+
await resolveOnNextTick();
183+
expect(settled).to.equal(false);
184+
185+
delayed.resolve(undefined);
186+
187+
const anotherDelayed = promiseWithResolvers<undefined>();
188+
tracker.add(anotherDelayed.promise);
189+
await resolveOnNextTick();
190+
expect(settled).to.equal(false);
191+
192+
anotherDelayed.resolve(undefined);
193+
194+
await wait;
195+
expect(settled).to.equal(true);
196+
});
197+
});
198+
});
199+
200+
describe('wait', () => {
201+
it('returns immediately with no pending promises', async () => {
202+
const tracker = new AsyncWorkTracker();
203+
await tracker.wait();
204+
});
205+
206+
it('waits when tracked async work is present', async () => {
207+
const tracker = new AsyncWorkTracker();
208+
209+
const delayed = promiseWithResolvers<undefined>();
210+
tracker.add(delayed.promise);
211+
212+
let settled = false;
213+
const wait = tracker.wait().then(() => {
214+
settled = true;
215+
});
216+
await resolveOnNextTick();
217+
expect(settled).to.equal(false);
218+
219+
delayed.resolve(undefined);
220+
await wait;
221+
expect(settled).to.equal(true);
222+
});
223+
224+
it('keeps waiting when tracked async work is followed by more tracked async work', async () => {
225+
const tracker = new AsyncWorkTracker();
226+
227+
const delayed = promiseWithResolvers<undefined>();
228+
tracker.add(delayed.promise);
229+
230+
let settled = false;
231+
const wait = tracker.wait().then(() => {
232+
settled = true;
233+
});
234+
await resolveOnNextTick();
235+
expect(settled).to.equal(false);
236+
237+
delayed.resolve(undefined);
238+
239+
const anotherDelayed = promiseWithResolvers<undefined>();
240+
tracker.add(anotherDelayed.promise);
241+
await resolveOnNextTick();
242+
expect(settled).to.equal(false);
243+
244+
anotherDelayed.resolve(undefined);
245+
246+
await wait;
247+
expect(settled).to.equal(true);
248+
});
147249
});

0 commit comments

Comments
 (0)