Skip to content

Commit ba48d64

Browse files
committed
feat(execution): expose async work finished execution hook
1 parent 106e8ab commit ba48d64

7 files changed

Lines changed: 362 additions & 0 deletions

File tree

src/execution/Executor.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import { createSharedExecutionContext } from './createSharedExecutionContext.js'
6262
import { buildResolveInfo } from './execute.js';
6363
import type { StreamUsage } from './getStreamUsage.js';
6464
import { getStreamUsage as _getStreamUsage } from './getStreamUsage.js';
65+
import type { ExecutionHooks } from './hooks.js';
66+
import { runPostExecutionHooks } from './hooks.js';
6567
import { returnIteratorCatchingErrors } from './returnIteratorCatchingErrors.js';
6668
import type { VariableValues } from './values.js';
6769
import { getArgumentValues } from './values.js';
@@ -117,6 +119,7 @@ export interface ValidatedExecutionArgs {
117119
errorPropagation: boolean;
118120
externalAbortSignal: AbortSignal | undefined;
119121
enableEarlyExecution: boolean;
122+
experimentalHooks: ExecutionHooks | undefined;
120123
}
121124

122125
/**
@@ -368,9 +371,23 @@ export class Executor<
368371
}
369372
this.abortResultPromise?.(this.abortReason);
370373
this.resolverAbortController?.abort(this.abortReason);
374+
const hooks = this.validatedExecutionArgs.experimentalHooks;
375+
if (hooks !== undefined) {
376+
runPostExecutionHooks(
377+
this.validatedExecutionArgs,
378+
this.sharedExecutionContext,
379+
);
380+
}
371381
}
372382

373383
finish(): void {
384+
const hooks = this.validatedExecutionArgs.experimentalHooks;
385+
if (hooks !== undefined) {
386+
runPostExecutionHooks(
387+
this.validatedExecutionArgs,
388+
this.sharedExecutionContext,
389+
);
390+
}
374391
this.throwIfAborted();
375392
this.aborted = true;
376393
}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { assert, expect } from 'chai';
2+
import { describe, it } from 'mocha';
3+
4+
import { expectPromise } from '../../__testUtils__/expectPromise.js';
5+
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
6+
7+
import { promiseWithResolvers } from '../../jsutils/promiseWithResolvers.js';
8+
9+
import { parse } from '../../language/parser.js';
10+
11+
import type { GraphQLResolveInfo } from '../../type/definition.js';
12+
import { GraphQLObjectType } from '../../type/definition.js';
13+
import { GraphQLString } from '../../type/scalars.js';
14+
import { GraphQLSchema } from '../../type/schema.js';
15+
16+
import { buildSchema } from '../../utilities/buildASTSchema.js';
17+
18+
import { createSharedExecutionContext } from '../createSharedExecutionContext.js';
19+
import { execute } from '../execute.js';
20+
import type { ValidatedExecutionArgs } from '../Executor.js';
21+
import { runPostExecutionHooks } from '../hooks.js';
22+
23+
const executeHookSchema = new GraphQLSchema({
24+
query: new GraphQLObjectType({
25+
name: 'Query',
26+
fields: {
27+
test: {
28+
type: GraphQLString,
29+
resolve: () => 'ok',
30+
},
31+
},
32+
}),
33+
});
34+
35+
const cancellationHookSchema = buildSchema(`
36+
type Todo {
37+
id: ID
38+
items: [String]
39+
}
40+
41+
type Query {
42+
todo: Todo
43+
}
44+
`);
45+
46+
describe('Execute: Hooks', () => {
47+
it('ignores errors thrown by hooks', async () => {
48+
const calls: Array<string> = [];
49+
const { promise: hooksFinished, resolve: resolveHooksFinished } =
50+
promiseWithResolvers<undefined>();
51+
52+
const result = execute({
53+
schema: executeHookSchema,
54+
document: parse('{ test }'),
55+
experimentalHooks: {
56+
queryOrMutationOrSubscriptionEventAsyncWorkFinished() {
57+
calls.push('asyncWork');
58+
resolveHooksFinished(undefined);
59+
throw new Error(
60+
'queryOrMutationOrSubscriptionEventAsyncWorkFinished failed',
61+
);
62+
},
63+
},
64+
});
65+
66+
expect(result).to.deep.equal({
67+
data: {
68+
test: 'ok',
69+
},
70+
});
71+
await hooksFinished;
72+
expect(calls).to.deep.equal(['asyncWork']);
73+
});
74+
75+
it('runs post execution hooks for synchronous execution', async () => {
76+
const calls: Array<string> = [];
77+
const { promise: hooksFinished, resolve: resolveHooksFinished } =
78+
promiseWithResolvers<undefined>();
79+
80+
const result = execute({
81+
schema: executeHookSchema,
82+
document: parse('{ test }'),
83+
experimentalHooks: {
84+
queryOrMutationOrSubscriptionEventAsyncWorkFinished() {
85+
calls.push('asyncWork');
86+
resolveHooksFinished(undefined);
87+
},
88+
},
89+
});
90+
91+
expect(result).to.deep.equal({
92+
data: {
93+
test: 'ok',
94+
},
95+
});
96+
await hooksFinished;
97+
expect(calls).to.deep.equal(['asyncWork']);
98+
});
99+
100+
it('runs post execution hooks for asynchronous execution', async () => {
101+
const { promise: resolvedValue, resolve } = promiseWithResolvers<string>();
102+
const calls: Array<string> = [];
103+
const { promise: hooksFinished, resolve: resolveHooksFinished } =
104+
promiseWithResolvers<undefined>();
105+
const asyncSchema = new GraphQLSchema({
106+
query: new GraphQLObjectType({
107+
name: 'Query',
108+
fields: {
109+
test: {
110+
type: GraphQLString,
111+
resolve: () => resolvedValue,
112+
},
113+
},
114+
}),
115+
});
116+
117+
const resultPromise = execute({
118+
schema: asyncSchema,
119+
document: parse('{ test }'),
120+
experimentalHooks: {
121+
queryOrMutationOrSubscriptionEventAsyncWorkFinished() {
122+
calls.push('asyncWork');
123+
resolveHooksFinished(undefined);
124+
},
125+
},
126+
});
127+
128+
expect(calls).to.deep.equal([]);
129+
resolve('ok');
130+
131+
const result = await resultPromise;
132+
expect(result).to.deep.equal({
133+
data: {
134+
test: 'ok',
135+
},
136+
});
137+
await hooksFinished;
138+
expect(calls).to.deep.equal(['asyncWork']);
139+
});
140+
141+
it('runs post execution hooks for aborted execution', async () => {
142+
const abortController = new AbortController();
143+
const { promise: pendingCleanup, resolve: resolveCleanup } =
144+
promiseWithResolvers<string>();
145+
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
146+
promiseWithResolvers<undefined>();
147+
const calls: Array<string> = [];
148+
const document = parse(`
149+
query {
150+
todo {
151+
id
152+
}
153+
}
154+
`);
155+
156+
const resultPromise = execute({
157+
document,
158+
schema: cancellationHookSchema,
159+
abortSignal: abortController.signal,
160+
experimentalHooks: {
161+
queryOrMutationOrSubscriptionEventAsyncWorkFinished() {
162+
calls.push('asyncWork');
163+
resolveAsyncWorkFinished(undefined);
164+
},
165+
},
166+
rootValue: {
167+
todo: (_args: any, _context: any, info: GraphQLResolveInfo) => {
168+
const abortSignal = info.getAbortSignal();
169+
assert(abortSignal instanceof AbortSignal);
170+
const abortPromise = new Promise<never>((_resolve, reject) => {
171+
abortSignal.addEventListener('abort', () => {
172+
reject(new Error('This operation was aborted'));
173+
});
174+
});
175+
return info
176+
.getAsyncHelpers()
177+
.promiseAll([abortPromise, pendingCleanup]);
178+
},
179+
},
180+
});
181+
182+
abortController.abort();
183+
await expectPromise(resultPromise).toRejectWith(
184+
'This operation was aborted',
185+
);
186+
expect(calls).to.deep.equal([]);
187+
188+
resolveCleanup('done');
189+
await asyncWorkFinished;
190+
191+
expect(calls).to.deep.equal(['asyncWork']);
192+
});
193+
194+
it('fires queryOrMutationOrSubscriptionEventAsyncWorkFinished after async iterator return cleanup', async () => {
195+
const abortController = new AbortController();
196+
const document = parse(`
197+
query {
198+
todo {
199+
items
200+
}
201+
}
202+
`);
203+
const { promise: nextReturned, resolve: resolveNextReturned } =
204+
promiseWithResolvers<IteratorResult<string>>();
205+
const { promise: nextStarted, resolve: resolveNextStarted } =
206+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
207+
promiseWithResolvers<void>();
208+
const { promise: returnStarted, resolve: resolveReturnStarted } =
209+
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
210+
promiseWithResolvers<void>();
211+
const { promise: returnFinished, resolve: resolveReturnFinished } =
212+
promiseWithResolvers<IteratorResult<string>>();
213+
const { promise: asyncWorkFinished, resolve: resolveAsyncWorkFinished } =
214+
promiseWithResolvers<undefined>();
215+
const asyncIterator = {
216+
[Symbol.asyncIterator]() {
217+
return this;
218+
},
219+
next() {
220+
resolveNextStarted(undefined);
221+
return nextReturned;
222+
},
223+
return() {
224+
resolveReturnStarted(undefined);
225+
return returnFinished;
226+
},
227+
};
228+
229+
const resultPromise = execute({
230+
schema: cancellationHookSchema,
231+
document,
232+
abortSignal: abortController.signal,
233+
experimentalHooks: {
234+
queryOrMutationOrSubscriptionEventAsyncWorkFinished() {
235+
resolveAsyncWorkFinished(undefined);
236+
},
237+
},
238+
rootValue: {
239+
todo: {
240+
items: asyncIterator,
241+
},
242+
},
243+
});
244+
245+
await nextStarted;
246+
abortController.abort();
247+
resolveNextReturned({ value: 'value', done: false });
248+
249+
await expectPromise(resultPromise).toRejectWith(
250+
'This operation was aborted',
251+
);
252+
await returnStarted;
253+
254+
let asyncWorkHookCalled = false;
255+
const asyncWorkObserved = asyncWorkFinished.then(() => {
256+
asyncWorkHookCalled = true;
257+
});
258+
await resolveOnNextTick();
259+
expect(asyncWorkHookCalled).to.equal(false);
260+
261+
resolveReturnFinished({ value: undefined, done: true });
262+
await asyncWorkFinished;
263+
await asyncWorkObserved;
264+
});
265+
266+
it('runs post execution hooks only once', async () => {
267+
const calls: Array<string> = [];
268+
const { promise: hooksFinished, resolve: resolveHooksFinished } =
269+
promiseWithResolvers<undefined>();
270+
271+
const validatedExecutionArgs = {
272+
experimentalHooks: {
273+
queryOrMutationOrSubscriptionEventAsyncWorkFinished() {
274+
calls.push('asyncWork');
275+
resolveHooksFinished(undefined);
276+
},
277+
},
278+
} as Pick<ValidatedExecutionArgs, 'experimentalHooks'>;
279+
280+
const sharedExecutionContext = createSharedExecutionContext(undefined);
281+
runPostExecutionHooks(
282+
validatedExecutionArgs as ValidatedExecutionArgs,
283+
sharedExecutionContext,
284+
);
285+
runPostExecutionHooks(
286+
validatedExecutionArgs as ValidatedExecutionArgs,
287+
sharedExecutionContext,
288+
);
289+
290+
await hooksFinished;
291+
expect(calls).to.deep.equal(['asyncWork']);
292+
});
293+
});

src/execution/createSharedExecutionContext.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { AsyncWorkTracker } from './AsyncWorkTracker.js';
77
/** @internal */
88
export interface SharedExecutionContext {
99
asyncWorkTracker: AsyncWorkTracker;
10+
postExecutionHooksStarted: boolean;
1011
getAbortSignal: () => AbortSignal | undefined;
1112
promiseAll: <T>(
1213
values: ReadonlyArray<PromiseOrValue<T>>,
@@ -75,6 +76,7 @@ export function createSharedExecutionContext(
7576

7677
return {
7778
asyncWorkTracker,
79+
postExecutionHooksStarted: false,
7880
getAbortSignal: () => abortSignal,
7981
promiseAll,
8082
getAsyncHelpers,

src/execution/execute.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import type { ExecutionResult, ValidatedExecutionArgs } from './Executor.js';
4040
import { Executor } from './Executor.js';
4141
import { ExecutorThrowingOnIncremental } from './ExecutorThrowingOnIncremental.js';
4242
import { getVariableSignature } from './getVariableSignature.js';
43+
import type { ExecutionHooks } from './hooks.js';
4344
import type { ExperimentalIncrementalExecutionResults } from './incremental/IncrementalExecutor.js';
4445
import { IncrementalExecutor } from './incremental/IncrementalExecutor.js';
4546
import { mapAsyncIterable } from './mapAsyncIterable.js';
@@ -298,6 +299,7 @@ export interface ExecutionArgs {
298299
hideSuggestions?: Maybe<boolean>;
299300
abortSignal?: Maybe<AbortSignal>;
300301
enableEarlyExecution?: Maybe<boolean>;
302+
experimentalHooks?: Maybe<ExecutionHooks>;
301303
/** Additional execution options. */
302304
options?: {
303305
/** Set the maximum number of errors allowed for coercing (defaults to 50). */
@@ -330,6 +332,7 @@ export function validateExecutionArgs(
330332
perEventExecutor,
331333
abortSignal: externalAbortSignal,
332334
enableEarlyExecution,
335+
experimentalHooks,
333336
options,
334337
} = args;
335338

@@ -419,6 +422,7 @@ export function validateExecutionArgs(
419422
errorPropagation,
420423
externalAbortSignal: externalAbortSignal ?? undefined,
421424
enableEarlyExecution: enableEarlyExecution === true,
425+
experimentalHooks: experimentalHooks ?? undefined,
422426
};
423427
}
424428

0 commit comments

Comments
 (0)