diff --git a/benchmark/batch-resolution-benchmark.js b/benchmark/batch-resolution-benchmark.js new file mode 100644 index 0000000000..dd8714fb71 --- /dev/null +++ b/benchmark/batch-resolution-benchmark.js @@ -0,0 +1,60 @@ +import { execute } from 'graphql/execution/execute.js'; +import { parse } from 'graphql/language/parser.js'; +import { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLObjectType, + GraphQLSchema, +} from 'graphql/type/index.js'; + +const WidgetType = new GraphQLObjectType({ + name: 'Widget', + fields: () => ({ + id: { type: GraphQLID }, + widget: { + type: WidgetType, + resolve: async (source) => { + await Promise.resolve(); + return source.widget; + }, + experimentalBatchResolve: async (sources) => { + await Promise.resolve(); + return sources.map((source) => source.widget); + }, + }, + widgets: { type: new GraphQLList(WidgetType) }, + }), +}); + +const QueryType = new GraphQLObjectType({ + name: 'Query', + fields: { + widgets: { + type: new GraphQLList(WidgetType), + args: { first: { type: GraphQLInt } }, + resolve: (source, args) => source.widgets.slice(0, args.first), + }, + }, +}); + +const schema = new GraphQLSchema({ query: QueryType }); + +const rootValue = { + widgets: Array.from({ length: 10000 }, () => ({ + id: 'gid://owner/Widget/1', + widget: { id: 'gid://owner/Widget/1' }, + })), +}; +const document = parse(`{ widgets(first: 10000) { id widget { id } } }`); + +export const benchmark = { + name: 'Execute Async Widget Field With Batch Resolver', + measure: () => + execute({ + schema, + document, + rootValue, + enableBatchResolvers: true, + }), +}; diff --git a/benchmark/batch-resolution-list-fields.ts b/benchmark/batch-resolution-list-fields.ts new file mode 100644 index 0000000000..a62ff21dc1 --- /dev/null +++ b/benchmark/batch-resolution-list-fields.ts @@ -0,0 +1,1153 @@ +/* eslint-disable no-await-in-loop, no-console */ + +import childProcess from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance, PerformanceObserver } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; + +import { prepareBenchmarkProjects } from '../resources/benchmark/projects.ts'; +import { execute as localExecute } from '../src/execution/execute.ts'; +import type { DocumentNode } from '../src/language/ast.ts'; +import { parse } from '../src/language/parser.ts'; +import { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLObjectType, + GraphQLSchema, +} from '../src/type/index.ts'; + +import { createGrafastRuntime as createConfiguredGrafastRuntime } from './batch-resolution-setups/grafast/setup.ts'; +import { createGraphQLBreadthRuntime as createConfiguredGraphQLBreadthRuntime } from './batch-resolution-setups/graphql-breadth-js/setup.ts'; +import { createGraphQLJITRuntime as createConfiguredGraphQLJITRuntime } from './batch-resolution-setups/graphql-jit/setup.ts'; +import type { ExecutorRuntime } from './batch-resolution-setups/shared/runtime.ts'; + +type MaybePromise = PromiseLike | T; + +interface WidgetSource { + [key: string]: unknown; +} + +interface BenchContext { + readonly loaders?: { + readonly widget: RequestBatchLoader; + }; +} + +interface FirstArgs { + readonly first?: number | null; +} + +interface Scenario { + readonly rowLabel: string; + readonly query: string; + readonly document: DocumentNode; + readonly source: WidgetSource; + readonly expectedRootWidgetCount: number | undefined; +} + +interface ExecutionResultLike { + readonly data?: unknown; + readonly errors?: ReadonlyArray<{ readonly message: string }>; +} + +interface RuntimeModules { + readonly execute: (args: { + schema: unknown; + document: unknown; + rootValue: unknown; + contextValue?: unknown; + enableBatchResolvers?: true; + }) => MaybePromise; + readonly GraphQLID: unknown; + readonly GraphQLInt: unknown; + readonly GraphQLList: new (type: unknown) => unknown; + readonly GraphQLObjectType: new (config: { + readonly [key: string]: unknown; + }) => unknown; + readonly GraphQLSchema: new (config: { + readonly [key: string]: unknown; + }) => unknown; +} + +const modeOrder = [ + 'graphql-js-17', + 'graphql-js-17-dataloader', + 'graphql-js-local-batch', + 'graphql-breadth-js', + 'grafast', + 'graphql-jit', +] as const; +type Mode = (typeof modeOrder)[number]; + +interface TimingResult { + readonly mode: Mode; + readonly opsPerSecond: number; +} + +interface MemoryResult { + readonly mode: Mode; + readonly gcMsPerIteration: number; + readonly gcCount: number; +} + +interface ScenarioGroup { + readonly title: string; + readonly query: string; + readonly rowHeader: string; + readonly scenarios: ReadonlyArray; +} + +interface ExecutorMode { + readonly label: string; + readonly prepare?: (scenario: Scenario) => MaybePromise; + readonly execute: (scenario: Scenario) => MaybePromise; +} + +const baselineRevision = process.env.BASELINE_REV ?? '17.x.x'; +const options = { + modes: envModes('MODES', modeOrder.join(',')), + sizes: envInts('SIZES', '1,10,100,1000,10000'), + treeListDepths: envInts('TREE_LIST_DEPTHS', '1,5'), + treeListBreadths: envInts('TREE_LIST_BREADTHS', '10,100,1000'), + treeDepths: envInts('TREE_DEPTHS', '1,5,10,18'), + warmupMs: envInt('WARMUP_MS', 500), + runMs: envInt('RUN_MS', 1500), + timingMinRounds: envInt('TIMING_MIN_ROUNDS', 10), + memoryIterations: envInt('MEMORY_ITERATIONS', 200), + memoryWarmup: envInt('MEMORY_WARMUP', 50), +}; + +const graphQLBreadthRepoURL = 'https://github.com/gmac/graphql-breadth-js.git'; +const graphQLBreadthDefaultRef = 'main'; +const externalExecutorPackageVersions = { + graphql: '16.9.0', + graphqlJIT: '0.8.7', + grafast: '1.0.2', +}; +const localRuntime: RuntimeModules = { + execute: localExecute as RuntimeModules['execute'], + GraphQLID, + GraphQLInt, + GraphQLList: GraphQLList as RuntimeModules['GraphQLList'], + GraphQLObjectType: GraphQLObjectType as RuntimeModules['GraphQLObjectType'], + GraphQLSchema, +}; + +const multiplicationSign = 'x'; + +let graphqlBreadthRuntimePromise: Promise | undefined; +let graphqlJITRuntimePromise: Promise | undefined; +let grafastRuntimePromise: Promise | undefined; + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); + +async function main(): Promise { + ensureGC(); + validateOptions(); + + const baselineRuntime = await loadBaselineRuntime(); + const executors = createExecutorModes(baselineRuntime); + + console.log('Batch resolution cross-revision benchmark'); + console.log( + [ + `baseline=${baselineRevision}`, + `modes=${options.modes.join(',')}`, + `sizes=${options.sizes.join(',')}`, + `treeListDepths=${options.treeListDepths.join(',')}`, + `treeListBreadths=${options.treeListBreadths.join(',')}`, + `treeDepths=${options.treeDepths.join(',')}`, + `warmup=${options.warmupMs}ms`, + `run=${options.runMs}ms`, + `timingMinRounds=${options.timingMinRounds}`, + `memoryIterations=${options.memoryIterations}`, + `memoryWarmup=${options.memoryWarmup}`, + ].join(' '), + ); + console.log(''); + console.log( + 'Ratios compare each speed cell to the slowest executor in its row and each GC cell to the highest observed GC time.', + ); + + for (const group of createScenarioGroups()) { + await prepareScenarioGroup(group, executors); + await validateScenarioGroup(group, executors); + + console.log(''); + console.log(`### ${group.title}`); + console.log(''); + console.log('```graphql'); + console.log(group.query); + console.log('```'); + console.log(''); + console.log('**Speed**'); + console.log(''); + + const speedRows: Array> = []; + for (const scenario of group.scenarios) { + const timingResults = await measureTimings(executors, scenario); + speedRows.push(speedRow(scenario, timingResults)); + } + printMarkdownTable(group.rowHeader, speedRows); + + const memoryRows: Array> = []; + for (const scenario of group.scenarios) { + const memoryResults = []; + for (const mode of options.modes) { + memoryResults.push( + await measureMemory(mode, executorForMode(executors, mode), scenario), + ); + } + if (memoryResults.some((result) => result.gcCount !== 0)) { + memoryRows.push(memoryRow(scenario, memoryResults)); + } + } + if (memoryRows.length !== 0) { + console.log(''); + console.log('**GC pressure**'); + console.log(''); + printMarkdownTable(group.rowHeader, memoryRows); + } + } +} + +async function loadBaselineRuntime(): Promise { + const [project] = prepareBenchmarkProjects([baselineRevision]); + const moduleURL = (modulePath: string) => + pathToFileURL( + path.join(project.projectPath, 'node_modules/graphql', modulePath), + ).href; + + const executionModule = (await import( + moduleURL('execution/execute.js') + )) as Pick; + const typeModule = (await import(moduleURL('type/index.js'))) as Omit< + RuntimeModules, + 'execute' + >; + + return { + execute: executionModule.execute, + GraphQLID: typeModule.GraphQLID, + GraphQLInt: typeModule.GraphQLInt, + GraphQLList: typeModule.GraphQLList, + GraphQLObjectType: typeModule.GraphQLObjectType, + GraphQLSchema: typeModule.GraphQLSchema, + }; +} + +function createExecutorModes( + baselineRuntime: RuntimeModules, +): ReadonlyMap { + return new Map([ + [ + 'graphql-js-17', + createGraphQLJSExecutor({ + label: `${baselineRevision} regular async`, + runtime: baselineRuntime, + schema: createRegularAsyncSchema(baselineRuntime), + createContext: () => ({}), + }), + ], + [ + 'graphql-js-17-dataloader', + createGraphQLJSExecutor({ + label: `${baselineRevision} dataloader`, + runtime: baselineRuntime, + schema: createDataloaderSchema(baselineRuntime), + createContext: createDataloaderContext, + }), + ], + [ + 'graphql-js-local-batch', + createGraphQLJSExecutor({ + label: 'working tree batch', + runtime: localRuntime, + schema: createBatchResolverSchema(localRuntime), + createContext: () => ({}), + enableBatchResolvers: true, + }), + ], + [ + 'graphql-breadth-js', + createExternalExecutor('graphql-breadth-js', getGraphQLBreadthRuntime), + ], + ['grafast', createExternalExecutor('grafast', getGrafastRuntime)], + [ + 'graphql-jit', + createExternalExecutor('graphql-jit', getGraphQLJITRuntime), + ], + ]); +} + +function createGraphQLJSExecutor(config: { + readonly label: string; + readonly runtime: RuntimeModules; + readonly schema: unknown; + readonly createContext: () => BenchContext; + readonly enableBatchResolvers?: true; +}): ExecutorMode { + return { + label: config.label, + execute: (scenario) => + config.runtime.execute({ + schema: config.schema, + document: scenario.document, + rootValue: scenario.source, + contextValue: config.createContext(), + ...(config.enableBatchResolvers === undefined + ? {} + : { enableBatchResolvers: config.enableBatchResolvers }), + }), + }; +} + +function createExternalExecutor( + label: string, + getRuntime: () => Promise, +): ExecutorMode { + return { + label, + prepare: async (scenario) => { + await (await getRuntime()).prepare(scenario); + }, + execute: async (scenario) => (await getRuntime()).execute(scenario), + }; +} + +function createRegularAsyncSchema(runtime: RuntimeModules): unknown { + return createWidgetSchema(runtime, { + resolve: async (source: WidgetSource) => { + await Promise.resolve(); + return source.widget; + }, + }); +} + +function createDataloaderSchema(runtime: RuntimeModules): unknown { + return createWidgetSchema(runtime, { + resolve: ( + source: WidgetSource, + _args: unknown, + context: Required, + ) => context.loaders.widget.load(source), + }); +} + +function createDataloaderContext(): Required { + return { + loaders: { + widget: new RequestBatchLoader(async (sources) => { + await Promise.resolve(); + return sources.map((source) => source.widget); + }), + }, + }; +} + +function createBatchResolverSchema(runtime: RuntimeModules): unknown { + return createWidgetSchema(runtime, { + experimentalBatchResolve: async (sources: ReadonlyArray) => { + await Promise.resolve(); + return sources.map((source) => source.widget); + }, + }); +} + +function createWidgetSchema( + runtime: RuntimeModules, + widgetField: { readonly [key: string]: unknown }, +): unknown { + const WidgetType: unknown = new runtime.GraphQLObjectType({ + name: 'Widget', + fields: () => ({ + id: { type: runtime.GraphQLID }, + widget: { type: WidgetType, ...widgetField }, + widgets: { type: new runtime.GraphQLList(WidgetType) }, + }), + }); + + const QueryType = new runtime.GraphQLObjectType({ + name: 'Query', + fields: { + widget: { type: WidgetType }, + widgets: { + type: new runtime.GraphQLList(WidgetType), + args: { first: { type: runtime.GraphQLInt } }, + resolve: (source: WidgetSource, args: FirstArgs) => { + const widgets = source.widgets as ReadonlyArray; + return typeof args.first === 'number' + ? widgets.slice(0, args.first) + : widgets; + }, + }, + }, + }); + + return new runtime.GraphQLSchema({ query: QueryType }); +} + +function createScenarioGroups(): ReadonlyArray { + return [ + { + title: 'Flat list', + query: 'query { widgets(first: N) { id } }', + rowHeader: 'size', + scenarios: options.sizes.map(createFlatListScenario), + }, + { + title: 'Tree within list', + query: + '# inner tree depth D\nquery { widgets(first: N) { widget { widget { id } id } id } }', + rowHeader: `D ${multiplicationSign} N`, + scenarios: options.treeListDepths.flatMap((depth) => + options.treeListBreadths.map((breadth) => + createTreeListScenario(depth, breadth), + ), + ), + }, + { + title: 'List with async widget field', + query: + 'query { widgets(first: N) { id widget { id } } } # N async widget resolutions', + rowHeader: 'size', + scenarios: options.sizes.map(createWidgetListScenario), + }, + { + title: 'Deep flat tree', + query: 'query { widget { widget { widget { id } id } id } } # depth D', + rowHeader: 'depth', + scenarios: options.treeDepths.map(createDeepTreeScenario), + }, + ]; +} + +async function prepareScenarioGroup( + group: ScenarioGroup, + executors: ReadonlyMap, +): Promise { + for (const scenario of group.scenarios) { + for (const mode of options.modes) { + const prepare = executorForMode(executors, mode).prepare; + if (prepare !== undefined) { + await prepare(scenario); + } + } + } +} + +async function validateScenarioGroup( + group: ScenarioGroup, + executors: ReadonlyMap, +): Promise { + const baselineMode = options.modes[0]; + const baselineExecutor = executorForMode(executors, baselineMode); + for (const scenario of group.scenarios) { + const baseline = await runExecution( + baselineMode, + baselineExecutor, + scenario, + ); + validateScenarioResult(group, scenario, baselineExecutor, baseline); + const baselineJSON = JSON.stringify(baseline.data); + + for (const mode of options.modes.slice(1)) { + const executor = executorForMode(executors, mode); + const result = await runExecution(mode, executor, scenario); + validateScenarioResult(group, scenario, executor, result); + const resultJSON = JSON.stringify(result.data); + if (resultJSON !== baselineJSON) { + throw new Error( + `Result mismatch for ${group.title} ${scenario.rowLabel}: ` + + `${baselineExecutor.label} != ${executor.label}`, + ); + } + } + } +} + +function validateScenarioResult( + group: ScenarioGroup, + scenario: Scenario, + executor: ExecutorMode, + result: ExecutionResultLike, +): void { + const expectedRootWidgetCount = scenario.expectedRootWidgetCount; + if (expectedRootWidgetCount === undefined) { + return; + } + + const widgets = (result.data as { readonly widgets: ReadonlyArray }) + .widgets; + if (widgets.length !== expectedRootWidgetCount) { + throw new Error( + `${executor.label} returned an invalid root widget count for ` + + `${group.title} ${scenario.rowLabel}: expected ` + + `${expectedRootWidgetCount}, got ${widgets.length}`, + ); + } +} + +async function measureTimings( + executors: ReadonlyMap, + scenario: Scenario, +): Promise> { + const warmupEnd = performance.now() + options.warmupMs; + while (performance.now() < warmupEnd) { + for (const mode of shuffled(options.modes)) { + await runExecution(mode, executorForMode(executors, mode), scenario); + } + } + + forceGC(); + + const timingByMode = new Map( + options.modes.map((mode) => [mode, { elapsedMs: 0, iterations: 0 }]), + ); + const benchmarkStart = performance.now(); + let rounds = 0; + while ( + rounds < options.timingMinRounds || + performance.now() - benchmarkStart < options.runMs + ) { + for (const mode of shuffled(options.modes)) { + const start = performance.now(); + await runExecution(mode, executorForMode(executors, mode), scenario); + const elapsedMs = performance.now() - start; + const timing = timingByMode.get(mode); + if (timing === undefined) { + throw new Error(`No timing accumulator for ${mode}.`); + } + timing.elapsedMs += elapsedMs; + timing.iterations++; + } + rounds++; + } + + return options.modes.map((mode) => { + const timing = timingByMode.get(mode); + if (timing === undefined) { + throw new Error(`No timing accumulator for ${mode}.`); + } + + return { + mode, + opsPerSecond: (timing.iterations / timing.elapsedMs) * 1000, + }; + }); +} + +async function measureMemory( + mode: Mode, + executor: ExecutorMode, + scenario: Scenario, +): Promise { + for (let i = 0; i < options.memoryWarmup; i++) { + await runExecution(mode, executor, scenario); + } + + forceGC(); + await flushPerformanceObserver(); + + const events: Array<{ duration: number }> = []; + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + events.push({ duration: entry.duration }); + } + }); + observer.observe({ entryTypes: ['gc'] }); + + for (let i = 0; i < options.memoryIterations; i++) { + await runExecution(mode, executor, scenario); + } + + await flushPerformanceObserver(); + observer.disconnect(); + + forceGC(); + + return { + mode, + gcMsPerIteration: + events.reduce((sum, event) => sum + event.duration, 0) / + options.memoryIterations, + gcCount: events.length, + }; +} + +async function runExecution( + mode: Mode, + executor: ExecutorMode, + scenario: Scenario, +): Promise { + const result = await executor.execute(scenario); + + if (result.errors !== undefined && result.errors.length !== 0) { + throw new Error( + `${executor.label} failed in ${mode}: ` + + result.errors.map((error) => error.message).join('; '), + ); + } + return result; +} + +async function getGraphQLBreadthRuntime(): Promise { + graphqlBreadthRuntimePromise ??= Promise.resolve( + createGraphQLBreadthRuntime(), + ); + return graphqlBreadthRuntimePromise; +} + +function createGraphQLBreadthRuntime(): ExecutorRuntime { + const rootPath = prepareGraphQLBreadthPackage(); + const revision = readGitRevision(rootPath); + + console.log( + `Using graphql-breadth-js from ${rootPath}${revision === undefined ? '' : ` (${revision})`}`, + ); + + return createConfiguredGraphQLBreadthRuntime(rootPath); +} + +function prepareGraphQLBreadthPackage(): string { + const configuredPath = process.env.GRAPHQL_BREADTH_JS_PATH; + const rootPath = + configuredPath === undefined + ? cachedGraphQLBreadthPath() + : path.resolve(configuredPath); + + if (configuredPath === undefined) { + const ref = process.env.GRAPHQL_BREADTH_JS_REF ?? graphQLBreadthDefaultRef; + if (!fs.existsSync(rootPath)) { + fs.mkdirSync(path.dirname(rootPath), { recursive: true }); + runCommand( + 'git', + ['clone', graphQLBreadthRepoURL, rootPath], + process.cwd(), + ); + } + runCommand('git', ['fetch', 'origin'], rootPath); + runCommand('git', ['checkout', ref], rootPath); + if (ref === graphQLBreadthDefaultRef) { + runCommand('git', ['pull', '--ff-only', 'origin', ref], rootPath); + } + } + + if (!fs.existsSync(path.join(rootPath, 'package.json'))) { + throw new Error( + `GRAPHQL_BREADTH_JS_PATH does not point at a graphql-breadth-js checkout: ${rootPath}`, + ); + } + + if (!fs.existsSync(path.join(rootPath, 'node_modules'))) { + runCommand('npm', ['install', '--ignore-scripts'], rootPath); + } + runCommand('npm', ['run', 'build'], rootPath); + + return rootPath; +} + +function cachedGraphQLBreadthPath(): string { + const ref = process.env.GRAPHQL_BREADTH_JS_REF ?? graphQLBreadthDefaultRef; + return path.join( + os.tmpdir(), + 'graphql-js-benchmark-graphql-breadth-js', + ref.replaceAll(/[^a-zA-Z0-9_.-]/g, '_'), + ); +} + +async function getGraphQLJITRuntime(): Promise { + graphqlJITRuntimePromise ??= Promise.resolve( + createConfiguredGraphQLJITRuntime(prepareExternalExecutorsPackage()), + ); + return graphqlJITRuntimePromise; +} + +async function getGrafastRuntime(): Promise { + grafastRuntimePromise ??= Promise.resolve( + createConfiguredGrafastRuntime(prepareExternalExecutorsPackage()), + ); + return grafastRuntimePromise; +} + +function prepareExternalExecutorsPackage(): string { + const configuredPath = process.env.GRAPHQL_EXECUTOR_BENCH_DEPS_PATH; + const rootPath = + configuredPath === undefined + ? path.join(os.tmpdir(), 'graphql-js-benchmark-external-executors') + : path.resolve(configuredPath); + + fs.mkdirSync(rootPath, { recursive: true }); + const packageJSONPath = path.join(rootPath, 'package.json'); + if (!fs.existsSync(packageJSONPath)) { + fs.writeFileSync( + packageJSONPath, + JSON.stringify( + { + private: true, + type: 'commonjs', + dependencies: {}, + }, + null, + 2, + ), + ); + } + + if ( + !fs.existsSync(path.join(rootPath, 'node_modules/graphql-jit')) || + !fs.existsSync(path.join(rootPath, 'node_modules/grafast')) + ) { + runCommand( + 'npm', + [ + 'install', + '--ignore-scripts', + `graphql@${externalExecutorPackageVersions.graphql}`, + `graphql-jit@${externalExecutorPackageVersions.graphqlJIT}`, + `grafast@${externalExecutorPackageVersions.grafast}`, + ], + rootPath, + ); + } + + return rootPath; +} + +function readGitRevision(rootPath: string): string | undefined { + const result = childProcess.spawnSync( + 'git', + ['rev-parse', '--short', 'HEAD'], + { + cwd: rootPath, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +function runCommand( + command: string, + args: ReadonlyArray, + cwd: string, +): void { + const result = childProcess.spawnSync(command, args, { + cwd, + stdio: 'inherit', + }); + + if (result.error !== undefined) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `Command failed with status ${result.status}: ${command} ${args.join(' ')}`, + ); + } +} + +function executorForMode( + executors: ReadonlyMap, + mode: Mode, +): ExecutorMode { + const executor = executors.get(mode); + if (executor === undefined) { + throw new Error(`No executor for mode ${mode}.`); + } + return executor; +} + +function createFlatListScenario(size: number): Scenario { + return createScenario( + String(size), + `widgets(first: ${size}) { id }`, + createListSource(size, createWidget), + size, + ); +} + +function createWidgetListScenario(size: number): Scenario { + return createScenario( + String(size), + `widgets(first: ${size}) { id widget { id } }`, + createListSource(size, () => createWidget({ widget: createWidget() })), + size, + ); +} + +function createTreeListScenario(depth: number, breadth: number): Scenario { + return createScenario( + `${depth} ${multiplicationSign} ${breadth}`, + `widgets(first: ${breadth}) { ${buildTreeQuery(depth)} }`, + createListSource(breadth, () => createWidgetTree(depth)), + breadth, + ); +} + +function createDeepTreeScenario(depth: number): Scenario { + return createScenario( + String(depth), + buildTreeQuery(depth), + createWidgetTree(depth), + undefined, + ); +} + +function createScenario( + rowLabel: string, + query: string, + source: WidgetSource, + expectedRootWidgetCount: number | undefined, +): Scenario { + const operation = `{ ${query} }`; + return { + rowLabel, + query: operation, + document: parse(operation), + source, + expectedRootWidgetCount, + }; +} + +function createWidget(fields?: WidgetSource): WidgetSource { + return { id: 'gid://owner/Widget/1', ...fields }; +} + +function createWidgetTree(depth: number): WidgetSource { + assertDepth(depth); + + let source = createWidget(); + for (let i = 0; i < depth; i++) { + source = createWidget({ widget: source }); + } + + return source; +} + +function buildTreeQuery(depth: number): string { + assertDepth(depth); + + let query = 'id'; + for (let i = 0; i < depth; i++) { + query = `widget { ${query} id }`; + } + + return query; +} + +function createListSource( + size: number, + createItem: () => WidgetSource, +): WidgetSource { + return { widgets: Array.from({ length: size + 1 }, createItem) }; +} + +function assertDepth(depth: number): void { + if (depth < 0 || depth > 18) { + throw new Error('Depth must be between 0 and 18.'); + } +} + +class RequestBatchLoader { + private readonly loadMany: ( + keys: ReadonlyArray, + ) => MaybePromise>; + + private queue: Array<{ + key: TKey; + resolve: (value: TValue) => void; + reject: (reason: unknown) => void; + }> = []; + + private scheduled = false; + + constructor( + loadMany: ( + keys: ReadonlyArray, + ) => MaybePromise>, + ) { + this.loadMany = loadMany; + } + + load(key: TKey): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ key, resolve, reject }); + if (!this.scheduled) { + this.scheduled = true; + queueMicrotask(() => this.dispatch()); + } + }); + } + + private dispatch(): void { + const queue = this.queue; + this.queue = []; + this.scheduled = false; + + let values: MaybePromise>; + try { + values = this.loadMany(queue.map((entry) => entry.key)); + } catch (error) { + rejectAll(queue, error); + return; + } + + Promise.resolve(values).then( + (resolvedValues) => { + if (resolvedValues.length !== queue.length) { + rejectAll( + queue, + new Error( + `Batch loader returned ${resolvedValues.length} values for ${queue.length} keys.`, + ), + ); + return; + } + + for (const [index, entry] of queue.entries()) { + entry.resolve(resolvedValues[index]); + } + }, + (error: unknown) => rejectAll(queue, error), + ); + } +} + +function rejectAll( + queue: ReadonlyArray<{ reject: (reason: unknown) => void }>, + error: unknown, +): void { + for (const entry of queue) { + entry.reject(error); + } +} + +function validateOptions(): void { + if (options.modes.length === 0) { + throw new Error('MODES must include at least one executor.'); + } + if (options.sizes.length === 0) { + throw new Error('SIZES must include at least one size.'); + } +} + +function envModes(name: string, fallback: string): Array { + return envList(name, fallback).map((value) => { + if (!isMode(value)) { + throw new Error( + `Invalid ${name} value "${value}". Expected one of: ${modeOrder.join( + ', ', + )}.`, + ); + } + return value; + }); +} + +function isMode(value: string): value is Mode { + return modeOrder.includes(value as Mode); +} + +function envInts(name: string, fallback: string): Array { + return envList(name, fallback).map((value) => parseIntEnv(name, value)); +} + +function envInt(name: string, fallback: number): number { + return parseIntEnv(name, process.env[name] ?? String(fallback)); +} + +function parseIntEnv(name: string, value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || String(parsed) !== value) { + throw new Error(`${name} must contain integer values, got "${value}".`); + } + return parsed; +} + +function envList(name: string, fallback: string): Array { + return (process.env[name] ?? fallback) + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +function speedRow( + scenario: Scenario, + results: ReadonlyArray, +): ReadonlyArray { + const slowest = results.reduce( + (best, result) => + best === undefined || result.opsPerSecond < best.opsPerSecond + ? result + : best, + undefined, + ); + + return [ + scenario.rowLabel, + ...options.modes.map((mode) => { + const result = results.find((candidate) => candidate.mode === mode); + if (result === undefined) { + return '-'; + } + + return formatMetricWithRatio( + formatIterationsPerSecond(result.opsPerSecond), + slowest === undefined || slowest.opsPerSecond === 0 + ? undefined + : result.opsPerSecond / slowest.opsPerSecond, + ); + }), + ]; +} + +function memoryRow( + scenario: Scenario, + results: ReadonlyArray, +): ReadonlyArray { + const highest = results.reduce((best, result) => { + if (result.gcCount === 0 || result.gcMsPerIteration === 0) { + return best; + } + return best === undefined || result.gcMsPerIteration > best.gcMsPerIteration + ? result + : best; + }, undefined); + + return [ + scenario.rowLabel, + ...options.modes.map((mode) => { + const result = results.find((candidate) => candidate.mode === mode); + if ( + result === undefined || + result.gcCount === 0 || + result.gcMsPerIteration === 0 + ) { + return '-'; + } + + return formatMetricWithRatio( + formatMicroseconds(result.gcMsPerIteration), + highest === undefined + ? undefined + : highest.gcMsPerIteration / result.gcMsPerIteration, + ); + }), + ]; +} + +function formatMetricWithRatio( + metric: string, + ratio: number | undefined, +): string { + return ratio === undefined ? metric : `${metric} (${formatRatio(ratio)})`; +} + +function formatIterationsPerSecond(value: number): string { + if (value >= 1000) { + return `${formatNumber(value / 1000)}k ops/sec`; + } + return `${formatNumber(value)} ops/sec`; +} + +function formatMicroseconds(value: number): string { + return `${formatNumber(value * 1000)} us`; +} + +function formatRatio(value: number): string { + return `${formatNumber(value)}x`; +} + +function formatNumber(value: number): string { + const maximumFractionDigits = + value >= 100 ? 0 : value >= 10 ? 1 : value >= 1 ? 2 : 3; + return new Intl.NumberFormat('en-US', { + maximumFractionDigits, + }).format(value); +} + +function printMarkdownTable( + rowHeader: string, + rows: ReadonlyArray>, +): void { + const headers = [ + rowHeader, + ...options.modes.map((mode) => executorLabelForMode(mode)), + ]; + const tableRows = [headers, ...rows]; + const widths = headers.map((_, columnIndex) => + Math.max(...tableRows.map((row) => row[columnIndex]?.length ?? 0)), + ); + + printMarkdownRow(headers, widths); + printMarkdownRow( + widths.map((width) => '-'.repeat(Math.max(3, width))), + widths, + ); + for (const row of rows) { + printMarkdownRow(row, widths); + } +} + +function printMarkdownRow( + row: ReadonlyArray, + widths: ReadonlyArray, +): void { + console.log( + `| ${row + .map((cell, index) => cell.padEnd(widths[index] ?? 0)) + .join(' | ')} |`, + ); +} + +function executorLabelForMode(mode: Mode): string { + switch (mode) { + case 'graphql-js-17': + return `${baselineRevision} regular async`; + case 'graphql-js-17-dataloader': + return `${baselineRevision} dataloader`; + case 'graphql-js-local-batch': + return 'working tree batch'; + case 'graphql-breadth-js': + return 'graphql-breadth-js'; + case 'grafast': + return 'grafast'; + case 'graphql-jit': + return 'graphql-jit'; + } +} + +function ensureGC(): void { + if (globalThis.gc === undefined) { + throw new Error('Run with node --expose-gc.'); + } +} + +function forceGC(): void { + globalThis.gc?.(); +} + +async function flushPerformanceObserver(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +function shuffled(array: ReadonlyArray): Array { + const shuffledArray = [...array]; + for (let index = shuffledArray.length - 1; index > 0; --index) { + const randomIndex = Math.floor(Math.random() * (index + 1)); + [shuffledArray[index], shuffledArray[randomIndex]] = [ + shuffledArray[randomIndex], + shuffledArray[index], + ]; + } + return shuffledArray; +} diff --git a/benchmark/batch-resolution-setups/grafast/setup.ts b/benchmark/batch-resolution-setups/grafast/setup.ts new file mode 100644 index 0000000000..28e4fc1343 --- /dev/null +++ b/benchmark/batch-resolution-setups/grafast/setup.ts @@ -0,0 +1,112 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import { schemaSDL } from '../shared/fixture.ts'; +import type { + BenchmarkExecutionResult, + ExecutorRuntime, +} from '../shared/runtime.ts'; + +interface ExternalGraphQLModule { + readonly parse: (source: string) => unknown; +} + +interface GrafastModule { + readonly access: (source: unknown, path: string) => unknown; + readonly execute: (args: { + readonly schema: unknown; + readonly document: unknown; + readonly rootValue: unknown; + readonly contextValue: unknown; + }) => BenchmarkExecutionResult | PromiseLike; + readonly loadMany: ( + lookup: unknown, + loader: { + readonly load: ( + lookups: ReadonlyArray, + ) => PromiseLike> | ReadonlyArray; + }, + ) => unknown; + readonly lambda: ( + spec: ReadonlyArray, + fn: (values: ReadonlyArray) => unknown, + isSyncAndSafe?: boolean, + ) => unknown; + readonly makeGrafastSchema: (details: { + readonly typeDefs: string; + readonly objects: { readonly [typeName: string]: unknown }; + }) => unknown; +} + +interface GrafastFieldArgs { + readonly $first?: unknown; +} + +export function createGrafastRuntime(rootPath: string): ExecutorRuntime { + process.env.GRAPHILE_ENV ??= 'production'; + + const requireFromRoot = createRequire(path.join(rootPath, 'package.json')); + const graphQLModule = requireFromRoot('graphql') as ExternalGraphQLModule; + const grafastModule = requireFromRoot('grafast') as GrafastModule; + const { access, execute, lambda, loadMany, makeGrafastSchema } = + grafastModule; + const documentsByQuery = new Map(); + + const schema = makeGrafastSchema({ + typeDefs: schemaSDL, + objects: { + Query: { + plans: { + widget: ($source: unknown) => access($source, 'widget'), + widgets: ($source: unknown, args: GrafastFieldArgs) => + lambda( + [access($source, 'widgets'), args.$first], + ([values, first]) => { + const widgets = values as ReadonlyArray; + return typeof first === 'number' + ? widgets.slice(0, first) + : widgets; + }, + true, + ), + }, + }, + Widget: { + plans: { + id: ($widget: unknown) => access($widget, 'id'), + widget: ($widget: unknown) => + loadMany(access($widget, 'widget'), { + load: async (values) => { + await Promise.resolve(); + return values; + }, + }), + widgets: ($widget: unknown) => access($widget, 'widgets'), + }, + }, + }, + }); + + function getDocument(query: string): unknown { + let document = documentsByQuery.get(query); + if (document === undefined) { + document = graphQLModule.parse(query); + documentsByQuery.set(query, document); + } + + return document; + } + + return { + prepare: (scenario) => { + getDocument(scenario.query); + }, + execute: (scenario) => + execute({ + schema, + document: getDocument(scenario.query), + rootValue: scenario.source, + contextValue: scenario.source, + }), + }; +} diff --git a/benchmark/batch-resolution-setups/graphql-breadth-js/setup.ts b/benchmark/batch-resolution-setups/graphql-breadth-js/setup.ts new file mode 100644 index 0000000000..e739a7f780 --- /dev/null +++ b/benchmark/batch-resolution-setups/graphql-breadth-js/setup.ts @@ -0,0 +1,218 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import type { Scenario, WidgetSource } from '../shared/fixture.ts'; +import type { + BenchmarkExecutionResult, + ExecutorRuntime, +} from '../shared/runtime.ts'; + +interface GraphQLBreadthModule { + readonly Executor: { + readonly build: (options: { + readonly schema: unknown; + readonly document: unknown; + readonly resolvers: BreadthResolverMap; + readonly rootObject: unknown; + readonly validateDocument: false; + }) => { + readonly result: + | BenchmarkExecutionResult + | PromiseLike; + }; + }; + readonly FieldResolver: new () => object; + readonly LazyLoader: new (args?: StringKeyedObject) => BreadthLazyLoader; +} + +interface BreadthResolverMap { + readonly [typeName: string]: { + readonly [fieldName: string]: BreadthFieldResolver; + }; +} + +interface BreadthGraphQLModule { + readonly GraphQLID: unknown; + readonly GraphQLInt: unknown; + readonly GraphQLList: new (type: unknown) => unknown; + readonly GraphQLObjectType: new (config: { + readonly name: string; + readonly fields: + | { readonly [fieldName: string]: unknown } + | (() => { readonly [fieldName: string]: unknown }); + }) => unknown; + readonly GraphQLSchema: new (config: { readonly query: unknown }) => unknown; + readonly parse: (source: string) => unknown; +} + +interface BreadthFieldResolver { + readonly resolve: (execField: BreadthExecutionField) => unknown; +} + +interface BreadthExecutionField { + readonly key: string; + readonly arguments: StringKeyedObject; + readonly objects: ReadonlyArray; + readonly lazy: (options: { + readonly loaderClass: BreadthLazyLoaderConstructor; + readonly keys: ReadonlyArray; + readonly args?: StringKeyedObject; + }) => unknown; + readonly mapObjects: (fn: (obj: unknown) => T) => Array; +} + +interface BreadthLazyLoader { + async: boolean; + map: boolean; + identityFor?: (key: unknown) => unknown; + performAsync: ( + keys: ReadonlyArray, + ) => PromiseLike>; +} + +type BreadthLazyLoaderConstructor = new ( + args?: StringKeyedObject, +) => BreadthLazyLoader; + +interface StringKeyedObject { + readonly [key: string]: unknown; +} + +export function createGraphQLBreadthRuntime(rootPath: string): ExecutorRuntime { + const requireFromBreadth = createRequire(path.join(rootPath, 'package.json')); + const graphQLBreadthModule = requireFromBreadth( + path.join(rootPath, 'dist/index.js'), + ) as GraphQLBreadthModule; + const graphQLModule = requireFromBreadth('graphql') as BreadthGraphQLModule; + + return buildRuntime(graphQLBreadthModule, graphQLModule); +} + +function buildRuntime( + graphQLBreadthModule: GraphQLBreadthModule, + graphQLModule: BreadthGraphQLModule, +): ExecutorRuntime { + const { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLObjectType, + GraphQLSchema, + } = graphQLModule; + const { Executor, FieldResolver, LazyLoader } = graphQLBreadthModule; + const documentsByQuery = new Map(); + + if (typeof LazyLoader.prototype.performAsync !== 'function') { + throw new Error( + 'The configured graphql-breadth-js checkout does not support async LazyLoader.performAsync. Use the current main branch or set GRAPHQL_BREADTH_JS_REF to a compatible revision.', + ); + } + + class FieldKeyResolver extends FieldResolver { + resolve(execField: BreadthExecutionField): unknown { + return execField.mapObjects((obj) => + obj == null ? null : (obj as StringKeyedObject)[execField.key], + ); + } + } + + class ListFieldResolver extends FieldResolver { + resolve(execField: BreadthExecutionField): unknown { + const first = execField.arguments.first; + return execField.mapObjects((obj) => { + const widgets = (obj as WidgetSource).widgets as ReadonlyArray; + return typeof first === 'number' ? widgets.slice(0, first) : widgets; + }); + } + } + + class AsyncFieldKeyResolver extends FieldResolver { + resolve(execField: BreadthExecutionField): unknown { + return execField.lazy({ + loaderClass: BreadthBatchLoader, + args: { key: execField.key }, + keys: [...execField.objects], + }); + } + } + + class BreadthBatchLoader extends LazyLoader { + override async = true; + override map = true; + private readonly key: string; + + constructor(args?: StringKeyedObject) { + super(args); + this.key = String(args?.key); + } + + override identityFor = (obj: unknown): unknown => obj; + + override performAsync = async ( + keys: ReadonlyArray, + ): Promise> => { + await Promise.resolve(); + const key = this.key; + return keys.map((obj) => (obj as StringKeyedObject)[key]); + }; + } + + const WidgetType: unknown = new GraphQLObjectType({ + name: 'Widget', + fields: () => ({ + id: { type: GraphQLID }, + widget: { type: WidgetType }, + widgets: { type: new GraphQLList(WidgetType) }, + }), + }); + const QueryType = new GraphQLObjectType({ + name: 'Query', + fields: { + widget: { type: WidgetType }, + widgets: { + type: new GraphQLList(WidgetType), + args: { first: { type: GraphQLInt } }, + }, + }, + }); + + const fieldKey = new FieldKeyResolver() as BreadthFieldResolver; + const listField = new ListFieldResolver() as BreadthFieldResolver; + const asyncFieldKey = new AsyncFieldKeyResolver() as BreadthFieldResolver; + const schema = new GraphQLSchema({ query: QueryType }); + const resolvers = { + Query: { + widget: fieldKey, + widgets: listField, + }, + Widget: { + id: fieldKey, + widget: asyncFieldKey, + widgets: fieldKey, + }, + }; + + function getDocument(query: string): unknown { + let document = documentsByQuery.get(query); + if (document === undefined) { + document = graphQLModule.parse(query); + documentsByQuery.set(query, document); + } + + return document; + } + + return { + prepare: (scenario: Scenario) => { + getDocument(scenario.query); + }, + execute: (scenario: Scenario) => + Executor.build({ + schema, + document: getDocument(scenario.query), + resolvers, + rootObject: scenario.source, + validateDocument: false, + }).result, + }; +} diff --git a/benchmark/batch-resolution-setups/graphql-jit/setup.ts b/benchmark/batch-resolution-setups/graphql-jit/setup.ts new file mode 100644 index 0000000000..d2f290f296 --- /dev/null +++ b/benchmark/batch-resolution-setups/graphql-jit/setup.ts @@ -0,0 +1,159 @@ +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import type { + BenchContext, + FirstArgs, + Scenario, + WidgetSource, +} from '../shared/fixture.ts'; +import { RequestBatchLoader } from '../shared/request-batch-loader.ts'; +import type { + BenchmarkExecutionResult, + ExecutorRuntime, +} from '../shared/runtime.ts'; + +interface ExternalGraphQLModule { + readonly GraphQLID: unknown; + readonly GraphQLInt: unknown; + readonly GraphQLList: new (type: unknown) => unknown; + readonly GraphQLObjectType: new (config: { + readonly name: string; + readonly fields: + | { readonly [fieldName: string]: unknown } + | (() => { readonly [fieldName: string]: unknown }); + }) => unknown; + readonly GraphQLSchema: new (config: { readonly query: unknown }) => unknown; + readonly parse: (source: string) => unknown; +} + +interface GraphQLJITModule { + readonly compileQuery: ( + schema: unknown, + document: unknown, + ) => + | { + readonly query: ( + rootValue: unknown, + contextValue: BenchContext, + variableValues: { readonly [variableName: string]: unknown }, + ) => BenchmarkExecutionResult | PromiseLike; + } + | { readonly errors: ReadonlyArray<{ readonly message: string }> }; +} + +type CompiledQuery = Exclude< + ReturnType, + { readonly errors: ReadonlyArray<{ readonly message: string }> } +>; + +interface GraphQLJITOptions { + readonly dataloader?: boolean; +} + +export function createGraphQLJITRuntime( + rootPath: string, + options: GraphQLJITOptions = {}, +): ExecutorRuntime { + const requireFromRoot = createRequire(path.join(rootPath, 'package.json')); + const graphQLModule = requireFromRoot('graphql') as ExternalGraphQLModule; + const graphQLJITModule = requireFromRoot('graphql-jit') as GraphQLJITModule; + const schema = createSchema(graphQLModule, options); + const compiledByQuery = new Map(); + + function getCompiled(scenario: Scenario): CompiledQuery { + let compiled = compiledByQuery.get(scenario.query); + if (compiled !== undefined) { + return compiled; + } + + const result = graphQLJITModule.compileQuery( + schema, + graphQLModule.parse(scenario.query), + ); + if ('errors' in result) { + throw new Error( + `graphql-jit failed to compile query: ${result.errors + .map((error) => error.message) + .join('; ')}`, + ); + } + + compiled = result; + compiledByQuery.set(scenario.query, compiled); + return compiled; + } + + return { + prepare: (scenario) => { + getCompiled(scenario); + }, + execute: (scenario) => + getCompiled(scenario).query(scenario.source, createContext(options), {}), + }; +} + +function createSchema( + graphQLModule: ExternalGraphQLModule, + options: GraphQLJITOptions, +): unknown { + const { + GraphQLID, + GraphQLInt, + GraphQLList, + GraphQLObjectType, + GraphQLSchema, + } = graphQLModule; + + const WidgetType: unknown = new GraphQLObjectType({ + name: 'Widget', + fields: () => ({ + id: { type: GraphQLID }, + widget: { + type: WidgetType, + resolve: options.dataloader + ? (source: WidgetSource, _args: unknown, context: BenchContext) => + context.loaders?.widget.load(source) + : async (source: WidgetSource) => { + await Promise.resolve(); + return source.widget; + }, + }, + widgets: { type: new GraphQLList(WidgetType) }, + }), + }); + + const QueryType = new GraphQLObjectType({ + name: 'Query', + fields: { + widget: { type: WidgetType }, + widgets: { + type: new GraphQLList(WidgetType), + args: { first: { type: GraphQLInt } }, + resolve: (source: WidgetSource, args: FirstArgs) => { + const widgets = source.widgets as ReadonlyArray; + return typeof args.first === 'number' + ? widgets.slice(0, args.first) + : widgets; + }, + }, + }, + }); + + return new GraphQLSchema({ query: QueryType }); +} + +function createContext(options: GraphQLJITOptions): BenchContext { + if (options.dataloader !== true) { + return {}; + } + + return { + loaders: { + widget: new RequestBatchLoader(async (sources) => { + await Promise.resolve(); + return sources.map((source) => source.widget); + }), + }, + }; +} diff --git a/benchmark/batch-resolution-setups/shared/fixture.ts b/benchmark/batch-resolution-setups/shared/fixture.ts new file mode 100644 index 0000000000..e888962e87 --- /dev/null +++ b/benchmark/batch-resolution-setups/shared/fixture.ts @@ -0,0 +1,131 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import type { DocumentNode } from '../../../src/language/ast.ts'; +import { parse } from '../../../src/language/parser.ts'; + +export interface WidgetSource { + [key: string]: unknown; +} + +export interface BenchContext { + readonly loaders?: { + readonly widget: { load: (source: WidgetSource) => Promise }; + }; +} + +export interface Scenario { + readonly rowLabel: string; + readonly query: string; + readonly document: DocumentNode; + readonly source: WidgetSource; + readonly expectedRootWidgetCount: number | undefined; +} + +export interface FirstArgs { + readonly first?: number | null; +} + +const sharedDir = path.dirname(url.fileURLToPath(import.meta.url)); +const maxDepth = 18; +const multiplicationSign = '\u00d7'; + +export const schemaSDL: string = fs.readFileSync( + path.join(sharedDir, 'schema.graphql'), + 'utf8', +); + +export function createFlatListScenario(size: number): Scenario { + return createScenario( + String(size), + `widgets(first: ${size}) { id }`, + createListSource(size, createWidget), + size, + ); +} + +export function createWidgetListScenario(size: number): Scenario { + return createScenario( + String(size), + `widgets(first: ${size}) { id widget { id } }`, + createListSource(size, () => createWidget({ widget: createWidget() })), + size, + ); +} + +export function createTreeListScenario( + depth: number, + breadth: number, +): Scenario { + return createScenario( + `${depth} ${multiplicationSign} ${breadth}`, + `widgets(first: ${breadth}) { ${buildTreeQuery(depth)} }`, + createListSource(breadth, () => createWidgetTree(depth)), + breadth, + ); +} + +export function createDeepTreeScenario(depth: number): Scenario { + return createScenario( + String(depth), + buildTreeQuery(depth), + createWidgetTree(depth), + undefined, + ); +} + +function createScenario( + rowLabel: string, + query: string, + source: WidgetSource, + expectedRootWidgetCount: number | undefined, +): Scenario { + const operation = `{ ${query} }`; + return { + rowLabel, + query: operation, + document: parse(operation), + source, + expectedRootWidgetCount, + }; +} + +function createWidget(fields?: WidgetSource): WidgetSource { + return { id: 'gid://owner/Widget/1', ...fields }; +} + +function createWidgetTree(depth: number): WidgetSource { + assertDepth(depth); + + let source = createWidget(); + for (let i = 0; i < depth; i++) { + source = createWidget({ widget: source }); + } + + return source; +} + +function buildTreeQuery(depth: number): string { + assertDepth(depth); + + let query = 'id'; + for (let i = 0; i < depth; i++) { + query = `widget { ${query} id }`; + } + + return query; +} + +function createListSource( + size: number, + createItem: () => WidgetSource, +): WidgetSource { + return { widgets: Array.from({ length: size + 1 }, createItem) }; +} + +function assertDepth(depth: number): void { + if (depth < 0 || depth > maxDepth) { + throw new Error(`Depth must be between 0 and ${maxDepth}.`); + } +} diff --git a/benchmark/batch-resolution-setups/shared/request-batch-loader.ts b/benchmark/batch-resolution-setups/shared/request-batch-loader.ts new file mode 100644 index 0000000000..dd74fc91d4 --- /dev/null +++ b/benchmark/batch-resolution-setups/shared/request-batch-loader.ts @@ -0,0 +1,75 @@ +type MaybePromise = PromiseLike | T; + +export class RequestBatchLoader { + private readonly loadMany: ( + keys: ReadonlyArray, + ) => MaybePromise>; + + private queue: Array<{ + key: TKey; + resolve: (value: TValue) => void; + reject: (reason: unknown) => void; + }> = []; + + private scheduled = false; + + constructor( + loadMany: ( + keys: ReadonlyArray, + ) => MaybePromise>, + ) { + this.loadMany = loadMany; + } + + load(key: TKey): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ key, resolve, reject }); + if (!this.scheduled) { + this.scheduled = true; + queueMicrotask(() => this.dispatch()); + } + }); + } + + private dispatch(): void { + const queue = this.queue; + this.queue = []; + this.scheduled = false; + + let values: MaybePromise>; + try { + values = this.loadMany(queue.map((entry) => entry.key)); + } catch (error) { + rejectAll(queue, error); + return; + } + + Promise.resolve(values).then( + (resolvedValues) => { + if (resolvedValues.length !== queue.length) { + rejectAll( + queue, + new Error( + `Batch loader returned ${resolvedValues.length} values for ${queue.length} keys.`, + ), + ); + return; + } + + for (const [index, entry] of queue.entries()) { + entry.resolve(resolvedValues[index]); + } + }, + (error: unknown) => rejectAll(queue, error), + ); + } +} + +function rejectAll( + queue: ReadonlyArray<{ reject: (reason: unknown) => void }>, + error: unknown, +): void { + for (const entry of queue) { + entry.reject(error); + } +} diff --git a/benchmark/batch-resolution-setups/shared/runtime.ts b/benchmark/batch-resolution-setups/shared/runtime.ts new file mode 100644 index 0000000000..51da1d503e --- /dev/null +++ b/benchmark/batch-resolution-setups/shared/runtime.ts @@ -0,0 +1,13 @@ +import type { Scenario } from './fixture.ts'; + +export interface BenchmarkExecutionResult { + readonly data?: unknown; + readonly errors?: ReadonlyArray<{ readonly message: string }>; +} + +export interface ExecutorRuntime { + readonly prepare: (scenario: Scenario) => void | PromiseLike; + readonly execute: ( + scenario: Scenario, + ) => BenchmarkExecutionResult | PromiseLike; +} diff --git a/benchmark/batch-resolution-setups/shared/schema.graphql b/benchmark/batch-resolution-setups/shared/schema.graphql new file mode 100644 index 0000000000..885ade2a39 --- /dev/null +++ b/benchmark/batch-resolution-setups/shared/schema.graphql @@ -0,0 +1,10 @@ +type Query { + widget: Widget + widgets(first: Int): [Widget] +} + +type Widget { + id: ID + widget: Widget + widgets: [Widget] +} diff --git a/cspell.yml b/cspell.yml index edc9af76c5..e966ac725c 100644 --- a/cspell.yml +++ b/cspell.yml @@ -65,6 +65,8 @@ words: - hashbang - vitest - mitata + - Grafast + - Graphile - Rollup - Rspack - Rsbuild @@ -131,6 +133,7 @@ words: # TODO: remove bellow words - QLID # GraphQLID + - QLJIT # GraphQLJIT - QLJS # GraphQLJS - iface - Reqs diff --git a/package.json b/package.json index d497ebc62d..0b15ab6643 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "release:prepare": "node --experimental-strip-types resources/release-prepare.ts", "release:metadata": "node --experimental-strip-types resources/release-metadata.ts", "benchmark": "node --experimental-strip-types resources/benchmark.ts", + "benchmark:batch-resolution": "node --experimental-strip-types --expose-gc benchmark/batch-resolution-list-fields.ts", "test": "npm run lint && npm run check && npm run testonly:cover && npm run prettier:check && npm run prettier:examples:check && npm run check:spelling && npm run check:integrations", "lint": "eslint --cache --max-warnings 0 .", "check": "npm run check:ts && npm run check:deno", diff --git a/src/__tests__/diagnostics-test.ts b/src/__tests__/diagnostics-test.ts index ccb9b168f6..42891b3385 100644 --- a/src/__tests__/diagnostics-test.ts +++ b/src/__tests__/diagnostics-test.ts @@ -12,6 +12,7 @@ import { executeRootSelectionSetChannel, executeVariableCoercionChannel, parseChannel, + resolveBatchChannel, resolveChannel, shouldTrace, subscribeChannel, @@ -27,6 +28,7 @@ describe('diagnostics', () => { invariant(executeRootSelectionSetChannel !== undefined); invariant(subscribeChannel !== undefined); invariant(resolveChannel !== undefined); + invariant(resolveBatchChannel !== undefined); // Node.js `tracingChannel(name)` returns a fresh wrapper per call but // the underlying sub-channels are cached by name, so compare those. @@ -51,6 +53,9 @@ describe('diagnostics', () => { expect(resolveChannel.start).to.equal( dc.channel('tracing:graphql:resolve:start'), ); + expect(resolveBatchChannel.start).to.equal( + dc.channel('tracing:graphql:resolve:batch:start'), + ); }); describe('shouldTrace', () => { diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 1905a5e36a..b28befe103 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -14,8 +14,8 @@ * threw or rejected; it does not mean every `GraphQLError` returned by * GraphQL.js. Some channels complete normally and publish GraphQL errors on * `result`. Resolver errors can appear both as `message.error` on - * `graphql:resolve` and as formatted errors in an enclosing execution or - * subscription result. `graphql:parse`, `graphql:validate`, and + * `graphql:resolve` or `graphql:resolve:batch` and as formatted errors in an + * enclosing execution or subscription result. `graphql:parse`, `graphql:validate`, and * `graphql:execute:variableCoercion` are sync-only channels. * @category Diagnostics */ @@ -246,6 +246,34 @@ export interface GraphQLResolveContext { result?: unknown; } +/** + * Context published on `graphql:resolve:batch`. + * + * Batch resolver throws and rejections publish the `error` lifecycle event here. + * The same failure may also be formatted into the enclosing execution or + * subscription result. + */ +export interface GraphQLBatchResolveContext { + /** Field name being resolved. */ + fieldName: string; + /** Response keys for the field positions in this batch. */ + responseKeys: ReadonlyArray; + /** Parent type name for the fields being resolved. */ + parentType: string; + /** Return type string for the fields being resolved. */ + fieldType: string; + /** Argument values passed to the batch resolver. */ + args: ObjMap; + /** Number of active field positions in this batch. */ + batchSize: number; + /** Response paths for the field positions in this batch. */ + fieldPaths: ReadonlyArray; + /** Error thrown or rejected by the batch resolver, when resolution fails. */ + error?: unknown; + /** Value returned by the batch resolver, when resolution succeeds. */ + result?: unknown; +} + /** Mapping from tracing channel name to the context type published on it. */ export interface GraphQLChannelContextByName { /** Context published on `graphql:parse`. */ @@ -262,6 +290,8 @@ export interface GraphQLChannelContextByName { 'graphql:subscribe': GraphQLSubscribeContext; /** Context published on `graphql:resolve`. */ 'graphql:resolve': GraphQLResolveContext; + /** Context published on `graphql:resolve:batch`. */ + 'graphql:resolve:batch': GraphQLBatchResolveContext; } /** @@ -283,6 +313,8 @@ export interface GraphQLChannels { validate: MinimalTracingChannel; /** Tracing channel for `graphql:resolve`. */ resolve: MinimalTracingChannel; + /** Tracing channel for `graphql:resolve:batch`. */ + resolveBatch: MinimalTracingChannel; /** Tracing channel for `graphql:subscribe`. */ subscribe: MinimalTracingChannel; } @@ -348,6 +380,10 @@ export const subscribeChannel: export const resolveChannel: | MinimalTracingChannel | undefined = dc?.tracingChannel('graphql:resolve'); +/** @internal */ +export const resolveBatchChannel: + | MinimalTracingChannel + | undefined = dc?.tracingChannel('graphql:resolve:batch'); const SUB_CHANNEL_KEYS: ReadonlyArray< 'start' | 'end' | 'asyncStart' | 'asyncEnd' | 'error' diff --git a/src/execution/ExecutionArgs.ts b/src/execution/ExecutionArgs.ts index 8428b24e80..46324a113e 100644 --- a/src/execution/ExecutionArgs.ts +++ b/src/execution/ExecutionArgs.ts @@ -45,6 +45,8 @@ export interface ExecutionArgs { abortSignal?: Maybe; /** Whether incremental execution may begin eligible work early. */ enableEarlyExecution?: Maybe; + /** Whether experimental field batch resolvers should be used. */ + enableBatchResolvers?: Maybe; /** Execution hooks invoked during this operation. */ hooks?: Maybe; /** Additional execution options. */ @@ -100,6 +102,8 @@ export interface ValidatedExecutionArgs { externalAbortSignal: AbortSignal | undefined; /** Whether incremental execution may begin eligible work early. */ enableEarlyExecution: boolean; + /** Whether experimental field batch resolvers should be used. */ + enableBatchResolvers: boolean; /** Execution hooks supplied by the caller. */ hooks: ExecutionHooks | undefined; } diff --git a/src/execution/Executor.ts b/src/execution/Executor.ts index 88163c712f..1df685b124 100644 --- a/src/execution/Executor.ts +++ b/src/execution/Executor.ts @@ -6,7 +6,6 @@ import { isAsyncIterable } from '../jsutils/isAsyncIterable.ts'; import { isIterableObject } from '../jsutils/isIterableObject.ts'; import { isPromise, isPromiseLike } from '../jsutils/isPromise.ts'; import { memoize2 } from '../jsutils/memoize2.ts'; -import { memoize3 } from '../jsutils/memoize3.ts'; import type { ObjMap } from '../jsutils/ObjMap.ts'; import type { Path } from '../jsutils/Path.ts'; import { addPath, pathToArray } from '../jsutils/Path.ts'; @@ -53,6 +52,9 @@ import { } from '../diagnostics.ts'; import { AbortedGraphQLExecutionError } from './AbortedGraphQLExecutionError.ts'; +import { enqueueBatchField } from './batchResolve/enqueueBatchField.ts'; +import { executePendingBatches } from './batchResolve/executePendingBatches.ts'; +import type { BatchFieldGroupMap } from './batchResolve/types.ts'; import { buildResolveInfo } from './buildResolveInfo.ts'; import { withCancellation } from './cancellablePromise.ts'; import type { @@ -60,11 +62,9 @@ import type { FieldDetailsList, GroupedFieldSet, } from './collectFields.ts'; -import { - collectFields, - collectSubfields as _collectSubfields, -} from './collectFields.ts'; +import { collectFields } from './collectFields.ts'; import { collectIteratorPromises } from './collectIteratorPromises.ts'; +import { collectSubfields } from './collectSubfields.ts'; import type { SharedExecutionContext } from './createSharedExecutionContext.ts'; import { createSharedExecutionContext } from './createSharedExecutionContext.ts'; import type { ValidatedExecutionArgs } from './ExecutionArgs.ts'; @@ -100,35 +100,7 @@ import { getArgumentValues } from './values.ts'; * @internal */ -/** - * A memoized collection of relevant subfields with regard to the return - * type. Memoizing ensures the subfields are not repeatedly calculated, which - * saves overhead when resolving lists of values. - * - * @internal - */ -export const collectSubfields: ( - validatedExecutionArgs: ValidatedExecutionArgs, - returnType: GraphQLObjectType, - fieldDetailsList: FieldDetailsList, -) => ReturnType = memoize3( - ( - validatedExecutionArgs: ValidatedExecutionArgs, - returnType: GraphQLObjectType, - fieldDetailsList: FieldDetailsList, - ) => { - const { schema, fragments, variableValues, hideSuggestions } = - validatedExecutionArgs; - return _collectSubfields( - schema, - fragments, - variableValues, - returnType, - fieldDetailsList, - hideSuggestions, - ); - }, -); +export { collectSubfields }; /** @internal */ export const getStreamUsage: typeof _getStreamUsage = memoize2( @@ -138,6 +110,12 @@ export const getStreamUsage: typeof _getStreamUsage = memoize2( ) => _getStreamUsage(validatedExecutionArgs, fieldDetailsList), ); +function getResolveTracingChannel(): + | MinimalTracingChannel + | undefined { + return shouldTrace(resolveChannel) ? resolveChannel : undefined; +} + class CollectedErrors { private _errorPositions: Set; private _errors: Array; @@ -172,6 +150,10 @@ class CollectedErrors { } return this._errorPositions.has(undefined); } + + hasNulledAncestor(startPath: Path | undefined): boolean { + return this.hasNulledPosition(startPath?.prev); + } } /** @@ -220,6 +202,8 @@ export class Executor< abortReason: unknown; sharedExecutionContext: SharedExecutionContext; collectedErrors: CollectedErrors; + batchFieldGroups!: BatchFieldGroupMap; + rootGroupedFieldSet!: GroupedFieldSet | undefined; abortResultPromise: (() => void) | undefined; resolverAbortController: AbortController | undefined; getAbortSignal: () => AbortSignal | undefined; @@ -231,11 +215,16 @@ export class Executor< constructor( validatedExecutionArgs: ValidatedExecutionArgs, sharedExecutionContext?: SharedExecutionContext, + rootGroupedFieldSet?: GroupedFieldSet, ) { this.validatedExecutionArgs = validatedExecutionArgs; this.aborted = false; this.abortReason = defaultAbortReason; this.collectedErrors = new CollectedErrors(); + if (validatedExecutionArgs.enableBatchResolvers) { + this.batchFieldGroups = new Map(); + this.rootGroupedFieldSet = rootGroupedFieldSet; + } if (sharedExecutionContext === undefined) { this.resolverAbortController = new AbortController(); @@ -332,6 +321,9 @@ export class Executor< selectionSet, hideSuggestions, ); + if (this.validatedExecutionArgs.enableBatchResolvers) { + this.rootGroupedFieldSet = groupedFieldSet; + } result = this.executeCollectedRootFields( rootType, @@ -345,32 +337,68 @@ export class Executor< const promise = result.then( (data) => { maybeRemoveExternalAbortListener(); - return this.buildResponse(data); + return this.executeBatchesAndBuildResponse(data); }, (error: unknown) => { maybeRemoveExternalAbortListener(); this.collectedErrors.add(ensureGraphQLError(error), undefined); - return this.buildResponse(null); + return this.executeBatchesAndBuildResponse(null); }, ); - this.sharedExecutionContext.asyncWorkTracker.add(promise); - const { promise: cancellablePromise, abort: abortResultPromise } = - withCancellation(promise.then((resolved) => this.finish(resolved))); - this.abortResultPromise = () => { - abortResultPromise(this.createAbortedExecutionError(promise)); - }; - if (this.aborted) { - this.abortResultPromise(); - } - return cancellablePromise; + return this.returnCancellableResponse(promise); } maybeRemoveExternalAbortListener(); } catch (error) { maybeRemoveExternalAbortListener(); this.collectedErrors.add(ensureGraphQLError(error), undefined); - return this.finish(this.buildResponse(null)); + return this.finish(this.executeBatchesAndBuildResponse(null)); } - return this.finish(this.buildResponse(result)); + + const response = this.executeBatchesAndBuildResponse(result); + return isPromise(response) + ? this.returnCancellableResponse(response) + : this.finish(response); + } + + executeBatchesAndBuildResponse( + data: ObjMap | null, + ): PromiseOrValue { + if (data === null || !this.validatedExecutionArgs.enableBatchResolvers) { + return this.buildResponse(data); + } + + let maybePromisedData: PromiseOrValue>; + try { + maybePromisedData = executePendingBatches(this, data); + } catch (error) { + this.collectedErrors.add(ensureGraphQLError(error), undefined); + return this.buildResponse(null); + } + if (isPromise(maybePromisedData)) { + return maybePromisedData.then( + (batchResolvedData) => this.buildResponse(batchResolvedData), + (error: unknown) => { + this.collectedErrors.add(ensureGraphQLError(error), undefined); + return this.buildResponse(null); + }, + ); + } + return this.buildResponse(maybePromisedData); + } + + returnCancellableResponse( + promise: Promise, + ): Promise { + this.sharedExecutionContext.asyncWorkTracker.add(promise); + const { promise: cancellablePromise, abort: abortResultPromise } = + withCancellation(promise.then((resolved) => this.finish(resolved))); + this.abortResultPromise = () => { + abortResultPromise(this.createAbortedExecutionError(promise)); + }; + if (this.aborted) { + this.abortResultPromise(); + } + return cancellablePromise; } abort(reason?: unknown): void { @@ -486,9 +514,17 @@ export class Executor< groupedFieldSet: GroupedFieldSet, positionContext: TPositionContext | undefined, ): PromiseOrValue> { - let tracingChannel = shouldTrace(resolveChannel) - ? resolveChannel - : undefined; + if (this.validatedExecutionArgs.enableBatchResolvers) { + return this.executeFieldsSeriallyWithBatchResolvers( + parentType, + sourceValue, + path, + groupedFieldSet, + positionContext, + ); + } + + let tracingChannel = getResolveTracingChannel(); return promiseReduce( groupedFieldSet, @@ -511,9 +547,7 @@ export class Executor< if (isPromise(result)) { return result.then((resolved) => { results[responseName] = resolved; - tracingChannel = shouldTrace(resolveChannel) - ? resolveChannel - : undefined; + tracingChannel = getResolveTracingChannel(); return results; }); } @@ -524,6 +558,63 @@ export class Executor< ); } + executeFieldsSeriallyWithBatchResolvers( + parentType: GraphQLObjectType, + sourceValue: unknown, + path: Path | undefined, + groupedFieldSet: GroupedFieldSet, + positionContext: TPositionContext | undefined, + ): PromiseOrValue> { + let tracingChannel = getResolveTracingChannel(); + + return promiseReduce( + groupedFieldSet, + (results, [responseName, fieldDetailsList]) => { + if (this.aborted) { + throw new Error('Aborted!'); + } + const fieldPath = addPath(path, responseName, parentType.name); + const result = this.executeBatchField( + parentType, + sourceValue, + fieldDetailsList, + fieldPath, + positionContext, + results, + responseName, + tracingChannel, + ); + if ( + result === undefined && + !this.batchFieldGroups.has(fieldDetailsList) + ) { + return results; + } + const drainBatches = () => executePendingBatches(this, results); + if (isPromise(result)) { + return result.then((resolved) => { + results[responseName] = resolved; + return Promise.resolve(drainBatches()).then((drained) => { + tracingChannel = getResolveTracingChannel(); + return drained; + }); + }); + } + results[responseName] = result; + const drained = drainBatches(); + if (isPromise(drained)) { + return drained.then((resolved) => { + tracingChannel = getResolveTracingChannel(); + return resolved; + }); + } + tracingChannel = getResolveTracingChannel(); + return drained; + }, + Object.create(null), + ); + } + /** * Implements the "Executing selection sets" section of the spec * for fields that may be executed in parallel. @@ -537,11 +628,19 @@ export class Executor< groupedFieldSet: GroupedFieldSet, positionContext: TPositionContext | undefined, ): PromiseOrValue> { + if (this.validatedExecutionArgs.enableBatchResolvers) { + return this.executeFieldsWithBatchResolvers( + parentType, + sourceValue, + path, + groupedFieldSet, + positionContext, + ); + } + const results = Object.create(null); let containsPromise = false; - const tracingChannel = shouldTrace(resolveChannel) - ? resolveChannel - : undefined; + const tracingChannel = getResolveTracingChannel(); try { for (const [responseName, fieldDetailsList] of groupedFieldSet) { @@ -582,6 +681,112 @@ export class Executor< return promiseForObject(results, this.promiseAll); } + executeFieldsWithBatchResolvers( + parentType: GraphQLObjectType, + sourceValue: unknown, + path: Path | undefined, + groupedFieldSet: GroupedFieldSet, + positionContext: TPositionContext | undefined, + ): PromiseOrValue> { + const results = Object.create(null); + let containsPromise = false; + const tracingChannel = getResolveTracingChannel(); + + try { + for (const [responseName, fieldDetailsList] of groupedFieldSet) { + const fieldPath = addPath(path, responseName, parentType.name); + const result = this.executeBatchField( + parentType, + sourceValue, + fieldDetailsList, + fieldPath, + positionContext, + results, + responseName, + tracingChannel, + ); + + if ( + result !== undefined || + this.batchFieldGroups.has(fieldDetailsList) + ) { + results[responseName] = result; + if (isPromise(result)) { + containsPromise = true; + } + } + } + } catch (error) { + if (containsPromise) { + this.sharedExecutionContext.asyncWorkTracker.addValues( + Object.values(results), + ); + } + throw error; + } + + // If there are no promises, we can just return the object and any incrementalDataRecords + if (!containsPromise) { + return results; + } + + // Otherwise, results is a map from field name to the result of resolving that + // field, which is possibly a promise. Return a promise that will return this + // same map, but with any promises replaced with the values they resolved to. + return promiseForObject(results, this.promiseAll); + } + + executeBatchField( + parentType: GraphQLObjectType, + source: unknown, + fieldDetailsList: FieldDetailsList, + path: Path, + positionContext: TPositionContext | undefined, + responseTarget: ObjMap, + responseKey: string, + tracingChannel: MinimalTracingChannel | undefined, + ): PromiseOrValue { + const firstFieldDetails = fieldDetailsList[0]; + const fieldName = firstFieldDetails.node.name.value; + const fieldDef = this.validatedExecutionArgs.schema.getField( + parentType, + fieldName, + ); + if (!fieldDef) { + return; + } + + const batchResolve = fieldDef.experimentalBatchResolve; + if ( + this.validatedExecutionArgs.enableBatchResolvers && + batchResolve !== undefined + ) { + enqueueBatchField( + this, + parentType, + fieldDef, + batchResolve, + fieldDetailsList, + source, + path, + positionContext, + responseTarget, + responseKey, + ); + // Leave the response position for the batch to write after completion. + return undefined; + } + + return this.executeField( + parentType, + source, + fieldDetailsList, + path, + positionContext, + tracingChannel, + ); + } + /** * Implements the "Executing fields" section of the spec * In particular, this function figures out the value that the field returns by diff --git a/src/execution/batchResolve/__tests__/diagnostics-batch-resolve-test.ts b/src/execution/batchResolve/__tests__/diagnostics-batch-resolve-test.ts new file mode 100644 index 0000000000..512b629df4 --- /dev/null +++ b/src/execution/batchResolve/__tests__/diagnostics-batch-resolve-test.ts @@ -0,0 +1,149 @@ +import { describe, it } from 'node:test'; + +import { expectEvents } from '../../../__testUtils__/expectEvents.ts'; +import { expectNoTracingActivity } from '../../../__testUtils__/expectNoTracingActivity.ts'; +import { getTracingChannel } from '../../../__testUtils__/getTracingChannel.ts'; + +import { parse } from '../../../language/parser.ts'; + +import { GraphQLString } from '../../../type/scalars.ts'; + +import { execute as executeWithoutBatchResolvers } from '../../execute.ts'; + +import { + batchedField, + schemaWithQueryFields, + schemaWithUserFields, +} from './fixtures.ts'; + +function execute( + args: Parameters[0], +): ReturnType { + return executeWithoutBatchResolvers({ + ...args, + enableBatchResolvers: true, + }); +} + +const resolveBatchChannel = getTracingChannel('graphql:resolve:batch'); +const resolveChannel = getTracingChannel('graphql:resolve'); + +describe('batch resolve diagnostics channel', () => { + it('emits start and end around a synchronous batch resolver', async () => { + const schema = schemaWithUserFields({ + id: { type: GraphQLString }, + name: batchedField( + GraphQLString, + (sources, args) => + sources.map((source: any) => `${args.prefix}${source.id}`), + { + args: { + prefix: { type: GraphQLString }, + }, + }, + ), + }); + + await expectEvents( + resolveBatchChannel, + () => + execute({ + schema, + document: parse('{ users { name(prefix: "user-") } }'), + rootValue: { + users: [{ id: '1' }, { id: '2' }], + }, + }), + () => [ + { + channel: 'start', + context: { + fieldName: 'name', + responseKeys: ['name', 'name'], + parentType: 'User', + fieldType: 'String', + args: { prefix: 'user-' }, + batchSize: 2, + fieldPaths: ['users.0.name', 'users.1.name'], + }, + }, + { + channel: 'end', + context: { + fieldName: 'name', + responseKeys: ['name', 'name'], + parentType: 'User', + fieldType: 'String', + args: { prefix: 'user-' }, + batchSize: 2, + fieldPaths: ['users.0.name', 'users.1.name'], + result: ['user-1', 'user-2'], + }, + }, + ], + ); + }); + + it('emits full async lifecycle with error when a batch resolver rejects', async () => { + const error = new Error('batch boom'); + const schema = schemaWithUserFields({ + name: batchedField(GraphQLString, () => Promise.reject(error)), + }); + const expectedContext = { + fieldName: 'name', + responseKeys: ['name', 'name'], + parentType: 'User', + fieldType: 'String', + args: {}, + batchSize: 2, + fieldPaths: ['users.0.name', 'users.1.name'], + }; + + await expectEvents( + resolveBatchChannel, + () => + execute({ + schema, + document: parse('{ users { name } }'), + rootValue: { + users: [{ id: '1' }, { id: '2' }], + }, + }), + () => [ + { + channel: 'start', + context: expectedContext, + }, + { + channel: 'end', + context: expectedContext, + }, + { + channel: 'error', + context: { ...expectedContext, error }, + }, + { + channel: 'asyncStart', + context: { ...expectedContext, error }, + }, + { + channel: 'asyncEnd', + context: { ...expectedContext, error }, + }, + ], + ); + }); + + it('does not emit batch resolvers on the single-field resolve channel', async () => { + const schema = schemaWithQueryFields({ + name: batchedField(GraphQLString, () => ['Ada']), + }); + + await expectNoTracingActivity(resolveChannel, () => + execute({ + schema, + document: parse('{ name }'), + }), + ); + }); +}); diff --git a/src/execution/batchResolve/__tests__/fieldBatchResolver-test.ts b/src/execution/batchResolve/__tests__/fieldBatchResolver-test.ts new file mode 100644 index 0000000000..9e65b6105b --- /dev/null +++ b/src/execution/batchResolve/__tests__/fieldBatchResolver-test.ts @@ -0,0 +1,2321 @@ +import { describe, it } from 'node:test'; + +import { assert, expect } from 'chai'; + +import { expectJSON } from '../../../__testUtils__/expectJSON.ts'; + +import { invariant } from '../../../jsutils/invariant.ts'; +import { addPath, pathToArray } from '../../../jsutils/Path.ts'; + +import type { GraphQLError } from '../../../error/GraphQLError.ts'; + +import { Kind } from '../../../language/kinds.ts'; +import { parse } from '../../../language/parser.ts'; + +import type { GraphQLBatchedResolveInfo } from '../../../type/definition.ts'; +import { + GraphQLInterfaceType, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, +} from '../../../type/definition.ts'; +import { GraphQLString } from '../../../type/scalars.ts'; +import { GraphQLSchema } from '../../../type/schema.ts'; + +import { + execute as executeWithoutBatchResolvers, + executeSync as executeSyncWithoutBatchResolvers, + experimentalExecuteIncrementally as experimentalExecuteIncrementallyWithoutBatchResolvers, + validateExecutionArgs, +} from '../../execute.ts'; +import { Executor } from '../../Executor.ts'; +import type { + InitialIncrementalExecutionResult, + SubsequentIncrementalExecutionResult, +} from '../../incremental/IncrementalExecutor.ts'; + +import { completeFieldBatchGroup } from '../completeFieldBatchGroup.ts'; + +import type { BatchCall } from './fixtures.ts'; +import { + batchCall, + batchedField, + schemaWithBatchedProfileFields, + schemaWithBatchedUserName, + schemaWithQueryFields, + schemaWithUserFields, +} from './fixtures.ts'; + +function execute( + args: Parameters[0], +): ReturnType { + return executeWithoutBatchResolvers({ + ...args, + enableBatchResolvers: true, + }); +} + +function executeSync( + args: Parameters[0], +): ReturnType { + return executeSyncWithoutBatchResolvers({ + ...args, + enableBatchResolvers: true, + }); +} + +function experimentalExecuteIncrementally( + args: Parameters< + typeof experimentalExecuteIncrementallyWithoutBatchResolvers + >[0], +): ReturnType { + return experimentalExecuteIncrementallyWithoutBatchResolvers({ + ...args, + enableBatchResolvers: true, + }); +} + +async function collectIncrementalResults( + args: Parameters[0], +): Promise< + Array< + InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult + > +> { + const result = await experimentalExecuteIncrementally(args); + assert('initialResult' in result); + + const results: Array< + InitialIncrementalExecutionResult | SubsequentIncrementalExecutionResult + > = [result.initialResult]; + for await (const patch of result.subsequentResults) { + results.push(patch); + } + return results; +} + +describe('Execute: experimental field batch resolvers', () => { + it('ignores batch resolvers unless explicitly enabled', () => { + let didBatchResolve = false; + const schema = schemaWithUserFields({ + name: batchedField(GraphQLString, () => { + didBatchResolve = true; + return ['batched']; + }), + }); + + const result = executeWithoutBatchResolvers({ + schema, + document: parse('{ users { name } }'), + rootValue: { + users: [{ name: 'plain' }], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [{ name: 'plain' }], + }, + }); + expect(didBatchResolve).to.equal(false); + }); + + it('ignores unknown fields while batch resolvers are enabled', () => { + const QueryType = new GraphQLObjectType({ + name: 'BatchUnknownFieldQuery', + fields: { + known: { + type: GraphQLString, + resolve: () => 'ok', + }, + }, + }); + const schema = new GraphQLSchema({ query: QueryType }); + + const result = execute({ + schema, + document: parse('{ unknown known }'), + }); + + expectJSON(result).toDeepEqual({ + data: { + known: 'ok', + }, + }); + }); + + it('ignores unknown serial fields while batch resolvers are enabled', () => { + const QueryType = new GraphQLObjectType({ + name: 'BatchUnknownSerialFieldQuery', + fields: { + noop: { type: GraphQLString }, + }, + }); + const MutationType = new GraphQLObjectType({ + name: 'BatchUnknownSerialFieldMutation', + fields: { + known: { + type: GraphQLString, + resolve: () => 'ok', + }, + }, + }); + const schema = new GraphQLSchema({ + query: QueryType, + mutation: MutationType, + }); + + const result = execute({ + schema, + document: parse('mutation { unknown known }'), + }); + + expectJSON(result).toDeepEqual({ + data: { + known: 'ok', + }, + }); + }); + + it('completes serial async regular fields while batch resolvers are enabled', async () => { + const MutationType = new GraphQLObjectType({ + name: 'BatchSerialRegularMutation', + fields: { + first: { + type: GraphQLString, + resolve: async () => { + await Promise.resolve(); + return 'ok'; + }, + }, + }, + }); + const QueryType = new GraphQLObjectType({ + name: 'BatchSerialRegularQuery', + fields: { + noop: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ + query: QueryType, + mutation: MutationType, + }); + + const result = await execute({ + schema, + document: parse('mutation { first }'), + }); + + expectJSON(result).toDeepEqual({ + data: { + first: 'ok', + }, + }); + }); + + it('tracks pending regular field promises when a batch-enabled selection throws', async () => { + const QueryType = new GraphQLObjectType({ + name: 'BatchRegularThrowQuery', + fields: { + slow: { + type: GraphQLString, + resolve: async () => { + await Promise.resolve(); + return 'slow'; + }, + }, + bad: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => { + throw new Error('bad'); + }, + }, + }, + }); + const schema = new GraphQLSchema({ query: QueryType }); + + const result = await execute({ + schema, + document: parse('{ slow bad }'), + }); + + expect(result.data).to.equal(null); + expect(result.errors?.map((error) => error.message)).to.deep.equal(['bad']); + }); + + it('throws when a batch-enabled serial executor is already aborted', () => { + const document = parse('mutation { name }'); + const operation = document.definitions[0]; + invariant(operation.kind === Kind.OPERATION_DEFINITION); + const nameFieldNode = operation.selectionSet.selections[0]; + invariant(nameFieldNode.kind === Kind.FIELD); + const MutationType = new GraphQLObjectType({ + name: 'BatchAbortedMutation', + fields: { + name: { type: GraphQLString }, + }, + }); + const QueryType = new GraphQLObjectType({ + name: 'BatchAbortedQuery', + fields: { + noop: { type: GraphQLString }, + }, + }); + const schema = new GraphQLSchema({ + query: QueryType, + mutation: MutationType, + }); + const validatedExecutionArgs = validateExecutionArgs({ + schema, + document, + enableBatchResolvers: true, + }); + assert('schema' in validatedExecutionArgs); + const executor = new Executor(validatedExecutionArgs); + + executor.abort(); + + expect(() => + executor.executeFieldsSeriallyWithBatchResolvers( + MutationType, + {}, + undefined, + new Map([['name', [{ node: nameFieldNode }]]]), + undefined, + ), + ).to.throw('Aborted!'); + }); + + it('batches a field across list items', () => { + const calls: Array = []; + + const schema = schemaWithUserFields({ + id: { type: GraphQLString }, + name: batchedField( + GraphQLString, + (sources, args, context, info) => { + calls.push({ + sources, + args, + context, + paths: info.paths.map(pathToArray), + }); + return sources.map( + (source: any) => + `${args.prefix}${source.id}${(context as any).suffix}`, + ); + }, + { + args: { + prefix: { type: GraphQLString }, + }, + }, + ), + }); + + const rootValue = { + users: [{ id: '1' }, { id: '2' }], + }; + const contextValue = { suffix: '!' }; + + const result = executeSync({ + schema, + document: parse('{ users { id name(prefix: "user-") } }'), + rootValue, + contextValue, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [ + { id: '1', name: 'user-1!' }, + { id: '2', name: 'user-2!' }, + ], + }, + }); + expect(calls).to.deep.equal([ + { + sources: rootValue.users, + args: { prefix: 'user-' }, + context: contextValue, + paths: [ + ['users', 0, 'name'], + ['users', 1, 'name'], + ], + }, + ]); + }); + + it('provides batched info about current execution state', async () => { + let resolveBatch!: (value: ReadonlyArray) => void; + let resolvedInfo: GraphQLBatchedResolveInfo | undefined; + + const testType = new GraphQLObjectType({ + name: 'Test', + fields: { + test: { + ...batchedField(GraphQLString, (_sources, _args, _context, info) => { + resolvedInfo = info; + return new Promise>((resolve) => { + resolveBatch = resolve; + }); + }), + }, + }, + }); + const schema = new GraphQLSchema({ query: testType }); + + const document = parse('query ($var: String) { result: test }'); + const rootValue = { root: 'val' }; + const variableValues = { var: 'abc' }; + + const result = execute({ schema, document, rootValue, variableValues }); + + expect(resolvedInfo).to.have.all.keys( + 'fieldName', + 'fieldNodes', + 'returnType', + 'parentType', + 'paths', + 'schema', + 'fragments', + 'rootValue', + 'operation', + 'variableValues', + 'getAbortSignal', + 'getAsyncHelpers', + ); + const asyncHelpers = resolvedInfo?.getAsyncHelpers(); + expect(asyncHelpers).to.have.all.keys('promiseAll', 'track'); + + const operation = document.definitions[0]; + assert(operation.kind === Kind.OPERATION_DEFINITION); + + expect(resolvedInfo).to.include({ + fieldName: 'test', + returnType: GraphQLString, + parentType: testType, + schema, + rootValue, + operation, + }); + + const field = operation.selectionSet.selections[0]; + expect(resolvedInfo).to.deep.include({ + fieldNodes: [field], + paths: [ + { + prev: undefined, + key: 'result', + typename: 'Test', + }, + ], + variableValues: { + sources: { + var: { + signature: { + name: 'var', + type: GraphQLString, + default: undefined, + }, + value: 'abc', + }, + }, + coerced: { var: 'abc' }, + }, + }); + + const abortSignal = resolvedInfo?.getAbortSignal(); + expect(abortSignal).to.be.instanceOf(AbortSignal); + expect(resolvedInfo?.getAbortSignal()).to.equal(abortSignal); + + expect(resolvedInfo?.getAsyncHelpers()).to.equal(asyncHelpers); + + const promiseAll = asyncHelpers?.promiseAll; + expect(promiseAll).to.be.a('function'); + expect(resolvedInfo?.getAsyncHelpers().promiseAll).to.equal(promiseAll); + + const track = asyncHelpers?.track; + expect(track).to.be.a('function'); + expect(resolvedInfo?.getAsyncHelpers().track).to.equal(track); + track?.([Promise.resolve()]); + + resolveBatch(['ok']); + + await result; + + const lateAbortSignal = resolvedInfo?.getAbortSignal(); + expect(lateAbortSignal).to.be.instanceOf(AbortSignal); + expect(lateAbortSignal?.aborted).to.equal(true); + }); + + it('runs later batch rounds for subfields produced by a batch', async () => { + const calls: Array> = []; + + const UserType: GraphQLObjectType = new GraphQLObjectType({ + name: 'User', + fields: () => ({ + bestFriend: batchedField(UserType, (sources) => { + calls.push(sources.map((source: any) => source.id)); + return sources.map((source: any) => source.bestFriend); + }), + name: batchedField(GraphQLString, (sources) => { + calls.push(sources.map((source: any) => source.id)); + return Promise.resolve(sources.map((source: any) => source.name)); + }), + }), + }); + + const schema = schemaWithQueryFields({ + users: { type: new GraphQLList(UserType) }, + }); + + const result = await execute({ + schema, + document: parse('{ users { bestFriend { name } } }'), + rootValue: { + users: [ + { id: '1', bestFriend: { id: '2', name: 'Ada' } }, + { id: '3', bestFriend: { id: '4', name: 'Grace' } }, + ], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [ + { bestFriend: { name: 'Ada' } }, + { bestFriend: { name: 'Grace' } }, + ], + }, + }); + expect(calls).to.deep.equal([ + ['1', '3'], + ['2', '4'], + ]); + }); + + it('batches same concrete field positions across discontinuous abstract list paths', () => { + const calls: Array = []; + + const CharacterType = new GraphQLInterfaceType({ + name: 'Character', + fields: { + name: { type: GraphQLString }, + }, + }); + + const makeCharacterType = (name: string) => + new GraphQLObjectType({ + name, + interfaces: [CharacterType], + fields: { + name: batchedField( + GraphQLString, + (sources, _args, _context, info) => { + calls.push({ + ...batchCall(sources, info), + typename: name, + }); + return sources.map((source: any) => source.name); + }, + ), + }, + }); + + const HumanType = makeCharacterType('Human'); + const DroidType = makeCharacterType('Droid'); + + const schema = schemaWithQueryFields( + { characters: { type: new GraphQLList(CharacterType) } }, + [HumanType, DroidType], + ); + + const result = executeSync({ + schema, + document: parse(` + { + characters { + __typename + ... on Human { name } + ... on Droid { name } + } + } + `), + rootValue: { + characters: [ + { __typename: 'Human', id: 'h1', name: 'Luke' }, + { __typename: 'Droid', id: 'd1', name: 'R2-D2' }, + { __typename: 'Human', id: 'h2', name: 'Leia' }, + ], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + characters: [ + { __typename: 'Human', name: 'Luke' }, + { __typename: 'Droid', name: 'R2-D2' }, + { __typename: 'Human', name: 'Leia' }, + ], + }, + }); + expect(calls).to.deep.equal([ + { + typename: 'Human', + ids: ['h1', 'h2'], + paths: [ + ['characters', 0, 'name'], + ['characters', 2, 'name'], + ], + }, + { + typename: 'Droid', + ids: ['d1'], + paths: [['characters', 1, 'name']], + }, + ]); + }); + + it('batches a field across multidimensional list items', () => { + const calls: Array = []; + + const UserType = new GraphQLObjectType({ + name: 'User', + fields: { + name: batchedField(GraphQLString, (sources, _args, _context, info) => { + calls.push(batchCall(sources, info)); + return sources.map((source: any) => source.name); + }), + }, + }); + + const schema = schemaWithQueryFields({ + userGrid: { type: new GraphQLList(new GraphQLList(UserType)) }, + }); + + const result = executeSync({ + schema, + document: parse('{ userGrid { name } }'), + rootValue: { + userGrid: [ + [ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + ], + [{ id: '3', name: 'Lin' }], + ], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + userGrid: [[{ name: 'Ada' }, { name: 'Grace' }], [{ name: 'Lin' }]], + }, + }); + expect(calls).to.deep.equal([ + { + ids: ['1', '2', '3'], + paths: [ + ['userGrid', 0, 0, 'name'], + ['userGrid', 0, 1, 'name'], + ['userGrid', 1, 0, 'name'], + ], + }, + ]); + }); + + it('preserves field ordering when writing batch results', () => { + const UserType = new GraphQLObjectType({ + name: 'User', + fields: { + first: { type: GraphQLString }, + name: batchedField(GraphQLString, (sources) => + sources.map((source: any) => source.name), + ), + last: { type: GraphQLString }, + }, + }); + + const schema = schemaWithQueryFields({ + user: { type: UserType }, + }); + + const result = executeSync({ + schema, + document: parse('{ user { first name last } }'), + rootValue: { + user: { first: 'Ada', name: 'Byron', last: 'Lovelace' }, + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + user: { first: 'Ada', name: 'Byron', last: 'Lovelace' }, + }, + }); + expect(Object.keys((result.data as any).user)).to.deep.equal([ + 'first', + 'name', + 'last', + ]); + }); + + it('bubbles batch nulls to multidimensional list nullable positions', async () => { + const calls: Array = []; + + const UserType = new GraphQLObjectType({ + name: 'User', + fields: { + name: batchedField( + new GraphQLNonNull(GraphQLString), + (sources, _args, _context, info) => { + calls.push(batchCall(sources, info)); + return sources.map((source: any) => source.name); + }, + ), + }, + }); + + const schema = schemaWithQueryFields({ + nullableUsers: { + type: new GraphQLList(new GraphQLList(UserType)), + }, + nonNullUsers: { + type: new GraphQLList(new GraphQLList(new GraphQLNonNull(UserType))), + }, + nonNullLists: { + type: new GraphQLList( + new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UserType))), + ), + }, + }); + + const result = await execute({ + schema, + document: parse(` + { + nullableUsers { name } + nonNullUsers { name } + nonNullLists { name } + } + `), + rootValue: { + nullableUsers: [ + [ + { id: 'item-null', name: null }, + { id: 'item-kept', name: 'Ada' }, + ], + ], + nonNullUsers: [ + [ + { id: 'list-null', name: null }, + { id: 'list-skipped', name: 'Grace' }, + ], + ], + nonNullLists: [ + [ + { id: 'field-null', name: null }, + { id: 'field-skipped', name: 'Lin' }, + ], + ], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + nullableUsers: [[null, { name: 'Ada' }]], + nonNullUsers: [null], + nonNullLists: null, + }, + errors: [ + { + message: 'Cannot return null for non-nullable field User.name.', + path: ['nullableUsers', 0, 0, 'name'], + locations: [{ line: 3, column: 27 }], + }, + { + message: 'Cannot return null for non-nullable field User.name.', + path: ['nonNullUsers', 0, 0, 'name'], + locations: [{ line: 4, column: 26 }], + }, + { + message: 'Cannot return null for non-nullable field User.name.', + path: ['nonNullLists', 0, 0, 'name'], + locations: [{ line: 5, column: 26 }], + }, + ], + }); + expect(calls).to.deep.equal([ + { + ids: ['item-null', 'item-kept'], + paths: [ + ['nullableUsers', 0, 0, 'name'], + ['nullableUsers', 0, 1, 'name'], + ], + }, + { + ids: ['list-null', 'list-skipped'], + paths: [ + ['nonNullUsers', 0, 0, 'name'], + ['nonNullUsers', 0, 1, 'name'], + ], + }, + { + ids: ['field-null', 'field-skipped'], + paths: [ + ['nonNullLists', 0, 0, 'name'], + ['nonNullLists', 0, 1, 'name'], + ], + }, + ]); + }); + + it('bubbles batch nulls above multidimensional lists to the root', async () => { + const UserType = new GraphQLObjectType({ + name: 'User', + fields: { + name: batchedField(new GraphQLNonNull(GraphQLString), (sources) => + sources.map((source: any) => source.name), + ), + }, + }); + + const schema = schemaWithQueryFields({ + nonNullGrid: { + type: new GraphQLNonNull( + new GraphQLList( + new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(UserType))), + ), + ), + }, + }); + + const result = await execute({ + schema, + document: parse('{ nonNullGrid { name } }'), + rootValue: { + nonNullGrid: [ + [ + { id: 'root-null', name: null }, + { id: 'after-root-null', name: 'Grace' }, + ], + ], + }, + }); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Cannot return null for non-nullable field User.name.', + path: ['nonNullGrid', 0, 0, 'name'], + locations: [{ line: 1, column: 17 }], + }, + ], + }); + }); + + it('bubbles async batch nulls to the root', async () => { + const schema = schemaWithQueryFields({ + name: batchedField(new GraphQLNonNull(GraphQLString), () => + Promise.resolve([null]), + ), + }); + + const result = await execute({ + schema, + document: parse('{ name }'), + }); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Cannot return null for non-nullable field Query.name.', + path: ['name'], + locations: [{ line: 1, column: 3 }], + }, + ], + }); + }); + + it('filters batch records below a nulled path', async () => { + const nicknameSources: Array = []; + + const schema = schemaWithUserFields({ + name: batchedField(new GraphQLNonNull(GraphQLString), () => [ + null, + 'Bob', + ]), + nickname: batchedField(GraphQLString, (sources) => { + nicknameSources.push(...sources.map((source: any) => source.id)); + return sources.map((source: any) => `n-${source.id}`); + }), + }); + + const result = await execute({ + schema, + document: parse('{ users { name nickname } }'), + rootValue: { + users: [{ id: '1' }, { id: '2' }], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [null, { name: 'Bob', nickname: 'n-2' }], + }, + errors: [ + { + message: 'Cannot return null for non-nullable field User.name.', + path: ['users', 0, 'name'], + locations: [{ line: 1, column: 11 }], + }, + ], + }); + expect(nicknameSources).to.deep.equal(['2']); + }); + + it('skips sibling batch records after every entry is nulled', async () => { + let nicknameCalls = 0; + const schema = schemaWithUserFields( + { + name: batchedField(new GraphQLNonNull(GraphQLString), () => [null]), + nickname: batchedField(GraphQLString, () => { + nicknameCalls += 1; + return ['nickname']; + }), + }, + 'BatchAllNulledSiblingUser', + ); + + const result = await execute({ + schema, + document: parse('{ users { name nickname } }'), + rootValue: { + users: [{}], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [null], + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchAllNulledSiblingUser.name.', + path: ['users', 0, 'name'], + locations: [{ line: 1, column: 11 }], + }, + ], + }); + expect(nicknameCalls).to.equal(0); + }); + + it('reports errors for invalid batch resolver results', async () => { + const schema = schemaWithUserFields( + { + nonIterable: batchedField( + GraphQLString, + () => null as unknown as ReadonlyArray, + ), + tooFew: batchedField(GraphQLString, () => []), + throws: batchedField(GraphQLString, () => { + throw new Error('batch boom'); + }), + rejects: batchedField(GraphQLString, () => + Promise.reject(new Error('batch async boom')), + ), + }, + 'BatchErrorUser', + ); + + const result = await execute({ + schema, + document: parse('{ users { nonIterable tooFew throws rejects } }'), + rootValue: { + users: [{}], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [ + { + nonIterable: null, + tooFew: null, + throws: null, + rejects: null, + }, + ], + }, + errors: [ + { + message: + 'Expected batch resolver for field "BatchErrorUser.nonIterable" to return an Iterable.', + path: ['users', 0, 'nonIterable'], + locations: [{ line: 1, column: 11 }], + }, + { + message: + 'Expected batch resolver for field "BatchErrorUser.tooFew" to return 1 results, returned 0.', + path: ['users', 0, 'tooFew'], + locations: [{ line: 1, column: 23 }], + }, + { + message: 'batch boom', + path: ['users', 0, 'throws'], + locations: [{ line: 1, column: 30 }], + }, + { + message: 'batch async boom', + path: ['users', 0, 'rejects'], + locations: [{ line: 1, column: 37 }], + }, + ], + }); + }); + + it('throws a located error when a nullable batch error path is stale', () => { + const UserType = new GraphQLObjectType({ + name: 'BatchStaleUser', + fields: { + name: { type: GraphQLString }, + }, + }); + const schema = schemaWithQueryFields({ + user: { type: UserType }, + }); + const document = parse('{ user { name } }'); + const operation = document.definitions[0]; + assert(operation.kind === Kind.OPERATION_DEFINITION); + const userFieldNode = operation.selectionSet.selections[0]; + assert(userFieldNode.kind === Kind.FIELD); + const nameFieldNode = userFieldNode.selectionSet?.selections[0]; + assert(nameFieldNode?.kind === Kind.FIELD); + + const userPath = addPath(undefined, 'user', 'Query'); + const namePath = addPath(userPath, 'name', 'BatchStaleUser'); + const data = Object.assign(Object.create(null), { + user: 'not a response object', + }); + const collectedErrors: Array = []; + + let thrownError; + try { + completeFieldBatchGroup( + { + validatedExecutionArgs: { + schema, + fragmentDefinitions: Object.create(null), + fragments: Object.create(null), + rootValue: undefined, + contextValue: undefined, + operation, + variableValues: {}, + hideSuggestions: false, + } as any, + collectedErrors: { + errors: collectedErrors, + add: (error) => { + collectedErrors.push(error); + }, + hasNulledPosition: () => false, + hasNulledAncestor: () => false, + }, + batchFieldGroups: new Map(), + rootGroupedFieldSet: new Map([['user', [{ node: userFieldNode }]]]), + getAbortSignal: () => undefined, + getAsyncHelpers: () => ({ + promiseAll: (values) => Promise.all(values), + track: () => undefined, + }), + promiseAll: (values) => Promise.all(values), + completeValue: ( + _returnType, + _fieldDetailsList, + _info, + _path, + result, + ) => { + throw result; + }, + collectAndExecuteSubfields: () => { + throw new Error('Unexpected subfield completion.'); + }, + handleFieldError: () => undefined, + }, + data, + undefined, + { + fieldDef: UserType.getFields().name, + batchResolve: () => [], + fieldDetailsList: [{ node: nameFieldNode }], + fieldNodes: [nameFieldNode], + parentType: UserType, + entries: [], + }, + [ + { + source: {}, + path: namePath, + positionContext: undefined, + responseTarget: Object.create(null), + responseKey: 'name', + }, + ], + [new Error('name boom')], + ); + } catch (error) { + thrownError = error; + } + + assert(thrownError instanceof Error); + expect(thrownError.message).to.equal('name boom'); + expect((thrownError as any).path).to.deep.equal(['user', 'name']); + expect(data).to.deep.equal({ user: 'not a response object' }); + expect(collectedErrors).to.deep.equal([]); + }); + + it('stops handling a failed batch group once an ancestor is nulled', async () => { + const schema = schemaWithBatchedUserName( + 'BatchGroupFailureUser', + new GraphQLNonNull(GraphQLString), + () => { + throw new Error('group boom'); + }, + (userType) => new GraphQLList(new GraphQLNonNull(userType)), + ); + + const result = await execute({ + schema, + document: parse('{ users { name } }'), + rootValue: { + users: [{}, {}], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: null, + }, + errors: [ + { + message: 'group boom', + path: ['users', 0, 'name'], + locations: [{ line: 1, column: 11 }], + }, + ], + }); + }); + + it('does not run pending batches after root null bubbling', async () => { + let batchCalls = 0; + const schema = schemaWithQueryFields({ + batched: batchedField(GraphQLString, () => { + batchCalls += 1; + return ['batched']; + }), + boom: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => null, + }, + }); + + const result = await execute({ + schema, + document: parse('{ batched boom }'), + }); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Cannot return null for non-nullable field Query.boom.', + path: ['boom'], + locations: [{ line: 1, column: 11 }], + }, + ], + }); + expect(batchCalls).to.equal(0); + }); + + it('completes async batch resolvers and async subfields', async () => { + const friendType = new GraphQLObjectType({ + name: 'BatchFriend', + fields: { + asyncName: { + type: GraphQLString, + resolve: (source: any) => Promise.resolve(source.name), + }, + }, + }); + const schema = schemaWithUserFields( + { + promiseName: batchedField(GraphQLString, (sources) => + Promise.resolve(sources.map((source: any) => source.name)), + ), + friend: batchedField(friendType, (sources) => + Promise.resolve(sources.map((source: any) => source.friend)), + ), + }, + 'BatchPromiseUser', + ); + + const result = await execute({ + schema, + document: parse('{ users { promiseName friend { asyncName } } }'), + rootValue: { + users: [ + { name: 'Ada', friend: { name: 'Grace' } }, + { name: 'Lin', friend: { name: 'Katherine' } }, + ], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [ + { promiseName: 'Ada', friend: { asyncName: 'Grace' } }, + { promiseName: 'Lin', friend: { asyncName: 'Katherine' } }, + ], + }, + }); + }); + + it('completes concrete object batch nulls and errors', async () => { + const friendType = new GraphQLObjectType({ + name: 'BatchObjectEdgeFriend', + fields: { + name: { type: GraphQLString }, + asyncName: { + type: new GraphQLNonNull(GraphQLString), + resolve: () => Promise.reject(new Error('subfield boom')), + }, + }, + }); + const schema = schemaWithUserFields( + { + nullableFriend: batchedField(friendType, () => [null]), + errorFriend: batchedField(friendType, () => [new Error('friend boom')]), + rejectedFriend: batchedField(friendType, (sources) => + sources.map((source: any) => source.friend), + ), + }, + 'BatchObjectEdgeUser', + ); + + const result = await execute({ + schema, + document: parse(` + { + users { + nullableFriend { name } + errorFriend { name } + rejectedFriend { asyncName } + } + } + `), + rootValue: { + users: [{ friend: { name: 'Grace' } }], + }, + }); + + expectJSON(result.data).toDeepEqual({ + users: [ + { + nullableFriend: null, + errorFriend: null, + rejectedFriend: null, + }, + ], + }); + expect( + result.errors?.map((error) => ({ + message: error.message, + path: error.path, + })), + ).to.deep.equal([ + { + message: 'friend boom', + path: ['users', 0, 'errorFriend'], + }, + { + message: 'subfield boom', + path: ['users', 0, 'rejectedFriend', 'asyncName'], + }, + ]); + }); + + it('filters pending batch records below a path nulled in an earlier batch round', async () => { + const extraSources: Array = []; + const nicknameSources: Array = []; + + const schema = schemaWithBatchedProfileFields( + 'BatchFiltered', + { + nickname: batchedField(GraphQLString, (sources) => { + nicknameSources.push(...sources.map((source: any) => source.id)); + return sources.map((source: any) => `nickname-${source.id}`); + }), + }, + { + name: batchedField(new GraphQLNonNull(GraphQLString), (sources) => + sources.map((source: any) => source.name), + ), + extra: batchedField(GraphQLString, (sources) => { + extraSources.push(...sources.map((source: any) => source.id)); + return sources.map((source: any) => `extra-${source.id}`); + }), + }, + ); + + const result = await execute({ + schema, + document: parse( + '{ users { profile { details { nickname } name extra } } }', + ), + rootValue: { + users: [ + { profile: { id: '1', name: null, details: { id: '1' } } }, + { profile: { id: '2', name: 'Ada', details: { id: '2' } } }, + ], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [ + { profile: null }, + { + profile: { + details: { nickname: 'nickname-2' }, + name: 'Ada', + extra: 'extra-2', + }, + }, + ], + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchFilteredProfile.name.', + path: ['users', 0, 'profile', 'name'], + locations: [{ line: 1, column: 42 }], + }, + ], + }); + expect(extraSources).to.deep.equal(['2']); + expect(nicknameSources).to.deep.equal(['2']); + }); + + it('filters all pending batch records below a path nulled in an earlier batch round', async () => { + let extraCalls = 0; + let nicknameCalls = 0; + + const schema = schemaWithBatchedProfileFields( + 'BatchAllFiltered', + { + nickname: batchedField(GraphQLString, () => { + nicknameCalls += 1; + return ['nickname']; + }), + }, + { + name: batchedField(new GraphQLNonNull(GraphQLString), () => [null]), + extra: batchedField(GraphQLString, () => { + extraCalls += 1; + return ['extra']; + }), + }, + ); + + const result = await execute({ + schema, + document: parse( + '{ users { profile { details { nickname } name extra } } }', + ), + rootValue: { + users: [{ profile: { details: {} } }], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [{ profile: null }], + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchAllFilteredProfile.name.', + path: ['users', 0, 'profile', 'name'], + locations: [{ line: 1, column: 42 }], + }, + ], + }); + expect(extraCalls).to.equal(0); + expect(nicknameCalls).to.equal(0); + }); + + it('filters deferred work after initial batched errors', async () => { + let nicknameCalls = 0; + + const schema = schemaWithUserFields( + { + name: batchedField(new GraphQLNonNull(GraphQLString), () => [null]), + nickname: batchedField(GraphQLString, (sources) => { + nicknameCalls += 1; + return sources.map(() => 'nickname'); + }), + }, + 'BatchInitialErrorUser', + ); + + const result = await experimentalExecuteIncrementally({ + schema, + document: parse( + '{ users { ... @defer(label: "later") { nickname } name } }', + ), + rootValue: { + users: [{}], + }, + }); + + assert(!('initialResult' in result)); + expectJSON(result).toDeepEqual({ + data: { + users: [null], + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchInitialErrorUser.name.', + path: ['users', 0, 'name'], + locations: [{ line: 1, column: 51 }], + }, + ], + }); + expect(nicknameCalls).to.equal(0); + }); + + it('ignores late batch completions below nulled paths', async () => { + const friendType = new GraphQLObjectType({ + name: 'BatchLateFriend', + fields: { + asyncName: { + type: GraphQLString, + resolve: (source: any) => Promise.resolve(source.name), + }, + }, + }); + const schema = schemaWithUserFields( + { + friend: batchedField(friendType, (sources) => + sources.map((source: any) => source.friend), + ), + name: batchedField(new GraphQLNonNull(GraphQLString), () => [null]), + }, + 'BatchLateUser', + ); + + const result = await execute({ + schema, + document: parse('{ users { friend { asyncName } name } }'), + rootValue: { + users: [{ friend: { name: 'Grace' } }], + }, + }); + + expectJSON(result).toDeepEqual({ + data: { + users: [null], + }, + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchLateUser.name.', + path: ['users', 0, 'name'], + locations: [{ line: 1, column: 32 }], + }, + ], + }); + }); + + it('preserves batched mutation field ordering', async () => { + const calls: Array = []; + let value = ''; + + const mutationType = new GraphQLObjectType({ + name: 'BatchMutation', + fields: { + first: { + type: GraphQLString, + resolve: () => { + calls.push('first'); + value += '1'; + return value; + }, + }, + batched: batchedField(GraphQLString, async () => { + await Promise.resolve(); + calls.push('batched'); + value += '2'; + return [value]; + }), + last: { + type: GraphQLString, + resolve: () => { + calls.push('last'); + value += '3'; + return value; + }, + }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'BatchMutationQuery', + fields: { + dummy: { type: GraphQLString }, + }, + }), + mutation: mutationType, + }); + + const result = await execute({ + schema, + document: parse('mutation { first batched last }'), + }); + + expectJSON(result).toDeepEqual({ + data: { + first: '1', + batched: '12', + last: '123', + }, + }); + expect(calls).to.deep.equal(['first', 'batched', 'last']); + expect(Object.keys(result.data as any)).to.deep.equal([ + 'first', + 'batched', + 'last', + ]); + }); + + it('stops serial mutation execution after batched root null propagation', async () => { + const calls: Array = []; + + const mutationType = new GraphQLObjectType({ + name: 'BatchMutationNull', + fields: { + first: batchedField(new GraphQLNonNull(GraphQLString), () => { + calls.push('first'); + return [null]; + }), + last: { + type: GraphQLString, + resolve: () => { + calls.push('last'); + return 'last'; + }, + }, + }, + }); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'BatchMutationNullQuery', + fields: { + dummy: { type: GraphQLString }, + }, + }), + mutation: mutationType, + }); + + const result = await execute({ + schema, + document: parse('mutation { first last }'), + }); + + expectJSON(result).toDeepEqual({ + data: null, + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchMutationNull.first.', + locations: [{ line: 1, column: 12 }], + path: ['first'], + }, + ], + }); + expect(calls).to.deep.equal(['first']); + }); + + it('does not batch early-executed deferred fields with their parent payload', async () => { + const calls: Array = []; + + const schema = schemaWithUserFields( + { + id: { type: GraphQLString }, + name: batchedField(GraphQLString, (sources, _args, _context, info) => { + calls.push(batchCall(sources, info)); + return Promise.resolve(sources.map((source: any) => source.name)); + }), + }, + 'BatchDeferUser', + ); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + users { + id + parentName: name + } + ... @defer(label: "later") { + deferredUsers: users { + id + deferredName: name + } + } + } + `), + rootValue: { + users: [ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + ], + }, + enableEarlyExecution: true, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + users: [ + { id: '1', parentName: 'Ada' }, + { id: '2', parentName: 'Grace' }, + ], + }, + pending: [{ id: '0', path: [], label: 'later' }], + hasNext: true, + }, + { + incremental: [ + { + data: { + deferredUsers: [ + { id: '1', deferredName: 'Ada' }, + { id: '2', deferredName: 'Grace' }, + ], + }, + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + expect(calls).to.deep.equal([ + { + ids: ['1', '2'], + paths: [ + ['users', 0, 'parentName'], + ['users', 1, 'parentName'], + ], + }, + { + ids: ['1', '2'], + paths: [ + ['deferredUsers', 0, 'deferredName'], + ['deferredUsers', 1, 'deferredName'], + ], + }, + ]); + }); + + it('can complete a deferred payload from a synchronous batched field', async () => { + const schema = schemaWithQueryFields({ + name: batchedField(GraphQLString, () => ['Ada']), + }); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + ... @defer(label: "later") { + name + } + } + `), + }); + + expectJSON(results).toDeepEqual([ + { + data: {}, + pending: [{ id: '0', path: [], label: 'later' }], + hasNext: true, + }, + { + incremental: [{ data: { name: 'Ada' }, id: '0' }], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('completes a deferred payload when a batched field error bubbles past the payload root', async () => { + const schema = schemaWithQueryFields({ + name: batchedField(new GraphQLNonNull(GraphQLString), () => [null]), + }); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + ... @defer(label: "later") { + name + } + } + `), + }); + + expectJSON(results).toDeepEqual([ + { + data: {}, + pending: [{ id: '0', path: [], label: 'later' }], + hasNext: true, + }, + { + completed: [ + { + id: '0', + errors: [ + { + message: + 'Cannot return null for non-nullable field Query.name.', + locations: [{ line: 4, column: 13 }], + path: ['name'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('completes a deferred payload when an async batched field error bubbles past the payload root', async () => { + const schema = schemaWithQueryFields({ + name: batchedField(new GraphQLNonNull(GraphQLString), () => + Promise.resolve([null]), + ), + }); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + ... @defer(label: "later") { + name + } + } + `), + }); + + expectJSON(results).toDeepEqual([ + { + data: {}, + pending: [{ id: '0', path: [], label: 'later' }], + hasNext: true, + }, + { + completed: [ + { + id: '0', + errors: [ + { + message: + 'Cannot return null for non-nullable field Query.name.', + locations: [{ line: 4, column: 13 }], + path: ['name'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('can null a non-root deferred payload field from a batched field error', async () => { + const HeroType = new GraphQLObjectType({ + name: 'BatchDeferredPayloadHero', + fields: { + id: { type: GraphQLString }, + name: batchedField(GraphQLString, () => [new Error('name boom')]), + }, + }); + + const schema = schemaWithQueryFields({ + hero: { type: HeroType }, + }); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + hero { + id + ... @defer(label: "later") { + name + } + } + } + `), + rootValue: { + hero: { id: '1' }, + }, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + hero: { id: '1' }, + }, + pending: [{ id: '0', path: ['hero'], label: 'later' }], + hasNext: true, + }, + { + incremental: [ + { + data: { name: null }, + id: '0', + errors: [ + { + message: 'name boom', + locations: [{ line: 6, column: 15 }], + path: ['hero', 'name'], + }, + ], + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('can null a non-root deferred payload nested field from a batched field error', async () => { + const FriendType = new GraphQLObjectType({ + name: 'BatchDeferredPayloadFriend', + fields: { + name: batchedField(new GraphQLNonNull(GraphQLString), () => [null]), + }, + }); + const HeroType = new GraphQLObjectType({ + name: 'BatchDeferredPayloadNestedHero', + fields: { + id: { type: GraphQLString }, + friend: { type: FriendType }, + }, + }); + + const schema = schemaWithQueryFields({ + hero: { type: HeroType }, + }); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + hero { + id + ... @defer(label: "later") { + friend { name } + } + } + } + `), + rootValue: { + hero: { + id: '1', + friend: {}, + }, + }, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + hero: { id: '1' }, + }, + pending: [{ id: '0', path: ['hero'], label: 'later' }], + hasNext: true, + }, + { + incremental: [ + { + data: { friend: null }, + id: '0', + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchDeferredPayloadFriend.name.', + locations: [{ line: 6, column: 24 }], + path: ['hero', 'friend', 'name'], + }, + ], + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('completes a non-root deferred payload when a batched field error bubbles to the payload root', async () => { + const HeroType = new GraphQLObjectType({ + name: 'BatchDeferredPayloadNonNullHero', + fields: { + id: { type: GraphQLString }, + name: batchedField(new GraphQLNonNull(GraphQLString), () => [null]), + }, + }); + + const schema = schemaWithQueryFields({ + hero: { type: HeroType }, + }); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + hero { + id + ... @defer(label: "later") { + name + } + } + } + `), + rootValue: { + hero: { id: '1' }, + }, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + hero: { id: '1' }, + }, + pending: [{ id: '0', path: ['hero'], label: 'later' }], + hasNext: true, + }, + { + completed: [ + { + id: '0', + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchDeferredPayloadNonNullHero.name.', + locations: [{ line: 6, column: 15 }], + path: ['hero', 'name'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('does not batch separate early-executed stream items together', async () => { + const calls: Array = []; + + const schema = schemaWithUserFields( + { + id: { type: GraphQLString }, + name: batchedField(GraphQLString, (sources, _args, _context, info) => { + calls.push(batchCall(sources, info)); + return sources.map((source: any) => source.name); + }), + }, + 'BatchStreamUser', + ); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + users @stream(initialCount: 1, label: "users") { + id + name + } + } + `), + rootValue: { + users: [ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + { id: '3', name: 'Lin' }, + ], + }, + enableEarlyExecution: true, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + users: [{ id: '1', name: 'Ada' }], + }, + pending: [{ id: '0', path: ['users'], label: 'users' }], + hasNext: true, + }, + { + incremental: [ + { + items: [ + { id: '2', name: 'Grace' }, + { id: '3', name: 'Lin' }, + ], + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + expect(calls).to.deep.equal([ + { + ids: ['1'], + paths: [['users', 0, 'name']], + }, + { + ids: ['2'], + paths: [['users', 1, 'name']], + }, + { + ids: ['3'], + paths: [['users', 2, 'name']], + }, + ]); + }); + + it('can complete streamed items from an async batched field', async () => { + const schema = schemaWithUserFields( + { + name: batchedField(GraphQLString, (sources) => + Promise.resolve(sources.map((source: any) => source.name)), + ), + }, + 'BatchAsyncStreamItemUser', + ); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + users @stream(initialCount: 0, label: "users") { + name + } + } + `), + rootValue: { + users: [{ name: 'Ada' }], + }, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + users: [], + }, + pending: [{ id: '0', path: ['users'], label: 'users' }], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Ada' }], + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('can null a streamed item from a batched field error', async () => { + const schema = schemaWithBatchedUserName( + 'BatchNullableStreamUser', + new GraphQLNonNull(GraphQLString), + (sources) => sources.map((source: any) => source.name), + ); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + users @stream(initialCount: 0, label: "users") { + name + } + } + `), + rootValue: { + users: [{ name: null }, { name: 'Grace' }], + }, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + users: [], + }, + pending: [{ id: '0', path: ['users'], label: 'users' }], + hasNext: true, + }, + { + incremental: [ + { + items: [null, { name: 'Grace' }], + id: '0', + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchNullableStreamUser.name.', + locations: [{ line: 4, column: 13 }], + path: ['users', 0, 'name'], + }, + ], + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('can null a nested field in a streamed item from a batched field error', async () => { + const friendType = new GraphQLObjectType({ + name: 'BatchNullableStreamFriend', + fields: { + name: batchedField(new GraphQLNonNull(GraphQLString), (sources) => + sources.map((source: any) => source.name), + ), + }, + }); + const schema = schemaWithUserFields( + { + friend: { type: friendType }, + }, + 'BatchNullableNestedStreamUser', + ); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + users @stream(initialCount: 0, label: "users") { + friend { name } + } + } + `), + rootValue: { + users: [{ friend: { name: null } }], + }, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + users: [], + }, + pending: [{ id: '0', path: ['users'], label: 'users' }], + hasNext: true, + }, + { + incremental: [ + { + items: [{ friend: null }], + id: '0', + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchNullableStreamFriend.name.', + locations: [{ line: 4, column: 22 }], + path: ['users', 0, 'friend', 'name'], + }, + ], + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + + it('can null a stream from a non-null streamed item batched field error', async () => { + const resultsByType = await Promise.all( + [false, true].map(async (completeAsync) => { + const typeName = completeAsync + ? 'BatchAsyncNonNullStreamUser' + : 'BatchNonNullStreamUser'; + const schema = schemaWithBatchedUserName( + typeName, + new GraphQLNonNull(GraphQLString), + (sources) => { + const names = sources.map((source: any) => source.name); + return completeAsync ? Promise.resolve(names) : names; + }, + (userType) => new GraphQLList(new GraphQLNonNull(userType)), + ); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + users @stream(initialCount: 0, label: "users") { + name + } + } + `), + rootValue: { + users: [{ name: null }], + }, + }); + return { results, typeName }; + }), + ); + + for (const { results, typeName } of resultsByType) { + expectJSON(results).toDeepEqual([ + { + data: { + users: [], + }, + pending: [{ id: '0', path: ['users'], label: 'users' }], + hasNext: true, + }, + { + completed: [ + { + id: '0', + errors: [ + { + message: `Cannot return null for non-nullable field ${typeName}.name.`, + locations: [{ line: 4, column: 17 }], + path: ['users', 0, 'name'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + } + }); + + it('completes a stream when a batched field error bubbles past the stream root', async () => { + const schema = schemaWithBatchedUserName( + 'BatchNonNullListStreamUser', + new GraphQLNonNull(GraphQLString), + (sources) => Promise.resolve(sources.map((source: any) => source.name)), + (userType) => + new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(userType))), + ); + + const results = await collectIncrementalResults({ + schema, + document: parse(` + { + users @stream(initialCount: 0, label: "users") { + name + } + } + `), + rootValue: { + users: [{ name: null }], + }, + }); + + expectJSON(results).toDeepEqual([ + { + data: { + users: [], + }, + pending: [{ id: '0', path: ['users'], label: 'users' }], + hasNext: true, + }, + { + completed: [ + { + id: '0', + errors: [ + { + message: + 'Cannot return null for non-nullable field BatchNonNullListStreamUser.name.', + locations: [{ line: 4, column: 13 }], + path: ['users', 0, 'name'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('writes batched results to alias paths without changing prototypes', () => { + const UserType: GraphQLObjectType = new GraphQLObjectType({ + name: 'BatchAliasUser', + fields: () => ({ + friend: batchedField(UserType, (sources) => + sources.map((source: any) => source.friend), + ), + name: { type: GraphQLString }, + }), + }); + + const schema = schemaWithQueryFields({ + users: { type: new GraphQLList(UserType) }, + }); + + const result = executeSync({ + schema, + document: parse(` + { + users { + __proto__: friend { name } + constructor: friend { name } + prototype: friend { name } + } + } + `), + rootValue: { + users: [{ friend: { name: 'Ada' } }], + }, + }); + + const expectedUser = Object.create(null); + Object.defineProperty(expectedUser, '__proto__', { + value: { name: 'Ada' }, + enumerable: true, + }); + expectedUser.constructor = { name: 'Ada' }; + expectedUser.prototype = { name: 'Ada' }; + + expectJSON(result).toDeepEqual({ + data: { + users: [expectedUser], + }, + }); + const user = (result.data as any).users[0]; + expect(Object.getPrototypeOf(user)).to.equal(null); + expect(Object.hasOwn(user, '__proto__')).to.equal(true); + }); +}); diff --git a/src/execution/batchResolve/__tests__/fixtures.ts b/src/execution/batchResolve/__tests__/fixtures.ts new file mode 100644 index 0000000000..d79fcbcf9f --- /dev/null +++ b/src/execution/batchResolve/__tests__/fixtures.ts @@ -0,0 +1,110 @@ +import { pathToArray } from '../../../jsutils/Path.ts'; + +import type { + GraphQLBatchedResolveInfo, + GraphQLFieldBatchResolver, + GraphQLFieldConfig, + GraphQLFieldConfigMap, + GraphQLOutputType, +} from '../../../type/definition.ts'; +import { GraphQLList, GraphQLObjectType } from '../../../type/definition.ts'; +import { GraphQLSchema } from '../../../type/schema.ts'; + +export function schemaWithQueryFields( + fields: GraphQLFieldConfigMap, + types?: ReadonlyArray, +): GraphQLSchema { + return new GraphQLSchema({ + query: new GraphQLObjectType({ name: 'Query', fields }), + types, + }); +} + +export function schemaWithUserFields( + fields: GraphQLFieldConfigMap, + typeName = 'User', + usersType: (userType: GraphQLObjectType) => GraphQLOutputType = (userType) => + new GraphQLList(userType), +): GraphQLSchema { + const UserType = new GraphQLObjectType({ + name: typeName, + fields, + }); + + return schemaWithQueryFields({ + users: { type: usersType(UserType) }, + }); +} + +export function schemaWithBatchedUserName( + typeName: string, + type: GraphQLOutputType, + experimentalBatchResolve: GraphQLFieldBatchResolver, + usersType?: (userType: GraphQLObjectType) => GraphQLOutputType, +): GraphQLSchema { + return schemaWithUserFields( + { name: batchedField(type, experimentalBatchResolve) }, + typeName, + usersType, + ); +} + +export function batchedField( + type: GraphQLOutputType, + experimentalBatchResolve: GraphQLFieldBatchResolver, + config: Omit< + GraphQLFieldConfig, + 'type' | 'experimentalBatchResolve' + > = {}, +): GraphQLFieldConfig { + return { + type, + ...config, + experimentalBatchResolve, + }; +} + +export function schemaWithBatchedProfileFields( + typeNamePrefix: string, + detailsFields: GraphQLFieldConfigMap, + profileFields: GraphQLFieldConfigMap, +): GraphQLSchema { + const DetailsType = new GraphQLObjectType({ + name: `${typeNamePrefix}Details`, + fields: detailsFields, + }); + const ProfileType = new GraphQLObjectType({ + name: `${typeNamePrefix}Profile`, + fields: { + details: batchedField(DetailsType, (sources) => + sources.map((source: any) => source.details), + ), + ...profileFields, + }, + }); + + return schemaWithUserFields( + { + profile: batchedField(ProfileType, (sources) => + sources.map((source: any) => source.profile), + ), + }, + `${typeNamePrefix}User`, + ); +} + +export interface BatchCall { + ids: ReadonlyArray; + paths: ReadonlyArray>; +} + +export function batchCall( + sources: ReadonlyArray, + info: GraphQLBatchedResolveInfo, +): BatchCall { + const users = sources as ReadonlyArray<{ id: string }>; + return { + ids: users.map((source) => source.id), + paths: info.paths.map(pathToArray), + }; +} diff --git a/src/execution/batchResolve/completeFieldBatchGroup.ts b/src/execution/batchResolve/completeFieldBatchGroup.ts new file mode 100644 index 0000000000..5ad52db924 --- /dev/null +++ b/src/execution/batchResolve/completeFieldBatchGroup.ts @@ -0,0 +1,246 @@ +import { invariant } from '../../jsutils/invariant.ts'; +import { isIterableObject } from '../../jsutils/isIterableObject.ts'; +import { isPromise } from '../../jsutils/isPromise.ts'; +import type { Path } from '../../jsutils/Path.ts'; +import { pathToArray } from '../../jsutils/Path.ts'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; + +import { ensureGraphQLError } from '../../error/ensureGraphQLError.ts'; +import { GraphQLError as GraphQLErrorClass } from '../../error/GraphQLError.ts'; +import { locatedError } from '../../error/locatedError.ts'; + +import type { GraphQLObjectType } from '../../type/definition.ts'; +import { isObjectType } from '../../type/definition.ts'; + +import { buildResolveInfo } from '../buildResolveInfo.ts'; + +import { getNullablePath } from './getNullablePath.ts'; +import { setPathValue } from './setPathValue.ts'; +import type { + BatchExecutor, + BatchFieldEntry, + BatchFieldGroup, +} from './types.ts'; + +/** @internal */ +// eslint-disable-next-line max-params +export function completeFieldBatchGroup( + executor: BatchExecutor, + data: unknown, + rootPath: Path | undefined, + batchGroup: BatchFieldGroup, + batchEntries: ReadonlyArray>, + batchResult: unknown, +): PromiseOrValue { + if (!isIterableObject(batchResult)) { + return handleFieldBatchGroupError( + executor, + data, + rootPath, + new GraphQLErrorClass( + `Expected batch resolver for field "${batchGroup.parentType}.${batchGroup.fieldDef.name}" to return an Iterable.`, + ), + batchGroup, + batchEntries, + ); + } + + const batchValues = Array.from(batchResult); + if (batchValues.length !== batchEntries.length) { + return handleFieldBatchGroupError( + executor, + data, + rootPath, + new GraphQLErrorClass( + `Expected batch resolver for field "${batchGroup.parentType}.${batchGroup.fieldDef.name}" to return ${batchEntries.length} results, returned ${batchValues.length}.`, + ), + batchGroup, + batchEntries, + ); + } + + const batchObjectType = + isObjectType(batchGroup.fieldDef.type) && + batchGroup.fieldDef.type.isTypeOf === undefined + ? batchGroup.fieldDef.type + : undefined; + + const promises: Array> = []; + for (const [i, entry] of batchEntries.entries()) { + const result = completeFieldBatchRecord( + executor, + data, + rootPath, + batchGroup, + batchObjectType, + entry, + batchValues[i], + ); + if (isPromise(result)) { + promises.push(result); + } + } + + if (promises.length !== 0) { + return executor.promiseAll(promises).then(() => undefined); + } +} + +// eslint-disable-next-line max-params +function completeFieldBatchRecord( + executor: BatchExecutor, + data: unknown, + rootPath: Path | undefined, + batchGroup: BatchFieldGroup, + objectType: GraphQLObjectType | undefined, + entry: BatchFieldEntry, + result: unknown, +): PromiseOrValue { + if (executor.collectedErrors.hasNulledPosition(entry.path)) { + return; + } + + const returnType = batchGroup.fieldDef.type; + let completed; + try { + if (objectType !== undefined) { + if (result == null) { + completed = null; + } else { + if (result instanceof Error) { + throw result; + } + completed = executor.collectAndExecuteSubfields( + objectType, + batchGroup.fieldDetailsList, + entry.path, + result, + entry.positionContext, + ); + } + } else { + const info = buildResolveInfo( + executor.validatedExecutionArgs, + batchGroup.fieldDef, + batchGroup.fieldNodes, + batchGroup.parentType, + entry.path, + executor.getAbortSignal, + executor.getAsyncHelpers, + ); + + completed = executor.completeValue( + returnType, + batchGroup.fieldDetailsList, + info, + entry.path, + result, + entry.positionContext, + ); + } + } catch (rawError) { + return handleFieldBatchRecordError( + executor, + data, + rootPath, + rawError, + batchGroup, + entry, + ); + } + + if (isPromise(completed)) { + return completed.then( + (resolved) => { + if (!executor.collectedErrors.hasNulledAncestor(entry.path)) { + entry.responseTarget[entry.responseKey] = resolved; + } + }, + (rawError: unknown) => + handleFieldBatchRecordError( + executor, + data, + rootPath, + rawError, + batchGroup, + entry, + ), + ); + } + + if (!executor.collectedErrors.hasNulledAncestor(entry.path)) { + entry.responseTarget[entry.responseKey] = completed; + } +} + +/** @internal */ +// eslint-disable-next-line max-params +export function handleFieldBatchGroupError( + executor: BatchExecutor, + data: unknown, + rootPath: Path | undefined, + rawError: unknown, + batchGroup: BatchFieldGroup, + batchEntries: ReadonlyArray>, +): void { + for (const entry of batchEntries) { + if (executor.collectedErrors.hasNulledPosition(entry.path)) { + continue; + } + handleFieldBatchRecordError( + executor, + data, + rootPath, + rawError, + batchGroup, + entry, + ); + } +} + +// eslint-disable-next-line max-params +function handleFieldBatchRecordError( + executor: BatchExecutor, + data: unknown, + rootPath: Path | undefined, + rawError: unknown, + batchGroup: BatchFieldGroup, + entry: BatchFieldEntry, +): void { + try { + executor.handleFieldError( + rawError, + batchGroup.fieldDef.type, + batchGroup.fieldDetailsList, + entry.path, + ); + if (!setPathValue(data, rootPath, entry.path, null)) { + throw locatedError( + rawError, + batchGroup.fieldNodes, + pathToArray(entry.path), + ); + } + } catch (error) { + const graphQLError = ensureGraphQLError(error); + const rootGroupedFieldSet = executor.rootGroupedFieldSet; + invariant( + rootGroupedFieldSet !== undefined, + 'Cannot handle batch field errors before root fields have been collected.', + ); + const nullablePath = getNullablePath( + executor.validatedExecutionArgs, + rootGroupedFieldSet, + entry.path, + ); + if ( + nullablePath !== undefined && + setPathValue(data, rootPath, nullablePath, null) + ) { + executor.collectedErrors.add(graphQLError, nullablePath); + return; + } + + throw graphQLError; + } +} diff --git a/src/execution/batchResolve/enqueueBatchField.ts b/src/execution/batchResolve/enqueueBatchField.ts new file mode 100644 index 0000000000..e6fd61c270 --- /dev/null +++ b/src/execution/batchResolve/enqueueBatchField.ts @@ -0,0 +1,48 @@ +import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import type { Path } from '../../jsutils/Path.ts'; + +import type { + GraphQLField, + GraphQLFieldBatchResolver, + GraphQLObjectType, +} from '../../type/definition.ts'; + +import type { FieldDetailsList } from '../collectFields.ts'; + +import type { BatchExecutor } from './types.ts'; + +/** @internal */ +// eslint-disable-next-line max-params +export function enqueueBatchField( + executor: BatchExecutor, + parentType: GraphQLObjectType, + fieldDef: GraphQLField, + batchResolve: GraphQLFieldBatchResolver, + fieldDetailsList: FieldDetailsList, + source: unknown, + path: Path, + positionContext: TPositionContext | undefined, + responseTarget: ObjMap, + responseKey: string, +): void { + let batchGroup = executor.batchFieldGroups.get(fieldDetailsList); + if (batchGroup === undefined) { + batchGroup = { + fieldDef, + batchResolve, + fieldDetailsList, + fieldNodes: fieldDetailsList.map((fieldDetails) => fieldDetails.node), + parentType, + entries: [], + }; + executor.batchFieldGroups.set(fieldDetailsList, batchGroup); + } + + batchGroup.entries.push({ + source, + path, + positionContext, + responseTarget, + responseKey, + }); +} diff --git a/src/execution/batchResolve/executeFieldBatchResolvers.ts b/src/execution/batchResolve/executeFieldBatchResolvers.ts new file mode 100644 index 0000000000..1a511a4683 --- /dev/null +++ b/src/execution/batchResolve/executeFieldBatchResolvers.ts @@ -0,0 +1,179 @@ +import { isPromise, isPromiseLike } from '../../jsutils/isPromise.ts'; +import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import type { Path } from '../../jsutils/Path.ts'; +import { pathToArray } from '../../jsutils/Path.ts'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; + +import type { + GraphQLBatchResolveContext, + MinimalTracingChannel, +} from '../../diagnostics.ts'; +import { + resolveBatchChannel, + shouldTrace, + traceMixed, +} from '../../diagnostics.ts'; + +import { getArgumentValues } from '../values.ts'; + +import { + completeFieldBatchGroup, + handleFieldBatchGroupError, +} from './completeFieldBatchGroup.ts'; +import type { + BatchExecutor, + BatchFieldEntry, + BatchFieldGroup, + BatchFieldGroupMap, +} from './types.ts'; + +function getBatchResolveTracingChannel(): + | MinimalTracingChannel + | undefined { + return shouldTrace(resolveBatchChannel) ? resolveBatchChannel : undefined; +} + +/** @internal */ +export function executeFieldBatchResolvers( + executor: BatchExecutor, + data: unknown, + rootPath: Path | undefined, + batchFieldGroups: BatchFieldGroupMap, +): Array> { + const promises: Array> = []; + for (const batchGroup of batchFieldGroups.values()) { + const result = executeFieldBatchResolver( + executor, + data, + rootPath, + batchGroup, + ); + if (isPromise(result)) { + promises.push(result); + } + } + return promises; +} + +function executeFieldBatchResolver( + executor: BatchExecutor, + data: unknown, + rootPath: Path | undefined, + batchGroup: BatchFieldGroup, +): PromiseOrValue { + const activeEntries = batchGroup.entries.filter( + (entry) => !executor.collectedErrors.hasNulledPosition(entry.path), + ); + if (activeEntries.length === 0) { + return; + } + + let batchResult; + try { + const { + schema, + fragmentDefinitions, + rootValue, + operation, + variableValues, + hideSuggestions, + } = executor.validatedExecutionArgs; + const firstFieldDetails = batchGroup.fieldDetailsList[0]; + const args = getArgumentValues( + batchGroup.fieldDef, + firstFieldDetails.node, + variableValues, + firstFieldDetails.fragmentVariableValues, + hideSuggestions, + ); + const sources = activeEntries.map((entry) => entry.source); + const paths = activeEntries.map((entry) => entry.path); + const info = { + fieldName: batchGroup.fieldDef.name, + fieldNodes: batchGroup.fieldNodes, + returnType: batchGroup.fieldDef.type, + parentType: batchGroup.parentType, + paths, + schema, + fragments: fragmentDefinitions, + rootValue, + operation, + variableValues, + getAbortSignal: executor.getAbortSignal, + getAsyncHelpers: executor.getAsyncHelpers, + }; + const resolveBatch = () => + batchGroup.batchResolve( + sources, + args, + executor.validatedExecutionArgs.contextValue, + info, + ); + const tracingChannel = getBatchResolveTracingChannel(); + batchResult = + tracingChannel === undefined + ? resolveBatch() + : traceMixed( + tracingChannel, + buildBatchResolveContext(args, batchGroup, activeEntries), + resolveBatch, + ); + } catch (rawError) { + return handleFieldBatchGroupError( + executor, + data, + rootPath, + rawError, + batchGroup, + activeEntries, + ); + } + + if (isPromiseLike(batchResult)) { + return Promise.resolve(batchResult).then( + (resolvedBatchResult) => + completeFieldBatchGroup( + executor, + data, + rootPath, + batchGroup, + activeEntries, + resolvedBatchResult, + ), + (rawError: unknown) => + handleFieldBatchGroupError( + executor, + data, + rootPath, + rawError, + batchGroup, + activeEntries, + ), + ); + } + + return completeFieldBatchGroup( + executor, + data, + rootPath, + batchGroup, + activeEntries, + batchResult, + ); +} + +function buildBatchResolveContext( + args: ObjMap, + batchGroup: BatchFieldGroup, + activeEntries: ReadonlyArray>, +): Omit { + return { + fieldName: batchGroup.fieldDef.name, + responseKeys: activeEntries.map((entry) => entry.responseKey), + parentType: batchGroup.parentType.name, + fieldType: String(batchGroup.fieldDef.type), + args, + batchSize: activeEntries.length, + fieldPaths: activeEntries.map((entry) => pathToArray(entry.path).join('.')), + }; +} diff --git a/src/execution/batchResolve/executePendingBatches.ts b/src/execution/batchResolve/executePendingBatches.ts new file mode 100644 index 0000000000..fc5e7d31fb --- /dev/null +++ b/src/execution/batchResolve/executePendingBatches.ts @@ -0,0 +1,77 @@ +import { invariant } from '../../jsutils/invariant.ts'; +import type { Path } from '../../jsutils/Path.ts'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; + +import { executeFieldBatchResolvers } from './executeFieldBatchResolvers.ts'; +import { filterPendingBatchGroups } from './filterPendingBatchGroups.ts'; +import type { BatchExecutor, BatchFieldGroupMap } from './types.ts'; + +/** @internal */ +export function executePendingBatches( + executor: BatchExecutor, + data: TData, + rootPath?: Path, +): PromiseOrValue { + if (executor.batchFieldGroups.size === 0) { + return data; + } + + const rootGroupedFieldSet = executor.rootGroupedFieldSet; + invariant( + rootGroupedFieldSet !== undefined, + 'Cannot execute pending batch fields before root fields have been collected.', + ); + + const pendingBatchGroups: Array> = []; + + // Batch completion can execute subfields that enqueue more batch records. + const drainQueue = (): Array> | undefined => { + while (true) { + if (executor.batchFieldGroups.size !== 0) { + pendingBatchGroups.push(executor.batchFieldGroups); + executor.batchFieldGroups = new Map(); + } + + const pendingBatchFieldGroups = pendingBatchGroups.shift(); + if (pendingBatchFieldGroups === undefined) { + return; + } + + const batchFieldGroups = filterPendingBatchGroups( + executor, + pendingBatchFieldGroups, + ); + if (batchFieldGroups.size === 0) { + continue; + } + + const promises = executeFieldBatchResolvers( + executor, + data, + rootPath, + batchFieldGroups, + ); + if (promises.length !== 0) { + return promises; + } + } + }; + + const promises = drainQueue(); + if (promises === undefined) { + return data; + } + + const drainAsync = async () => { + let batchPromises: Array> | undefined = promises; + while (batchPromises !== undefined) { + // A later batch round must not run until all async work in the current + // round has completed. + // eslint-disable-next-line no-await-in-loop + await executor.promiseAll(batchPromises); + batchPromises = drainQueue(); + } + }; + + return drainAsync().then(() => data); +} diff --git a/src/execution/batchResolve/filterPendingBatchGroups.ts b/src/execution/batchResolve/filterPendingBatchGroups.ts new file mode 100644 index 0000000000..f56e65df29 --- /dev/null +++ b/src/execution/batchResolve/filterPendingBatchGroups.ts @@ -0,0 +1,30 @@ +import type { BatchExecutor, BatchFieldGroupMap } from './types.ts'; + +/** @internal */ +export function filterPendingBatchGroups( + executor: BatchExecutor, + batchFieldGroups: BatchFieldGroupMap, +): BatchFieldGroupMap { + if ( + batchFieldGroups.size === 0 || + executor.collectedErrors.errors.length === 0 + ) { + return batchFieldGroups; + } + + const filteredBatchFieldGroups: BatchFieldGroupMap = + new Map(); + for (const [fieldDetailsList, batchGroup] of batchFieldGroups) { + const entries = batchGroup.entries.filter( + (entry) => !executor.collectedErrors.hasNulledPosition(entry.path), + ); + if (entries.length !== 0) { + filteredBatchFieldGroups.set(fieldDetailsList, { + ...batchGroup, + entries, + }); + } + } + + return filteredBatchFieldGroups; +} diff --git a/src/execution/batchResolve/getNullablePath.ts b/src/execution/batchResolve/getNullablePath.ts new file mode 100644 index 0000000000..62d45b68f6 --- /dev/null +++ b/src/execution/batchResolve/getNullablePath.ts @@ -0,0 +1,110 @@ +import { invariant } from '../../jsutils/invariant.ts'; +import type { Path } from '../../jsutils/Path.ts'; + +import type { GraphQLOutputType } from '../../type/definition.ts'; +import { + isListType, + isNonNullType, + isObjectType, +} from '../../type/definition.ts'; + +import type { FieldDetailsList, GroupedFieldSet } from '../collectFields.ts'; +import { collectSubfields } from '../collectSubfields.ts'; +import type { ValidatedExecutionArgs } from '../ExecutionArgs.ts'; + +/** @internal */ +export function getNullablePath( + validatedExecutionArgs: ValidatedExecutionArgs, + rootGroupedFieldSet: GroupedFieldSet, + path: Path, +): Path | undefined { + const pathEntries: Array = []; + for ( + let currentPath: Path | undefined = path; + currentPath !== undefined; + currentPath = currentPath.prev + ) { + pathEntries.push(currentPath); + } + pathEntries.reverse(); + + const { schema, operation } = validatedExecutionArgs; + let parentType = schema.getRootType(operation.operation); + invariant( + parentType != null, + 'Cannot determine a batch error path without a root operation type.', + ); + + let groupedFieldSet: GroupedFieldSet | undefined = rootGroupedFieldSet; + let fieldDetailsList: FieldDetailsList | undefined; + let outputType: GraphQLOutputType | undefined; + let nullablePath: Path | undefined; + + for (const pathEntry of pathEntries) { + if (typeof pathEntry.key === 'number') { + invariant( + outputType !== undefined, + 'Cannot evaluate a batch list item path before its parent field type is known.', + ); + + const nullableListType = isNonNullType(outputType) + ? outputType.ofType + : outputType; + invariant( + isListType(nullableListType), + 'Cannot evaluate a batch list item path for a non-list parent field type.', + ); + + outputType = nullableListType.ofType; + if (!isNonNullType(outputType)) { + nullablePath = pathEntry; + } + continue; + } + + if (groupedFieldSet === undefined) { + invariant( + fieldDetailsList !== undefined, + 'Cannot collect batch subfields before parent field details are known.', + ); + invariant( + pathEntry.typename !== undefined, + 'Cannot collect batch subfields from a response path without a typename.', + ); + + const maybeParentType = schema.getType(pathEntry.typename); + invariant( + isObjectType(maybeParentType), + `Cannot collect batch subfields for non-object type "${pathEntry.typename}".`, + ); + + parentType = maybeParentType; + groupedFieldSet = collectSubfields( + validatedExecutionArgs, + parentType, + fieldDetailsList, + ).groupedFieldSet; + } + + fieldDetailsList = groupedFieldSet.get(pathEntry.key); + invariant( + fieldDetailsList !== undefined, + `Cannot find collected batch field details for response key "${pathEntry.key}".`, + ); + + const fieldName = fieldDetailsList[0].node.name.value; + const fieldDef = schema.getField(parentType, fieldName); + invariant( + fieldDef !== undefined, + `Cannot find field definition for batched field "${parentType.name}.${fieldName}".`, + ); + + outputType = fieldDef.type; + if (!isNonNullType(outputType)) { + nullablePath = pathEntry; + } + groupedFieldSet = undefined; + } + + return nullablePath; +} diff --git a/src/execution/batchResolve/setPathValue.ts b/src/execution/batchResolve/setPathValue.ts new file mode 100644 index 0000000000..e5fd719635 --- /dev/null +++ b/src/execution/batchResolve/setPathValue.ts @@ -0,0 +1,59 @@ +import { invariant } from '../../jsutils/invariant.ts'; +import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import type { Path } from '../../jsutils/Path.ts'; + +/** + * Batch resolvers write successful field values through the queued field + * entry's response target. This helper is only used for error handling, where + * nulls may bubble. + * @internal + */ +export function setPathValue( + data: unknown, + rootPath: Path | undefined, + path: Path, + value: unknown, +): boolean { + // Incremental payloads use rootPath to treat one payload or item as a root. + const pathKeys: Array = []; + for ( + let currentPath: Path | undefined = path; + currentPath !== rootPath; + currentPath = currentPath.prev + ) { + if (currentPath === undefined) { + return false; + } + pathKeys.push(currentPath.key); + } + + if (pathKeys.length === 0) { + return false; + } + + let parent: unknown = data; + for (let i = pathKeys.length - 1; i > 0; --i) { + parent = (parent as { [key: string | number]: unknown } | undefined)?.[ + pathKeys[i] + ]; + } + + if (isResponseObject(parent)) { + const key = pathKeys[0]; + invariant(key !== undefined, 'Missing response path key.'); + (parent as { [key: string | number]: unknown })[key] = value; + return true; + } + return false; +} + +function isResponseObject( + value: unknown, +): value is ObjMap | Array { + return ( + Array.isArray(value) || + (typeof value === 'object' && + value !== null && + Object.getPrototypeOf(value) === null) + ); +} diff --git a/src/execution/batchResolve/types.ts b/src/execution/batchResolve/types.ts new file mode 100644 index 0000000000..a892a3132a --- /dev/null +++ b/src/execution/batchResolve/types.ts @@ -0,0 +1,87 @@ +import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import type { Path } from '../../jsutils/Path.ts'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; + +import type { GraphQLError } from '../../error/GraphQLError.ts'; + +import type { FieldNode } from '../../language/ast.ts'; + +import type { + GraphQLField, + GraphQLFieldBatchResolver, + GraphQLObjectType, + GraphQLOutputType, + GraphQLResolveInfo, + GraphQLResolveInfoHelpers, +} from '../../type/definition.ts'; + +import type { FieldDetailsList, GroupedFieldSet } from '../collectFields.ts'; +import type { ValidatedExecutionArgs } from '../ExecutionArgs.ts'; + +/** @internal */ +export interface CollectedErrorsLike { + errors: ReadonlyArray; + add: (error: GraphQLError, path: Path | undefined) => void; + hasNulledPosition: (startPath: Path | undefined) => boolean; + hasNulledAncestor: (startPath: Path | undefined) => boolean; +} + +/** @internal */ +export interface BatchExecutor { + validatedExecutionArgs: ValidatedExecutionArgs; + collectedErrors: CollectedErrorsLike; + batchFieldGroups: BatchFieldGroupMap; + rootGroupedFieldSet: GroupedFieldSet | undefined; + getAbortSignal: () => AbortSignal | undefined; + getAsyncHelpers: () => GraphQLResolveInfoHelpers; + promiseAll: ( + values: ReadonlyArray>, + ) => Promise>; + // eslint-disable-next-line max-params + completeValue: ( + returnType: GraphQLOutputType, + fieldDetailsList: FieldDetailsList, + info: GraphQLResolveInfo, + path: Path, + result: unknown, + positionContext: TPositionContext | undefined, + ) => PromiseOrValue; + collectAndExecuteSubfields: ( + returnType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, + path: Path, + result: unknown, + positionContext: TPositionContext | undefined, + ) => PromiseOrValue>; + handleFieldError: ( + rawError: unknown, + returnType: GraphQLOutputType, + fieldDetailsList: FieldDetailsList, + path: Path, + ) => void; +} + +/** @internal */ +export interface BatchFieldGroup { + fieldDef: GraphQLField; + batchResolve: GraphQLFieldBatchResolver; + fieldDetailsList: FieldDetailsList; + fieldNodes: ReadonlyArray; + parentType: GraphQLObjectType; + entries: Array>; +} + +/** @internal */ +export interface BatchFieldEntry { + source: unknown; + path: Path; + positionContext: TPositionContext | undefined; + responseTarget: ObjMap; + responseKey: string; +} + +/** @internal */ +export type BatchFieldGroupMap = Map< + FieldDetailsList, + BatchFieldGroup +>; diff --git a/src/execution/collectSubfields.ts b/src/execution/collectSubfields.ts new file mode 100644 index 0000000000..bc2d3b7d7b --- /dev/null +++ b/src/execution/collectSubfields.ts @@ -0,0 +1,31 @@ +import { memoize3 } from '../jsutils/memoize3.ts'; + +import type { GraphQLObjectType } from '../type/definition.ts'; + +import type { FieldDetailsList } from './collectFields.ts'; +import { collectSubfields as _collectSubfields } from './collectFields.ts'; +import type { ValidatedExecutionArgs } from './ExecutionArgs.ts'; + +/** @internal */ +export const collectSubfields: ( + validatedExecutionArgs: ValidatedExecutionArgs, + returnType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, +) => ReturnType = memoize3( + ( + validatedExecutionArgs: ValidatedExecutionArgs, + returnType: GraphQLObjectType, + fieldDetailsList: FieldDetailsList, + ) => { + const { schema, fragments, variableValues, hideSuggestions } = + validatedExecutionArgs; + return _collectSubfields( + schema, + fragments, + variableValues, + returnType, + fieldDetailsList, + hideSuggestions, + ); + }, +); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 4dea28d5c4..0708c5e880 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -716,6 +716,7 @@ export function createSourceEventStream( * hideSuggestions: true, * abortSignal: abortController.signal, * enableEarlyExecution: true, + * enableBatchResolvers: true, * hooks: { * asyncWorkFinished: () => {}, * }, @@ -743,6 +744,7 @@ export function validateExecutionArgs( subscribeFieldResolver, abortSignal: externalAbortSignal, enableEarlyExecution, + enableBatchResolvers, hooks, options, } = args; @@ -866,6 +868,7 @@ export function validateExecutionArgs( errorPropagation, externalAbortSignal: externalAbortSignal ?? undefined, enableEarlyExecution: enableEarlyExecution === true, + enableBatchResolvers: enableBatchResolvers === true, hooks: hooks ?? undefined, rawVariableValues, }; diff --git a/src/execution/incremental/IncrementalExecutor.ts b/src/execution/incremental/IncrementalExecutor.ts index 4601546f75..d75f36fe95 100644 --- a/src/execution/incremental/IncrementalExecutor.ts +++ b/src/execution/incremental/IncrementalExecutor.ts @@ -25,12 +25,13 @@ import type { GraphQLResolveInfo, } from '../../type/definition.ts'; +import { executePendingBatches } from '../batchResolve/executePendingBatches.ts'; +import { filterPendingBatchGroups } from '../batchResolve/filterPendingBatchGroups.ts'; import type { DeferUsage, FieldDetailsList, GroupedFieldSet, } from '../collectFields.ts'; -import { collectSubfields as _collectSubfields } from '../collectFields.ts'; import { collectIteratorPromises } from '../collectIteratorPromises.ts'; import type { SharedExecutionContext } from '../createSharedExecutionContext.ts'; import type { ValidatedExecutionArgs } from '../ExecutionArgs.ts'; @@ -407,8 +408,9 @@ export class IncrementalExecutor< validatedExecutionArgs: ValidatedExecutionArgs, sharedExecutionContext?: SharedExecutionContext, deferUsageSet?: DeferUsageSet, + rootGroupedFieldSet?: GroupedFieldSet, ) { - super(validatedExecutionArgs, sharedExecutionContext); + super(validatedExecutionArgs, sharedExecutionContext, rootGroupedFieldSet); this.deferUsageSet = deferUsageSet; this.groups = []; this.tasks = []; @@ -420,12 +422,14 @@ export class IncrementalExecutor< ) => IncrementalExecutor { const validatedExecutionArgs = this.validatedExecutionArgs; const sharedExecutionContext = this.sharedExecutionContext; + const rootGroupedFieldSet = this.rootGroupedFieldSet; return (deferUsageSet?: DeferUsageSet) => new IncrementalExecutor( validatedExecutionArgs, sharedExecutionContext, deferUsageSet, + rootGroupedFieldSet, ); } @@ -663,7 +667,11 @@ export class IncrementalExecutor< if (isPromise(result)) { return result.then( (resolved) => - this.buildExecutionGroupResult(deliveryGroups, path, resolved), + this.executeBatchesAndBuildExecutionGroupResult( + deliveryGroups, + path, + resolved, + ), (error: unknown) => { this.abort(); throw error; @@ -671,7 +679,50 @@ export class IncrementalExecutor< ); } - return this.buildExecutionGroupResult(deliveryGroups, path, result); + return this.executeBatchesAndBuildExecutionGroupResult( + deliveryGroups, + path, + result, + ); + } + + executeBatchesAndBuildExecutionGroupResult( + deliveryGroups: ReadonlyArray, + path: Path | undefined, + result: ObjMap, + ): PromiseOrValue { + if ( + !this.validatedExecutionArgs.enableBatchResolvers || + this.batchFieldGroups.size === 0 + ) { + return this.buildExecutionGroupResult(deliveryGroups, path, result); + } + + let maybePromisedResult: PromiseOrValue>; + try { + maybePromisedResult = executePendingBatches(this, result, path); + } catch (error) { + this.abort(); + throw error; + } + return isPromise(maybePromisedResult) + ? maybePromisedResult.then( + (resolvedResult) => + this.buildExecutionGroupResult( + deliveryGroups, + path, + resolvedResult, + ), + (error: unknown) => { + this.abort(); + throw error; + }, + ) + : this.buildExecutionGroupResult( + deliveryGroups, + path, + maybePromisedResult, + ); } buildExecutionGroupResult( @@ -679,12 +730,11 @@ export class IncrementalExecutor< path: Path | undefined, result: ObjMap, ): ExecutionGroupResult { - const data = result; const errors = this.collectedErrors.errors; return this.finish({ value: errors.length - ? { deliveryGroups, path: pathToArray(path), errors, data } - : { deliveryGroups, path: pathToArray(path), data }, + ? { deliveryGroups, path: pathToArray(path), errors, data: result } + : { deliveryGroups, path: pathToArray(path), data: result }, work: this.getIncrementalWork(), }); } @@ -700,6 +750,13 @@ export class IncrementalExecutor< 'Cancelled secondary to null within original result', ); + if (this.validatedExecutionArgs.enableBatchResolvers) { + this.batchFieldGroups = filterPendingBatchGroups( + this, + this.batchFieldGroups, + ); + } + const filteredTasks: Array = []; for (const task of tasks) { if (collectedErrors.hasNulledPosition(task.path)) { @@ -958,7 +1015,13 @@ export class IncrementalExecutor< undefined, ) .then( - (resolvedItem) => this.buildStreamItemResult(resolvedItem), + (resolvedItem) => + this.executeBatchesAndBuildStreamItemResult( + itemPath, + resolvedItem, + fieldDetailsList, + itemType, + ), (rawError: unknown) => { this.handleFieldError( rawError, @@ -998,7 +1061,13 @@ export class IncrementalExecutor< if (isPromise(result)) { return result .then( - (resolved) => this.buildStreamItemResult(resolved), + (resolved) => + this.executeBatchesAndBuildStreamItemResult( + itemPath, + resolved, + fieldDetailsList, + itemType, + ), (rawError: unknown) => { this.handleFieldError( rawError, @@ -1015,7 +1084,58 @@ export class IncrementalExecutor< }); } - return this.buildStreamItemResult(result); + return this.executeBatchesAndBuildStreamItemResult( + itemPath, + result, + fieldDetailsList, + itemType, + ); + } + + executeBatchesAndBuildStreamItemResult( + itemPath: Path, + item: unknown, + fieldDetailsList: FieldDetailsList, + itemType: GraphQLOutputType, + ): PromiseOrValue { + if ( + !this.validatedExecutionArgs.enableBatchResolvers || + this.batchFieldGroups.size === 0 + ) { + return this.buildStreamItemResult(item); + } + + let maybePromisedItem: PromiseOrValue; + try { + maybePromisedItem = executePendingBatches(this, item, itemPath); + } catch (rawError) { + try { + this.handleFieldError(rawError, itemType, fieldDetailsList, itemPath); + return this.buildStreamItemResult(null); + } catch (error) { + this.abort(); + throw error; + } + } + return isPromise(maybePromisedItem) + ? maybePromisedItem + .then( + (resolvedItem) => this.buildStreamItemResult(resolvedItem), + (rawError: unknown) => { + this.handleFieldError( + rawError, + itemType, + fieldDetailsList, + itemPath, + ); + return this.buildStreamItemResult(null); + }, + ) + .then(undefined, (error: unknown) => { + this.abort(); + throw error; + }) + : this.buildStreamItemResult(maybePromisedItem); } buildStreamItemResult(result: unknown): StreamItemResult { diff --git a/src/execution/legacyIncremental/BranchingIncrementalExecutor.ts b/src/execution/legacyIncremental/BranchingIncrementalExecutor.ts index 3b9c9fc7ff..8a113e5a70 100644 --- a/src/execution/legacyIncremental/BranchingIncrementalExecutor.ts +++ b/src/execution/legacyIncremental/BranchingIncrementalExecutor.ts @@ -290,12 +290,14 @@ export class BranchingIncrementalExecutor extends IncrementalExecutor BranchingIncrementalExecutor { const validatedExecutionArgs = this.validatedExecutionArgs; const sharedExecutionContext = this.sharedExecutionContext; + const rootGroupedFieldSet = this.rootGroupedFieldSet; return (deferUsageSet?: DeferUsageSet) => new BranchingIncrementalExecutor( validatedExecutionArgs, sharedExecutionContext, deferUsageSet, + rootGroupedFieldSet, ); } diff --git a/src/index.ts b/src/index.ts index 219478ad42..7544e14f48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export { enableDevMode, isDevModeEnabled } from './devMode.ts'; // `graphql:*` channel context payloads. Channels are auto-registered on // `node:diagnostics_channel` at module load. export type { + GraphQLBatchResolveContext, GraphQLChannelContextByName, GraphQLChannels, GraphQLExecuteContext, @@ -199,6 +200,7 @@ export type { GraphQLDirectiveExtensions, GraphQLArgumentConfig, GraphQLArgumentExtensions, + GraphQLBatchedResolveInfo, GraphQLEnumTypeConfig, GraphQLEnumTypeExtensions, GraphQLEnumValueConfig, @@ -209,6 +211,7 @@ export type { GraphQLFieldConfigMap, GraphQLFieldExtensions, GraphQLFieldMap, + GraphQLFieldBatchResolver, GraphQLFieldResolver, GraphQLInputFieldConfig, GraphQLInputFieldConfigMap, diff --git a/src/type/__tests__/definition-test.ts b/src/type/__tests__/definition-test.ts index a9173e9499..4b5b931868 100644 --- a/src/type/__tests__/definition-test.ts +++ b/src/type/__tests__/definition-test.ts @@ -213,6 +213,7 @@ describe('Type System: Objects', () => { }, }, resolve: passThroughFunc, + experimentalBatchResolve: passThroughFunc, subscribe: passThroughFunc, deprecationReason: 'Field deprecation reason.', extensions: { someExtension: 'extension' }, @@ -336,6 +337,7 @@ describe('Type System: Objects', () => { type: ScalarType, args: [], resolve: undefined, + experimentalBatchResolve: undefined, subscribe: undefined, deprecationReason: undefined, extensions: {}, @@ -364,6 +366,7 @@ describe('Type System: Objects', () => { description: undefined, type: ScalarType, resolve: undefined, + experimentalBatchResolve: undefined, subscribe: undefined, deprecationReason: undefined, extensions: {}, @@ -502,6 +505,7 @@ describe('Type System: Interfaces', () => { }, }, resolve: passThroughFunc, + experimentalBatchResolve: passThroughFunc, subscribe: passThroughFunc, deprecationReason: 'Field deprecation reason.', extensions: { someExtension: 'extension' }, @@ -540,6 +544,7 @@ describe('Type System: Interfaces', () => { type: ScalarType, args: [], resolve: undefined, + experimentalBatchResolve: undefined, subscribe: undefined, deprecationReason: undefined, extensions: {}, diff --git a/src/type/definition.ts b/src/type/definition.ts index 3c0269a028..7124cb15b7 100644 --- a/src/type/definition.ts +++ b/src/type/definition.ts @@ -2815,6 +2815,25 @@ export type GraphQLFieldResolver< info: GraphQLResolveInfo, ) => TResult; +/** + * Experimental resolver for a batch of GraphQL field executions. + * @typeParam TSource - Source object type passed to resolvers. + * @typeParam TContext - Context object type passed to resolvers. + * @typeParam TArgs - Argument object type passed to resolvers. + * @typeParam TResult - Result value type for each source object. + */ +export type GraphQLFieldBatchResolver< + TSource, + TContext, + TArgs = any, + TResult = unknown, +> = ( + sources: ReadonlyArray, + args: TArgs, + context: TContext, + info: GraphQLBatchedResolveInfo, +) => PromiseOrValue>; + /** Utilities available from resolver info for tracking asynchronous work. */ export interface GraphQLResolveInfoHelpers { /** @@ -2877,6 +2896,15 @@ export interface GraphQLResolveInfo { readonly getAsyncHelpers: () => GraphQLResolveInfoHelpers; } +/** Information about a batch of currently executing GraphQL fields. */ +export interface GraphQLBatchedResolveInfo extends Omit< + GraphQLResolveInfo, + 'path' +> { + /** Response paths for each source value in the batch. */ + readonly paths: ReadonlyArray; +} + /** * Custom extensions * @remarks @@ -2909,6 +2937,10 @@ export interface GraphQLFieldConfig { args?: GraphQLFieldConfigArgumentMap | undefined; /** Resolver function used to produce this field value. */ resolve?: GraphQLFieldResolver | undefined; + /** Experimental resolver used to produce this field value for multiple sources. */ + experimentalBatchResolve?: + | GraphQLFieldBatchResolver + | undefined; /** Resolver function used to create a subscription event stream for this field. */ subscribe?: GraphQLFieldResolver | undefined; /** Reason this element is deprecated, if one was provided. */ @@ -3022,6 +3054,10 @@ export class GraphQLField< args: ReadonlyArray; /** Resolver function used to produce this field value. */ resolve?: GraphQLFieldResolver | undefined; + /** Experimental resolver used to produce this field value for multiple sources. */ + experimentalBatchResolve?: + | GraphQLFieldBatchResolver + | undefined; /** Resolver function used to create a subscription event stream for this field. */ subscribe?: GraphQLFieldResolver | undefined; /** Reason this element is deprecated, if one was provided. */ @@ -3090,6 +3126,7 @@ export class GraphQLField< : []; this.resolve = config.resolve; + this.experimentalBatchResolve = config.experimentalBatchResolve; this.subscribe = config.subscribe; this.deprecationReason = config.deprecationReason; this.extensions = toObjMapWithSymbols(config.extensions); @@ -3131,6 +3168,7 @@ export class GraphQLField< (arg) => arg.toConfig(), ), resolve: this.resolve, + experimentalBatchResolve: this.experimentalBatchResolve, subscribe: this.subscribe, deprecationReason: this.deprecationReason, extensions: this.extensions, diff --git a/src/type/index.ts b/src/type/index.ts index 0f7f6206a9..e3a42e73ec 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -105,6 +105,7 @@ export type { ThunkObjMap, GraphQLArgumentConfig, GraphQLArgumentExtensions, + GraphQLBatchedResolveInfo, GraphQLEnumTypeConfig, GraphQLEnumTypeExtensions, GraphQLEnumValueConfig, @@ -115,6 +116,7 @@ export type { GraphQLFieldConfigMap, GraphQLFieldExtensions, GraphQLFieldMap, + GraphQLFieldBatchResolver, GraphQLFieldResolver, GraphQLInputFieldConfig, GraphQLInputFieldConfigMap, diff --git a/tsconfig.json b/tsconfig.json index 3987e50c4d..ecd71be9fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "src/**/*", "integrationTests/*", "resources/**/*", - "benchmark/benchmark.ts" + "benchmark/**/*.ts" ], "compilerOptions": { "lib": ["es2024"],