Skip to content

Commit

Permalink
Always create abortController for each execution, listen to external …
Browse files Browse the repository at this point in the history
…(passed in by client) abort signal and abort our own signal after the execution is ended. Polyfill AbortController
  • Loading branch information
igrlk committed Feb 11, 2023
1 parent f8b148f commit e449cae
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 133 deletions.
170 changes: 63 additions & 107 deletions src/execution/__tests__/executor-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it } from 'mocha';
import { expectJSON } from '../../__testUtils__/expectJSON.js';
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';

import { AbortController } from '../../jsutils/AbortController.js';
import { inspect } from '../../jsutils/inspect.js';

import { Kind } from '../../language/kinds.js';
Expand Down Expand Up @@ -1314,59 +1315,59 @@ describe('Execute: Handles basic execution tasks', () => {
expect(possibleTypes).to.deep.equal([fooObject]);
});

describe('Abort execution', () => {
it('stops execution and throws an error when signal is aborted', async () => {
/**
* This test has 3 resolvers nested in each other.
* Every resolve function waits 200ms before returning data.
*
* The test waits for the first resolver and half of the 2nd resolver execution time (200ms + 100ms)
* and then aborts the execution.
*
* 2nd resolver execution finishes, and we then expect to not execute the 3rd resolver
* and to get an error about aborted operation.
*/

const WAIT_MS_BEFORE_RESOLVING = 200;
const ABORT_IN_MS_AFTER_STARTING_EXECUTION =
WAIT_MS_BEFORE_RESOLVING + WAIT_MS_BEFORE_RESOLVING / 2;

const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
resolvesIn500ms: {
type: new GraphQLObjectType({
name: 'ResolvesIn500ms',
fields: {
resolvesIn400ms: {
type: new GraphQLObjectType({
name: 'ResolvesIn400ms',
fields: {
shouldNotBeResolved: {
type: GraphQLString,
resolve: () => {
throw new Error('This should not be executed!');
},
it('stops execution and throws an error when signal is aborted', async () => {
/**
* This test has 3 resolvers nested in each other.
* Every resolve function waits 200ms before returning data.
*
* The test waits for the first resolver and half of the 2nd resolver execution time (200ms + 100ms)
* and then aborts the execution.
*
* 2nd resolver execution finishes, and we then expect to not execute the 3rd resolver
* and to get an error about aborted operation.
*/

const WAIT_MS_BEFORE_RESOLVING = 200;
const ABORT_IN_MS_AFTER_STARTING_EXECUTION =
WAIT_MS_BEFORE_RESOLVING + WAIT_MS_BEFORE_RESOLVING / 2;

const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
resolvesIn500ms: {
type: new GraphQLObjectType({
name: 'ResolvesIn500ms',
fields: {
resolvesIn400ms: {
type: new GraphQLObjectType({
name: 'ResolvesIn400ms',
fields: {
shouldNotBeResolved: {
type: GraphQLString,
/* c8 ignore next 3 */
resolve: () => {
throw new Error('This should not be executed!');
},
},
},
}),
resolve: () =>
new Promise((resolve) => {
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
}),
resolve: () =>
new Promise((resolve) => {
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
}),
},
},
},
}),
resolve: () =>
new Promise((resolve) => {
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
}),
resolve: () =>
new Promise((resolve) => {
setTimeout(() => resolve({}), WAIT_MS_BEFORE_RESOLVING);
}),
},
},
}),
});
const document = parse(`
},
}),
});
const document = parse(`
query {
resolvesIn500ms {
resolvesIn400ms {
Expand All @@ -1376,67 +1377,22 @@ describe('Execute: Handles basic execution tasks', () => {
}
`);

const abortController = new AbortController();
const executionPromise = execute({
schema,
document,
signal: abortController.signal,
});

setTimeout(
() => abortController.abort(),
ABORT_IN_MS_AFTER_STARTING_EXECUTION,
);

const result = await executionPromise;
expect(result.errors?.[0].message).to.eq(
'Execution aborted. Reason: AbortError: This operation was aborted',
);
expect(result.data).to.eql({
resolvesIn500ms: { resolvesIn400ms: null },
});
});

const abortMessageTestInputs = [
{ message: 'Aborted from somewhere', reason: 'Aborted from somewhere' },
{ message: undefined, reason: 'AbortError: This operation was aborted' },
];

for (const { message, reason } of abortMessageTestInputs) {
it('aborts with "Reason:" in the error message', async () => {
const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'Query',
fields: {
a: {
type: GraphQLString,
resolve: () =>
new Promise((resolve) => {
setTimeout(() => resolve({}), 100);
}),
},
},
}),
});

const document = parse(`
query { a }
`);

const abortController = new AbortController();
const executionPromise = execute({
schema,
document,
signal: abortController.signal,
});
const abortController = new AbortController();
const executionPromise = execute({
schema,
document,
signal: abortController.signal,
});

abortController.abort(message);
setTimeout(
() => abortController.abort(),
ABORT_IN_MS_AFTER_STARTING_EXECUTION,
);

const { errors } = await executionPromise;
expect(errors?.[0].message).to.eq(
`Execution aborted. Reason: ${reason}`,
);
});
}
const result = await executionPromise;
expect(result.errors?.[0].message).to.eq('Execution aborted.');
expect(result.data).to.eql({
resolvesIn500ms: { resolvesIn400ms: null },
});
});
});
67 changes: 41 additions & 26 deletions src/execution/execute.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type {
IAbortController,
IAbortSignal,
} from '../jsutils/AbortController.js';
import { AbortController } from '../jsutils/AbortController.js';
import { inspect } from '../jsutils/inspect.js';
import { invariant } from '../jsutils/invariant.js';
import { isAsyncIterable } from '../jsutils/isAsyncIterable.js';
Expand Down Expand Up @@ -122,9 +127,10 @@ export interface ExecutionContext {
subscribeFieldResolver: GraphQLFieldResolver<any, any>;
errors: Array<GraphQLError>;
subsequentPayloads: Set<AsyncPayloadRecord>;
signal: Maybe<{
isAborted: boolean;
instance: AbortSignal;
abortion: Maybe<{
passedInAbortSignal: IAbortSignal;
executionAbortController: IAbortController;
executionAbortSignal: IAbortSignal;
}>;
}

Expand Down Expand Up @@ -265,7 +271,7 @@ export interface ExecutionArgs {
fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
typeResolver?: Maybe<GraphQLTypeResolver<any, any>>;
subscribeFieldResolver?: Maybe<GraphQLFieldResolver<any, any>>;
signal?: AbortSignal;
signal?: IAbortSignal;
}

const UNEXPECTED_EXPERIMENTAL_DIRECTIVES =
Expand Down Expand Up @@ -342,28 +348,25 @@ export function experimentalExecuteIncrementally(
return executeImpl(exeContext);
}

function subscribeToAbortSignal(exeContext: ExecutionContext): {
unsubscribeFromAbortSignal: () => void;
} {
const onAbort = () => {
if ('signal' in exeContext && exeContext.signal) {
exeContext.signal.isAborted = true;
}
};
function subscribeToAbortSignal(exeContext: ExecutionContext): () => void {
const { abortion } = exeContext;
if (!abortion) {
return () => null;
}

exeContext.signal?.instance.addEventListener('abort', onAbort);
const onAbort = () => abortion.executionAbortController.abort(abortion);
abortion.passedInAbortSignal.addEventListener('abort', onAbort);

return {
unsubscribeFromAbortSignal: () => {
exeContext.signal?.instance.removeEventListener('abort', onAbort);
},
return () => {
abortion.passedInAbortSignal.removeEventListener('abort', onAbort);
abortion.executionAbortController.abort();
};
}

function executeImpl(
exeContext: ExecutionContext,
): PromiseOrValue<ExecutionResult | ExperimentalIncrementalExecutionResults> {
const { unsubscribeFromAbortSignal } = subscribeToAbortSignal(exeContext);
const unsubscribeFromAbortSignal = subscribeToAbortSignal(exeContext);

// Return a Promise that will eventually resolve to the data described by
// The "Response" section of the GraphQL specification.
Expand Down Expand Up @@ -473,7 +476,7 @@ export function buildExecutionContext(
fieldResolver,
typeResolver,
subscribeFieldResolver,
signal,
signal: passedInAbortSignal,
} = args;

// If the schema used for execution is invalid, throw an error.
Expand Down Expand Up @@ -539,7 +542,23 @@ export function buildExecutionContext(
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
subsequentPayloads: new Set(),
errors: [],
signal: signal ? { instance: signal, isAborted: false } : null,
abortion: getContextAbortionEntities(passedInAbortSignal),
};
}

function getContextAbortionEntities(
passedInAbortSignal: Maybe<IAbortSignal>,
): ExecutionContext['abortion'] {
if (!passedInAbortSignal) {
return null;
}

const executionAbortController = new AbortController();

return {
passedInAbortSignal,
executionAbortController,
executionAbortSignal: executionAbortController.signal,
};
}

Expand Down Expand Up @@ -873,12 +892,8 @@ function completeValue(
result: unknown,
asyncPayloadRecord?: AsyncPayloadRecord,
): PromiseOrValue<unknown> {
if (exeContext.signal?.isAborted) {
throw new GraphQLError(
`Execution aborted. Reason: ${
exeContext.signal.instance.reason ?? 'Unknown.'
}`,
);
if (exeContext.abortion?.executionAbortSignal.aborted) {
throw new GraphQLError('Execution aborted.');
}

// If result is an Error, throw a located error.
Expand Down
31 changes: 31 additions & 0 deletions src/jsutils/AbortController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export interface IAbortSignal {
aborted: boolean;
addEventListener: (type: string, handler: () => void) => void;
removeEventListener: (type: string, handler: () => void) => void;
}

export interface IAbortController {
signal: IAbortSignal;
abort: (reason?: any) => void;
}

/* c8 ignore start */
export const AbortController: new () => IAbortController =
// eslint-disable-next-line no-undef
global.AbortController ||
class MockAbortController implements IAbortController {
private _signal: IAbortSignal = {
aborted: false,
addEventListener: () => null,
removeEventListener: () => null,
};

public get signal(): IAbortSignal {
return this._signal;
}

public abort(): void {
this._signal.aborted = true;
}
};
/* c8 ignore stop */

0 comments on commit e449cae

Please sign in to comment.