Skip to content

Bind context to async execution avoiding race-conditions #2521

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/graphql-modules-2521-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'graphql-modules': patch
---
dependencies updates:
- Updated dependency [`ramda@^0.30.0` ↗︎](https://www.npmjs.com/package/ramda/v/0.30.0) (from `^0.29.0`, in `dependencies`)
5 changes: 5 additions & 0 deletions .changeset/shiny-turkeys-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'graphql-modules': patch
---

Bind context to async execution avoiding race-conditions
46 changes: 25 additions & 21 deletions packages/graphql-modules/src/application/apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { wrapSchema } from '@graphql-tools/wrap';
import { DocumentNode, execute, GraphQLSchema } from 'graphql';
import { uniqueId } from '../shared/utils';
import { InternalAppContext } from './application';
import { ExecutionContextBuilder } from './context';
import { ExecutionContextBuilder, ExecutionContextEnv } from './context';
import { Application } from './types';

const CONTEXT_ID = Symbol.for('context-id');
Expand Down Expand Up @@ -60,11 +60,12 @@ export function apolloSchemaCreator({
> = {};
const subscription = createSubscription();

function getSession(ctx: any) {
function getSession(
ctx: any,
{ context, ɵdestroy: destroy }: ExecutionContextEnv
) {
if (!ctx[CONTEXT_ID]) {
ctx[CONTEXT_ID] = uniqueId((id) => !sessions[id]);
const { context, ɵdestroy: destroy } = contextBuilder(ctx);

sessions[ctx[CONTEXT_ID]] = {
count: 0,
session: {
Expand Down Expand Up @@ -99,24 +100,27 @@ export function apolloSchemaCreator({
operationName: input.operationName,
});
}
// Create an execution context
const { context, destroy } = getSession(input.context!);

// It's important to wrap the executeFn within a promise
// so we can easily control the end of execution (with finally)
return Promise.resolve()
.then(
() =>
execute({
schema,
document: input.document,
contextValue: context,
variableValues: input.variables as any,
rootValue: input.rootValue,
operationName: input.operationName,
}) as any
)
.finally(destroy);
// Create an execution context and run within it
return contextBuilder(input.context!).runWithContext((env) => {
const { context, destroy } = getSession(input.context!, env);

// It's important to wrap the executeFn within a promise
// so we can easily control the end of execution (with finally)
return Promise.resolve()
.then(
() =>
execute({
schema,
document: input.document,
contextValue: context,
variableValues: input.variables as any,
rootValue: input.rootValue,
operationName: input.operationName,
}) as any
)
.finally(destroy);
});
},
});
};
Expand Down
32 changes: 32 additions & 0 deletions packages/graphql-modules/src/application/async-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { AsyncLocalStorage } from 'async_hooks';
import module from 'module';

export interface AsyncContext {
getApplicationContext(): GraphQLModules.AppContext;
getModuleContext(moduleId: string): GraphQLModules.ModuleContext;
}

let alc: AsyncLocalStorage<AsyncContext> | undefined;
if (typeof process !== 'undefined') {
// probably nodejs runtime
const require = module.createRequire(
'file:///' /** path is not relevant since we're only loading a builtin */
);
const hooks = require('async_hooks') as typeof import('async_hooks');
alc = new hooks.AsyncLocalStorage();
}

export function getAsyncContext() {
return alc?.getStore();
}

export function runWithAsyncContext<R, TArgs extends any[]>(
asyncContext: AsyncContext,
callback: (...args: TArgs) => R,
...args: TArgs
): R {
if (!alc) {
return callback(...args);
}
return alc.run(asyncContext, callback, ...args);
}
36 changes: 32 additions & 4 deletions packages/graphql-modules/src/application/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@ import { ResolvedProvider } from '../di/resolution';
import { ID } from '../shared/types';
import { once, merge } from '../shared/utils';
import type { InternalAppContext, ModulesMap } from './application';
import { getAsyncContext, runWithAsyncContext } from './async-context';
import { attachGlobalProvidersMap } from './di';
import { CONTEXT } from './tokens';

export type ExecutionContextBuilder<
TContext extends {
[key: string]: any;
} = {},
> = (context: TContext) => {
> = (context: TContext) => ExecutionContextEnv & {
runWithContext<TReturn = any>(
cb: (env: ExecutionContextEnv) => TReturn
): TReturn;
};

export type ExecutionContextEnv = {
context: InternalAppContext;
ɵdestroy(): void;
ɵinjector: Injector;
Expand Down Expand Up @@ -67,12 +74,15 @@ export function createContextBuilder({
});

appInjector.setExecutionContextGetter(function executionContextGetter() {
return appContext;
return getAsyncContext()?.getApplicationContext() || appContext;
} as any);

function createModuleExecutionContextGetter(moduleId: string) {
return function moduleExecutionContextGetter() {
return getModuleContext(moduleId, context);
return (
getAsyncContext()?.getModuleContext(moduleId) ||
getModuleContext(moduleId, context)
);
};
}

Expand Down Expand Up @@ -164,7 +174,7 @@ export function createContextBuilder({
},
});

return {
const env: ExecutionContextEnv = {
ɵdestroy: once(() => {
providersToDestroy.forEach(([injector, keyId]) => {
// If provider was instantiated
Expand All @@ -178,6 +188,24 @@ export function createContextBuilder({
ɵinjector: operationAppInjector,
context: sharedContext,
};

return {
...env,
runWithContext(cb) {
return runWithAsyncContext(
{
getApplicationContext() {
return appContext;
},
getModuleContext(moduleId) {
return getModuleContext(moduleId, context);
},
},
cb,
env
);
},
};
};

return contextBuilder;
Expand Down
70 changes: 40 additions & 30 deletions packages/graphql-modules/src/application/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Application } from './types';
import { ExecutionContextBuilder } from './context';
import { Maybe } from '../shared/types';
import { isNotSchema } from '../shared/utils';
import { InternalAppContext } from './application';

export function executionCreator({
contextBuilder,
Expand All @@ -30,38 +31,47 @@ export function executionCreator({
fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>,
typeResolver?: Maybe<GraphQLTypeResolver<any, any>>
) => {
// Create an execution context
const { context, ɵdestroy: destroy } =
options?.controller ??
contextBuilder(
isNotSchema<ExecutionArgs>(argsOrSchema)
? argsOrSchema.contextValue
: contextValue
);
function perform({
context,
ɵdestroy: destroy,
}: {
context: InternalAppContext;
ɵdestroy: () => void;
}) {
const executionArgs: ExecutionArgs = isNotSchema<ExecutionArgs>(
argsOrSchema
)
? {
...argsOrSchema,
contextValue: context,
}
: {
schema: argsOrSchema,
document: document!,
rootValue,
contextValue: context,
variableValues,
operationName,
fieldResolver,
typeResolver,
};

const executionArgs: ExecutionArgs = isNotSchema<ExecutionArgs>(
argsOrSchema
)
? {
...argsOrSchema,
contextValue: context,
}
: {
schema: argsOrSchema,
document: document!,
rootValue,
contextValue: context,
variableValues,
operationName,
fieldResolver,
typeResolver,
};
// It's important to wrap the executeFn within a promise
// so we can easily control the end of execution (with finally)
return Promise.resolve()
.then(() => executeFn(executionArgs))
.finally(destroy);
}

// It's important to wrap the executeFn within a promise
// so we can easily control the end of execution (with finally)
return Promise.resolve()
.then(() => executeFn(executionArgs))
.finally(destroy);
if (options?.controller) {
return perform(options.controller);
}

return contextBuilder(
isNotSchema<ExecutionArgs>(argsOrSchema)
? argsOrSchema.contextValue
: contextValue
).runWithContext(perform);
};
};

Expand Down
91 changes: 50 additions & 41 deletions packages/graphql-modules/src/application/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '../shared/utils';
import { ExecutionContextBuilder } from './context';
import { Application } from './types';
import { InternalAppContext } from './application';

export function subscriptionCreator({
contextBuilder,
Expand All @@ -33,51 +34,59 @@ export function subscriptionCreator({
fieldResolver?: Maybe<GraphQLFieldResolver<any, any>>,
subscribeFieldResolver?: Maybe<GraphQLFieldResolver<any, any>>
) => {
// Create an subscription context
const { context, ɵdestroy: destroy } =
options?.controller ??
contextBuilder(
function perform({
context,
ɵdestroy: destroy,
}: {
context: InternalAppContext;
ɵdestroy: () => void;
}) {
const subscriptionArgs: SubscriptionArgs =
isNotSchema<SubscriptionArgs>(argsOrSchema)
? argsOrSchema.contextValue
: contextValue
);
? {
...argsOrSchema,
contextValue: context,
}
: {
schema: argsOrSchema,
document: document!,
rootValue,
contextValue: context,
variableValues,
operationName,
fieldResolver,
subscribeFieldResolver,
};

const subscriptionArgs: SubscriptionArgs = isNotSchema<SubscriptionArgs>(
argsOrSchema
)
? {
...argsOrSchema,
contextValue: context,
}
: {
schema: argsOrSchema,
document: document!,
rootValue,
contextValue: context,
variableValues,
operationName,
fieldResolver,
subscribeFieldResolver,
};
let isIterable = false;

let isIterable = false;
// It's important to wrap the subscribeFn within a promise
// so we can easily control the end of subscription (with finally)
return Promise.resolve()
.then(() => subscribeFn(subscriptionArgs))
.then((sub) => {
if (isAsyncIterable(sub)) {
isIterable = true;
return tapAsyncIterator(sub, destroy);
}
return sub;
})
.finally(() => {
if (!isIterable) {
destroy();
}
});
}

// It's important to wrap the subscribeFn within a promise
// so we can easily control the end of subscription (with finally)
return Promise.resolve()
.then(() => subscribeFn(subscriptionArgs))
.then((sub) => {
if (isAsyncIterable(sub)) {
isIterable = true;
return tapAsyncIterator(sub, destroy);
}
return sub;
})
.finally(() => {
if (!isIterable) {
destroy();
}
});
if (options?.controller) {
return perform(options.controller);
}

return contextBuilder(
isNotSchema<SubscriptionArgs>(argsOrSchema)
? argsOrSchema.contextValue
: contextValue
).runWithContext(perform);
};
};

Expand Down
Loading
Loading