diff --git a/change/@graphitation-supermassive-8876a266-3c69-4f89-819b-4fc57b5b4470.json b/change/@graphitation-supermassive-8876a266-3c69-4f89-819b-4fc57b5b4470.json new file mode 100644 index 000000000..625ed330f --- /dev/null +++ b/change/@graphitation-supermassive-8876a266-3c69-4f89-819b-4fc57b5b4470.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "early execution and merge", + "packageName": "@graphitation/supermassive", + "email": "pavelglac@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/supermassive/src/__tests__/executeWithSchema.test.ts b/packages/supermassive/src/__tests__/executeWithSchema.test.ts new file mode 100644 index 000000000..24d27a604 --- /dev/null +++ b/packages/supermassive/src/__tests__/executeWithSchema.test.ts @@ -0,0 +1,429 @@ +import { parse } from "graphql"; +import { executeWithSchema } from "../executeWithSchema"; + +describe("executeWithSchema - @defer behavior", () => { + function createDeferred() { + let resolve: (value: T) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve: resolve! }; + } + + const definitions = parse(` + type Query { obj: Obj } + type Obj { + critical: String + deferred: String + } + `); + + const document = parse(` + { + obj { + critical + ... on Obj @defer { + deferred + } + } + } + `); + + type Resolver = () => string | Promise; + + function executeTestQuery( + Obj: { critical: Resolver; deferred: Resolver }, + enableIncrementalPayloadBatching?: number, + ) { + return Promise.resolve( + executeWithSchema({ + document, + definitions, + enableEarlyExecution: true, + enableDeferredMerge: true, + enableIncrementalPayloadBatching, + resolvers: { + Query: { + obj: () => ({}), + }, + Obj, + }, + }), + ); + } + + test("includes deferred fields in the initial response when they complete within the merge timeout", async () => { + const result = await executeTestQuery( + { + critical: () => "critical", + deferred: () => + new Promise((resolve) => setTimeout(resolve, 0, "deferred")), + }, + 10, + ); + + expect(result).toEqual({ + data: { + obj: { + critical: "critical", + deferred: "deferred", + }, + }, + }); + expect("initialResult" in (result as object)).toBe(false); + }); + + test("folds a synchronous deferred field into the initial response instead of streaming it", async () => { + const result = await executeTestQuery({ + critical: () => "critical", + deferred: () => "deferred", + }); + + // Both fields resolve synchronously, so the deferred fragment is already + // complete by the time the response is built. The optimization folds it + // into the initial data rather than emitting a redundant incremental + // payload, producing a single (non-incremental) result. + expect(result).toEqual({ + data: { + obj: { + critical: "critical", + deferred: "deferred", + }, + }, + }); + expect("initialResult" in (result as object)).toBe(false); + }); + + const twoDeferDefinitions = parse(` + type Query { obj: Obj } + type Obj { + critical: String + deferredEarly: String + deferredLate: String + } + `); + + const twoDeferDocument = parse(` + { + obj { + critical + ... on Obj @defer(label: "early") { + deferredEarly + } + ... on Obj @defer(label: "late") { + deferredLate + } + } + } + `); + + function executeTwoDeferQuery(Obj: { + critical: Resolver; + deferredEarly: Resolver; + deferredLate: Resolver; + }) { + return Promise.resolve( + executeWithSchema({ + document: twoDeferDocument, + definitions: twoDeferDefinitions, + enableEarlyExecution: true, + enableDeferredMerge: true, + resolvers: { + Query: { + obj: () => ({}), + }, + Obj, + }, + }), + ); + } + + test("folds the deferred fragment that settled before critical and streams the one that settled after", async () => { + const critical = createDeferred(); + const late = createDeferred(); + + const resultPromise = executeTwoDeferQuery({ + // Resolves synchronously, so this fragment completes before critical. + deferredEarly: () => "early", + // Stays pending past the initial response, so this fragment streams. + deferredLate: () => late.promise, + critical: () => critical.promise, + }); + + setTimeout(() => critical.resolve("critical"), 0); + + const result = await resultPromise; + + // The early fragment is folded into the initial response; the late one is + // still pending, so the overall result is incremental. + expect(result).toMatchObject({ + initialResult: { + data: { + obj: { + critical: "critical", + deferredEarly: "early", + }, + }, + hasNext: true, + }, + }); + + if (!("initialResult" in result)) { + throw new Error("Expected an incremental result"); + } + + expect(result.initialResult.data).toEqual({ + obj: { critical: "critical", deferredEarly: "early" }, + }); + + const subsequentResultPromise = result.subsequentResults.next(); + late.resolve("late"); + + await expect(subsequentResultPromise).resolves.toMatchObject({ + value: { + incremental: [ + { + data: { + deferredLate: "late", + }, + path: ["obj"], + }, + ], + hasNext: false, + }, + done: false, + }); + }); + + // Schema where the critical field is non-null: when it errors, the null + // bubbles up and destroys the whole `obj` branch the deferred fragment is + // anchored to. + const nonNullCriticalDefinitions = parse(` + type Query { obj: Obj } + type Obj { + critical: String! + deferred: String + } + `); + + function executeNonNullCriticalQuery(Obj: { + critical: Resolver; + deferred: Resolver; + }) { + return Promise.resolve( + executeWithSchema({ + document, + definitions: nonNullCriticalDefinitions, + resolvers: { + Query: { + obj: () => ({}), + }, + Obj, + }, + }), + ); + } + + test("does not fold a synchronously-completed deferred fragment when a non-null sibling error nulls its parent", async () => { + const result = await executeNonNullCriticalQuery({ + critical: () => { + throw new Error("Critical boom"); + }, + deferred: () => "deferred", + }); + + // The non-null `critical` error bubbles up and nulls `obj`. The deferred + // fragment completed synchronously, but its anchor object is gone, so it + // must not be folded into the (now null) branch. + expect(result).toMatchObject({ + data: { + obj: null, + }, + }); + expect("initialResult" in (result as object)).toBe(false); + const errors = (result as { errors?: ReadonlyArray<{ message: string }> }) + .errors; + expect(errors).toHaveLength(1); + expect(errors?.[0].message).toBe("Critical boom"); + }); + + const messageListDefinitions = parse(` + type Query { messages: [Message!]! } + type Message { + id: String! + name: String + } + `); + + const messageListDocument = parse(` + { + messages { + id + ... on Message @defer { + name + } + } + } + `); + + test("batches deferred patches that complete within the batching timeout", async () => { + const firstName = createDeferred(); + const secondName = createDeferred(); + const thirdName = createDeferred(); + + const result = await Promise.resolve( + executeWithSchema({ + document: messageListDocument, + definitions: messageListDefinitions, + enableEarlyExecution: true, + enableDeferredMerge: true, + enableIncrementalPayloadBatching: 10, + resolvers: { + Query: { + messages: () => [ + { id: "1", name: firstName.promise }, + { id: "2", name: secondName.promise }, + { id: "3", name: thirdName.promise }, + ], + }, + }, + }), + ); + + expect(result).toMatchObject({ + initialResult: { + data: { + messages: [{ id: "1" }, { id: "2" }, { id: "3" }], + }, + hasNext: true, + }, + }); + + if (!("initialResult" in result)) { + throw new Error("Expected an incremental result"); + } + + const subsequentResultPromise = result.subsequentResults.next(); + firstName.resolve("Ada"); + setTimeout(() => secondName.resolve("Grace"), 0); + setTimeout(() => thirdName.resolve("Linus"), 0); + + await expect(subsequentResultPromise).resolves.toMatchObject({ + value: { + incremental: [ + { + data: { + name: "Ada", + }, + path: ["messages", 0], + }, + { + data: { + name: "Grace", + }, + path: ["messages", 1], + }, + { + data: { + name: "Linus", + }, + path: ["messages", 2], + }, + ], + hasNext: false, + }, + done: false, + }); + }); + + test("emits deferred patches individually without additional waiting by default", async () => { + const firstName = createDeferred(); + const secondName = createDeferred(); + const thirdName = createDeferred(); + + const resultPromise = Promise.resolve( + executeWithSchema({ + document: messageListDocument, + definitions: messageListDefinitions, + enableEarlyExecution: true, + enableDeferredMerge: true, + resolvers: { + Query: { + messages: () => [ + { id: "1", name: firstName.promise }, + { id: "2", name: secondName.promise }, + { id: "3", name: thirdName.promise }, + ], + }, + }, + }), + ); + + const result = await Promise.race([ + resultPromise, + new Promise<"blocked">((resolve) => setTimeout(resolve, 0, "blocked")), + ]); + + if (result === "blocked") { + throw new Error("Initial response waited without batching enabled"); + } + + if (!("initialResult" in result)) { + throw new Error("Expected an incremental result"); + } + + const firstSubsequentResultPromise = result.subsequentResults.next(); + firstName.resolve("Ada"); + + await expect(firstSubsequentResultPromise).resolves.toMatchObject({ + value: { + incremental: [ + { + data: { + name: "Ada", + }, + path: ["messages", 0], + }, + ], + hasNext: true, + }, + done: false, + }); + + const secondSubsequentResultPromise = result.subsequentResults.next(); + secondName.resolve("Grace"); + + await expect(secondSubsequentResultPromise).resolves.toMatchObject({ + value: { + incremental: [ + { + data: { + name: "Grace", + }, + path: ["messages", 1], + }, + ], + hasNext: true, + }, + done: false, + }); + + const thirdSubsequentResultPromise = result.subsequentResults.next(); + thirdName.resolve("Linus"); + + await expect(thirdSubsequentResultPromise).resolves.toMatchObject({ + value: { + incremental: [ + { + data: { + name: "Linus", + }, + path: ["messages", 2], + }, + ], + hasNext: false, + }, + done: false, + }); + }); +}); diff --git a/packages/supermassive/src/executeWithSchema.ts b/packages/supermassive/src/executeWithSchema.ts index 2b5d242a8..d4f9808d6 100644 --- a/packages/supermassive/src/executeWithSchema.ts +++ b/packages/supermassive/src/executeWithSchema.ts @@ -16,6 +16,9 @@ export function executeWithSchema({ typeResolver, fieldExecutionHooks, enablePerEventContext, + enableEarlyExecution, + enableDeferredMerge, + enableIncrementalPayloadBatching, }: ExecutionWithSchemaArgs): PromiseOrValue { const extracted = extractMinimalViableSchemaForRequestDocument( buildASTSchema(definitions), @@ -36,5 +39,8 @@ export function executeWithSchema({ typeResolver, fieldExecutionHooks, enablePerEventContext, + enableEarlyExecution, + enableDeferredMerge, + enableIncrementalPayloadBatching, }); } diff --git a/packages/supermassive/src/executeWithoutSchema.ts b/packages/supermassive/src/executeWithoutSchema.ts index 340c4523c..b4e947a89 100644 --- a/packages/supermassive/src/executeWithoutSchema.ts +++ b/packages/supermassive/src/executeWithoutSchema.ts @@ -13,8 +13,10 @@ import { collectSubfields as _collectSubfields, FieldGroup, GroupedFieldSet, + PatchFields, } from "./collectFields"; import { devAssert } from "./jsutils/devAssert"; +import { getObjectAtPath } from "./jsutils/getObjectAtPath"; import { inspect } from "./jsutils/inspect"; import { invariant } from "./jsutils/invariant"; import { isIterableObject } from "./jsutils/isIterableObject"; @@ -122,6 +124,9 @@ export interface ExecutionContext { fieldExecutionHooks?: ExecutionHooks; subsequentPayloads: Set; enablePerEventContext: boolean; + enableEarlyExecution: boolean; + enableDeferredMerge: boolean; + enableIncrementalPayloadBatching?: number; } /** @@ -193,6 +198,9 @@ function buildExecutionContext( subscribeFieldResolver, fieldExecutionHooks, enablePerEventContext, + enableEarlyExecution, + enableDeferredMerge, + enableIncrementalPayloadBatching, } = args; assertValidExecutionArguments(document, variableValues); @@ -262,6 +270,9 @@ function buildExecutionContext( fieldExecutionHooks, subsequentPayloads: new Set(), enablePerEventContext: enablePerEventContext ?? true, + enableEarlyExecution: enableEarlyExecution ?? false, + enableDeferredMerge: enableDeferredMerge ?? false, + enableIncrementalPayloadBatching, }; } @@ -328,6 +339,7 @@ function executeOperation( path, groupedFieldSet, undefined, + exeContext.enableEarlyExecution ? patches : undefined, ); result = buildResponse(exeContext, result); break; @@ -338,6 +350,7 @@ function executeOperation( rootValue, path, groupedFieldSet, + exeContext.enableEarlyExecution ? patches : undefined, ); result = buildResponse(exeContext, result); break; @@ -359,16 +372,18 @@ function executeOperation( ); } - for (const patch of patches) { - const { label, groupedFieldSet: patchGroupedFieldSet } = patch; - executeDeferredFragment( - exeContext, - rootTypeName, - rootValue, - patchGroupedFieldSet, - label, - path, - ); + if (!exeContext.enableEarlyExecution) { + for (const patch of patches) { + const { label, groupedFieldSet: patchGroupedFieldSet } = patch; + executeDeferredFragment( + exeContext, + rootTypeName, + rootValue, + patchGroupedFieldSet, + label, + path, + ); + } } return result; @@ -385,6 +400,7 @@ function executeOperation( function buildResponse( exeContext: ExecutionContext, data: PromiseOrValue | null>, + deferredMergeAwaited = false, ): PromiseOrValue { if (isPromise(data)) { return data.then( @@ -398,6 +414,22 @@ function buildResponse( const hooks = exeContext.fieldExecutionHooks; try { + if (exeContext.enableDeferredMerge && data !== null) { + const pendingDeferredFragments = + includeCompletedDeferredFragmentsInResult(exeContext, data); + const batchTimeout = exeContext.enableIncrementalPayloadBatching; + if ( + typeof batchTimeout === "number" && + !deferredMergeAwaited && + pendingDeferredFragments.length > 0 + ) { + return raceIncrementalPayloadBatch( + pendingDeferredFragments, + batchTimeout, + ).then(() => buildResponse(exeContext, data, true)); + } + } + const initialResult = exeContext.errors.length === 0 ? { data } @@ -442,7 +474,18 @@ function executeFieldsSerially( sourceValue: unknown, path: Path | undefined, groupedFieldSet: GroupedFieldSet, + patches?: Array, ): PromiseOrValue> { + if (exeContext.enableEarlyExecution) { + startExecutingPatches( + exeContext, + parentTypeName, + sourceValue, + path, + patches, + undefined, + ); + } return promiseReduce( groupedFieldSet, (results, [responseName, fieldGroup]) => { @@ -486,10 +529,22 @@ function executeFields( path: Path | undefined, groupedFieldSet: GroupedFieldSet, incrementalDataRecord: IncrementalDataRecord | undefined, + patches?: Array, ): PromiseOrValue> { const results = Object.create(null); let containsPromise = false; + if (exeContext.enableEarlyExecution) { + startExecutingPatches( + exeContext, + parentTypeName, + sourceValue, + path, + patches, + incrementalDataRecord, + ); + } + try { for (const [responseName, fieldGroup] of groupedFieldSet) { const fieldPath = addPath(path, responseName, parentTypeName); @@ -530,6 +585,66 @@ function executeFields( return promiseForObject(results); } +function startExecutingPatches( + exeContext: ExecutionContext, + parentTypeName: string, + sourceValue: unknown, + path: Path | undefined, + patches: Array | undefined, + incrementalDataRecord: IncrementalDataRecord | undefined, +): void { + if (!patches) { + return; + } + + for (const { label, groupedFieldSet: patchGroupedFieldSet } of patches) { + executeDeferredFragment( + exeContext, + parentTypeName, + sourceValue, + patchGroupedFieldSet, + label, + path, + incrementalDataRecord, + ); + } +} + +function includeCompletedDeferredFragmentsInResult( + exeContext: ExecutionContext, + result: ObjMap, +): Array> { + const pendingDeferredFragments: Array> = []; + + for (const incrementalDataRecord of exeContext.subsequentPayloads) { + if (!isDeferredFragmentRecord(incrementalDataRecord)) { + continue; + } + + if (!incrementalDataRecord.isCompleted) { + pendingDeferredFragments.push(incrementalDataRecord.promise); + continue; + } + + const target = getObjectAtPath(result, incrementalDataRecord.path); + if (!target) { + continue; + } + + if (incrementalDataRecord.data != null) { + Object.assign(target, incrementalDataRecord.data); + } + + if (incrementalDataRecord.errors.length > 0) { + exeContext.errors.push(...incrementalDataRecord.errors); + } + + exeContext.subsequentPayloads.delete(incrementalDataRecord); + } + + return pendingDeferredFragments; +} + /** * Implements the "Executing field" section of the spec * In particular, this function figures out the value that the field returns by @@ -2003,6 +2118,18 @@ function collectAndExecuteSubfields( const { groupedFieldSet: subGroupedFieldSet, patches: subPatches } = collectSubfields(exeContext, { name: returnTypeName }, fieldGroup); + if (exeContext.enableEarlyExecution) { + return executeFields( + exeContext, + returnTypeName, + result, + path, + subGroupedFieldSet, + incrementalDataRecord, + subPatches, + ); + } + const subFields = executeFields( exeContext, returnTypeName, @@ -2794,6 +2921,39 @@ function getCompletedIncrementalResults( return incrementalResults; } +async function getCompletedIncrementalResultsWithBatching( + exeContext: ExecutionContext, + batchTimeout: number, +): Promise> { + const incremental = getCompletedIncrementalResults(exeContext); + + if (incremental.length && exeContext.subsequentPayloads.size > 0) { + await raceIncrementalPayloadBatch( + Array.from(exeContext.subsequentPayloads).map((p) => p.promise), + batchTimeout, + ); + incremental.push(...getCompletedIncrementalResults(exeContext)); + } + + return incremental; +} + +function raceIncrementalPayloadBatch( + promises: Array>, + batchTimeout: number, +): Promise { + return Promise.race([ + Promise.all(promises).then(() => undefined), + waitForIncrementalPayloadBatchTimeout(batchTimeout), + ]); +} + +function waitForIncrementalPayloadBatchTimeout( + batchTimeout: number, +): Promise { + return new Promise((resolve) => setTimeout(resolve, batchTimeout)); +} + function yieldSubsequentPayloads( exeContext: ExecutionContext, ): AsyncGenerator { @@ -2815,7 +2975,14 @@ function yieldSubsequentPayloads( return { value: undefined, done: true }; } - const incremental = getCompletedIncrementalResults(exeContext); + const batchTimeout = exeContext.enableIncrementalPayloadBatching; + const incremental = + typeof batchTimeout === "number" + ? await getCompletedIncrementalResultsWithBatching( + exeContext, + batchTimeout, + ) + : getCompletedIncrementalResults(exeContext); const hasNext = exeContext.subsequentPayloads.size > 0; if (!incremental.length && hasNext) { @@ -2875,6 +3042,12 @@ function isStreamItemsRecord( return incrementalDataRecord.type === "stream"; } +function isDeferredFragmentRecord( + incrementalDataRecord: IncrementalDataRecord, +): incrementalDataRecord is DeferredFragmentRecord { + return incrementalDataRecord.type === "defer"; +} + class DeferredFragmentRecord { type: "defer"; errors: Array; @@ -2906,10 +3079,12 @@ class DeferredFragmentRecord { this._resolve = (promiseOrValue) => { resolve(promiseOrValue); }; - }).then((data) => { - this.data = data; - this.isCompleted = true; - }); + }).then((data) => this.complete(data)); + } + + private complete(data: ObjMap | null): void { + this.data = data; + this.isCompleted = true; } addData(data: PromiseOrValue | null>) { @@ -2918,6 +3093,9 @@ class DeferredFragmentRecord { this._resolve?.(parentData.then(() => data)); return; } + if (!isPromise(data)) { + this.complete(data); + } this._resolve?.(data); } } diff --git a/packages/supermassive/src/jsutils/getObjectAtPath.ts b/packages/supermassive/src/jsutils/getObjectAtPath.ts new file mode 100644 index 000000000..a1b1d4501 --- /dev/null +++ b/packages/supermassive/src/jsutils/getObjectAtPath.ts @@ -0,0 +1,22 @@ +import type { ObjMap } from "./ObjMap"; + +/** + * Walks `result` following `path` and returns the nested value, but only when + * that value is a non-null object (a valid `Object.assign` target). Returns + * `undefined` if any segment is missing or the resolved value is not an object. + */ +export function getObjectAtPath( + result: ObjMap, + path: ReadonlyArray, +): ObjMap | undefined { + let target = result; + for (const key of path) { + const next = target[key]; + if (next === null || typeof next !== "object") { + return undefined; + } + target = next as ObjMap; + } + + return target; +} diff --git a/packages/supermassive/src/types.ts b/packages/supermassive/src/types.ts index 457134685..e4a439640 100644 --- a/packages/supermassive/src/types.ts +++ b/packages/supermassive/src/types.ts @@ -257,6 +257,9 @@ export interface CommonExecutionArgs { subscribeFieldResolver?: Maybe>; fieldExecutionHooks?: ExecutionHooks; enablePerEventContext?: boolean; + enableEarlyExecution?: boolean; + enableDeferredMerge?: boolean; + enableIncrementalPayloadBatching?: number; } export type ExecutionWithoutSchemaArgs = CommonExecutionArgs & { schemaFragment: SchemaFragment;