From 05e7a2983d649dc84f6bc0e671b2349ccc56d6b4 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Thu, 18 Apr 2024 16:45:58 +0300 Subject: [PATCH 01/26] incremental: introduce GraphQLWrappedResult to avoid filtering (#4026) following https://github.com/graphql/graphql-spec/pull/1077 now part of the following PR stack, with the laters PRs extracted from this one #4026: incremental: introduce GraphQLWrappedResult to avoid filtering #4050: perf: allow skipping of field plan generation #4051: perf: introduce completePromisedListItemValue #4052: refactor: introduce completeIterableValue #4053: perf: optimize completion loops #4046: perf: use undefined for empty --- src/execution/IncrementalPublisher.ts | 952 ++++++++-------- src/execution/__tests__/defer-test.ts | 50 +- src/execution/__tests__/executor-test.ts | 51 + src/execution/__tests__/stream-test.ts | 68 +- src/execution/buildFieldPlan.ts | 50 +- src/execution/execute.ts | 1250 ++++++++++++---------- src/jsutils/promiseForObject.ts | 7 +- 7 files changed, 1207 insertions(+), 1221 deletions(-) diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 1ca31acb88..b5f66b6322 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -1,6 +1,8 @@ +import { isPromise } from '../jsutils/isPromise.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; import type { Path } from '../jsutils/Path.js'; import { pathToArray } from '../jsutils/Path.js'; +import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; import type { @@ -8,14 +10,6 @@ import type { GraphQLFormattedError, } from '../error/GraphQLError.js'; -import type { GroupedFieldSet } from './buildFieldPlan.js'; - -interface IncrementalUpdate> { - pending: ReadonlyArray; - incremental: ReadonlyArray>; - completed: ReadonlyArray; -} - /** * The result of GraphQL execution. * @@ -78,7 +72,10 @@ export interface FormattedInitialIncrementalExecutionResult< export interface SubsequentIncrementalExecutionResult< TData = unknown, TExtensions = ObjMap, -> extends Partial> { +> { + pending?: ReadonlyArray; + incremental?: ReadonlyArray>; + completed?: ReadonlyArray; hasNext: boolean; extensions?: TExtensions; } @@ -94,12 +91,15 @@ export interface FormattedSubsequentIncrementalExecutionResult< extensions?: TExtensions; } +interface BareDeferredGroupedFieldSetResult> { + errors?: ReadonlyArray; + data: TData; +} + export interface IncrementalDeferResult< TData = ObjMap, TExtensions = ObjMap, -> { - errors?: ReadonlyArray; - data: TData; +> extends BareDeferredGroupedFieldSetResult { id: string; subPath?: ReadonlyArray; extensions?: TExtensions; @@ -116,12 +116,15 @@ export interface FormattedIncrementalDeferResult< extensions?: TExtensions; } -export interface IncrementalStreamResult< - TData = Array, - TExtensions = ObjMap, -> { +interface BareStreamItemsResult> { errors?: ReadonlyArray; items: TData; +} + +export interface IncrementalStreamResult< + TData = ReadonlyArray, + TExtensions = ObjMap, +> extends BareStreamItemsResult { id: string; subPath?: ReadonlyArray; extensions?: TExtensions; @@ -166,214 +169,209 @@ export interface FormattedCompletedResult { errors?: ReadonlyArray; } +export function buildIncrementalResponse( + context: IncrementalPublisherContext, + result: ObjMap, + errors: ReadonlyArray, + incrementalDataRecords: ReadonlyArray, +): ExperimentalIncrementalExecutionResults { + const incrementalPublisher = new IncrementalPublisher(context); + return incrementalPublisher.buildResponse( + result, + errors, + incrementalDataRecords, + ); +} + +interface IncrementalPublisherContext { + cancellableStreams: Set; +} + /** * This class is used to publish incremental results to the client, enabling semi-concurrent * execution while preserving result order. * - * The internal publishing state is managed as follows: - * - * '_released': the set of Subsequent Result records that are ready to be sent to the client, - * i.e. their parents have completed and they have also completed. - * - * `_pending`: the set of Subsequent Result records that are definitely pending, i.e. their - * parents have completed so that they can no longer be filtered. This includes all Subsequent - * Result records in `released`, as well as the records that have not yet completed. - * * @internal */ -export class IncrementalPublisher { - private _nextId = 0; - private _released: Set; +class IncrementalPublisher { + private _context: IncrementalPublisherContext; + private _nextId: number; private _pending: Set; - + private _completedResultQueue: Array; + private _newPending: Set; + private _incremental: Array; + private _completed: Array; // these are assigned within the Promise executor called synchronously within the constructor private _signalled!: Promise; private _resolve!: () => void; - constructor() { - this._released = new Set(); + constructor(context: IncrementalPublisherContext) { + this._context = context; + this._nextId = 0; this._pending = new Set(); + this._completedResultQueue = []; + this._newPending = new Set(); + this._incremental = []; + this._completed = []; this._reset(); } - reportNewDeferFragmentRecord( - deferredFragmentRecord: DeferredFragmentRecord, - parentIncrementalResultRecord: - | InitialResultRecord - | DeferredFragmentRecord - | StreamItemsRecord, - ): void { - parentIncrementalResultRecord.children.add(deferredFragmentRecord); - } + buildResponse( + data: ObjMap, + errors: ReadonlyArray, + incrementalDataRecords: ReadonlyArray, + ): ExperimentalIncrementalExecutionResults { + this._addIncrementalDataRecords(incrementalDataRecords); + this._pruneEmpty(); - reportNewDeferredGroupedFieldSetRecord( - deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord, - ): void { - for (const deferredFragmentRecord of deferredGroupedFieldSetRecord.deferredFragmentRecords) { - deferredFragmentRecord._pending.add(deferredGroupedFieldSetRecord); - deferredFragmentRecord.deferredGroupedFieldSetRecords.add( - deferredGroupedFieldSetRecord, - ); - } + const pending = this._pendingSourcesToResults(); + + const initialResult: InitialIncrementalExecutionResult = + errors.length === 0 + ? { data, pending, hasNext: true } + : { errors, data, pending, hasNext: true }; + + return { + initialResult, + subsequentResults: this._subscribe(), + }; } - reportNewStreamItemsRecord( - streamItemsRecord: StreamItemsRecord, - parentIncrementalDataRecord: IncrementalDataRecord, + private _addIncrementalDataRecords( + incrementalDataRecords: ReadonlyArray, ): void { - if (isDeferredGroupedFieldSetRecord(parentIncrementalDataRecord)) { - for (const parent of parentIncrementalDataRecord.deferredFragmentRecords) { - parent.children.add(streamItemsRecord); + for (const incrementalDataRecord of incrementalDataRecords) { + if (isDeferredGroupedFieldSetRecord(incrementalDataRecord)) { + for (const deferredFragmentRecord of incrementalDataRecord.deferredFragmentRecords) { + deferredFragmentRecord.expectedReconcilableResults++; + + this._addDeferredFragmentRecord(deferredFragmentRecord); + } + + const result = incrementalDataRecord.result; + if (isPromise(result)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + result.then((resolved) => { + this._enqueueCompletedDeferredGroupedFieldSet(resolved); + }); + } else { + this._enqueueCompletedDeferredGroupedFieldSet(result); + } + + continue; } - } else { - parentIncrementalDataRecord.children.add(streamItemsRecord); - } - } - completeDeferredGroupedFieldSet( - deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord, - data: ObjMap, - ): void { - deferredGroupedFieldSetRecord.data = data; - for (const deferredFragmentRecord of deferredGroupedFieldSetRecord.deferredFragmentRecords) { - deferredFragmentRecord._pending.delete(deferredGroupedFieldSetRecord); - if (deferredFragmentRecord._pending.size === 0) { - this.completeDeferredFragmentRecord(deferredFragmentRecord); + const streamRecord = incrementalDataRecord.streamRecord; + if (streamRecord.id === undefined) { + this._newPending.add(streamRecord); } - } - } - markErroredDeferredGroupedFieldSet( - deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord, - error: GraphQLError, - ): void { - for (const deferredFragmentRecord of deferredGroupedFieldSetRecord.deferredFragmentRecords) { - deferredFragmentRecord.errors.push(error); - this.completeDeferredFragmentRecord(deferredFragmentRecord); + const result = incrementalDataRecord.result; + if (isPromise(result)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + result.then((resolved) => { + this._enqueueCompletedStreamItems(resolved); + }); + } else { + this._enqueueCompletedStreamItems(result); + } } } - completeDeferredFragmentRecord( + private _addDeferredFragmentRecord( deferredFragmentRecord: DeferredFragmentRecord, ): void { - this._release(deferredFragmentRecord); - } - - completeStreamItemsRecord( - streamItemsRecord: StreamItemsRecord, - items: Array, - ) { - streamItemsRecord.items = items; - streamItemsRecord.isCompleted = true; - this._release(streamItemsRecord); - } + const parent = deferredFragmentRecord.parent; + if (parent === undefined) { + // Below is equivalent and slightly faster version of: + // if (this._pending.has(deferredFragmentRecord)) { ... } + // as all released deferredFragmentRecords have ids. + if (deferredFragmentRecord.id !== undefined) { + return; + } - markErroredStreamItemsRecord( - streamItemsRecord: StreamItemsRecord, - error: GraphQLError, - ) { - streamItemsRecord.streamRecord.errors.push(error); - this.setIsFinalRecord(streamItemsRecord); - streamItemsRecord.isCompleted = true; - streamItemsRecord.streamRecord.earlyReturn?.().catch(() => { - // ignore error - }); - this._release(streamItemsRecord); - } + this._newPending.add(deferredFragmentRecord); + return; + } - setIsFinalRecord(streamItemsRecord: StreamItemsRecord) { - streamItemsRecord.isFinalRecord = true; - } + if (parent.children.has(deferredFragmentRecord)) { + return; + } - setIsCompletedAsyncIterator(streamItemsRecord: StreamItemsRecord) { - streamItemsRecord.isCompletedAsyncIterator = true; - this.setIsFinalRecord(streamItemsRecord); - } + parent.children.add(deferredFragmentRecord); - addFieldError( - incrementalDataRecord: IncrementalDataRecord, - error: GraphQLError, - ) { - incrementalDataRecord.errors.push(error); + this._addDeferredFragmentRecord(parent); } - buildDataResponse( - initialResultRecord: InitialResultRecord, - data: ObjMap | null, - ): ExecutionResult | ExperimentalIncrementalExecutionResults { - const pendingSources = this._publish(initialResultRecord.children); - - const errors = initialResultRecord.errors; - const initialResult = errors.length === 0 ? { data } : { errors, data }; - if (pendingSources.size > 0) { - return { - initialResult: { - ...initialResult, - pending: this._pendingSourcesToResults(pendingSources), - hasNext: true, - }, - subsequentResults: this._subscribe(), - }; + private _pruneEmpty() { + const maybeEmptyNewPending = this._newPending; + this._newPending = new Set(); + for (const node of maybeEmptyNewPending) { + if (isDeferredFragmentRecord(node)) { + if (node.expectedReconcilableResults) { + this._newPending.add(node); + continue; + } + for (const child of node.children) { + this._addNonEmptyNewPending(child); + } + } else { + this._newPending.add(node); + } } - return initialResult; } - buildErrorResponse( - initialResultRecord: InitialResultRecord, - error: GraphQLError, - ): ExecutionResult { - const errors = initialResultRecord.errors; - errors.push(error); - return { data: null, errors }; + private _addNonEmptyNewPending( + deferredFragmentRecord: DeferredFragmentRecord, + ): void { + if (deferredFragmentRecord.expectedReconcilableResults) { + this._newPending.add(deferredFragmentRecord); + return; + } + /* c8 ignore next 5 */ + // TODO: add test case for this, if when skipping an empty deferred fragment, the empty fragment has nested children. + for (const child of deferredFragmentRecord.children) { + this._addNonEmptyNewPending(child); + } } - filter( - nullPath: Path | undefined, - erroringIncrementalDataRecord: IncrementalDataRecord, + private _enqueueCompletedDeferredGroupedFieldSet( + result: DeferredGroupedFieldSetResult, ): void { - const nullPathArray = pathToArray(nullPath); - - const streams = new Set(); - - const children = this._getChildren(erroringIncrementalDataRecord); - const descendants = this._getDescendants(children); - - for (const child of descendants) { - if (!this._nullsChildSubsequentResultRecord(child, nullPathArray)) { - continue; - } - - child.filtered = true; - - if (isStreamItemsRecord(child)) { - streams.add(child.streamRecord); + let hasPendingParent = false; + for (const deferredFragmentRecord of result.deferredFragmentRecords) { + if (deferredFragmentRecord.id !== undefined) { + hasPendingParent = true; } + deferredFragmentRecord.results.push(result); } + if (hasPendingParent) { + this._completedResultQueue.push(result); + this._trigger(); + } + } - streams.forEach((stream) => { - stream.earlyReturn?.().catch(() => { - // ignore error - }); - }); + private _enqueueCompletedStreamItems(result: StreamItemsResult): void { + this._completedResultQueue.push(result); + this._trigger(); } - private _pendingSourcesToResults( - pendingSources: ReadonlySet, - ): Array { + private _pendingSourcesToResults(): Array { const pendingResults: Array = []; - for (const pendingSource of pendingSources) { - pendingSource.pendingSent = true; - const id = this._getNextId(); + for (const pendingSource of this._newPending) { + const id = String(this._getNextId()); + this._pending.add(pendingSource); pendingSource.id = id; const pendingResult: PendingResult = { id, - path: pendingSource.path, + path: pathToArray(pendingSource.path), }; if (pendingSource.label !== undefined) { pendingResult.label = pendingSource.label; } pendingResults.push(pendingResult); } + this._newPending.clear(); return pendingResults; } @@ -391,47 +389,67 @@ export class IncrementalPublisher { const _next = async (): Promise< IteratorResult > => { - // eslint-disable-next-line no-constant-condition - while (true) { - if (isDone) { - return { value: undefined, done: true }; - } + while (!isDone) { + let pending: Array = []; + + let completedResult: IncrementalDataRecordResult | undefined; + while ( + (completedResult = this._completedResultQueue.shift()) !== undefined + ) { + if (isDeferredGroupedFieldSetResult(completedResult)) { + this._handleCompletedDeferredGroupedFieldSet(completedResult); + } else { + this._handleCompletedStreamItems(completedResult); + } - for (const item of this._released) { - this._pending.delete(item); + pending = [...pending, ...this._pendingSourcesToResults()]; } - const released = this._released; - this._released = new Set(); - const result = this._getIncrementalResult(released); + if (this._incremental.length > 0 || this._completed.length > 0) { + const hasNext = this._pending.size > 0; - if (this._pending.size === 0) { - isDone = true; - } + if (!hasNext) { + isDone = true; + } + + const subsequentIncrementalExecutionResult: SubsequentIncrementalExecutionResult = + { hasNext }; + + if (pending.length > 0) { + subsequentIncrementalExecutionResult.pending = pending; + } + if (this._incremental.length > 0) { + subsequentIncrementalExecutionResult.incremental = + this._incremental; + } + if (this._completed.length > 0) { + subsequentIncrementalExecutionResult.completed = this._completed; + } - if (result !== undefined) { - return { value: result, done: false }; + this._incremental = []; + this._completed = []; + + return { value: subsequentIncrementalExecutionResult, done: false }; } // eslint-disable-next-line no-await-in-loop await this._signalled; } + + await returnStreamIterators().catch(() => { + // ignore errors + }); + + return { value: undefined, done: true }; }; const returnStreamIterators = async (): Promise => { - const streams = new Set(); - const descendants = this._getDescendants(this._pending); - for (const subsequentResultRecord of descendants) { - if (isStreamItemsRecord(subsequentResultRecord)) { - streams.add(subsequentResultRecord.streamRecord); - } - } const promises: Array> = []; - streams.forEach((streamRecord) => { - if (streamRecord.earlyReturn) { + for (const streamRecord of this._context.cancellableStreams) { + if (streamRecord.earlyReturn !== undefined) { promises.push(streamRecord.earlyReturn()); } - }); + } await Promise.all(promises); }; @@ -475,385 +493,293 @@ export class IncrementalPublisher { this._signalled = signalled; } - private _introduce(item: SubsequentResultRecord) { - this._pending.add(item); - } - - private _release(item: SubsequentResultRecord): void { - if (this._pending.has(item)) { - this._released.add(item); - this._trigger(); - } - } - - private _push(item: SubsequentResultRecord): void { - this._released.add(item); - this._pending.add(item); - this._trigger(); - } - - private _getIncrementalResult( - completedRecords: ReadonlySet, - ): SubsequentIncrementalExecutionResult | undefined { - const { pending, incremental, completed } = - this._processPending(completedRecords); - - const hasNext = this._pending.size > 0; - if (incremental.length === 0 && completed.length === 0 && hasNext) { - return undefined; - } - - const result: SubsequentIncrementalExecutionResult = { hasNext }; - if (pending.length) { - result.pending = pending; - } - if (incremental.length) { - result.incremental = incremental; - } - if (completed.length) { - result.completed = completed; - } - - return result; - } - - private _processPending( - completedRecords: ReadonlySet, - ): IncrementalUpdate { - const newPendingSources = new Set(); - const incrementalResults: Array = []; - const completedResults: Array = []; - for (const subsequentResultRecord of completedRecords) { - this._publish(subsequentResultRecord.children, newPendingSources); - if (isStreamItemsRecord(subsequentResultRecord)) { - if (subsequentResultRecord.isFinalRecord) { - newPendingSources.delete(subsequentResultRecord.streamRecord); - completedResults.push( - this._completedRecordToResult(subsequentResultRecord.streamRecord), - ); - } - if (subsequentResultRecord.isCompletedAsyncIterator) { - // async iterable resolver just finished but there may be pending payloads - continue; - } - if (subsequentResultRecord.streamRecord.errors.length > 0) { - continue; - } - const incrementalResult: IncrementalStreamResult = { - // safe because `items` is always defined when the record is completed - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - items: subsequentResultRecord.items!, - // safe because `id` is defined once the stream has been released as pending - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: subsequentResultRecord.streamRecord.id!, - }; - if (subsequentResultRecord.errors.length > 0) { - incrementalResult.errors = subsequentResultRecord.errors; - } - incrementalResults.push(incrementalResult); - } else { - newPendingSources.delete(subsequentResultRecord); - completedResults.push( - this._completedRecordToResult(subsequentResultRecord), - ); - if (subsequentResultRecord.errors.length > 0) { - continue; - } - for (const deferredGroupedFieldSetRecord of subsequentResultRecord.deferredGroupedFieldSetRecords) { - if (!deferredGroupedFieldSetRecord.sent) { - deferredGroupedFieldSetRecord.sent = true; - const incrementalResult: IncrementalDeferResult = - this._getIncrementalDeferResult(deferredGroupedFieldSetRecord); - if (deferredGroupedFieldSetRecord.errors.length > 0) { - incrementalResult.errors = deferredGroupedFieldSetRecord.errors; - } - incrementalResults.push(incrementalResult); - } + private _handleCompletedDeferredGroupedFieldSet( + deferredGroupedFieldSetResult: DeferredGroupedFieldSetResult, + ): void { + if ( + isNonReconcilableDeferredGroupedFieldSetResult( + deferredGroupedFieldSetResult, + ) + ) { + for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { + const id = deferredFragmentRecord.id; + if (id !== undefined) { + this._completed.push({ + id, + errors: deferredGroupedFieldSetResult.errors, + }); + this._pending.delete(deferredFragmentRecord); } } + return; + } + for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { + deferredFragmentRecord.reconcilableResults.push( + deferredGroupedFieldSetResult, + ); } - return { - pending: this._pendingSourcesToResults(newPendingSources), - incremental: incrementalResults, - completed: completedResults, - }; - } + this._addIncrementalDataRecords( + deferredGroupedFieldSetResult.incrementalDataRecords, + ); - private _getIncrementalDeferResult( - deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord, - ): IncrementalDeferResult { - const { data, deferredFragmentRecords } = deferredGroupedFieldSetRecord; - let maxLength: number | undefined; - let idWithLongestPath: string | undefined; - for (const deferredFragmentRecord of deferredFragmentRecords) { + for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { const id = deferredFragmentRecord.id; - // TODO: add test + // TODO: add test case for this. + // Presumably, this can occur if an error causes a fragment to be completed early, + // while an asynchronous deferred grouped field set result is enqueued. /* c8 ignore next 3 */ if (id === undefined) { continue; } - const length = deferredFragmentRecord.path.length; - if (maxLength === undefined || length > maxLength) { - maxLength = length; - idWithLongestPath = id; - } - } - const subPath = deferredGroupedFieldSetRecord.path.slice(maxLength); - const incrementalDeferResult: IncrementalDeferResult = { - // safe because `data``is always defined when the record is completed - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - data: data!, - // safe because `id` is always defined once the fragment has been released - // as pending and at least one fragment has been completed, so must have been - // released as pending - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: idWithLongestPath!, - }; - - if (subPath.length > 0) { - incrementalDeferResult.subPath = subPath; - } - - return incrementalDeferResult; - } - - private _completedRecordToResult( - completedRecord: DeferredFragmentRecord | StreamRecord, - ): CompletedResult { - const result: CompletedResult = { - // safe because `id` is defined once the stream has been released as pending - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: completedRecord.id!, - }; - if (completedRecord.errors.length > 0) { - result.errors = completedRecord.errors; - } - return result; - } - - private _publish( - subsequentResultRecords: ReadonlySet, - pendingSources = new Set(), - ): Set { - const emptyRecords: Array = []; - - for (const subsequentResultRecord of subsequentResultRecords) { - if (subsequentResultRecord.filtered) { + const reconcilableResults = deferredFragmentRecord.reconcilableResults; + if ( + deferredFragmentRecord.expectedReconcilableResults !== + reconcilableResults.length + ) { continue; } - if (isStreamItemsRecord(subsequentResultRecord)) { - if (subsequentResultRecord.isCompleted) { - this._push(subsequentResultRecord); - } else { - this._introduce(subsequentResultRecord); + for (const reconcilableResult of reconcilableResults) { + if (reconcilableResult.sent) { + continue; } - - const stream = subsequentResultRecord.streamRecord; - if (!stream.pendingSent) { - pendingSources.add(stream); + reconcilableResult.sent = true; + const { bestId, subPath } = this._getBestIdAndSubPath( + id, + deferredFragmentRecord, + reconcilableResult, + ); + const incrementalEntry: IncrementalDeferResult = { + ...reconcilableResult.result, + id: bestId, + }; + if (subPath !== undefined) { + incrementalEntry.subPath = subPath; } - continue; - } - - if (subsequentResultRecord._pending.size > 0) { - this._introduce(subsequentResultRecord); - } else if ( - subsequentResultRecord.deferredGroupedFieldSetRecords.size === 0 - ) { - emptyRecords.push(subsequentResultRecord); - continue; - } else { - this._push(subsequentResultRecord); + this._incremental.push(incrementalEntry); } - - if (!subsequentResultRecord.pendingSent) { - pendingSources.add(subsequentResultRecord); + this._completed.push({ id }); + this._pending.delete(deferredFragmentRecord); + for (const child of deferredFragmentRecord.children) { + this._newPending.add(child); + this._completedResultQueue.push(...child.results); } } - for (const emptyRecord of emptyRecords) { - this._publish(emptyRecord.children, pendingSources); - } - - return pendingSources; + this._pruneEmpty(); } - private _getChildren( - erroringIncrementalDataRecord: IncrementalDataRecord, - ): ReadonlySet { - const children = new Set(); - if (isDeferredGroupedFieldSetRecord(erroringIncrementalDataRecord)) { - for (const erroringIncrementalResultRecord of erroringIncrementalDataRecord.deferredFragmentRecords) { - for (const child of erroringIncrementalResultRecord.children) { - children.add(child); - } + private _handleCompletedStreamItems( + streamItemsResult: StreamItemsResult, + ): void { + const streamRecord = streamItemsResult.streamRecord; + const id = streamRecord.id; + // TODO: Consider adding invariant or non-null assertion, as this should never happen. Since the stream is converted into a linked list + // for ordering purposes, if an entry errors, additional entries will not be processed. + /* c8 ignore next 3 */ + if (id === undefined) { + return; + } + if (streamItemsResult.errors !== undefined) { + this._completed.push({ + id, + errors: streamItemsResult.errors, + }); + this._pending.delete(streamRecord); + if (isCancellableStreamRecord(streamRecord)) { + this._context.cancellableStreams.delete(streamRecord); + streamRecord.earlyReturn().catch(() => { + /* c8 ignore next 1 */ + // ignore error + }); } - } else { - for (const child of erroringIncrementalDataRecord.children) { - children.add(child); + } else if (streamItemsResult.result === undefined) { + this._completed.push({ id }); + this._pending.delete(streamRecord); + if (isCancellableStreamRecord(streamRecord)) { + this._context.cancellableStreams.delete(streamRecord); } - } - return children; - } - - private _getDescendants( - children: ReadonlySet, - descendants = new Set(), - ): ReadonlySet { - for (const child of children) { - descendants.add(child); - this._getDescendants(child.children, descendants); - } - return descendants; - } + } else { + const incrementalEntry: IncrementalStreamResult = { + id, + ...streamItemsResult.result, + }; - private _nullsChildSubsequentResultRecord( - subsequentResultRecord: SubsequentResultRecord, - nullPath: ReadonlyArray, - ): boolean { - const incrementalDataRecords = isStreamItemsRecord(subsequentResultRecord) - ? [subsequentResultRecord] - : subsequentResultRecord.deferredGroupedFieldSetRecords; + this._incremental.push(incrementalEntry); - for (const incrementalDataRecord of incrementalDataRecords) { - if (this._matchesPath(incrementalDataRecord.path, nullPath)) { - return true; + if (streamItemsResult.incrementalDataRecords.length > 0) { + this._addIncrementalDataRecords( + streamItemsResult.incrementalDataRecords, + ); + this._pruneEmpty(); } } - - return false; } - private _matchesPath( - testPath: ReadonlyArray, - basePath: ReadonlyArray, - ): boolean { - for (let i = 0; i < basePath.length; i++) { - if (basePath[i] !== testPath[i]) { - // testPath points to a path unaffected at basePath - return false; + private _getBestIdAndSubPath( + initialId: string, + initialDeferredFragmentRecord: DeferredFragmentRecord, + deferredGroupedFieldSetResult: DeferredGroupedFieldSetResult, + ): { bestId: string; subPath: ReadonlyArray | undefined } { + let maxLength = pathToArray(initialDeferredFragmentRecord.path).length; + let bestId = initialId; + + for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { + if (deferredFragmentRecord === initialDeferredFragmentRecord) { + continue; + } + const id = deferredFragmentRecord.id; + // TODO: add test case for when an fragment has not been released, but might be processed for the shortest path. + /* c8 ignore next 3 */ + if (id === undefined) { + continue; + } + const fragmentPath = pathToArray(deferredFragmentRecord.path); + const length = fragmentPath.length; + if (length > maxLength) { + maxLength = length; + bestId = id; } } - return true; + const subPath = deferredGroupedFieldSetResult.path.slice(maxLength); + return { + bestId, + subPath: subPath.length > 0 ? subPath : undefined, + }; } } +function isDeferredFragmentRecord( + subsequentResultRecord: SubsequentResultRecord, +): subsequentResultRecord is DeferredFragmentRecord { + return 'parent' in subsequentResultRecord; +} + function isDeferredGroupedFieldSetRecord( - incrementalDataRecord: unknown, + incrementalDataRecord: IncrementalDataRecord, ): incrementalDataRecord is DeferredGroupedFieldSetRecord { - return incrementalDataRecord instanceof DeferredGroupedFieldSetRecord; + return 'deferredFragmentRecords' in incrementalDataRecord; } -function isStreamItemsRecord( - subsequentResultRecord: unknown, -): subsequentResultRecord is StreamItemsRecord { - return subsequentResultRecord instanceof StreamItemsRecord; +export type DeferredGroupedFieldSetResult = + | ReconcilableDeferredGroupedFieldSetResult + | NonReconcilableDeferredGroupedFieldSetResult; + +function isDeferredGroupedFieldSetResult( + subsequentResult: DeferredGroupedFieldSetResult | StreamItemsResult, +): subsequentResult is DeferredGroupedFieldSetResult { + return 'deferredFragmentRecords' in subsequentResult; } -/** @internal */ -export class InitialResultRecord { - errors: Array; - children: Set; - constructor() { - this.errors = []; - this.children = new Set(); - } +interface ReconcilableDeferredGroupedFieldSetResult { + deferredFragmentRecords: ReadonlyArray; + path: Array; + result: BareDeferredGroupedFieldSetResult; + incrementalDataRecords: ReadonlyArray; + sent?: true | undefined; + errors?: never; } -/** @internal */ -export class DeferredGroupedFieldSetRecord { - path: ReadonlyArray; +interface NonReconcilableDeferredGroupedFieldSetResult { + errors: ReadonlyArray; deferredFragmentRecords: ReadonlyArray; - groupedFieldSet: GroupedFieldSet; - shouldInitiateDefer: boolean; - errors: Array; - data: ObjMap | undefined; - sent: boolean; + path: Array; + result?: never; +} - constructor(opts: { - path: Path | undefined; - deferredFragmentRecords: ReadonlyArray; - groupedFieldSet: GroupedFieldSet; - shouldInitiateDefer: boolean; - }) { - this.path = pathToArray(opts.path); - this.deferredFragmentRecords = opts.deferredFragmentRecords; - this.groupedFieldSet = opts.groupedFieldSet; - this.shouldInitiateDefer = opts.shouldInitiateDefer; - this.errors = []; - this.sent = false; - } +function isNonReconcilableDeferredGroupedFieldSetResult( + deferredGroupedFieldSetResult: DeferredGroupedFieldSetResult, +): deferredGroupedFieldSetResult is NonReconcilableDeferredGroupedFieldSetResult { + return deferredGroupedFieldSetResult.errors !== undefined; } -/** @internal */ -export class DeferredFragmentRecord { - path: ReadonlyArray; +export interface DeferredGroupedFieldSetRecord { + deferredFragmentRecords: ReadonlyArray; + result: PromiseOrValue; +} + +export interface SubsequentResultRecord { + path: Path | undefined; label: string | undefined; - id: string | undefined; - children: Set; - deferredGroupedFieldSetRecords: Set; - errors: Array; - filtered: boolean; - pendingSent?: boolean; - _pending: Set; - - constructor(opts: { path: Path | undefined; label: string | undefined }) { - this.path = pathToArray(opts.path); - this.label = opts.label; - this.children = new Set(); - this.filtered = false; - this.deferredGroupedFieldSetRecords = new Set(); - this.errors = []; - this._pending = new Set(); - } + id?: string | undefined; } /** @internal */ -export class StreamRecord { +export class DeferredFragmentRecord implements SubsequentResultRecord { + path: Path | undefined; label: string | undefined; - path: ReadonlyArray; - id: string | undefined; - errors: Array; - earlyReturn?: (() => Promise) | undefined; - pendingSent?: boolean; + id?: string | undefined; + parent: DeferredFragmentRecord | undefined; + expectedReconcilableResults: number; + results: Array; + reconcilableResults: Array; + children: Set; + constructor(opts: { + path: Path | undefined; label: string | undefined; - path: Path; - earlyReturn?: (() => Promise) | undefined; + parent: DeferredFragmentRecord | undefined; }) { + this.path = opts.path; this.label = opts.label; - this.path = pathToArray(opts.path); - this.errors = []; - this.earlyReturn = opts.earlyReturn; + this.parent = opts.parent; + this.expectedReconcilableResults = 0; + this.results = []; + this.reconcilableResults = []; + this.children = new Set(); } } -/** @internal */ -export class StreamItemsRecord { - errors: Array; - streamRecord: StreamRecord; - path: ReadonlyArray; - items: Array | undefined; - children: Set; - isFinalRecord?: boolean; - isCompletedAsyncIterator?: boolean; - isCompleted: boolean; - filtered: boolean; - - constructor(opts: { streamRecord: StreamRecord; path: Path | undefined }) { - this.streamRecord = opts.streamRecord; - this.path = pathToArray(opts.path); - this.children = new Set(); - this.errors = []; - this.isCompleted = false; - this.filtered = false; - } +export interface CancellableStreamRecord extends SubsequentResultRecord { + earlyReturn: () => Promise; +} + +function isCancellableStreamRecord( + subsequentResultRecord: SubsequentResultRecord, +): subsequentResultRecord is CancellableStreamRecord { + return 'earlyReturn' in subsequentResultRecord; +} + +interface ReconcilableStreamItemsResult { + streamRecord: SubsequentResultRecord; + result: BareStreamItemsResult; + incrementalDataRecords: ReadonlyArray; + errors?: never; +} + +export function isReconcilableStreamItemsResult( + streamItemsResult: StreamItemsResult, +): streamItemsResult is ReconcilableStreamItemsResult { + return streamItemsResult.result !== undefined; +} + +interface TerminatingStreamItemsResult { + streamRecord: SubsequentResultRecord; + result?: never; + incrementalDataRecords?: never; + errors?: never; +} + +interface NonReconcilableStreamItemsResult { + streamRecord: SubsequentResultRecord; + errors: ReadonlyArray; + result?: never; +} + +export type StreamItemsResult = + | ReconcilableStreamItemsResult + | TerminatingStreamItemsResult + | NonReconcilableStreamItemsResult; + +export interface StreamItemsRecord { + streamRecord: SubsequentResultRecord; + result: PromiseOrValue; } export type IncrementalDataRecord = - | InitialResultRecord | DeferredGroupedFieldSetRecord | StreamItemsRecord; -type SubsequentResultRecord = DeferredFragmentRecord | StreamItemsRecord; +export type IncrementalDataRecordResult = + | DeferredGroupedFieldSetResult + | StreamItemsResult; diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index d03570270a..03bf8126c6 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -367,12 +367,6 @@ describe('Execute: defer directive', () => { }, id: '0', }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - incremental: [ { data: { friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], @@ -380,7 +374,7 @@ describe('Execute: defer directive', () => { id: '1', }, ], - completed: [{ id: '1' }], + completed: [{ id: '0' }, { id: '1' }], hasNext: false, }, ]); @@ -674,8 +668,8 @@ describe('Execute: defer directive', () => { hero: {}, }, pending: [ - { id: '0', path: [], label: 'DeferName' }, - { id: '1', path: ['hero'], label: 'DeferID' }, + { id: '0', path: ['hero'], label: 'DeferID' }, + { id: '1', path: [], label: 'DeferName' }, ], hasNext: true, }, @@ -685,17 +679,17 @@ describe('Execute: defer directive', () => { data: { id: '1', }, - id: '1', + id: '0', }, { data: { name: 'Luke', }, - id: '0', + id: '1', subPath: ['hero'], }, ], - completed: [{ id: '1' }, { id: '0' }], + completed: [{ id: '0' }, { id: '1' }], hasNext: false, }, ]); @@ -983,37 +977,27 @@ describe('Execute: defer directive', () => { hasNext: true, }, { - pending: [{ id: '1', path: ['hero', 'nestedObject'] }], + pending: [ + { id: '1', path: ['hero', 'nestedObject'] }, + { id: '2', path: ['hero', 'nestedObject', 'deeperObject'] }, + ], incremental: [ { data: { bar: 'bar' }, id: '0', subPath: ['nestedObject', 'deeperObject'], }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - pending: [{ id: '2', path: ['hero', 'nestedObject', 'deeperObject'] }], - incremental: [ { data: { baz: 'baz' }, id: '1', subPath: ['deeperObject'], }, - ], - hasNext: true, - completed: [{ id: '1' }], - }, - { - incremental: [ { data: { bak: 'bak' }, id: '2', }, ], - completed: [{ id: '2' }], + completed: [{ id: '0' }, { id: '1' }, { id: '2' }], hasNext: false, }, ]); @@ -1132,8 +1116,8 @@ describe('Execute: defer directive', () => { }, }, pending: [ - { id: '0', path: [] }, - { id: '1', path: ['a', 'b'] }, + { id: '0', path: ['a', 'b'] }, + { id: '1', path: [] }, ], hasNext: true, }, @@ -1141,14 +1125,14 @@ describe('Execute: defer directive', () => { incremental: [ { data: { e: { f: 'f' } }, - id: '1', + id: '0', }, { data: { g: { h: 'h' } }, - id: '0', + id: '1', }, ], - completed: [{ id: '1' }, { id: '0' }], + completed: [{ id: '0' }, { id: '1' }], hasNext: false, }, ]); @@ -1277,6 +1261,7 @@ describe('Execute: defer directive', () => { }, ], completed: [ + { id: '0' }, { id: '1', errors: [ @@ -1288,7 +1273,6 @@ describe('Execute: defer directive', () => { }, ], }, - { id: '0' }, ], hasNext: false, }, diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index c29b4ae60d..de33f8c91b 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -635,6 +635,57 @@ describe('Execute: Handles basic execution tasks', () => { expect(isAsyncResolverFinished).to.equal(true); }); + it('handles async bubbling errors combined with non-bubbling errors', async () => { + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: { + asyncNonNullError: { + type: new GraphQLNonNull(GraphQLString), + async resolve() { + await resolveOnNextTick(); + return null; + }, + }, + asyncError: { + type: GraphQLString, + async resolve() { + await resolveOnNextTick(); + throw new Error('Oops'); + }, + }, + }, + }), + }); + + // Order is important here, as the nullable error should resolve first + const document = parse(` + { + asyncError + asyncNonNullError + } + `); + + const result = execute({ schema, document }); + + expectJSON(await result).toDeepEqual({ + data: null, + errors: [ + { + message: 'Oops', + locations: [{ line: 3, column: 9 }], + path: ['asyncError'], + }, + { + message: + 'Cannot return null for non-nullable field Query.asyncNonNullError.', + locations: [{ line: 4, column: 9 }], + path: ['asyncNonNullError'], + }, + ], + }); + }); + it('Full response path is included for non-nullable fields', () => { const A: GraphQLObjectType = new GraphQLObjectType({ name: 'A', diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index 6e1928f945..522b82f3d4 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -146,11 +146,10 @@ describe('Execute: stream directive', () => { hasNext: true, }, { - incremental: [{ items: ['banana'], id: '0' }], - hasNext: true, - }, - { - incremental: [{ items: ['coconut'], id: '0' }], + incremental: [ + { items: ['banana'], id: '0' }, + { items: ['coconut'], id: '0' }, + ], completed: [{ id: '0' }], hasNext: false, }, @@ -170,15 +169,11 @@ describe('Execute: stream directive', () => { hasNext: true, }, { - incremental: [{ items: ['apple'], id: '0' }], - hasNext: true, - }, - { - incremental: [{ items: ['banana'], id: '0' }], - hasNext: true, - }, - { - incremental: [{ items: ['coconut'], id: '0' }], + incremental: [ + { items: ['apple'], id: '0' }, + { items: ['banana'], id: '0' }, + { items: ['coconut'], id: '0' }, + ], completed: [{ id: '0' }], hasNext: false, }, @@ -228,11 +223,6 @@ describe('Execute: stream directive', () => { items: ['banana'], id: '0', }, - ], - hasNext: true, - }, - { - incremental: [ { items: ['coconut'], id: '0', @@ -297,11 +287,6 @@ describe('Execute: stream directive', () => { items: [['banana', 'banana', 'banana']], id: '0', }, - ], - hasNext: true, - }, - { - incremental: [ { items: [['coconut', 'coconut', 'coconut']], id: '0', @@ -811,7 +796,7 @@ describe('Execute: stream directive', () => { } `); const result = await complete(document, { - nonNullFriendList: () => [friends[0], null], + nonNullFriendList: () => [friends[0], null, friends[1]], }); expectJSON(result).toDeepEqual([ @@ -1954,9 +1939,7 @@ describe('Execute: stream directive', () => { hasNext: true, }); - const result2Promise = iterator.next(); - resolveIterableCompletion(null); - const result2 = await result2Promise; + const result2 = await iterator.next(); expectJSON(result2).toDeepEqual({ value: { pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], @@ -1977,7 +1960,7 @@ describe('Execute: stream directive', () => { }); const result3Promise = iterator.next(); - resolveSlowField('Han'); + resolveIterableCompletion(null); const result3 = await result3Promise; expectJSON(result3).toDeepEqual({ value: { @@ -1986,7 +1969,9 @@ describe('Execute: stream directive', () => { }, done: false, }); - const result4 = await iterator.next(); + const result4Promise = iterator.next(); + resolveSlowField('Han'); + const result4 = await result4Promise; expectJSON(result4).toDeepEqual({ value: { incremental: [ @@ -2077,8 +2062,19 @@ describe('Execute: stream directive', () => { done: false, }); - const result3 = await iterator.next(); + const result3Promise = iterator.next(); + resolveIterableCompletion(null); + const result3 = await result3Promise; expectJSON(result3).toDeepEqual({ + value: { + completed: [{ id: '1' }], + hasNext: true, + }, + done: false, + }); + + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ value: { incremental: [ { @@ -2087,16 +2083,6 @@ describe('Execute: stream directive', () => { }, ], completed: [{ id: '2' }], - hasNext: true, - }, - done: false, - }); - const result4Promise = iterator.next(); - resolveIterableCompletion(null); - const result4 = await result4Promise; - expectJSON(result4).toDeepEqual({ - value: { - completed: [{ id: '1' }], hasNext: false, }, done: false, diff --git a/src/execution/buildFieldPlan.ts b/src/execution/buildFieldPlan.ts index 390e2cf813..970b8d5c46 100644 --- a/src/execution/buildFieldPlan.ts +++ b/src/execution/buildFieldPlan.ts @@ -12,17 +12,12 @@ export interface FieldGroup { export type GroupedFieldSet = Map; -export interface NewGroupedFieldSetDetails { - groupedFieldSet: GroupedFieldSet; - shouldInitiateDefer: boolean; -} - export function buildFieldPlan( fields: Map>, parentDeferUsages: DeferUsageSet = new Set(), ): { groupedFieldSet: GroupedFieldSet; - newGroupedFieldSetDetailsMap: Map; + newGroupedFieldSets: Map; } { const groupedFieldSet = new Map< string, @@ -32,18 +27,15 @@ export function buildFieldPlan( } >(); - const newGroupedFieldSetDetailsMap = new Map< + const newGroupedFieldSets = new Map< DeferUsageSet, - { - groupedFieldSet: Map< - string, - { - fields: Array; - deferUsages: DeferUsageSet; - } - >; - shouldInitiateDefer: boolean; - } + Map< + string, + { + fields: Array; + deferUsages: DeferUsageSet; + } + > >(); const map = new Map< @@ -94,12 +86,8 @@ export function buildFieldPlan( continue; } - let newGroupedFieldSetDetails = getBySet( - newGroupedFieldSetDetailsMap, - deferUsageSet, - ); - let newGroupedFieldSet; - if (newGroupedFieldSetDetails === undefined) { + let newGroupedFieldSet = getBySet(newGroupedFieldSets, deferUsageSet); + if (newGroupedFieldSet === undefined) { newGroupedFieldSet = new Map< string, { @@ -108,19 +96,7 @@ export function buildFieldPlan( knownDeferUsages: DeferUsageSet; } >(); - - newGroupedFieldSetDetails = { - groupedFieldSet: newGroupedFieldSet, - shouldInitiateDefer: Array.from(deferUsageSet).some( - (deferUsage) => !parentDeferUsages.has(deferUsage), - ), - }; - newGroupedFieldSetDetailsMap.set( - deferUsageSet, - newGroupedFieldSetDetails, - ); - } else { - newGroupedFieldSet = newGroupedFieldSetDetails.groupedFieldSet; + newGroupedFieldSets.set(deferUsageSet, newGroupedFieldSet); } let fieldGroup = newGroupedFieldSet.get(responseKey); if (fieldGroup === undefined) { @@ -135,7 +111,7 @@ export function buildFieldPlan( return { groupedFieldSet, - newGroupedFieldSetDetailsMap, + newGroupedFieldSets, }; } diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 767a3f77d1..68037516e1 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -51,23 +51,25 @@ import type { DeferUsageSet, FieldGroup, GroupedFieldSet, - NewGroupedFieldSetDetails, } from './buildFieldPlan.js'; import { buildFieldPlan } from './buildFieldPlan.js'; import type { DeferUsage, FieldDetails } from './collectFields.js'; import { collectFields, collectSubfields } from './collectFields.js'; import type { + CancellableStreamRecord, + DeferredGroupedFieldSetRecord, + DeferredGroupedFieldSetResult, ExecutionResult, ExperimentalIncrementalExecutionResults, IncrementalDataRecord, + StreamItemsRecord, + StreamItemsResult, + SubsequentResultRecord, } from './IncrementalPublisher.js'; import { + buildIncrementalResponse, DeferredFragmentRecord, - DeferredGroupedFieldSetRecord, - IncrementalPublisher, - InitialResultRecord, - StreamItemsRecord, - StreamRecord, + isReconcilableStreamItemsResult, } from './IncrementalPublisher.js'; import { mapAsyncIterable } from './mapAsyncIterable.js'; import { @@ -142,7 +144,7 @@ export interface ExecutionContext { fieldResolver: GraphQLFieldResolver; typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; - incrementalPublisher: IncrementalPublisher; + cancellableStreams: Set; } export interface ExecutionArgs { @@ -163,6 +165,8 @@ export interface StreamUsage { fieldGroup: FieldGroup; } +type GraphQLWrappedResult = [T, Array]; + const UNEXPECTED_EXPERIMENTAL_DIRECTIVES = 'The provided schema unexpectedly contains experimental directives (@defer or @stream). These directives may only be utilized if experimental execution features are explicitly enabled.'; @@ -255,16 +259,10 @@ export function experimentalExecuteIncrementally( function executeOperation( exeContext: ExecutionContext, ): PromiseOrValue { - const initialResultRecord = new InitialResultRecord(); + const errors: Array = []; try { - const { - operation, - schema, - fragments, - variableValues, - rootValue, - incrementalPublisher, - } = exeContext; + const { operation, schema, fragments, variableValues, rootValue } = + exeContext; const rootType = schema.getRootType(operation.operation); if (rootType == null) { throw new GraphQLError( @@ -280,58 +278,93 @@ function executeOperation( rootType, operation, ); - const { groupedFieldSet, newGroupedFieldSetDetailsMap } = - buildFieldPlan(fields); + const { groupedFieldSet, newGroupedFieldSets } = buildFieldPlan(fields); - const newDeferMap = addNewDeferredFragments( - incrementalPublisher, - newDeferUsages, - initialResultRecord, - ); + const newDeferMap = addNewDeferredFragments(newDeferUsages, new Map()); - const path = undefined; - - const newDeferredGroupedFieldSetRecords = addNewDeferredGroupedFieldSets( - incrementalPublisher, - newGroupedFieldSetDetailsMap, - newDeferMap, - path, - ); - - const result = executeRootGroupedFieldSet( + let graphqlWrappedResult = executeRootGroupedFieldSet( exeContext, operation.operation, rootType, rootValue, groupedFieldSet, - initialResultRecord, + errors, newDeferMap, ); - executeDeferredGroupedFieldSets( + const newDeferredGroupedFieldSetRecords = executeDeferredGroupedFieldSets( exeContext, rootType, rootValue, - path, - newDeferredGroupedFieldSetRecords, + undefined, + undefined, + newGroupedFieldSets, newDeferMap, ); - if (isPromise(result)) { - return result.then( + graphqlWrappedResult = withNewDeferredGroupedFieldSets( + graphqlWrappedResult, + newDeferredGroupedFieldSetRecords, + ); + if (isPromise(graphqlWrappedResult)) { + return graphqlWrappedResult.then( (resolved) => - incrementalPublisher.buildDataResponse(initialResultRecord, resolved), - (error) => - incrementalPublisher.buildErrorResponse(initialResultRecord, error), + buildDataResponse(exeContext, resolved[0], errors, resolved[1]), + (error) => ({ + data: null, + errors: withError(errors, error), + }), ); } - return incrementalPublisher.buildDataResponse(initialResultRecord, result); - } catch (error) { - return exeContext.incrementalPublisher.buildErrorResponse( - initialResultRecord, - error, + return buildDataResponse( + exeContext, + graphqlWrappedResult[0], + errors, + graphqlWrappedResult[1], ); + } catch (error) { + return { data: null, errors: withError(errors, error) }; + } +} + +function withNewDeferredGroupedFieldSets( + result: PromiseOrValue>>, + newDeferredGroupedFieldSetRecords: ReadonlyArray, +): PromiseOrValue>> { + if (isPromise(result)) { + return result.then((resolved) => { + resolved[1].push(...newDeferredGroupedFieldSetRecords); + return resolved; + }); } + + result[1].push(...newDeferredGroupedFieldSetRecords); + return result; +} + +function withError( + errors: Array, + error: GraphQLError, +): ReadonlyArray { + return errors.length === 0 ? [error] : [...errors, error]; +} + +function buildDataResponse( + exeContext: ExecutionContext, + data: ObjMap, + errors: ReadonlyArray, + incrementalDataRecords: ReadonlyArray, +): ExecutionResult | ExperimentalIncrementalExecutionResults { + if (incrementalDataRecords.length === 0) { + return errors.length > 0 ? { errors, data } : { data }; + } + + return buildIncrementalResponse( + exeContext, + data, + errors, + incrementalDataRecords, + ); } /** @@ -435,7 +468,7 @@ export function buildExecutionContext( fieldResolver: fieldResolver ?? defaultFieldResolver, typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, - incrementalPublisher: new IncrementalPublisher(), + cancellableStreams: new Set(), }; } @@ -455,9 +488,9 @@ function executeRootGroupedFieldSet( rootType: GraphQLObjectType, rootValue: unknown, groupedFieldSet: GroupedFieldSet, - initialResultRecord: InitialResultRecord, - newDeferMap: ReadonlyMap, -): PromiseOrValue> { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue>> { switch (operation) { case OperationTypeNode.QUERY: return executeFields( @@ -466,8 +499,8 @@ function executeRootGroupedFieldSet( rootValue, undefined, groupedFieldSet, - initialResultRecord, - newDeferMap, + errors, + deferMap, ); case OperationTypeNode.MUTATION: return executeFieldsSerially( @@ -476,8 +509,8 @@ function executeRootGroupedFieldSet( rootValue, undefined, groupedFieldSet, - initialResultRecord, - newDeferMap, + errors, + deferMap, ); case OperationTypeNode.SUBSCRIPTION: // TODO: deprecate `subscribe` and move all logic here @@ -488,8 +521,8 @@ function executeRootGroupedFieldSet( rootValue, undefined, groupedFieldSet, - initialResultRecord, - newDeferMap, + errors, + deferMap, ); } } @@ -504,12 +537,12 @@ function executeFieldsSerially( sourceValue: unknown, path: Path | undefined, groupedFieldSet: GroupedFieldSet, - incrementalDataRecord: InitialResultRecord, - deferMap: ReadonlyMap, -): PromiseOrValue> { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue>> { return promiseReduce( groupedFieldSet, - (results, [responseName, fieldGroup]) => { + (graphqlWrappedResult, [responseName, fieldGroup]) => { const fieldPath = addPath(path, responseName, parentType.name); const result = executeField( exeContext, @@ -517,22 +550,24 @@ function executeFieldsSerially( sourceValue, fieldGroup, fieldPath, - incrementalDataRecord, + errors, deferMap, ); if (result === undefined) { - return results; + return graphqlWrappedResult; } if (isPromise(result)) { - return result.then((resolvedResult) => { - results[responseName] = resolvedResult; - return results; + return result.then((resolved) => { + graphqlWrappedResult[0][responseName] = resolved[0]; + graphqlWrappedResult[1].push(...resolved[1]); + return graphqlWrappedResult; }); } - results[responseName] = result; - return results; + graphqlWrappedResult[0][responseName] = result[0]; + graphqlWrappedResult[1].push(...result[1]); + return graphqlWrappedResult; }, - Object.create(null), + [Object.create(null), []] as GraphQLWrappedResult>, ); } @@ -546,10 +581,14 @@ function executeFields( sourceValue: unknown, path: Path | undefined, groupedFieldSet: GroupedFieldSet, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): PromiseOrValue> { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue>> { const results = Object.create(null); + const graphqlWrappedResult: GraphQLWrappedResult> = [ + results, + [], + ]; let containsPromise = false; try { @@ -561,36 +600,47 @@ function executeFields( sourceValue, fieldGroup, fieldPath, - incrementalDataRecord, + errors, deferMap, ); if (result !== undefined) { - results[responseName] = result; if (isPromise(result)) { + results[responseName] = result.then((resolved) => { + graphqlWrappedResult[1].push(...resolved[1]); + return resolved[0]; + }); containsPromise = true; + } else { + results[responseName] = result[0]; + graphqlWrappedResult[1].push(...result[1]); } } } } catch (error) { if (containsPromise) { // Ensure that any promises returned by other fields are handled, as they may also reject. - return promiseForObject(results).finally(() => { + return promiseForObject(results, () => { + /* noop */ + }).finally(() => { throw error; - }); + }) as never; } throw error; } - // If there are no promises, we can just return the object + // If there are no promises, we can just return the object and any incrementalDataRecords if (!containsPromise) { - return results; + return graphqlWrappedResult; } // 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); + return promiseForObject(results, (resolved) => [ + resolved, + graphqlWrappedResult[1], + ]); } function toNodes(fieldGroup: FieldGroup): ReadonlyArray { @@ -609,9 +659,9 @@ function executeField( source: unknown, fieldGroup: FieldGroup, path: Path, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): PromiseOrValue { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue> | undefined { const fieldName = fieldGroup.fields[0].node.name.value; const fieldDef = exeContext.schema.getField(parentType, fieldName); if (!fieldDef) { @@ -655,7 +705,7 @@ function executeField( info, path, result, - incrementalDataRecord, + errors, deferMap, ); } @@ -667,7 +717,7 @@ function executeField( info, path, result, - incrementalDataRecord, + errors, deferMap, ); @@ -675,30 +725,14 @@ function executeField( // Note: we don't rely on a `catch` method, but we do expect "thenable" // to take a second callback for the error case. return completed.then(undefined, (rawError) => { - handleFieldError( - rawError, - exeContext, - returnType, - fieldGroup, - path, - incrementalDataRecord, - ); - exeContext.incrementalPublisher.filter(path, incrementalDataRecord); - return null; + handleFieldError(rawError, returnType, fieldGroup, path, errors); + return [null, []]; }); } return completed; } catch (rawError) { - handleFieldError( - rawError, - exeContext, - returnType, - fieldGroup, - path, - incrementalDataRecord, - ); - exeContext.incrementalPublisher.filter(path, incrementalDataRecord); - return null; + handleFieldError(rawError, returnType, fieldGroup, path, errors); + return [null, []]; } } @@ -731,11 +765,10 @@ export function buildResolveInfo( function handleFieldError( rawError: unknown, - exeContext: ExecutionContext, returnType: GraphQLOutputType, fieldGroup: FieldGroup, path: Path, - incrementalDataRecord: IncrementalDataRecord, + errors: Array, ): void { const error = locatedError(rawError, toNodes(fieldGroup), pathToArray(path)); @@ -747,7 +780,7 @@ function handleFieldError( // Otherwise, error protection is applied, logging the error and resolving // a null value for this field if one is encountered. - exeContext.incrementalPublisher.addFieldError(incrementalDataRecord, error); + errors.push(error); } /** @@ -778,9 +811,9 @@ function completeValue( info: GraphQLResolveInfo, path: Path, result: unknown, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): PromiseOrValue { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue> { // If result is an Error, throw a located error. if (result instanceof Error) { throw result; @@ -796,10 +829,10 @@ function completeValue( info, path, result, - incrementalDataRecord, + errors, deferMap, ); - if (completed === null) { + if ((completed as GraphQLWrappedResult)[0] === null) { throw new Error( `Cannot return null for non-nullable field ${info.parentType.name}.${info.fieldName}.`, ); @@ -809,7 +842,7 @@ function completeValue( // If result value is null or undefined then return null. if (result == null) { - return null; + return [null, []]; } // If field type is List, complete each item in the list with the inner type @@ -821,7 +854,7 @@ function completeValue( info, path, result, - incrementalDataRecord, + errors, deferMap, ); } @@ -829,7 +862,7 @@ function completeValue( // If field type is a leaf type, Scalar or Enum, serialize to a valid value, // returning null if serialization is not possible. if (isLeafType(returnType)) { - return completeLeafValue(returnType, result); + return [completeLeafValue(returnType, result), []]; } // If field type is an abstract type, Interface or Union, determine the @@ -842,7 +875,7 @@ function completeValue( info, path, result, - incrementalDataRecord, + errors, deferMap, ); } @@ -856,7 +889,7 @@ function completeValue( info, path, result, - incrementalDataRecord, + errors, deferMap, ); } @@ -875,9 +908,9 @@ async function completePromisedValue( info: GraphQLResolveInfo, path: Path, result: Promise, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): Promise { + errors: Array, + deferMap: ReadonlyMap | undefined, +): Promise> { try { const resolved = await result; let completed = completeValue( @@ -887,24 +920,17 @@ async function completePromisedValue( info, path, resolved, - incrementalDataRecord, + errors, deferMap, ); + if (isPromise(completed)) { completed = await completed; } return completed; } catch (rawError) { - handleFieldError( - rawError, - exeContext, - returnType, - fieldGroup, - path, - incrementalDataRecord, - ); - exeContext.incrementalPublisher.filter(path, incrementalDataRecord); - return null; + handleFieldError(rawError, returnType, fieldGroup, path, errors); + return [null, []]; } } @@ -982,6 +1008,7 @@ function getStreamUsage( return streamUsage; } + /** * Complete a async iterator value by completing the result and calling * recursively until all the results are completed. @@ -993,37 +1020,48 @@ async function completeAsyncIteratorValue( info: GraphQLResolveInfo, path: Path, asyncIterator: AsyncIterator, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): Promise> { - const streamUsage = getStreamUsage(exeContext, fieldGroup, path); + errors: Array, + deferMap: ReadonlyMap | undefined, +): Promise>> { let containsPromise = false; const completedResults: Array = []; + const graphqlWrappedResult: GraphQLWrappedResult> = [ + completedResults, + [], + ]; let index = 0; + const streamUsage = getStreamUsage(exeContext, fieldGroup, path); // eslint-disable-next-line no-constant-condition while (true) { if (streamUsage && index >= streamUsage.initialCount) { - const earlyReturn = asyncIterator.return; - const streamRecord = new StreamRecord({ - label: streamUsage.label, + const returnFn = asyncIterator.return; + let streamRecord: SubsequentResultRecord | CancellableStreamRecord; + if (returnFn === undefined) { + streamRecord = { + label: streamUsage.label, + path, + } as SubsequentResultRecord; + } else { + streamRecord = { + label: streamUsage.label, + path, + earlyReturn: returnFn.bind(asyncIterator), + }; + exeContext.cancellableStreams.add(streamRecord); + } + + const firstStreamItems = firstAsyncStreamItems( + streamRecord, path, - earlyReturn: - earlyReturn === undefined - ? undefined - : earlyReturn.bind(asyncIterator), - }); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - executeStreamAsyncIterator( index, asyncIterator, exeContext, streamUsage.fieldGroup, info, itemType, - path, - incrementalDataRecord, - streamRecord, ); + + graphqlWrappedResult[1].push(firstStreamItems); break; } @@ -1032,31 +1070,65 @@ async function completeAsyncIteratorValue( try { // eslint-disable-next-line no-await-in-loop iteration = await asyncIterator.next(); - if (iteration.done) { - break; - } } catch (rawError) { throw locatedError(rawError, toNodes(fieldGroup), pathToArray(path)); } - if ( + // TODO: add test case for stream returning done before initialCount + /* c8 ignore next 3 */ + if (iteration.done) { + break; + } + + const item = iteration.value; + // TODO: add tests for stream backed by asyncIterator that returns a promise + /* c8 ignore start */ + if (isPromise(item)) { + completedResults.push( + completePromisedValue( + exeContext, + itemType, + fieldGroup, + info, + itemPath, + item, + errors, + deferMap, + ).then((resolved) => { + graphqlWrappedResult[1].push(...resolved[1]); + return resolved[0]; + }), + ); + containsPromise = true; + } else if ( + /* c8 ignore stop */ completeListItemValue( - iteration.value, + item, completedResults, + graphqlWrappedResult, exeContext, itemType, fieldGroup, info, itemPath, - incrementalDataRecord, + errors, deferMap, ) + // TODO: add tests for stream backed by asyncIterator that completes to a promise + /* c8 ignore start */ ) { containsPromise = true; } - index += 1; + /* c8 ignore stop */ + index++; } - return containsPromise ? Promise.all(completedResults) : completedResults; + + return containsPromise + ? /* c8 ignore start */ Promise.all(completedResults).then((resolved) => [ + resolved, + graphqlWrappedResult[1], + ]) + : /* c8 ignore stop */ graphqlWrappedResult; } /** @@ -1070,9 +1142,9 @@ function completeListValue( info: GraphQLResolveInfo, path: Path, result: unknown, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): PromiseOrValue> { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue>> { const itemType = returnType.ofType; if (isAsyncIterable(result)) { @@ -1085,7 +1157,7 @@ function completeListValue( info, path, asyncIterator, - incrementalDataRecord, + errors, deferMap, ); } @@ -1096,65 +1168,90 @@ function completeListValue( ); } - const streamUsage = getStreamUsage(exeContext, fieldGroup, path); - // This is specified as a simple map, however we're optimizing the path // where the list contains no Promises by avoiding creating another Promise. let containsPromise = false; - let currentParents = incrementalDataRecord; const completedResults: Array = []; + const graphqlWrappedResult: GraphQLWrappedResult> = [ + completedResults, + [], + ]; let index = 0; - let streamRecord: StreamRecord | undefined; - for (const item of result) { - // No need to modify the info object containing the path, - // since from here on it is not ever accessed by resolver functions. - const itemPath = addPath(path, index, undefined); + const streamUsage = getStreamUsage(exeContext, fieldGroup, path); + const iterator = result[Symbol.iterator](); + let iteration = iterator.next(); + while (!iteration.done) { + const item = iteration.value; if (streamUsage && index >= streamUsage.initialCount) { - if (streamRecord === undefined) { - streamRecord = new StreamRecord({ label: streamUsage.label, path }); - } - currentParents = executeStreamField( + const streamRecord: SubsequentResultRecord = { + label: streamUsage.label, path, - itemPath, + }; + + const firstStreamItems = firstSyncStreamItems( + streamRecord, item, + index, + iterator, exeContext, streamUsage.fieldGroup, info, itemType, - currentParents, - streamRecord, ); - index++; - continue; + + graphqlWrappedResult[1].push(firstStreamItems); + break; } - if ( + // No need to modify the info object containing the path, + // since from here on it is not ever accessed by resolver functions. + const itemPath = addPath(path, index, undefined); + + if (isPromise(item)) { + completedResults.push( + completePromisedValue( + exeContext, + itemType, + fieldGroup, + info, + itemPath, + item, + errors, + deferMap, + ).then((resolved) => { + graphqlWrappedResult[1].push(...resolved[1]); + return resolved[0]; + }), + ); + containsPromise = true; + } else if ( completeListItemValue( item, completedResults, + graphqlWrappedResult, exeContext, itemType, fieldGroup, info, itemPath, - incrementalDataRecord, + errors, deferMap, ) ) { containsPromise = true; } - index++; - } - if (streamRecord !== undefined) { - exeContext.incrementalPublisher.setIsFinalRecord( - currentParents as StreamItemsRecord, - ); + iteration = iterator.next(); } - return containsPromise ? Promise.all(completedResults) : completedResults; + return containsPromise + ? Promise.all(completedResults).then((resolved) => [ + resolved, + graphqlWrappedResult[1], + ]) + : graphqlWrappedResult; } /** @@ -1165,31 +1262,15 @@ function completeListValue( function completeListItemValue( item: unknown, completedResults: Array, + parent: GraphQLWrappedResult>, exeContext: ExecutionContext, itemType: GraphQLOutputType, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemPath: Path, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, + errors: Array, + deferMap: ReadonlyMap | undefined, ): boolean { - if (isPromise(item)) { - completedResults.push( - completePromisedValue( - exeContext, - itemType, - fieldGroup, - info, - itemPath, - item, - incrementalDataRecord, - deferMap, - ), - ); - - return true; - } - try { const completedItem = completeValue( exeContext, @@ -1198,7 +1279,7 @@ function completeListItemValue( info, itemPath, item, - incrementalDataRecord, + errors, deferMap, ); @@ -1206,40 +1287,26 @@ function completeListItemValue( // Note: we don't rely on a `catch` method, but we do expect "thenable" // to take a second callback for the error case. completedResults.push( - completedItem.then(undefined, (rawError) => { - handleFieldError( - rawError, - exeContext, - itemType, - fieldGroup, - itemPath, - incrementalDataRecord, - ); - exeContext.incrementalPublisher.filter( - itemPath, - incrementalDataRecord, - ); - return null; - }), + completedItem.then( + (resolved) => { + parent[1].push(...resolved[1]); + return resolved[0]; + }, + (rawError) => { + handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + return null; + }, + ), ); - return true; } - completedResults.push(completedItem); + completedResults.push(completedItem[0]); + parent[1].push(...completedItem[1]); } catch (rawError) { - handleFieldError( - rawError, - exeContext, - itemType, - fieldGroup, - itemPath, - incrementalDataRecord, - ); - exeContext.incrementalPublisher.filter(itemPath, incrementalDataRecord); + handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); completedResults.push(null); } - return false; } @@ -1272,9 +1339,9 @@ function completeAbstractValue( info: GraphQLResolveInfo, path: Path, result: unknown, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): PromiseOrValue> { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue>> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; const contextValue = exeContext.contextValue; const runtimeType = resolveTypeFn(result, contextValue, info, returnType); @@ -1295,7 +1362,7 @@ function completeAbstractValue( info, path, result, - incrementalDataRecord, + errors, deferMap, ), ); @@ -1315,7 +1382,7 @@ function completeAbstractValue( info, path, result, - incrementalDataRecord, + errors, deferMap, ); } @@ -1385,9 +1452,9 @@ function completeObjectValue( info: GraphQLResolveInfo, path: Path, result: unknown, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): PromiseOrValue> { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue>> { // If there is an isTypeOf predicate function, call it with the // current result. If isTypeOf returns false, then raise an error rather // than continuing execution. @@ -1405,7 +1472,7 @@ function completeObjectValue( fieldGroup, path, result, - incrementalDataRecord, + errors, deferMap, ); }); @@ -1422,7 +1489,7 @@ function completeObjectValue( fieldGroup, path, result, - incrementalDataRecord, + errors, deferMap, ); } @@ -1456,46 +1523,25 @@ function invalidReturnTypeError( * */ function addNewDeferredFragments( - incrementalPublisher: IncrementalPublisher, newDeferUsages: ReadonlyArray, - incrementalDataRecord: IncrementalDataRecord, - deferMap?: ReadonlyMap, + newDeferMap: Map, path?: Path | undefined, ): ReadonlyMap { - if (newDeferUsages.length === 0) { - // Given no DeferUsages, return the existing map, creating one if necessary. - return deferMap ?? new Map(); - } - - // Create a copy of the old map. - const newDeferMap = - deferMap === undefined - ? new Map() - : new Map(deferMap); - // For each new deferUsage object: for (const newDeferUsage of newDeferUsages) { const parentDeferUsage = newDeferUsage.parentDeferUsage; - // If the parent defer usage is not defined, the parent result record is either: - // - the InitialResultRecord, or - // - a StreamItemsRecord, as `@defer` may be nested under `@stream`. const parent = parentDeferUsage === undefined - ? (incrementalDataRecord as InitialResultRecord | StreamItemsRecord) + ? undefined : deferredFragmentRecordFromDeferUsage(parentDeferUsage, newDeferMap); // Instantiate the new record. const deferredFragmentRecord = new DeferredFragmentRecord({ path, label: newDeferUsage.label, - }); - - // Report the new record to the Incremental Publisher. - incrementalPublisher.reportNewDeferFragmentRecord( - deferredFragmentRecord, parent, - ); + }); // Update the map. newDeferMap.set(newDeferUsage, deferredFragmentRecord); @@ -1512,74 +1558,22 @@ function deferredFragmentRecordFromDeferUsage( return deferMap.get(deferUsage)!; } -function addNewDeferredGroupedFieldSets( - incrementalPublisher: IncrementalPublisher, - newGroupedFieldSetDetailsMap: Map, - deferMap: ReadonlyMap, - path?: Path | undefined, -): ReadonlyArray { - const newDeferredGroupedFieldSetRecords: Array = - []; - - for (const [ - deferUsageSet, - { groupedFieldSet, shouldInitiateDefer }, - ] of newGroupedFieldSetDetailsMap) { - const deferredFragmentRecords = getDeferredFragmentRecords( - deferUsageSet, - deferMap, - ); - const deferredGroupedFieldSetRecord = new DeferredGroupedFieldSetRecord({ - path, - deferredFragmentRecords, - groupedFieldSet, - shouldInitiateDefer, - }); - incrementalPublisher.reportNewDeferredGroupedFieldSetRecord( - deferredGroupedFieldSetRecord, - ); - newDeferredGroupedFieldSetRecords.push(deferredGroupedFieldSetRecord); - } - - return newDeferredGroupedFieldSetRecords; -} - -function getDeferredFragmentRecords( - deferUsages: DeferUsageSet, - deferMap: ReadonlyMap, -): ReadonlyArray { - return Array.from(deferUsages).map((deferUsage) => - deferredFragmentRecordFromDeferUsage(deferUsage, deferMap), - ); -} - function collectAndExecuteSubfields( exeContext: ExecutionContext, returnType: GraphQLObjectType, fieldGroup: FieldGroup, path: Path, result: unknown, - incrementalDataRecord: IncrementalDataRecord, - deferMap: ReadonlyMap, -): PromiseOrValue> { + errors: Array, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue>> { // Collect sub-fields to execute to complete this value. - const { groupedFieldSet, newGroupedFieldSetDetailsMap, newDeferUsages } = + const { groupedFieldSet, newGroupedFieldSets, newDeferUsages } = buildSubFieldPlan(exeContext, returnType, fieldGroup); - const incrementalPublisher = exeContext.incrementalPublisher; - const newDeferMap = addNewDeferredFragments( - incrementalPublisher, newDeferUsages, - incrementalDataRecord, - deferMap, - path, - ); - - const newDeferredGroupedFieldSetRecords = addNewDeferredGroupedFieldSets( - incrementalPublisher, - newGroupedFieldSetDetailsMap, - newDeferMap, + new Map(deferMap), path, ); @@ -1589,20 +1583,24 @@ function collectAndExecuteSubfields( result, path, groupedFieldSet, - incrementalDataRecord, + errors, newDeferMap, ); - executeDeferredGroupedFieldSets( + const newDeferredGroupedFieldSetRecords = executeDeferredGroupedFieldSets( exeContext, returnType, result, path, - newDeferredGroupedFieldSetRecords, + fieldGroup.deferUsages, + newGroupedFieldSets, newDeferMap, ); - return subFields; + return withNewDeferredGroupedFieldSets( + subFields, + newDeferredGroupedFieldSetRecords, + ); } /** @@ -1902,343 +1900,407 @@ function executeDeferredGroupedFieldSets( parentType: GraphQLObjectType, sourceValue: unknown, path: Path | undefined, - newDeferredGroupedFieldSetRecords: ReadonlyArray, + parentDeferUsages: DeferUsageSet | undefined, + newGroupedFieldSets: Map, deferMap: ReadonlyMap, -): void { - for (const deferredGroupedFieldSetRecord of newDeferredGroupedFieldSetRecords) { - if (deferredGroupedFieldSetRecord.shouldInitiateDefer) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - Promise.resolve().then(() => - executeDeferredGroupedFieldSet( - exeContext, - parentType, - sourceValue, - path, - deferredGroupedFieldSetRecord, - deferMap, - ), - ); - continue; - } +): ReadonlyArray { + const newDeferredGroupedFieldSetRecords: Array = + []; - executeDeferredGroupedFieldSet( - exeContext, - parentType, - sourceValue, - path, - deferredGroupedFieldSetRecord, + for (const [deferUsageSet, groupedFieldSet] of newGroupedFieldSets) { + const deferredFragmentRecords = getDeferredFragmentRecords( + deferUsageSet, deferMap, ); + + const executor = () => + executeDeferredGroupedFieldSet( + deferredFragmentRecords, + exeContext, + parentType, + sourceValue, + path, + groupedFieldSet, + [], + deferMap, + ); + + const deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord = { + deferredFragmentRecords, + result: shouldDefer(parentDeferUsages, deferUsageSet) + ? Promise.resolve().then(executor) + : executor(), + }; + + newDeferredGroupedFieldSetRecords.push(deferredGroupedFieldSetRecord); } + + return newDeferredGroupedFieldSetRecords; +} + +function shouldDefer( + parentDeferUsages: undefined | DeferUsageSet, + deferUsages: DeferUsageSet, +): boolean { + // If we have a new child defer usage, defer. + // Otherwise, this defer usage was already deferred when it was initially + // encountered, and is now in the midst of executing early, so the new + // deferred grouped fields set can be executed immediately. + return ( + parentDeferUsages === undefined || + !Array.from(deferUsages).every((deferUsage) => + parentDeferUsages.has(deferUsage), + ) + ); } function executeDeferredGroupedFieldSet( + deferredFragmentRecords: ReadonlyArray, exeContext: ExecutionContext, parentType: GraphQLObjectType, sourceValue: unknown, path: Path | undefined, - deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord, + groupedFieldSet: GroupedFieldSet, + errors: Array, deferMap: ReadonlyMap, -): void { +): PromiseOrValue { + let result; try { - const incrementalResult = executeFields( + result = executeFields( exeContext, parentType, sourceValue, path, - deferredGroupedFieldSetRecord.groupedFieldSet, - deferredGroupedFieldSetRecord, + groupedFieldSet, + errors, deferMap, ); - - if (isPromise(incrementalResult)) { - incrementalResult.then( - (resolved) => - exeContext.incrementalPublisher.completeDeferredGroupedFieldSet( - deferredGroupedFieldSetRecord, - resolved, - ), - (error) => - exeContext.incrementalPublisher.markErroredDeferredGroupedFieldSet( - deferredGroupedFieldSetRecord, - error, - ), - ); - return; - } - - exeContext.incrementalPublisher.completeDeferredGroupedFieldSet( - deferredGroupedFieldSetRecord, - incrementalResult, - ); } catch (error) { - exeContext.incrementalPublisher.markErroredDeferredGroupedFieldSet( - deferredGroupedFieldSetRecord, - error, + return { + deferredFragmentRecords, + path: pathToArray(path), + errors: withError(errors, error), + }; + } + + if (isPromise(result)) { + return result.then( + (resolved) => + buildDeferredGroupedFieldSetResult( + errors, + deferredFragmentRecords, + path, + resolved, + ), + (error) => ({ + deferredFragmentRecords, + path: pathToArray(path), + errors: withError(errors, error), + }), ); } + + return buildDeferredGroupedFieldSetResult( + errors, + deferredFragmentRecords, + path, + result, + ); } -function executeStreamField( - path: Path, - itemPath: Path, - item: PromiseOrValue, +function buildDeferredGroupedFieldSetResult( + errors: ReadonlyArray, + deferredFragmentRecords: ReadonlyArray, + path: Path | undefined, + result: GraphQLWrappedResult>, +): DeferredGroupedFieldSetResult { + return { + deferredFragmentRecords, + path: pathToArray(path), + result: + errors.length === 0 ? { data: result[0] } : { data: result[0], errors }, + incrementalDataRecords: result[1], + }; +} + +function getDeferredFragmentRecords( + deferUsages: DeferUsageSet, + deferMap: ReadonlyMap, +): ReadonlyArray { + return Array.from(deferUsages).map((deferUsage) => + deferredFragmentRecordFromDeferUsage(deferUsage, deferMap), + ); +} + +function firstSyncStreamItems( + streamRecord: SubsequentResultRecord, + initialItem: PromiseOrValue, + initialIndex: number, + iterator: Iterator, exeContext: ExecutionContext, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, - incrementalDataRecord: IncrementalDataRecord, - streamRecord: StreamRecord, ): StreamItemsRecord { - const incrementalPublisher = exeContext.incrementalPublisher; - const streamItemsRecord = new StreamItemsRecord({ - streamRecord, - path: itemPath, - }); - incrementalPublisher.reportNewStreamItemsRecord( - streamItemsRecord, - incrementalDataRecord, - ); + const path = streamRecord.path; + const initialPath = addPath(path, initialIndex, undefined); - if (isPromise(item)) { - completePromisedValue( - exeContext, - itemType, - fieldGroup, - info, - itemPath, - item, - streamItemsRecord, - new Map(), - ).then( - (value) => - incrementalPublisher.completeStreamItemsRecord(streamItemsRecord, [ - value, - ]), - (error) => { - incrementalPublisher.filter(path, streamItemsRecord); - incrementalPublisher.markErroredStreamItemsRecord( - streamItemsRecord, - error, - ); - }, - ); - - return streamItemsRecord; - } - - let completedItem: PromiseOrValue; - try { - try { - completedItem = completeValue( + const firstStreamItems: StreamItemsRecord = { + streamRecord, + result: Promise.resolve().then(() => { + let result = completeStreamItems( + streamRecord, + initialPath, + initialItem, exeContext, - itemType, + [], fieldGroup, info, - itemPath, - item, - streamItemsRecord, - new Map(), - ); - } catch (rawError) { - handleFieldError( - rawError, - exeContext, itemType, - fieldGroup, - itemPath, - streamItemsRecord, ); - completedItem = null; - incrementalPublisher.filter(itemPath, streamItemsRecord); - } - } catch (error) { - incrementalPublisher.filter(path, streamItemsRecord); - incrementalPublisher.markErroredStreamItemsRecord(streamItemsRecord, error); - return streamItemsRecord; - } - - if (isPromise(completedItem)) { - completedItem - .then(undefined, (rawError) => { - handleFieldError( - rawError, + const results = [result]; + let currentIndex = initialIndex; + let iteration = iterator.next(); + let erroredSynchronously = false; + while (!iteration.done) { + if (!isPromise(result) && !isReconcilableStreamItemsResult(result)) { + erroredSynchronously = true; + break; + } + const item = iteration.value; + currentIndex++; + const currentPath = addPath(path, currentIndex, undefined); + result = completeStreamItems( + streamRecord, + currentPath, + item, exeContext, - itemType, + [], fieldGroup, - itemPath, - streamItemsRecord, + info, + itemType, ); - incrementalPublisher.filter(itemPath, streamItemsRecord); - return null; - }) - .then( - (value) => - incrementalPublisher.completeStreamItemsRecord(streamItemsRecord, [ - value, - ]), - (error) => { - incrementalPublisher.filter(path, streamItemsRecord); - incrementalPublisher.markErroredStreamItemsRecord( - streamItemsRecord, - error, - ); - }, - ); + results.push(result); + iteration = iterator.next(); + } + + currentIndex = results.length - 1; + // If a non-reconcilable stream items result was encountered, then the stream terminates in error. + // Otherwise, add a stream terminator. + let currentResult = erroredSynchronously + ? results[currentIndex] + : prependNextStreamItems(results[currentIndex], { + streamRecord, + result: { streamRecord }, + }); + + while (currentIndex-- > 0) { + currentResult = prependNextStreamItems(results[currentIndex], { + streamRecord, + result: currentResult, + }); + } + + return currentResult; + }), + }; + return firstStreamItems; +} - return streamItemsRecord; +function prependNextStreamItems( + result: PromiseOrValue, + nextStreamItems: StreamItemsRecord, +): PromiseOrValue { + if (isPromise(result)) { + return result.then((resolved) => + prependNextResolvedStreamItems(resolved, nextStreamItems), + ); } + return prependNextResolvedStreamItems(result, nextStreamItems); +} - incrementalPublisher.completeStreamItemsRecord(streamItemsRecord, [ - completedItem, - ]); - return streamItemsRecord; +function prependNextResolvedStreamItems( + result: StreamItemsResult, + nextStreamItems: StreamItemsRecord, +): StreamItemsResult { + return isReconcilableStreamItemsResult(result) + ? { + ...result, + incrementalDataRecords: [ + nextStreamItems, + ...result.incrementalDataRecords, + ], + } + : result; } -async function executeStreamAsyncIteratorItem( +function firstAsyncStreamItems( + streamRecord: SubsequentResultRecord, + path: Path, + initialIndex: number, asyncIterator: AsyncIterator, exeContext: ExecutionContext, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, - streamItemsRecord: StreamItemsRecord, - itemPath: Path, -): Promise> { - let item; - try { - const iteration = await asyncIterator.next(); - if (streamItemsRecord.streamRecord.errors.length > 0) { - return { done: true, value: undefined }; - } - if (iteration.done) { - exeContext.incrementalPublisher.setIsCompletedAsyncIterator( - streamItemsRecord, - ); - return { done: true, value: undefined }; - } - item = iteration.value; - } catch (rawError) { - throw locatedError( - rawError, - toNodes(fieldGroup), - streamItemsRecord.streamRecord.path, - ); - } - let completedItem; - try { - completedItem = completeValue( +): StreamItemsRecord { + const firstStreamItems: StreamItemsRecord = { + streamRecord, + result: getNextAsyncStreamItemsResult( + streamRecord, + path, + initialIndex, + asyncIterator, exeContext, - itemType, fieldGroup, info, - itemPath, - item, - streamItemsRecord, - new Map(), - ); - - if (isPromise(completedItem)) { - completedItem = completedItem.then(undefined, (rawError) => { - handleFieldError( - rawError, - exeContext, - itemType, - fieldGroup, - itemPath, - streamItemsRecord, - ); - exeContext.incrementalPublisher.filter(itemPath, streamItemsRecord); - return null; - }); - } - return { done: false, value: completedItem }; - } catch (rawError) { - handleFieldError( - rawError, - exeContext, itemType, - fieldGroup, - itemPath, - streamItemsRecord, - ); - exeContext.incrementalPublisher.filter(itemPath, streamItemsRecord); - return { done: false, value: null }; - } + ), + }; + return firstStreamItems; } -async function executeStreamAsyncIterator( - initialIndex: number, +async function getNextAsyncStreamItemsResult( + streamRecord: SubsequentResultRecord, + path: Path, + index: number, asyncIterator: AsyncIterator, exeContext: ExecutionContext, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, - path: Path, - incrementalDataRecord: IncrementalDataRecord, - streamRecord: StreamRecord, -): Promise { - const incrementalPublisher = exeContext.incrementalPublisher; - let index = initialIndex; - let currentIncrementalDataRecord = incrementalDataRecord; - // eslint-disable-next-line no-constant-condition - while (true) { - const itemPath = addPath(path, index, undefined); - const streamItemsRecord = new StreamItemsRecord({ +): Promise { + let iteration; + try { + iteration = await asyncIterator.next(); + } catch (error) { + return { streamRecord, - path: itemPath, - }); - incrementalPublisher.reportNewStreamItemsRecord( - streamItemsRecord, - currentIncrementalDataRecord, + errors: [locatedError(error, toNodes(fieldGroup), pathToArray(path))], + }; + } + + if (iteration.done) { + return { streamRecord }; + } + + const itemPath = addPath(path, index, undefined); + + const result = completeStreamItems( + streamRecord, + itemPath, + iteration.value, + exeContext, + [], + fieldGroup, + info, + itemType, + ); + + const nextStreamItems: StreamItemsRecord = { + streamRecord, + result: getNextAsyncStreamItemsResult( + streamRecord, + path, + index, + asyncIterator, + exeContext, + fieldGroup, + info, + itemType, + ), + }; + + return prependNextStreamItems(result, nextStreamItems); +} + +function completeStreamItems( + streamRecord: SubsequentResultRecord, + itemPath: Path, + item: unknown, + exeContext: ExecutionContext, + errors: Array, + fieldGroup: FieldGroup, + info: GraphQLResolveInfo, + itemType: GraphQLOutputType, +): PromiseOrValue { + if (isPromise(item)) { + return completePromisedValue( + exeContext, + itemType, + fieldGroup, + info, + itemPath, + item, + errors, + new Map(), + ).then( + (resolvedItem) => + buildStreamItemsResult(errors, streamRecord, resolvedItem), + (error) => ({ + streamRecord, + errors: withError(errors, error), + }), ); + } - let iteration; + let result: PromiseOrValue>; + try { try { - // eslint-disable-next-line no-await-in-loop - iteration = await executeStreamAsyncIteratorItem( - asyncIterator, + result = completeValue( exeContext, + itemType, fieldGroup, info, - itemType, - streamItemsRecord, itemPath, + item, + errors, + new Map(), ); - } catch (error) { - incrementalPublisher.filter(path, streamItemsRecord); - incrementalPublisher.markErroredStreamItemsRecord( - streamItemsRecord, - error, - ); - return; + } catch (rawError) { + handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + result = [null, []]; } + } catch (error) { + return { + streamRecord, + errors: withError(errors, error), + }; + } - const { done, value: completedItem } = iteration; - - if (isPromise(completedItem)) { - completedItem.then( - (value) => - incrementalPublisher.completeStreamItemsRecord(streamItemsRecord, [ - value, - ]), - (error) => { - incrementalPublisher.filter(path, streamItemsRecord); - incrementalPublisher.markErroredStreamItemsRecord( - streamItemsRecord, - error, - ); - }, + if (isPromise(result)) { + return result + .then(undefined, (rawError) => { + handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + return [null, []] as GraphQLWrappedResult; + }) + .then( + (resolvedItem) => + buildStreamItemsResult(errors, streamRecord, resolvedItem), + (error) => ({ + streamRecord, + errors: withError(errors, error), + }), ); - } else { - incrementalPublisher.completeStreamItemsRecord(streamItemsRecord, [ - completedItem, - ]); - } - - if (done) { - break; - } - currentIncrementalDataRecord = streamItemsRecord; - index++; } + + return buildStreamItemsResult(errors, streamRecord, result); +} + +function buildStreamItemsResult( + errors: ReadonlyArray, + streamRecord: SubsequentResultRecord, + result: GraphQLWrappedResult, +): StreamItemsResult { + return { + streamRecord, + result: + errors.length === 0 + ? { items: [result[0]] } + : { + items: [result[0]], + errors: [...errors], + }, + incrementalDataRecords: result[1], + }; } diff --git a/src/jsutils/promiseForObject.ts b/src/jsutils/promiseForObject.ts index ff48d9f218..25b3413923 100644 --- a/src/jsutils/promiseForObject.ts +++ b/src/jsutils/promiseForObject.ts @@ -7,9 +7,10 @@ import type { ObjMap } from './ObjMap.js'; * This is akin to bluebird's `Promise.props`, but implemented only using * `Promise.all` so it will work with any implementation of ES6 promises. */ -export async function promiseForObject( +export async function promiseForObject( object: ObjMap>, -): Promise> { + callback: (object: ObjMap) => U, +): Promise { const keys = Object.keys(object); const values = Object.values(object); @@ -18,5 +19,5 @@ export async function promiseForObject( for (let i = 0; i < keys.length; ++i) { resolvedObject[keys[i]] = resolvedValues[i]; } - return resolvedObject; + return callback(resolvedObject); } From 6d777e6fc07da962fe4d357c24921f0022f3e5c9 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Fri, 19 Apr 2024 17:04:57 +0300 Subject: [PATCH 02/26] perf: allow skipping of field plan generation (#4050) in the non-deferred case. depends on #4026 --------- Co-authored-by: Rob Richard --- src/execution/buildFieldPlan.ts | 79 +--- src/execution/collectFields.ts | 16 +- src/execution/execute.ts | 368 +++++++++++------- .../rules/SingleFieldSubscriptionsRule.ts | 16 +- 4 files changed, 267 insertions(+), 212 deletions(-) diff --git a/src/execution/buildFieldPlan.ts b/src/execution/buildFieldPlan.ts index 970b8d5c46..d29ae94cde 100644 --- a/src/execution/buildFieldPlan.ts +++ b/src/execution/buildFieldPlan.ts @@ -1,55 +1,39 @@ import { getBySet } from '../jsutils/getBySet.js'; import { isSameSet } from '../jsutils/isSameSet.js'; -import type { DeferUsage, FieldDetails } from './collectFields.js'; +import type { + DeferUsage, + FieldGroup, + GroupedFieldSet, +} from './collectFields.js'; export type DeferUsageSet = ReadonlySet; -export interface FieldGroup { - fields: ReadonlyArray; - deferUsages?: DeferUsageSet | undefined; +export interface FieldPlan { + groupedFieldSet: GroupedFieldSet; + newGroupedFieldSets: Map; } -export type GroupedFieldSet = Map; - export function buildFieldPlan( - fields: Map>, + originalGroupedFieldSet: GroupedFieldSet, parentDeferUsages: DeferUsageSet = new Set(), -): { - groupedFieldSet: GroupedFieldSet; - newGroupedFieldSets: Map; -} { - const groupedFieldSet = new Map< - string, - { - fields: Array; - deferUsages: DeferUsageSet; - } - >(); +): FieldPlan { + const groupedFieldSet = new Map(); - const newGroupedFieldSets = new Map< - DeferUsageSet, - Map< - string, - { - fields: Array; - deferUsages: DeferUsageSet; - } - > - >(); + const newGroupedFieldSets = new Map>(); const map = new Map< string, { deferUsageSet: DeferUsageSet; - fieldDetailsList: ReadonlyArray; + fieldGroup: FieldGroup; } >(); - for (const [responseKey, fieldDetailsList] of fields) { + for (const [responseKey, fieldGroup] of originalGroupedFieldSet) { const deferUsageSet = new Set(); let inOriginalResult = false; - for (const fieldDetails of fieldDetailsList) { + for (const fieldDetails of fieldGroup) { const deferUsage = fieldDetails.deferUsage; if (deferUsage === undefined) { inOriginalResult = true; @@ -69,44 +53,21 @@ export function buildFieldPlan( } }); } - map.set(responseKey, { deferUsageSet, fieldDetailsList }); + map.set(responseKey, { deferUsageSet, fieldGroup }); } - for (const [responseKey, { deferUsageSet, fieldDetailsList }] of map) { + for (const [responseKey, { deferUsageSet, fieldGroup }] of map) { if (isSameSet(deferUsageSet, parentDeferUsages)) { - let fieldGroup = groupedFieldSet.get(responseKey); - if (fieldGroup === undefined) { - fieldGroup = { - fields: [], - deferUsages: deferUsageSet, - }; - groupedFieldSet.set(responseKey, fieldGroup); - } - fieldGroup.fields.push(...fieldDetailsList); + groupedFieldSet.set(responseKey, fieldGroup); continue; } let newGroupedFieldSet = getBySet(newGroupedFieldSets, deferUsageSet); if (newGroupedFieldSet === undefined) { - newGroupedFieldSet = new Map< - string, - { - fields: Array; - deferUsages: DeferUsageSet; - knownDeferUsages: DeferUsageSet; - } - >(); + newGroupedFieldSet = new Map(); newGroupedFieldSets.set(deferUsageSet, newGroupedFieldSet); } - let fieldGroup = newGroupedFieldSet.get(responseKey); - if (fieldGroup === undefined) { - fieldGroup = { - fields: [], - deferUsages: deferUsageSet, - }; - newGroupedFieldSet.set(responseKey, fieldGroup); - } - fieldGroup.fields.push(...fieldDetailsList); + newGroupedFieldSet.set(responseKey, fieldGroup); } return { diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index 03ba5efde6..d411ff3f77 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -36,6 +36,10 @@ export interface FieldDetails { deferUsage: DeferUsage | undefined; } +export type FieldGroup = ReadonlyArray; + +export type GroupedFieldSet = ReadonlyMap; + interface CollectFieldsContext { schema: GraphQLSchema; fragments: ObjMap; @@ -61,7 +65,7 @@ export function collectFields( runtimeType: GraphQLObjectType, operation: OperationDefinitionNode, ): { - fields: Map>; + groupedFieldSet: GroupedFieldSet; newDeferUsages: ReadonlyArray; } { const groupedFieldSet = new AccumulatorMap(); @@ -81,7 +85,7 @@ export function collectFields( groupedFieldSet, newDeferUsages, ); - return { fields: groupedFieldSet, newDeferUsages }; + return { groupedFieldSet, newDeferUsages }; } /** @@ -101,9 +105,9 @@ export function collectSubfields( variableValues: { [variable: string]: unknown }, operation: OperationDefinitionNode, returnType: GraphQLObjectType, - fieldDetails: ReadonlyArray, + fieldGroup: FieldGroup, ): { - fields: Map>; + groupedFieldSet: GroupedFieldSet; newDeferUsages: ReadonlyArray; } { const context: CollectFieldsContext = { @@ -117,7 +121,7 @@ export function collectSubfields( const subGroupedFieldSet = new AccumulatorMap(); const newDeferUsages: Array = []; - for (const fieldDetail of fieldDetails) { + for (const fieldDetail of fieldGroup) { const node = fieldDetail.node; if (node.selectionSet) { collectFieldsImpl( @@ -131,7 +135,7 @@ export function collectSubfields( } return { - fields: subGroupedFieldSet, + groupedFieldSet: subGroupedFieldSet, newDeferUsages, }; } diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 68037516e1..23bfa50702 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -47,14 +47,17 @@ import { GraphQLStreamDirective } from '../type/directives.js'; import type { GraphQLSchema } from '../type/schema.js'; import { assertValidSchema } from '../type/validate.js'; +import type { DeferUsageSet, FieldPlan } from './buildFieldPlan.js'; +import { buildFieldPlan } from './buildFieldPlan.js'; import type { - DeferUsageSet, + DeferUsage, FieldGroup, GroupedFieldSet, -} from './buildFieldPlan.js'; -import { buildFieldPlan } from './buildFieldPlan.js'; -import type { DeferUsage, FieldDetails } from './collectFields.js'; -import { collectFields, collectSubfields } from './collectFields.js'; +} from './collectFields.js'; +import { + collectFields, + collectSubfields as _collectSubfields, +} from './collectFields.js'; import type { CancellableStreamRecord, DeferredGroupedFieldSetRecord, @@ -83,29 +86,24 @@ import { // so just disable it for entire file. /** - * A memoized function for building subfield plans with regard to the return - * type. Memoizing ensures the subfield plans are not repeatedly calculated, which + * 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. */ -const buildSubFieldPlan = memoize3( +const collectSubfields = memoize3( ( exeContext: ExecutionContext, returnType: GraphQLObjectType, fieldGroup: FieldGroup, - ) => { - const { fields: subFields, newDeferUsages } = collectSubfields( + ) => + _collectSubfields( exeContext.schema, exeContext.fragments, exeContext.variableValues, exeContext.operation, returnType, - fieldGroup.fields, - ); - return { - ...buildFieldPlan(subFields, fieldGroup.deferUsages), - newDeferUsages, - }; - }, + fieldGroup, + ), ); /** @@ -144,9 +142,15 @@ export interface ExecutionContext { fieldResolver: GraphQLFieldResolver; typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; + errors: Array; cancellableStreams: Set; } +interface IncrementalContext { + errors: Array; + deferUsageSet?: DeferUsageSet | undefined; +} + export interface ExecutionArgs { schema: GraphQLSchema; document: DocumentNode; @@ -259,7 +263,6 @@ export function experimentalExecuteIncrementally( function executeOperation( exeContext: ExecutionContext, ): PromiseOrValue { - const errors: Array = []; try { const { operation, schema, fragments, variableValues, rootValue } = exeContext; @@ -271,59 +274,76 @@ function executeOperation( ); } - const { fields, newDeferUsages } = collectFields( + const collectedFields = collectFields( schema, fragments, variableValues, rootType, operation, ); - const { groupedFieldSet, newGroupedFieldSets } = buildFieldPlan(fields); - - const newDeferMap = addNewDeferredFragments(newDeferUsages, new Map()); - - let graphqlWrappedResult = executeRootGroupedFieldSet( - exeContext, - operation.operation, - rootType, - rootValue, - groupedFieldSet, - errors, - newDeferMap, - ); + let groupedFieldSet = collectedFields.groupedFieldSet; + const newDeferUsages = collectedFields.newDeferUsages; + let graphqlWrappedResult: PromiseOrValue< + GraphQLWrappedResult> + >; + if (newDeferUsages.length === 0) { + graphqlWrappedResult = executeRootGroupedFieldSet( + exeContext, + operation.operation, + rootType, + rootValue, + groupedFieldSet, + undefined, + ); + } else { + const fieldPLan = buildFieldPlan(groupedFieldSet); + groupedFieldSet = fieldPLan.groupedFieldSet; + const newGroupedFieldSets = fieldPLan.newGroupedFieldSets; + const newDeferMap = addNewDeferredFragments(newDeferUsages, new Map()); - const newDeferredGroupedFieldSetRecords = executeDeferredGroupedFieldSets( - exeContext, - rootType, - rootValue, - undefined, - undefined, - newGroupedFieldSets, - newDeferMap, - ); + graphqlWrappedResult = executeRootGroupedFieldSet( + exeContext, + operation.operation, + rootType, + rootValue, + groupedFieldSet, + newDeferMap, + ); - graphqlWrappedResult = withNewDeferredGroupedFieldSets( - graphqlWrappedResult, - newDeferredGroupedFieldSetRecords, - ); + if (newGroupedFieldSets.size > 0) { + const newDeferredGroupedFieldSetRecords = + executeDeferredGroupedFieldSets( + exeContext, + rootType, + rootValue, + undefined, + undefined, + newGroupedFieldSets, + newDeferMap, + ); + + graphqlWrappedResult = withNewDeferredGroupedFieldSets( + graphqlWrappedResult, + newDeferredGroupedFieldSetRecords, + ); + } + } if (isPromise(graphqlWrappedResult)) { return graphqlWrappedResult.then( - (resolved) => - buildDataResponse(exeContext, resolved[0], errors, resolved[1]), + (resolved) => buildDataResponse(exeContext, resolved[0], resolved[1]), (error) => ({ data: null, - errors: withError(errors, error), + errors: withError(exeContext.errors, error), }), ); } return buildDataResponse( exeContext, graphqlWrappedResult[0], - errors, graphqlWrappedResult[1], ); } catch (error) { - return { data: null, errors: withError(errors, error) }; + return { data: null, errors: withError(exeContext.errors, error) }; } } @@ -352,9 +372,9 @@ function withError( function buildDataResponse( exeContext: ExecutionContext, data: ObjMap, - errors: ReadonlyArray, incrementalDataRecords: ReadonlyArray, ): ExecutionResult | ExperimentalIncrementalExecutionResults { + const errors = exeContext.errors; if (incrementalDataRecords.length === 0) { return errors.length > 0 ? { errors, data } : { data }; } @@ -468,6 +488,7 @@ export function buildExecutionContext( fieldResolver: fieldResolver ?? defaultFieldResolver, typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, + errors: [], cancellableStreams: new Set(), }; } @@ -478,6 +499,7 @@ function buildPerEventExecutionContext( ): ExecutionContext { return { ...exeContext, + errors: [], rootValue: payload, }; } @@ -488,7 +510,6 @@ function executeRootGroupedFieldSet( rootType: GraphQLObjectType, rootValue: unknown, groupedFieldSet: GroupedFieldSet, - errors: Array, deferMap: ReadonlyMap | undefined, ): PromiseOrValue>> { switch (operation) { @@ -499,7 +520,7 @@ function executeRootGroupedFieldSet( rootValue, undefined, groupedFieldSet, - errors, + undefined, deferMap, ); case OperationTypeNode.MUTATION: @@ -509,7 +530,7 @@ function executeRootGroupedFieldSet( rootValue, undefined, groupedFieldSet, - errors, + undefined, deferMap, ); case OperationTypeNode.SUBSCRIPTION: @@ -521,7 +542,7 @@ function executeRootGroupedFieldSet( rootValue, undefined, groupedFieldSet, - errors, + undefined, deferMap, ); } @@ -537,7 +558,7 @@ function executeFieldsSerially( sourceValue: unknown, path: Path | undefined, groupedFieldSet: GroupedFieldSet, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): PromiseOrValue>> { return promiseReduce( @@ -550,7 +571,7 @@ function executeFieldsSerially( sourceValue, fieldGroup, fieldPath, - errors, + incrementalContext, deferMap, ); if (result === undefined) { @@ -581,7 +602,7 @@ function executeFields( sourceValue: unknown, path: Path | undefined, groupedFieldSet: GroupedFieldSet, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): PromiseOrValue>> { const results = Object.create(null); @@ -600,7 +621,7 @@ function executeFields( sourceValue, fieldGroup, fieldPath, - errors, + incrementalContext, deferMap, ); @@ -644,7 +665,7 @@ function executeFields( } function toNodes(fieldGroup: FieldGroup): ReadonlyArray { - return fieldGroup.fields.map((fieldDetails) => fieldDetails.node); + return fieldGroup.map((fieldDetails) => fieldDetails.node); } /** @@ -659,10 +680,10 @@ function executeField( source: unknown, fieldGroup: FieldGroup, path: Path, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): PromiseOrValue> | undefined { - const fieldName = fieldGroup.fields[0].node.name.value; + const fieldName = fieldGroup[0].node.name.value; const fieldDef = exeContext.schema.getField(parentType, fieldName); if (!fieldDef) { return; @@ -686,7 +707,7 @@ function executeField( // TODO: find a way to memoize, in case this field is within a List type. const args = getArgumentValues( fieldDef, - fieldGroup.fields[0].node, + fieldGroup[0].node, exeContext.variableValues, ); @@ -705,7 +726,7 @@ function executeField( info, path, result, - errors, + incrementalContext, deferMap, ); } @@ -717,7 +738,7 @@ function executeField( info, path, result, - errors, + incrementalContext, deferMap, ); @@ -725,12 +746,14 @@ function executeField( // Note: we don't rely on a `catch` method, but we do expect "thenable" // to take a second callback for the error case. return completed.then(undefined, (rawError) => { + const errors = (incrementalContext ?? exeContext).errors; handleFieldError(rawError, returnType, fieldGroup, path, errors); return [null, []]; }); } return completed; } catch (rawError) { + const errors = (incrementalContext ?? exeContext).errors; handleFieldError(rawError, returnType, fieldGroup, path, errors); return [null, []]; } @@ -811,7 +834,7 @@ function completeValue( info: GraphQLResolveInfo, path: Path, result: unknown, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): PromiseOrValue> { // If result is an Error, throw a located error. @@ -829,7 +852,7 @@ function completeValue( info, path, result, - errors, + incrementalContext, deferMap, ); if ((completed as GraphQLWrappedResult)[0] === null) { @@ -854,7 +877,7 @@ function completeValue( info, path, result, - errors, + incrementalContext, deferMap, ); } @@ -875,7 +898,7 @@ function completeValue( info, path, result, - errors, + incrementalContext, deferMap, ); } @@ -889,7 +912,7 @@ function completeValue( info, path, result, - errors, + incrementalContext, deferMap, ); } @@ -908,7 +931,7 @@ async function completePromisedValue( info: GraphQLResolveInfo, path: Path, result: Promise, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): Promise> { try { @@ -920,7 +943,7 @@ async function completePromisedValue( info, path, resolved, - errors, + incrementalContext, deferMap, ); @@ -929,6 +952,7 @@ async function completePromisedValue( } return completed; } catch (rawError) { + const errors = (incrementalContext ?? exeContext).errors; handleFieldError(rawError, returnType, fieldGroup, path, errors); return [null, []]; } @@ -963,7 +987,7 @@ function getStreamUsage( // safe to only check the first fieldNode for the stream directive const stream = getDirectiveValues( GraphQLStreamDirective, - fieldGroup.fields[0].node, + fieldGroup[0].node, exeContext.variableValues, ); @@ -990,12 +1014,10 @@ function getStreamUsage( '`@stream` directive not supported on subscription operations. Disable `@stream` by setting the `if` argument to `false`.', ); - const streamedFieldGroup: FieldGroup = { - fields: fieldGroup.fields.map((fieldDetails) => ({ - node: fieldDetails.node, - deferUsage: undefined, - })), - }; + const streamedFieldGroup: FieldGroup = fieldGroup.map((fieldDetails) => ({ + node: fieldDetails.node, + deferUsage: undefined, + })); const streamUsage = { initialCount: stream.initialCount, @@ -1020,7 +1042,7 @@ async function completeAsyncIteratorValue( info: GraphQLResolveInfo, path: Path, asyncIterator: AsyncIterator, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): Promise>> { let containsPromise = false; @@ -1092,7 +1114,7 @@ async function completeAsyncIteratorValue( info, itemPath, item, - errors, + incrementalContext, deferMap, ).then((resolved) => { graphqlWrappedResult[1].push(...resolved[1]); @@ -1111,7 +1133,7 @@ async function completeAsyncIteratorValue( fieldGroup, info, itemPath, - errors, + incrementalContext, deferMap, ) // TODO: add tests for stream backed by asyncIterator that completes to a promise @@ -1142,7 +1164,7 @@ function completeListValue( info: GraphQLResolveInfo, path: Path, result: unknown, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): PromiseOrValue>> { const itemType = returnType.ofType; @@ -1157,7 +1179,7 @@ function completeListValue( info, path, asyncIterator, - errors, + incrementalContext, deferMap, ); } @@ -1217,7 +1239,7 @@ function completeListValue( info, itemPath, item, - errors, + incrementalContext, deferMap, ).then((resolved) => { graphqlWrappedResult[1].push(...resolved[1]); @@ -1235,7 +1257,7 @@ function completeListValue( fieldGroup, info, itemPath, - errors, + incrementalContext, deferMap, ) ) { @@ -1268,7 +1290,7 @@ function completeListItemValue( fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemPath: Path, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): boolean { try { @@ -1279,7 +1301,7 @@ function completeListItemValue( info, itemPath, item, - errors, + incrementalContext, deferMap, ); @@ -1293,6 +1315,7 @@ function completeListItemValue( return resolved[0]; }, (rawError) => { + const errors = (incrementalContext ?? exeContext).errors; handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); return null; }, @@ -1304,6 +1327,7 @@ function completeListItemValue( completedResults.push(completedItem[0]); parent[1].push(...completedItem[1]); } catch (rawError) { + const errors = (incrementalContext ?? exeContext).errors; handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); completedResults.push(null); } @@ -1339,7 +1363,7 @@ function completeAbstractValue( info: GraphQLResolveInfo, path: Path, result: unknown, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): PromiseOrValue>> { const resolveTypeFn = returnType.resolveType ?? exeContext.typeResolver; @@ -1362,7 +1386,7 @@ function completeAbstractValue( info, path, result, - errors, + incrementalContext, deferMap, ), ); @@ -1382,7 +1406,7 @@ function completeAbstractValue( info, path, result, - errors, + incrementalContext, deferMap, ); } @@ -1452,7 +1476,7 @@ function completeObjectValue( info: GraphQLResolveInfo, path: Path, result: unknown, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): PromiseOrValue>> { // If there is an isTypeOf predicate function, call it with the @@ -1472,7 +1496,7 @@ function completeObjectValue( fieldGroup, path, result, - errors, + incrementalContext, deferMap, ); }); @@ -1489,7 +1513,7 @@ function completeObjectValue( fieldGroup, path, result, - errors, + incrementalContext, deferMap, ); } @@ -1564,13 +1588,35 @@ function collectAndExecuteSubfields( fieldGroup: FieldGroup, path: Path, result: unknown, - errors: Array, + incrementalContext: IncrementalContext | undefined, deferMap: ReadonlyMap | undefined, ): PromiseOrValue>> { // Collect sub-fields to execute to complete this value. - const { groupedFieldSet, newGroupedFieldSets, newDeferUsages } = - buildSubFieldPlan(exeContext, returnType, fieldGroup); + const collectedSubfields = collectSubfields( + exeContext, + returnType, + fieldGroup, + ); + let groupedFieldSet = collectedSubfields.groupedFieldSet; + const newDeferUsages = collectedSubfields.newDeferUsages; + if (deferMap === undefined && newDeferUsages.length === 0) { + return executeFields( + exeContext, + returnType, + result, + path, + groupedFieldSet, + incrementalContext, + undefined, + ); + } + const subFieldPlan = buildSubFieldPlan( + groupedFieldSet, + incrementalContext?.deferUsageSet, + ); + groupedFieldSet = subFieldPlan.groupedFieldSet; + const newGroupedFieldSets = subFieldPlan.newGroupedFieldSets; const newDeferMap = addNewDeferredFragments( newDeferUsages, new Map(deferMap), @@ -1583,24 +1629,43 @@ function collectAndExecuteSubfields( result, path, groupedFieldSet, - errors, + incrementalContext, newDeferMap, ); - const newDeferredGroupedFieldSetRecords = executeDeferredGroupedFieldSets( - exeContext, - returnType, - result, - path, - fieldGroup.deferUsages, - newGroupedFieldSets, - newDeferMap, - ); + if (newGroupedFieldSets.size > 0) { + const newDeferredGroupedFieldSetRecords = executeDeferredGroupedFieldSets( + exeContext, + returnType, + result, + path, + incrementalContext?.deferUsageSet, + newGroupedFieldSets, + newDeferMap, + ); - return withNewDeferredGroupedFieldSets( - subFields, - newDeferredGroupedFieldSetRecords, - ); + return withNewDeferredGroupedFieldSets( + subFields, + newDeferredGroupedFieldSetRecords, + ); + } + return subFields; +} + +function buildSubFieldPlan( + originalGroupedFieldSet: GroupedFieldSet, + deferUsageSet: DeferUsageSet | undefined, +): FieldPlan { + let fieldPlan = ( + originalGroupedFieldSet as unknown as { _fieldPlan: FieldPlan } + )._fieldPlan; + if (fieldPlan !== undefined) { + return fieldPlan; + } + fieldPlan = buildFieldPlan(originalGroupedFieldSet, deferUsageSet); + (originalGroupedFieldSet as unknown as { _fieldPlan: FieldPlan })._fieldPlan = + fieldPlan; + return fieldPlan; } /** @@ -1816,7 +1881,7 @@ function executeSubscription( ); } - const { fields } = collectFields( + const { groupedFieldSet } = collectFields( schema, fragments, variableValues, @@ -1824,15 +1889,15 @@ function executeSubscription( operation, ); - const firstRootField = fields.entries().next().value as [ + const firstRootField = groupedFieldSet.entries().next().value as [ string, - ReadonlyArray, + FieldGroup, ]; - const [responseName, fieldDetailsList] = firstRootField; - const fieldName = fieldDetailsList[0].node.name.value; + const [responseName, fieldGroup] = firstRootField; + const fieldName = fieldGroup[0].node.name.value; const fieldDef = schema.getField(rootType, fieldName); - const fieldNodes = fieldDetailsList.map((fieldDetails) => fieldDetails.node); + const fieldNodes = fieldGroup.map((fieldDetails) => fieldDetails.node); if (!fieldDef) { throw new GraphQLError( `The subscription field "${fieldName}" is not defined.`, @@ -1921,7 +1986,10 @@ function executeDeferredGroupedFieldSets( sourceValue, path, groupedFieldSet, - [], + { + errors: [], + deferUsageSet, + }, deferMap, ); @@ -1961,7 +2029,7 @@ function executeDeferredGroupedFieldSet( sourceValue: unknown, path: Path | undefined, groupedFieldSet: GroupedFieldSet, - errors: Array, + incrementalContext: IncrementalContext, deferMap: ReadonlyMap, ): PromiseOrValue { let result; @@ -1972,14 +2040,14 @@ function executeDeferredGroupedFieldSet( sourceValue, path, groupedFieldSet, - errors, + incrementalContext, deferMap, ); } catch (error) { return { deferredFragmentRecords, path: pathToArray(path), - errors: withError(errors, error), + errors: withError(incrementalContext.errors, error), }; } @@ -1987,7 +2055,7 @@ function executeDeferredGroupedFieldSet( return result.then( (resolved) => buildDeferredGroupedFieldSetResult( - errors, + incrementalContext.errors, deferredFragmentRecords, path, resolved, @@ -1995,13 +2063,13 @@ function executeDeferredGroupedFieldSet( (error) => ({ deferredFragmentRecords, path: pathToArray(path), - errors: withError(errors, error), + errors: withError(incrementalContext.errors, error), }), ); } return buildDeferredGroupedFieldSetResult( - errors, + incrementalContext.errors, deferredFragmentRecords, path, result, @@ -2053,7 +2121,7 @@ function firstSyncStreamItems( initialPath, initialItem, exeContext, - [], + { errors: [] }, fieldGroup, info, itemType, @@ -2075,7 +2143,7 @@ function firstSyncStreamItems( currentPath, item, exeContext, - [], + { errors: [] }, fieldGroup, info, itemType, @@ -2191,7 +2259,7 @@ async function getNextAsyncStreamItemsResult( itemPath, iteration.value, exeContext, - [], + { errors: [] }, fieldGroup, info, itemType, @@ -2219,7 +2287,7 @@ function completeStreamItems( itemPath: Path, item: unknown, exeContext: ExecutionContext, - errors: Array, + incrementalContext: IncrementalContext, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, @@ -2232,14 +2300,18 @@ function completeStreamItems( info, itemPath, item, - errors, + incrementalContext, new Map(), ).then( (resolvedItem) => - buildStreamItemsResult(errors, streamRecord, resolvedItem), + buildStreamItemsResult( + incrementalContext.errors, + streamRecord, + resolvedItem, + ), (error) => ({ streamRecord, - errors: withError(errors, error), + errors: withError(incrementalContext.errors, error), }), ); } @@ -2254,37 +2326,57 @@ function completeStreamItems( info, itemPath, item, - errors, + incrementalContext, new Map(), ); } catch (rawError) { - handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + handleFieldError( + rawError, + itemType, + fieldGroup, + itemPath, + incrementalContext.errors, + ); result = [null, []]; } } catch (error) { return { streamRecord, - errors: withError(errors, error), + errors: withError(incrementalContext.errors, error), }; } if (isPromise(result)) { return result .then(undefined, (rawError) => { - handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + handleFieldError( + rawError, + itemType, + fieldGroup, + itemPath, + incrementalContext.errors, + ); return [null, []] as GraphQLWrappedResult; }) .then( (resolvedItem) => - buildStreamItemsResult(errors, streamRecord, resolvedItem), + buildStreamItemsResult( + incrementalContext.errors, + streamRecord, + resolvedItem, + ), (error) => ({ streamRecord, - errors: withError(errors, error), + errors: withError(incrementalContext.errors, error), }), ); } - return buildStreamItemsResult(errors, streamRecord, result); + return buildStreamItemsResult( + incrementalContext.errors, + streamRecord, + result, + ); } function buildStreamItemsResult( diff --git a/src/validation/rules/SingleFieldSubscriptionsRule.ts b/src/validation/rules/SingleFieldSubscriptionsRule.ts index 06d9545fbc..700bc0bda7 100644 --- a/src/validation/rules/SingleFieldSubscriptionsRule.ts +++ b/src/validation/rules/SingleFieldSubscriptionsRule.ts @@ -10,15 +10,13 @@ import type { import { Kind } from '../../language/kinds.js'; import type { ASTVisitor } from '../../language/visitor.js'; -import type { FieldDetails } from '../../execution/collectFields.js'; +import type { FieldGroup } from '../../execution/collectFields.js'; import { collectFields } from '../../execution/collectFields.js'; import type { ValidationContext } from '../ValidationContext.js'; -function toNodes( - fieldDetailsList: ReadonlyArray, -): ReadonlyArray { - return fieldDetailsList.map((fieldDetails) => fieldDetails.node); +function toNodes(fieldGroup: FieldGroup): ReadonlyArray { + return fieldGroup.map((fieldDetails) => fieldDetails.node); } /** @@ -49,15 +47,15 @@ export function SingleFieldSubscriptionsRule( fragments[definition.name.value] = definition; } } - const { fields } = collectFields( + const { groupedFieldSet } = collectFields( schema, fragments, variableValues, subscriptionType, node, ); - if (fields.size > 1) { - const fieldGroups = [...fields.values()]; + if (groupedFieldSet.size > 1) { + const fieldGroups = [...groupedFieldSet.values()]; const extraFieldGroups = fieldGroups.slice(1); const extraFieldSelections = extraFieldGroups.flatMap( (fieldGroup) => toNodes(fieldGroup), @@ -71,7 +69,7 @@ export function SingleFieldSubscriptionsRule( ), ); } - for (const fieldGroup of fields.values()) { + for (const fieldGroup of groupedFieldSet.values()) { const fieldName = toNodes(fieldGroup)[0].name.value; if (fieldName.startsWith('__')) { context.reportError( From 6acf33fcbf67388d03c648dd82d7c365133364a5 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 24 Apr 2024 19:29:39 +0300 Subject: [PATCH 03/26] perf: introduce completePromisedListItemValue (#4051) depends on #4050 --- src/execution/execute.ts | 55 +++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 23bfa50702..42e3b9adf8 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1107,19 +1107,17 @@ async function completeAsyncIteratorValue( /* c8 ignore start */ if (isPromise(item)) { completedResults.push( - completePromisedValue( + completePromisedListItemValue( + item, + graphqlWrappedResult, exeContext, itemType, fieldGroup, info, itemPath, - item, incrementalContext, deferMap, - ).then((resolved) => { - graphqlWrappedResult[1].push(...resolved[1]); - return resolved[0]; - }), + ), ); containsPromise = true; } else if ( @@ -1232,19 +1230,17 @@ function completeListValue( if (isPromise(item)) { completedResults.push( - completePromisedValue( + completePromisedListItemValue( + item, + graphqlWrappedResult, exeContext, itemType, fieldGroup, info, itemPath, - item, incrementalContext, deferMap, - ).then((resolved) => { - graphqlWrappedResult[1].push(...resolved[1]); - return resolved[0]; - }), + ), ); containsPromise = true; } else if ( @@ -1334,6 +1330,41 @@ function completeListItemValue( return false; } +async function completePromisedListItemValue( + item: unknown, + parent: GraphQLWrappedResult>, + exeContext: ExecutionContext, + itemType: GraphQLOutputType, + fieldGroup: FieldGroup, + info: GraphQLResolveInfo, + itemPath: Path, + incrementalContext: IncrementalContext | undefined, + deferMap: ReadonlyMap | undefined, +): Promise { + try { + const resolved = await item; + let completed = completeValue( + exeContext, + itemType, + fieldGroup, + info, + itemPath, + resolved, + incrementalContext, + deferMap, + ); + if (isPromise(completed)) { + completed = await completed; + } + parent[1].push(...completed[1]); + return completed[0]; + } catch (rawError) { + const errors = (incrementalContext ?? exeContext).errors; + handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + return null; + } +} + /** * Complete a Scalar or Enum by serializing to a valid value, returning * null if serialization is not possible. From d811c97d57d14f579fc546b2f03f783f590c33bb Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 24 Apr 2024 19:35:28 +0300 Subject: [PATCH 04/26] refactor: introduce completeIterableValue (#4052) refactoring that will streamline when we introduce two versions of this function to optimize the loop when not streaming depends on #4051 --- src/execution/execute.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 42e3b9adf8..cda6ab8254 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1188,6 +1188,28 @@ function completeListValue( ); } + return completeIterableValue( + exeContext, + itemType, + fieldGroup, + info, + path, + result, + incrementalContext, + deferMap, + ); +} + +function completeIterableValue( + exeContext: ExecutionContext, + itemType: GraphQLOutputType, + fieldGroup: FieldGroup, + info: GraphQLResolveInfo, + path: Path, + items: Iterable, + incrementalContext: IncrementalContext | undefined, + deferMap: ReadonlyMap | undefined, +): PromiseOrValue>> { // This is specified as a simple map, however we're optimizing the path // where the list contains no Promises by avoiding creating another Promise. let containsPromise = false; @@ -1198,7 +1220,7 @@ function completeListValue( ]; let index = 0; const streamUsage = getStreamUsage(exeContext, fieldGroup, path); - const iterator = result[Symbol.iterator](); + const iterator = items[Symbol.iterator](); let iteration = iterator.next(); while (!iteration.done) { const item = iteration.value; From d245e653064746ebcd9e80617025a82a4c60a031 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 8 May 2024 20:59:05 +0300 Subject: [PATCH 05/26] incremental: avoid double loop with stream from sync iterables (#4076) --- src/execution/execute.ts | 41 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/execution/execute.ts b/src/execution/execute.ts index cda6ab8254..919bfa0bcd 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -2163,12 +2163,12 @@ function firstSyncStreamItems( info: GraphQLResolveInfo, itemType: GraphQLOutputType, ): StreamItemsRecord { - const path = streamRecord.path; - const initialPath = addPath(path, initialIndex, undefined); - - const firstStreamItems: StreamItemsRecord = { + return { streamRecord, result: Promise.resolve().then(() => { + const path = streamRecord.path; + const initialPath = addPath(path, initialIndex, undefined); + let result = completeStreamItems( streamRecord, initialPath, @@ -2179,7 +2179,8 @@ function firstSyncStreamItems( info, itemType, ); - const results = [result]; + const firstStreamItems = { result }; + let currentStreamItems = firstStreamItems; let currentIndex = initialIndex; let iteration = iterator.next(); let erroredSynchronously = false; @@ -2201,31 +2202,29 @@ function firstSyncStreamItems( info, itemType, ); - results.push(result); + + const nextStreamItems: StreamItemsRecord = { streamRecord, result }; + currentStreamItems.result = prependNextStreamItems( + currentStreamItems.result, + nextStreamItems, + ); + currentStreamItems = nextStreamItems; + iteration = iterator.next(); } - currentIndex = results.length - 1; // If a non-reconcilable stream items result was encountered, then the stream terminates in error. // Otherwise, add a stream terminator. - let currentResult = erroredSynchronously - ? results[currentIndex] - : prependNextStreamItems(results[currentIndex], { - streamRecord, - result: { streamRecord }, - }); - - while (currentIndex-- > 0) { - currentResult = prependNextStreamItems(results[currentIndex], { - streamRecord, - result: currentResult, - }); + if (!erroredSynchronously) { + currentStreamItems.result = prependNextStreamItems( + currentStreamItems.result, + { streamRecord, result: { streamRecord } }, + ); } - return currentResult; + return firstStreamItems.result; }), }; - return firstStreamItems; } function prependNextStreamItems( From 92f9bb0888151f0c3929b38da05557bd79b5e843 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 8 May 2024 22:49:01 +0300 Subject: [PATCH 06/26] perf: use undefined for empty (#4046) As convention, we will lazily instantiate arrays/sets when adding the first item. This applies to arrays/sets on execution/incremental context, as well as the second member of the GraphQLWrappedResult tuple holding the array of incremental data records. --- src/execution/IncrementalPublisher.ts | 31 ++-- src/execution/execute.ts | 196 +++++++++++++++++--------- 2 files changed, 150 insertions(+), 77 deletions(-) diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index b5f66b6322..0722da1ed1 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -1,3 +1,4 @@ +import { invariant } from '../jsutils/invariant.js'; import { isPromise } from '../jsutils/isPromise.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; import type { Path } from '../jsutils/Path.js'; @@ -172,7 +173,7 @@ export interface FormattedCompletedResult { export function buildIncrementalResponse( context: IncrementalPublisherContext, result: ObjMap, - errors: ReadonlyArray, + errors: ReadonlyArray | undefined, incrementalDataRecords: ReadonlyArray, ): ExperimentalIncrementalExecutionResults { const incrementalPublisher = new IncrementalPublisher(context); @@ -184,7 +185,7 @@ export function buildIncrementalResponse( } interface IncrementalPublisherContext { - cancellableStreams: Set; + cancellableStreams: Set | undefined; } /** @@ -218,7 +219,7 @@ class IncrementalPublisher { buildResponse( data: ObjMap, - errors: ReadonlyArray, + errors: ReadonlyArray | undefined, incrementalDataRecords: ReadonlyArray, ): ExperimentalIncrementalExecutionResults { this._addIncrementalDataRecords(incrementalDataRecords); @@ -227,7 +228,7 @@ class IncrementalPublisher { const pending = this._pendingSourcesToResults(); const initialResult: InitialIncrementalExecutionResult = - errors.length === 0 + errors === undefined ? { data, pending, hasNext: true } : { errors, data, pending, hasNext: true }; @@ -444,8 +445,12 @@ class IncrementalPublisher { }; const returnStreamIterators = async (): Promise => { + const cancellableStreams = this._context.cancellableStreams; + if (cancellableStreams === undefined) { + return; + } const promises: Array> = []; - for (const streamRecord of this._context.cancellableStreams) { + for (const streamRecord of cancellableStreams) { if (streamRecord.earlyReturn !== undefined) { promises.push(streamRecord.earlyReturn()); } @@ -519,9 +524,11 @@ class IncrementalPublisher { ); } - this._addIncrementalDataRecords( - deferredGroupedFieldSetResult.incrementalDataRecords, - ); + const incrementalDataRecords = + deferredGroupedFieldSetResult.incrementalDataRecords; + if (incrementalDataRecords !== undefined) { + this._addIncrementalDataRecords(incrementalDataRecords); + } for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { const id = deferredFragmentRecord.id; @@ -587,6 +594,7 @@ class IncrementalPublisher { }); this._pending.delete(streamRecord); if (isCancellableStreamRecord(streamRecord)) { + invariant(this._context.cancellableStreams !== undefined); this._context.cancellableStreams.delete(streamRecord); streamRecord.earlyReturn().catch(() => { /* c8 ignore next 1 */ @@ -597,6 +605,7 @@ class IncrementalPublisher { this._completed.push({ id }); this._pending.delete(streamRecord); if (isCancellableStreamRecord(streamRecord)) { + invariant(this._context.cancellableStreams !== undefined); this._context.cancellableStreams.delete(streamRecord); } } else { @@ -607,7 +616,7 @@ class IncrementalPublisher { this._incremental.push(incrementalEntry); - if (streamItemsResult.incrementalDataRecords.length > 0) { + if (streamItemsResult.incrementalDataRecords !== undefined) { this._addIncrementalDataRecords( streamItemsResult.incrementalDataRecords, ); @@ -675,7 +684,7 @@ interface ReconcilableDeferredGroupedFieldSetResult { deferredFragmentRecords: ReadonlyArray; path: Array; result: BareDeferredGroupedFieldSetResult; - incrementalDataRecords: ReadonlyArray; + incrementalDataRecords: ReadonlyArray | undefined; sent?: true | undefined; errors?: never; } @@ -743,7 +752,7 @@ function isCancellableStreamRecord( interface ReconcilableStreamItemsResult { streamRecord: SubsequentResultRecord; result: BareStreamItemsResult; - incrementalDataRecords: ReadonlyArray; + incrementalDataRecords: ReadonlyArray | undefined; errors?: never; } diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 919bfa0bcd..e5e220dd66 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -142,12 +142,12 @@ export interface ExecutionContext { fieldResolver: GraphQLFieldResolver; typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; - errors: Array; - cancellableStreams: Set; + errors: Array | undefined; + cancellableStreams: Set | undefined; } interface IncrementalContext { - errors: Array; + errors: Array | undefined; deferUsageSet?: DeferUsageSet | undefined; } @@ -169,7 +169,7 @@ export interface StreamUsage { fieldGroup: FieldGroup; } -type GraphQLWrappedResult = [T, Array]; +type GraphQLWrappedResult = [T, Array | undefined]; const UNEXPECTED_EXPERIMENTAL_DIRECTIVES = 'The provided schema unexpectedly contains experimental directives (@defer or @stream). These directives may only be utilized if experimental execution features are explicitly enabled.'; @@ -353,30 +353,44 @@ function withNewDeferredGroupedFieldSets( ): PromiseOrValue>> { if (isPromise(result)) { return result.then((resolved) => { - resolved[1].push(...newDeferredGroupedFieldSetRecords); + addIncrementalDataRecords(resolved, newDeferredGroupedFieldSetRecords); return resolved; }); } - result[1].push(...newDeferredGroupedFieldSetRecords); + addIncrementalDataRecords(result, newDeferredGroupedFieldSetRecords); return result; } +function addIncrementalDataRecords( + graphqlWrappedResult: GraphQLWrappedResult, + incrementalDataRecords: ReadonlyArray | undefined, +): void { + if (incrementalDataRecords === undefined) { + return; + } + if (graphqlWrappedResult[1] === undefined) { + graphqlWrappedResult[1] = [...incrementalDataRecords]; + } else { + graphqlWrappedResult[1].push(...incrementalDataRecords); + } +} + function withError( - errors: Array, + errors: Array | undefined, error: GraphQLError, ): ReadonlyArray { - return errors.length === 0 ? [error] : [...errors, error]; + return errors === undefined ? [error] : [...errors, error]; } function buildDataResponse( exeContext: ExecutionContext, data: ObjMap, - incrementalDataRecords: ReadonlyArray, + incrementalDataRecords: ReadonlyArray | undefined, ): ExecutionResult | ExperimentalIncrementalExecutionResults { const errors = exeContext.errors; - if (incrementalDataRecords.length === 0) { - return errors.length > 0 ? { errors, data } : { data }; + if (incrementalDataRecords === undefined) { + return errors !== undefined ? { errors, data } : { data }; } return buildIncrementalResponse( @@ -488,8 +502,8 @@ export function buildExecutionContext( fieldResolver: fieldResolver ?? defaultFieldResolver, typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, - errors: [], - cancellableStreams: new Set(), + errors: undefined, + cancellableStreams: undefined, }; } @@ -499,8 +513,8 @@ function buildPerEventExecutionContext( ): ExecutionContext { return { ...exeContext, - errors: [], rootValue: payload, + errors: undefined, }; } @@ -580,15 +594,15 @@ function executeFieldsSerially( if (isPromise(result)) { return result.then((resolved) => { graphqlWrappedResult[0][responseName] = resolved[0]; - graphqlWrappedResult[1].push(...resolved[1]); + addIncrementalDataRecords(graphqlWrappedResult, resolved[1]); return graphqlWrappedResult; }); } graphqlWrappedResult[0][responseName] = result[0]; - graphqlWrappedResult[1].push(...result[1]); + addIncrementalDataRecords(graphqlWrappedResult, result[1]); return graphqlWrappedResult; }, - [Object.create(null), []] as GraphQLWrappedResult>, + [Object.create(null), undefined] as GraphQLWrappedResult>, ); } @@ -608,7 +622,7 @@ function executeFields( const results = Object.create(null); const graphqlWrappedResult: GraphQLWrappedResult> = [ results, - [], + undefined, ]; let containsPromise = false; @@ -628,13 +642,13 @@ function executeFields( if (result !== undefined) { if (isPromise(result)) { results[responseName] = result.then((resolved) => { - graphqlWrappedResult[1].push(...resolved[1]); + addIncrementalDataRecords(graphqlWrappedResult, resolved[1]); return resolved[0]; }); containsPromise = true; } else { results[responseName] = result[0]; - graphqlWrappedResult[1].push(...result[1]); + addIncrementalDataRecords(graphqlWrappedResult, result[1]); } } } @@ -746,16 +760,28 @@ function executeField( // Note: we don't rely on a `catch` method, but we do expect "thenable" // to take a second callback for the error case. return completed.then(undefined, (rawError) => { - const errors = (incrementalContext ?? exeContext).errors; - handleFieldError(rawError, returnType, fieldGroup, path, errors); - return [null, []]; + handleFieldError( + rawError, + exeContext, + returnType, + fieldGroup, + path, + incrementalContext, + ); + return [null, undefined]; }); } return completed; } catch (rawError) { - const errors = (incrementalContext ?? exeContext).errors; - handleFieldError(rawError, returnType, fieldGroup, path, errors); - return [null, []]; + handleFieldError( + rawError, + exeContext, + returnType, + fieldGroup, + path, + incrementalContext, + ); + return [null, undefined]; } } @@ -788,10 +814,11 @@ export function buildResolveInfo( function handleFieldError( rawError: unknown, + exeContext: ExecutionContext, returnType: GraphQLOutputType, fieldGroup: FieldGroup, path: Path, - errors: Array, + incrementalContext: IncrementalContext | undefined, ): void { const error = locatedError(rawError, toNodes(fieldGroup), pathToArray(path)); @@ -803,6 +830,12 @@ function handleFieldError( // Otherwise, error protection is applied, logging the error and resolving // a null value for this field if one is encountered. + const context = incrementalContext ?? exeContext; + let errors = context.errors; + if (errors === undefined) { + errors = []; + context.errors = errors; + } errors.push(error); } @@ -865,7 +898,7 @@ function completeValue( // If result value is null or undefined then return null. if (result == null) { - return [null, []]; + return [null, undefined]; } // If field type is List, complete each item in the list with the inner type @@ -885,7 +918,7 @@ function completeValue( // If field type is a leaf type, Scalar or Enum, serialize to a valid value, // returning null if serialization is not possible. if (isLeafType(returnType)) { - return [completeLeafValue(returnType, result), []]; + return [completeLeafValue(returnType, result), undefined]; } // If field type is an abstract type, Interface or Union, determine the @@ -952,9 +985,15 @@ async function completePromisedValue( } return completed; } catch (rawError) { - const errors = (incrementalContext ?? exeContext).errors; - handleFieldError(rawError, returnType, fieldGroup, path, errors); - return [null, []]; + handleFieldError( + rawError, + exeContext, + returnType, + fieldGroup, + path, + incrementalContext, + ); + return [null, undefined]; } } @@ -1049,7 +1088,7 @@ async function completeAsyncIteratorValue( const completedResults: Array = []; const graphqlWrappedResult: GraphQLWrappedResult> = [ completedResults, - [], + undefined, ]; let index = 0; const streamUsage = getStreamUsage(exeContext, fieldGroup, path); @@ -1069,6 +1108,9 @@ async function completeAsyncIteratorValue( path, earlyReturn: returnFn.bind(asyncIterator), }; + if (exeContext.cancellableStreams === undefined) { + exeContext.cancellableStreams = new Set(); + } exeContext.cancellableStreams.add(streamRecord); } @@ -1083,7 +1125,7 @@ async function completeAsyncIteratorValue( itemType, ); - graphqlWrappedResult[1].push(firstStreamItems); + addIncrementalDataRecords(graphqlWrappedResult, [firstStreamItems]); break; } @@ -1216,7 +1258,7 @@ function completeIterableValue( const completedResults: Array = []; const graphqlWrappedResult: GraphQLWrappedResult> = [ completedResults, - [], + undefined, ]; let index = 0; const streamUsage = getStreamUsage(exeContext, fieldGroup, path); @@ -1242,7 +1284,7 @@ function completeIterableValue( itemType, ); - graphqlWrappedResult[1].push(firstStreamItems); + addIncrementalDataRecords(graphqlWrappedResult, [firstStreamItems]); break; } @@ -1329,12 +1371,18 @@ function completeListItemValue( completedResults.push( completedItem.then( (resolved) => { - parent[1].push(...resolved[1]); + addIncrementalDataRecords(parent, resolved[1]); return resolved[0]; }, (rawError) => { - const errors = (incrementalContext ?? exeContext).errors; - handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + handleFieldError( + rawError, + exeContext, + itemType, + fieldGroup, + itemPath, + incrementalContext, + ); return null; }, ), @@ -1343,10 +1391,16 @@ function completeListItemValue( } completedResults.push(completedItem[0]); - parent[1].push(...completedItem[1]); + addIncrementalDataRecords(parent, completedItem[1]); } catch (rawError) { - const errors = (incrementalContext ?? exeContext).errors; - handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + handleFieldError( + rawError, + exeContext, + itemType, + fieldGroup, + itemPath, + incrementalContext, + ); completedResults.push(null); } return false; @@ -1378,11 +1432,17 @@ async function completePromisedListItemValue( if (isPromise(completed)) { completed = await completed; } - parent[1].push(...completed[1]); + addIncrementalDataRecords(parent, completed[1]); return completed[0]; } catch (rawError) { - const errors = (incrementalContext ?? exeContext).errors; - handleFieldError(rawError, itemType, fieldGroup, itemPath, errors); + handleFieldError( + rawError, + exeContext, + itemType, + fieldGroup, + itemPath, + incrementalContext, + ); return null; } } @@ -2040,7 +2100,7 @@ function executeDeferredGroupedFieldSets( path, groupedFieldSet, { - errors: [], + errors: undefined, deferUsageSet, }, deferMap, @@ -2130,7 +2190,7 @@ function executeDeferredGroupedFieldSet( } function buildDeferredGroupedFieldSetResult( - errors: ReadonlyArray, + errors: ReadonlyArray | undefined, deferredFragmentRecords: ReadonlyArray, path: Path | undefined, result: GraphQLWrappedResult>, @@ -2139,7 +2199,7 @@ function buildDeferredGroupedFieldSetResult( deferredFragmentRecords, path: pathToArray(path), result: - errors.length === 0 ? { data: result[0] } : { data: result[0], errors }, + errors === undefined ? { data: result[0] } : { data: result[0], errors }, incrementalDataRecords: result[1], }; } @@ -2174,7 +2234,7 @@ function firstSyncStreamItems( initialPath, initialItem, exeContext, - { errors: [] }, + { errors: undefined }, fieldGroup, info, itemType, @@ -2197,7 +2257,7 @@ function firstSyncStreamItems( currentPath, item, exeContext, - { errors: [] }, + { errors: undefined }, fieldGroup, info, itemType, @@ -2243,15 +2303,17 @@ function prependNextResolvedStreamItems( result: StreamItemsResult, nextStreamItems: StreamItemsRecord, ): StreamItemsResult { - return isReconcilableStreamItemsResult(result) - ? { - ...result, - incrementalDataRecords: [ - nextStreamItems, - ...result.incrementalDataRecords, - ], - } - : result; + if (!isReconcilableStreamItemsResult(result)) { + return result; + } + const incrementalDataRecords = result.incrementalDataRecords; + return { + ...result, + incrementalDataRecords: + incrementalDataRecords === undefined + ? [nextStreamItems] + : [nextStreamItems, ...incrementalDataRecords], + }; } function firstAsyncStreamItems( @@ -2311,7 +2373,7 @@ async function getNextAsyncStreamItemsResult( itemPath, iteration.value, exeContext, - { errors: [] }, + { errors: undefined }, fieldGroup, info, itemType, @@ -2384,12 +2446,13 @@ function completeStreamItems( } catch (rawError) { handleFieldError( rawError, + exeContext, itemType, fieldGroup, itemPath, - incrementalContext.errors, + incrementalContext, ); - result = [null, []]; + result = [null, undefined]; } } catch (error) { return { @@ -2403,12 +2466,13 @@ function completeStreamItems( .then(undefined, (rawError) => { handleFieldError( rawError, + exeContext, itemType, fieldGroup, itemPath, - incrementalContext.errors, + incrementalContext, ); - return [null, []] as GraphQLWrappedResult; + return [null, undefined] as GraphQLWrappedResult; }) .then( (resolvedItem) => @@ -2432,14 +2496,14 @@ function completeStreamItems( } function buildStreamItemsResult( - errors: ReadonlyArray, + errors: ReadonlyArray | undefined, streamRecord: SubsequentResultRecord, result: GraphQLWrappedResult, ): StreamItemsResult { return { streamRecord, result: - errors.length === 0 + errors === undefined ? { items: [result[0]] } : { items: [result[0]], From e15c3ec4dc21d9fd1df34fe9798cadf3bf02c6ea Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Fri, 10 May 2024 00:36:58 +0300 Subject: [PATCH 07/26] Add test for consolidating grouped field sets properly into deferred fragments (#3997) Group field sets should be properly consolidated when some of the fields in a sibling defer overlap with a child deferred fragment. The child deferred fragment should not prompt creation of an additional incremental data record for that field, because the field would always be sent with the parent. --- src/execution/__tests__/defer-test.ts | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 03bf8126c6..31529c078d 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -1138,6 +1138,61 @@ describe('Execute: defer directive', () => { ]); }); + it('Correctly bundles varying subfields into incremental data records unique by defer combination, ignoring fields in a fragment masked by a parent defer', async () => { + const document = parse(` + query HeroNameQuery { + ... @defer { + hero { + id + } + } + ... @defer { + hero { + name + shouldBeWithNameDespiteAdditionalDefer: name + ... @defer { + shouldBeWithNameDespiteAdditionalDefer: name + } + } + } + } + `); + const result = await complete(document); + expectJSON(result).toDeepEqual([ + { + data: {}, + pending: [ + { id: '0', path: [] }, + { id: '1', path: [] }, + ], + hasNext: true, + }, + { + incremental: [ + { + data: { hero: {} }, + id: '0', + }, + { + data: { id: '1' }, + id: '0', + subPath: ['hero'], + }, + { + data: { + name: 'Luke', + shouldBeWithNameDespiteAdditionalDefer: 'Luke', + }, + id: '1', + subPath: ['hero'], + }, + ], + completed: [{ id: '0' }, { id: '1' }], + hasNext: false, + }, + ]); + }); + it('Nulls cross defer boundaries, null first', async () => { const document = parse(` query { From 06bb157fc14f13677cd2696b2aa67e4956c2bf10 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Thu, 30 May 2024 21:09:26 +0300 Subject: [PATCH 08/26] refactor: extract execution types to separate file (#4099) --- src/execution/IncrementalPublisher.ts | 324 ++-------------------- src/execution/__tests__/defer-test.ts | 2 +- src/execution/__tests__/lists-test.ts | 2 +- src/execution/__tests__/nonnull-test.ts | 2 +- src/execution/__tests__/oneof-test.ts | 2 +- src/execution/__tests__/stream-test.ts | 2 +- src/execution/__tests__/subscribe-test.ts | 2 +- src/execution/execute.ts | 8 +- src/execution/index.ts | 2 +- src/execution/types.ts | 302 ++++++++++++++++++++ src/graphql.ts | 2 +- 11 files changed, 339 insertions(+), 311 deletions(-) create mode 100644 src/execution/types.ts diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 0722da1ed1..0504238eae 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -1,174 +1,35 @@ import { invariant } from '../jsutils/invariant.js'; import { isPromise } from '../jsutils/isPromise.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; -import type { Path } from '../jsutils/Path.js'; import { pathToArray } from '../jsutils/Path.js'; -import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; -import type { - GraphQLError, - GraphQLFormattedError, -} from '../error/GraphQLError.js'; - -/** - * The result of GraphQL execution. - * - * - `errors` is included when any errors occurred as a non-empty array. - * - `data` is the result of a successful execution of the query. - * - `hasNext` is true if a future payload is expected. - * - `extensions` is reserved for adding non-standard properties. - * - `incremental` is a list of the results from defer/stream directives. - */ -export interface ExecutionResult< - TData = ObjMap, - TExtensions = ObjMap, -> { - errors?: ReadonlyArray; - data?: TData | null; - extensions?: TExtensions; -} - -export interface FormattedExecutionResult< - TData = ObjMap, - TExtensions = ObjMap, -> { - errors?: ReadonlyArray; - data?: TData | null; - extensions?: TExtensions; -} - -export interface ExperimentalIncrementalExecutionResults< - TData = unknown, - TExtensions = ObjMap, -> { - initialResult: InitialIncrementalExecutionResult; - subsequentResults: AsyncGenerator< - SubsequentIncrementalExecutionResult, - void, - void - >; -} - -export interface InitialIncrementalExecutionResult< - TData = ObjMap, - TExtensions = ObjMap, -> extends ExecutionResult { - data: TData; - pending: ReadonlyArray; - hasNext: true; - extensions?: TExtensions; -} - -export interface FormattedInitialIncrementalExecutionResult< - TData = ObjMap, - TExtensions = ObjMap, -> extends FormattedExecutionResult { - data: TData; - pending: ReadonlyArray; - hasNext: boolean; - extensions?: TExtensions; -} - -export interface SubsequentIncrementalExecutionResult< - TData = unknown, - TExtensions = ObjMap, -> { - pending?: ReadonlyArray; - incremental?: ReadonlyArray>; - completed?: ReadonlyArray; - hasNext: boolean; - extensions?: TExtensions; -} - -export interface FormattedSubsequentIncrementalExecutionResult< - TData = unknown, - TExtensions = ObjMap, -> { - hasNext: boolean; - pending?: ReadonlyArray; - incremental?: ReadonlyArray>; - completed?: ReadonlyArray; - extensions?: TExtensions; -} - -interface BareDeferredGroupedFieldSetResult> { - errors?: ReadonlyArray; - data: TData; -} - -export interface IncrementalDeferResult< - TData = ObjMap, - TExtensions = ObjMap, -> extends BareDeferredGroupedFieldSetResult { - id: string; - subPath?: ReadonlyArray; - extensions?: TExtensions; -} +import type { GraphQLError } from '../error/GraphQLError.js'; -export interface FormattedIncrementalDeferResult< - TData = ObjMap, - TExtensions = ObjMap, -> { - errors?: ReadonlyArray; - data: TData; - id: string; - subPath?: ReadonlyArray; - extensions?: TExtensions; -} - -interface BareStreamItemsResult> { - errors?: ReadonlyArray; - items: TData; -} - -export interface IncrementalStreamResult< - TData = ReadonlyArray, - TExtensions = ObjMap, -> extends BareStreamItemsResult { - id: string; - subPath?: ReadonlyArray; - extensions?: TExtensions; -} - -export interface FormattedIncrementalStreamResult< - TData = Array, - TExtensions = ObjMap, -> { - errors?: ReadonlyArray; - items: TData; - id: string; - subPath?: ReadonlyArray; - extensions?: TExtensions; -} - -export type IncrementalResult> = - | IncrementalDeferResult - | IncrementalStreamResult; - -export type FormattedIncrementalResult< - TData = unknown, - TExtensions = ObjMap, -> = - | FormattedIncrementalDeferResult - | FormattedIncrementalStreamResult; - -export interface PendingResult { - id: string; - path: ReadonlyArray; - label?: string; -} - -export interface CompletedResult { - id: string; - errors?: ReadonlyArray; -} - -export interface FormattedCompletedResult { - path: ReadonlyArray; - label?: string; - errors?: ReadonlyArray; -} +import type { + CancellableStreamRecord, + CompletedResult, + DeferredFragmentRecord, + DeferredGroupedFieldSetResult, + ExperimentalIncrementalExecutionResults, + IncrementalDataRecord, + IncrementalDataRecordResult, + IncrementalDeferResult, + IncrementalResult, + IncrementalStreamResult, + InitialIncrementalExecutionResult, + PendingResult, + StreamItemsResult, + SubsequentIncrementalExecutionResult, + SubsequentResultRecord, +} from './types.js'; +import { + isCancellableStreamRecord, + isDeferredFragmentRecord, + isDeferredGroupedFieldSetRecord, + isDeferredGroupedFieldSetResult, + isNonReconcilableDeferredGroupedFieldSetResult, +} from './types.js'; export function buildIncrementalResponse( context: IncrementalPublisherContext, @@ -657,138 +518,3 @@ class IncrementalPublisher { }; } } - -function isDeferredFragmentRecord( - subsequentResultRecord: SubsequentResultRecord, -): subsequentResultRecord is DeferredFragmentRecord { - return 'parent' in subsequentResultRecord; -} - -function isDeferredGroupedFieldSetRecord( - incrementalDataRecord: IncrementalDataRecord, -): incrementalDataRecord is DeferredGroupedFieldSetRecord { - return 'deferredFragmentRecords' in incrementalDataRecord; -} - -export type DeferredGroupedFieldSetResult = - | ReconcilableDeferredGroupedFieldSetResult - | NonReconcilableDeferredGroupedFieldSetResult; - -function isDeferredGroupedFieldSetResult( - subsequentResult: DeferredGroupedFieldSetResult | StreamItemsResult, -): subsequentResult is DeferredGroupedFieldSetResult { - return 'deferredFragmentRecords' in subsequentResult; -} - -interface ReconcilableDeferredGroupedFieldSetResult { - deferredFragmentRecords: ReadonlyArray; - path: Array; - result: BareDeferredGroupedFieldSetResult; - incrementalDataRecords: ReadonlyArray | undefined; - sent?: true | undefined; - errors?: never; -} - -interface NonReconcilableDeferredGroupedFieldSetResult { - errors: ReadonlyArray; - deferredFragmentRecords: ReadonlyArray; - path: Array; - result?: never; -} - -function isNonReconcilableDeferredGroupedFieldSetResult( - deferredGroupedFieldSetResult: DeferredGroupedFieldSetResult, -): deferredGroupedFieldSetResult is NonReconcilableDeferredGroupedFieldSetResult { - return deferredGroupedFieldSetResult.errors !== undefined; -} - -export interface DeferredGroupedFieldSetRecord { - deferredFragmentRecords: ReadonlyArray; - result: PromiseOrValue; -} - -export interface SubsequentResultRecord { - path: Path | undefined; - label: string | undefined; - id?: string | undefined; -} - -/** @internal */ -export class DeferredFragmentRecord implements SubsequentResultRecord { - path: Path | undefined; - label: string | undefined; - id?: string | undefined; - parent: DeferredFragmentRecord | undefined; - expectedReconcilableResults: number; - results: Array; - reconcilableResults: Array; - children: Set; - - constructor(opts: { - path: Path | undefined; - label: string | undefined; - parent: DeferredFragmentRecord | undefined; - }) { - this.path = opts.path; - this.label = opts.label; - this.parent = opts.parent; - this.expectedReconcilableResults = 0; - this.results = []; - this.reconcilableResults = []; - this.children = new Set(); - } -} - -export interface CancellableStreamRecord extends SubsequentResultRecord { - earlyReturn: () => Promise; -} - -function isCancellableStreamRecord( - subsequentResultRecord: SubsequentResultRecord, -): subsequentResultRecord is CancellableStreamRecord { - return 'earlyReturn' in subsequentResultRecord; -} - -interface ReconcilableStreamItemsResult { - streamRecord: SubsequentResultRecord; - result: BareStreamItemsResult; - incrementalDataRecords: ReadonlyArray | undefined; - errors?: never; -} - -export function isReconcilableStreamItemsResult( - streamItemsResult: StreamItemsResult, -): streamItemsResult is ReconcilableStreamItemsResult { - return streamItemsResult.result !== undefined; -} - -interface TerminatingStreamItemsResult { - streamRecord: SubsequentResultRecord; - result?: never; - incrementalDataRecords?: never; - errors?: never; -} - -interface NonReconcilableStreamItemsResult { - streamRecord: SubsequentResultRecord; - errors: ReadonlyArray; - result?: never; -} - -export type StreamItemsResult = - | ReconcilableStreamItemsResult - | TerminatingStreamItemsResult - | NonReconcilableStreamItemsResult; - -export interface StreamItemsRecord { - streamRecord: SubsequentResultRecord; - result: PromiseOrValue; -} - -export type IncrementalDataRecord = - | DeferredGroupedFieldSetRecord - | StreamItemsRecord; - -export type IncrementalDataRecordResult = - | DeferredGroupedFieldSetResult - | StreamItemsResult; diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 31529c078d..71d86862f4 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -20,7 +20,7 @@ import { execute, experimentalExecuteIncrementally } from '../execute.js'; import type { InitialIncrementalExecutionResult, SubsequentIncrementalExecutionResult, -} from '../IncrementalPublisher.js'; +} from '../types.js'; const friendType = new GraphQLObjectType({ fields: { diff --git a/src/execution/__tests__/lists-test.ts b/src/execution/__tests__/lists-test.ts index 167d580ef5..5d646e1770 100644 --- a/src/execution/__tests__/lists-test.ts +++ b/src/execution/__tests__/lists-test.ts @@ -19,7 +19,7 @@ import { GraphQLSchema } from '../../type/schema.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; import { execute, executeSync } from '../execute.js'; -import type { ExecutionResult } from '../IncrementalPublisher.js'; +import type { ExecutionResult } from '../types.js'; describe('Execute: Accepts any iterable as list value', () => { function complete(rootValue: unknown) { diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 12b223a622..9ad78a09cd 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -14,7 +14,7 @@ import { GraphQLSchema } from '../../type/schema.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; import { execute, executeSync } from '../execute.js'; -import type { ExecutionResult } from '../IncrementalPublisher.js'; +import type { ExecutionResult } from '../types.js'; const syncError = new Error('sync'); const syncNonNullError = new Error('syncNonNull'); diff --git a/src/execution/__tests__/oneof-test.ts b/src/execution/__tests__/oneof-test.ts index af0e0580ab..f4a11f8997 100644 --- a/src/execution/__tests__/oneof-test.ts +++ b/src/execution/__tests__/oneof-test.ts @@ -7,7 +7,7 @@ import { parse } from '../../language/parser.js'; import { buildSchema } from '../../utilities/buildASTSchema.js'; import { execute } from '../execute.js'; -import type { ExecutionResult } from '../IncrementalPublisher.js'; +import type { ExecutionResult } from '../types.js'; const schema = buildSchema(` type Query { diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index 522b82f3d4..905b00be9e 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -22,7 +22,7 @@ import { experimentalExecuteIncrementally } from '../execute.js'; import type { InitialIncrementalExecutionResult, SubsequentIncrementalExecutionResult, -} from '../IncrementalPublisher.js'; +} from '../types.js'; const friendType = new GraphQLObjectType({ fields: { diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index eff5032811..e6faca31e5 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -22,7 +22,7 @@ import { GraphQLSchema } from '../../type/schema.js'; import type { ExecutionArgs } from '../execute.js'; import { createSourceEventStream, subscribe } from '../execute.js'; -import type { ExecutionResult } from '../IncrementalPublisher.js'; +import type { ExecutionResult } from '../types.js'; import { SimplePubSub } from './simplePubSub.js'; diff --git a/src/execution/execute.ts b/src/execution/execute.ts index e5e220dd66..dfb7f36074 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -58,6 +58,8 @@ import { collectFields, collectSubfields as _collectSubfields, } from './collectFields.js'; +import { buildIncrementalResponse } from './IncrementalPublisher.js'; +import { mapAsyncIterable } from './mapAsyncIterable.js'; import type { CancellableStreamRecord, DeferredGroupedFieldSetRecord, @@ -68,13 +70,11 @@ import type { StreamItemsRecord, StreamItemsResult, SubsequentResultRecord, -} from './IncrementalPublisher.js'; +} from './types.js'; import { - buildIncrementalResponse, DeferredFragmentRecord, isReconcilableStreamItemsResult, -} from './IncrementalPublisher.js'; -import { mapAsyncIterable } from './mapAsyncIterable.js'; +} from './types.js'; import { getArgumentValues, getDirectiveValues, diff --git a/src/execution/index.ts b/src/execution/index.ts index 9d481ea6af..adf4b109f5 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -26,7 +26,7 @@ export type { FormattedIncrementalDeferResult, FormattedIncrementalStreamResult, FormattedIncrementalResult, -} from './IncrementalPublisher.js'; +} from './types.js'; export { getArgumentValues, diff --git a/src/execution/types.ts b/src/execution/types.ts new file mode 100644 index 0000000000..d2fd84827b --- /dev/null +++ b/src/execution/types.ts @@ -0,0 +1,302 @@ +import type { ObjMap } from '../jsutils/ObjMap.js'; +import type { Path } from '../jsutils/Path.js'; +import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; + +import type { + GraphQLError, + GraphQLFormattedError, +} from '../error/GraphQLError.js'; + +/** + * The result of GraphQL execution. + * + * - `errors` is included when any errors occurred as a non-empty array. + * - `data` is the result of a successful execution of the query. + * - `hasNext` is true if a future payload is expected. + * - `extensions` is reserved for adding non-standard properties. + * - `incremental` is a list of the results from defer/stream directives. + */ +export interface ExecutionResult< + TData = ObjMap, + TExtensions = ObjMap, +> { + errors?: ReadonlyArray; + data?: TData | null; + extensions?: TExtensions; +} + +export interface FormattedExecutionResult< + TData = ObjMap, + TExtensions = ObjMap, +> { + errors?: ReadonlyArray; + data?: TData | null; + extensions?: TExtensions; +} + +export interface ExperimentalIncrementalExecutionResults< + TData = unknown, + TExtensions = ObjMap, +> { + initialResult: InitialIncrementalExecutionResult; + subsequentResults: AsyncGenerator< + SubsequentIncrementalExecutionResult, + void, + void + >; +} + +export interface InitialIncrementalExecutionResult< + TData = ObjMap, + TExtensions = ObjMap, +> extends ExecutionResult { + data: TData; + pending: ReadonlyArray; + hasNext: true; + extensions?: TExtensions; +} + +export interface FormattedInitialIncrementalExecutionResult< + TData = ObjMap, + TExtensions = ObjMap, +> extends FormattedExecutionResult { + data: TData; + pending: ReadonlyArray; + hasNext: boolean; + extensions?: TExtensions; +} + +export interface SubsequentIncrementalExecutionResult< + TData = unknown, + TExtensions = ObjMap, +> { + pending?: ReadonlyArray; + incremental?: ReadonlyArray>; + completed?: ReadonlyArray; + hasNext: boolean; + extensions?: TExtensions; +} + +export interface FormattedSubsequentIncrementalExecutionResult< + TData = unknown, + TExtensions = ObjMap, +> { + hasNext: boolean; + pending?: ReadonlyArray; + incremental?: ReadonlyArray>; + completed?: ReadonlyArray; + extensions?: TExtensions; +} + +interface BareDeferredGroupedFieldSetResult> { + errors?: ReadonlyArray; + data: TData; +} + +export interface IncrementalDeferResult< + TData = ObjMap, + TExtensions = ObjMap, +> extends BareDeferredGroupedFieldSetResult { + id: string; + subPath?: ReadonlyArray; + extensions?: TExtensions; +} + +export interface FormattedIncrementalDeferResult< + TData = ObjMap, + TExtensions = ObjMap, +> { + errors?: ReadonlyArray; + data: TData; + id: string; + subPath?: ReadonlyArray; + extensions?: TExtensions; +} + +interface BareStreamItemsResult> { + errors?: ReadonlyArray; + items: TData; +} + +export interface IncrementalStreamResult< + TData = ReadonlyArray, + TExtensions = ObjMap, +> extends BareStreamItemsResult { + id: string; + subPath?: ReadonlyArray; + extensions?: TExtensions; +} + +export interface FormattedIncrementalStreamResult< + TData = Array, + TExtensions = ObjMap, +> { + errors?: ReadonlyArray; + items: TData; + id: string; + subPath?: ReadonlyArray; + extensions?: TExtensions; +} + +export type IncrementalResult> = + | IncrementalDeferResult + | IncrementalStreamResult; + +export type FormattedIncrementalResult< + TData = unknown, + TExtensions = ObjMap, +> = + | FormattedIncrementalDeferResult + | FormattedIncrementalStreamResult; + +export interface PendingResult { + id: string; + path: ReadonlyArray; + label?: string; +} + +export interface CompletedResult { + id: string; + errors?: ReadonlyArray; +} + +export interface FormattedCompletedResult { + path: ReadonlyArray; + label?: string; + errors?: ReadonlyArray; +} + +export function isDeferredFragmentRecord( + subsequentResultRecord: SubsequentResultRecord, +): subsequentResultRecord is DeferredFragmentRecord { + return 'parent' in subsequentResultRecord; +} + +export function isDeferredGroupedFieldSetRecord( + incrementalDataRecord: IncrementalDataRecord, +): incrementalDataRecord is DeferredGroupedFieldSetRecord { + return 'deferredFragmentRecords' in incrementalDataRecord; +} + +export type DeferredGroupedFieldSetResult = + | ReconcilableDeferredGroupedFieldSetResult + | NonReconcilableDeferredGroupedFieldSetResult; + +export function isDeferredGroupedFieldSetResult( + subsequentResult: DeferredGroupedFieldSetResult | StreamItemsResult, +): subsequentResult is DeferredGroupedFieldSetResult { + return 'deferredFragmentRecords' in subsequentResult; +} + +interface ReconcilableDeferredGroupedFieldSetResult { + deferredFragmentRecords: ReadonlyArray; + path: Array; + result: BareDeferredGroupedFieldSetResult; + incrementalDataRecords: ReadonlyArray | undefined; + sent?: true | undefined; + errors?: never; +} + +interface NonReconcilableDeferredGroupedFieldSetResult { + errors: ReadonlyArray; + deferredFragmentRecords: ReadonlyArray; + path: Array; + result?: never; +} + +export function isNonReconcilableDeferredGroupedFieldSetResult( + deferredGroupedFieldSetResult: DeferredGroupedFieldSetResult, +): deferredGroupedFieldSetResult is NonReconcilableDeferredGroupedFieldSetResult { + return deferredGroupedFieldSetResult.errors !== undefined; +} + +export interface DeferredGroupedFieldSetRecord { + deferredFragmentRecords: ReadonlyArray; + result: PromiseOrValue; +} + +export interface SubsequentResultRecord { + path: Path | undefined; + label: string | undefined; + id?: string | undefined; +} + +/** @internal */ +export class DeferredFragmentRecord implements SubsequentResultRecord { + path: Path | undefined; + label: string | undefined; + id?: string | undefined; + parent: DeferredFragmentRecord | undefined; + expectedReconcilableResults: number; + results: Array; + reconcilableResults: Array; + children: Set; + + constructor(opts: { + path: Path | undefined; + label: string | undefined; + parent: DeferredFragmentRecord | undefined; + }) { + this.path = opts.path; + this.label = opts.label; + this.parent = opts.parent; + this.expectedReconcilableResults = 0; + this.results = []; + this.reconcilableResults = []; + this.children = new Set(); + } +} + +export interface CancellableStreamRecord extends SubsequentResultRecord { + earlyReturn: () => Promise; +} + +export function isCancellableStreamRecord( + subsequentResultRecord: SubsequentResultRecord, +): subsequentResultRecord is CancellableStreamRecord { + return 'earlyReturn' in subsequentResultRecord; +} + +interface ReconcilableStreamItemsResult { + streamRecord: SubsequentResultRecord; + result: BareStreamItemsResult; + incrementalDataRecords: ReadonlyArray | undefined; + errors?: never; +} + +export function isReconcilableStreamItemsResult( + streamItemsResult: StreamItemsResult, +): streamItemsResult is ReconcilableStreamItemsResult { + return streamItemsResult.result !== undefined; +} + +interface TerminatingStreamItemsResult { + streamRecord: SubsequentResultRecord; + result?: never; + incrementalDataRecords?: never; + errors?: never; +} + +interface NonReconcilableStreamItemsResult { + streamRecord: SubsequentResultRecord; + errors: ReadonlyArray; + result?: never; +} + +export type StreamItemsResult = + | ReconcilableStreamItemsResult + | TerminatingStreamItemsResult + | NonReconcilableStreamItemsResult; + +export interface StreamItemsRecord { + streamRecord: SubsequentResultRecord; + result: PromiseOrValue; +} + +export type IncrementalDataRecord = + | DeferredGroupedFieldSetRecord + | StreamItemsRecord; + +export type IncrementalDataRecordResult = + | DeferredGroupedFieldSetResult + | StreamItemsResult; diff --git a/src/graphql.ts b/src/graphql.ts index 0c8187ae0e..7596cf524f 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -15,7 +15,7 @@ import { validateSchema } from './type/validate.js'; import { validate } from './validation/validate.js'; import { execute } from './execution/execute.js'; -import type { ExecutionResult } from './execution/IncrementalPublisher.js'; +import type { ExecutionResult } from './execution/types.js'; /** * This is the primary entry point function for fulfilling GraphQL operations From 62d347d7f3b8c10a8b83a906abc81e67543df9bb Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 3 Jun 2024 17:26:08 +0300 Subject: [PATCH 09/26] incremental(stream): revert test logic (#4101) Test logic was inadvertently altered in #4026. Tests still pass, just fixing logic. --- src/execution/__tests__/stream-test.ts | 34 +++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index 905b00be9e..f0a103b935 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -1939,7 +1939,9 @@ describe('Execute: stream directive', () => { hasNext: true, }); - const result2 = await iterator.next(); + const result2Promise = iterator.next(); + resolveIterableCompletion(null); + const result2 = await result2Promise; expectJSON(result2).toDeepEqual({ value: { pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], @@ -1960,7 +1962,7 @@ describe('Execute: stream directive', () => { }); const result3Promise = iterator.next(); - resolveIterableCompletion(null); + resolveSlowField('Han'); const result3 = await result3Promise; expectJSON(result3).toDeepEqual({ value: { @@ -1969,9 +1971,7 @@ describe('Execute: stream directive', () => { }, done: false, }); - const result4Promise = iterator.next(); - resolveSlowField('Han'); - const result4 = await result4Promise; + const result4 = await iterator.next(); expectJSON(result4).toDeepEqual({ value: { incremental: [ @@ -2062,19 +2062,8 @@ describe('Execute: stream directive', () => { done: false, }); - const result3Promise = iterator.next(); - resolveIterableCompletion(null); - const result3 = await result3Promise; + const result3 = await iterator.next(); expectJSON(result3).toDeepEqual({ - value: { - completed: [{ id: '1' }], - hasNext: true, - }, - done: false, - }); - - const result4 = await iterator.next(); - expectJSON(result4).toDeepEqual({ value: { incremental: [ { @@ -2083,6 +2072,17 @@ describe('Execute: stream directive', () => { }, ], completed: [{ id: '2' }], + hasNext: true, + }, + done: false, + }); + + const result4Promise = iterator.next(); + resolveIterableCompletion(null); + const result4 = await result4Promise; + expectJSON(result4).toDeepEqual({ + value: { + completed: [{ id: '1' }], hasNext: false, }, done: false, From cb43c83890cb3e2d7e3a5504d68f6ac860663a31 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Fri, 31 May 2024 16:38:35 +0300 Subject: [PATCH 10/26] refactor: extract incremental graph to separate file --- src/execution/IncrementalGraph.ts | 213 ++++++++++++++++++++++++++ src/execution/IncrementalPublisher.ts | 205 +++---------------------- src/execution/types.ts | 2 +- 3 files changed, 239 insertions(+), 181 deletions(-) create mode 100644 src/execution/IncrementalGraph.ts diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts new file mode 100644 index 0000000000..b998af5dda --- /dev/null +++ b/src/execution/IncrementalGraph.ts @@ -0,0 +1,213 @@ +import { isPromise } from '../jsutils/isPromise.js'; +import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; + +import type { + DeferredFragmentRecord, + DeferredGroupedFieldSetResult, + IncrementalDataRecord, + IncrementalDataRecordResult, + ReconcilableDeferredGroupedFieldSetResult, + StreamItemsResult, + SubsequentResultRecord, +} from './types.js'; +import { + isDeferredFragmentRecord, + isDeferredGroupedFieldSetRecord, +} from './types.js'; + +/** + * @internal + */ +export class IncrementalGraph { + // these are assigned within the Promise executor called synchronously within the constructor + newCompletedResultAvailable!: Promise; + private _resolve!: () => void; + + private _pending: Set; + private _newPending: Set; + private _completedResultQueue: Array; + + constructor() { + this._pending = new Set(); + this._newPending = new Set(); + this._completedResultQueue = []; + this._reset(); + } + + addIncrementalDataRecords( + incrementalDataRecords: ReadonlyArray, + ): void { + for (const incrementalDataRecord of incrementalDataRecords) { + if (isDeferredGroupedFieldSetRecord(incrementalDataRecord)) { + for (const deferredFragmentRecord of incrementalDataRecord.deferredFragmentRecords) { + deferredFragmentRecord.expectedReconcilableResults++; + + this._addDeferredFragmentRecord(deferredFragmentRecord); + } + + const result = incrementalDataRecord.result; + if (isPromise(result)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + result.then((resolved) => { + this._enqueueCompletedDeferredGroupedFieldSet(resolved); + }); + } else { + this._enqueueCompletedDeferredGroupedFieldSet(result); + } + + continue; + } + + const streamRecord = incrementalDataRecord.streamRecord; + if (streamRecord.id === undefined) { + this._newPending.add(streamRecord); + } + + const result = incrementalDataRecord.result; + if (isPromise(result)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + result.then((resolved) => { + this._enqueueCompletedStreamItems(resolved); + }); + } else { + this._enqueueCompletedStreamItems(result); + } + } + } + + getNewPending(): ReadonlyArray { + const maybeEmptyNewPending = this._newPending; + const newPending = []; + for (const node of maybeEmptyNewPending) { + if (isDeferredFragmentRecord(node)) { + if (node.expectedReconcilableResults) { + this._pending.add(node); + newPending.push(node); + continue; + } + for (const child of node.children) { + this._addNonEmptyNewPending(child, newPending); + } + } else { + this._pending.add(node); + newPending.push(node); + } + } + this._newPending.clear(); + return newPending; + } + + *completedResults(): Generator { + let completedResult: IncrementalDataRecordResult | undefined; + while ( + (completedResult = this._completedResultQueue.shift()) !== undefined + ) { + yield completedResult; + } + } + + hasNext(): boolean { + return this._pending.size > 0; + } + + completeDeferredFragment( + deferredFragmentRecord: DeferredFragmentRecord, + ): Array | undefined { + const reconcilableResults = deferredFragmentRecord.reconcilableResults; + if ( + deferredFragmentRecord.expectedReconcilableResults !== + reconcilableResults.length + ) { + return; + } + this._pending.delete(deferredFragmentRecord); + for (const child of deferredFragmentRecord.children) { + this._newPending.add(child); + this._completedResultQueue.push(...child.results); + } + return reconcilableResults; + } + + removeSubsequentResultRecord( + subsequentResultRecord: SubsequentResultRecord, + ): void { + this._pending.delete(subsequentResultRecord); + } + + private _addDeferredFragmentRecord( + deferredFragmentRecord: DeferredFragmentRecord, + ): void { + const parent = deferredFragmentRecord.parent; + if (parent === undefined) { + // Below is equivalent and slightly faster version of: + // if (this._pending.has(deferredFragmentRecord)) { ... } + // as all released deferredFragmentRecords have ids. + if (deferredFragmentRecord.id !== undefined) { + return; + } + + this._newPending.add(deferredFragmentRecord); + return; + } + + if (parent.children.has(deferredFragmentRecord)) { + return; + } + + parent.children.add(deferredFragmentRecord); + + this._addDeferredFragmentRecord(parent); + } + + private _addNonEmptyNewPending( + deferredFragmentRecord: DeferredFragmentRecord, + newPending: Array, + ): void { + if (deferredFragmentRecord.expectedReconcilableResults) { + this._pending.add(deferredFragmentRecord); + newPending.push(deferredFragmentRecord); + return; + } + /* c8 ignore next 5 */ + // TODO: add test case for this, if when skipping an empty deferred fragment, the empty fragment has nested children. + for (const child of deferredFragmentRecord.children) { + this._addNonEmptyNewPending(child, newPending); + } + } + + private _enqueueCompletedDeferredGroupedFieldSet( + result: DeferredGroupedFieldSetResult, + ): void { + let hasPendingParent = false; + for (const deferredFragmentRecord of result.deferredFragmentRecords) { + if (deferredFragmentRecord.id !== undefined) { + hasPendingParent = true; + } + deferredFragmentRecord.results.push(result); + } + if (hasPendingParent) { + this._completedResultQueue.push(result); + this._trigger(); + } + } + + private _enqueueCompletedStreamItems(result: StreamItemsResult): void { + this._completedResultQueue.push(result); + this._trigger(); + } + + private _trigger() { + this._resolve(); + this._reset(); + } + + private _reset() { + const { promise: newCompletedResultAvailable, resolve } = + // promiseWithResolvers uses void only as a generic type parameter + // see: https://typescript-eslint.io/rules/no-invalid-void-type/ + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + promiseWithResolvers(); + this._resolve = resolve; + this.newCompletedResultAvailable = newCompletedResultAvailable; + } +} diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 0504238eae..92a3f199c3 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -1,11 +1,10 @@ import { invariant } from '../jsutils/invariant.js'; -import { isPromise } from '../jsutils/isPromise.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; import { pathToArray } from '../jsutils/Path.js'; -import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; import type { GraphQLError } from '../error/GraphQLError.js'; +import { IncrementalGraph } from './IncrementalGraph.js'; import type { CancellableStreamRecord, CompletedResult, @@ -13,7 +12,6 @@ import type { DeferredGroupedFieldSetResult, ExperimentalIncrementalExecutionResults, IncrementalDataRecord, - IncrementalDataRecordResult, IncrementalDeferResult, IncrementalResult, IncrementalStreamResult, @@ -25,8 +23,6 @@ import type { } from './types.js'; import { isCancellableStreamRecord, - isDeferredFragmentRecord, - isDeferredGroupedFieldSetRecord, isDeferredGroupedFieldSetResult, isNonReconcilableDeferredGroupedFieldSetResult, } from './types.js'; @@ -58,24 +54,16 @@ interface IncrementalPublisherContext { class IncrementalPublisher { private _context: IncrementalPublisherContext; private _nextId: number; - private _pending: Set; - private _completedResultQueue: Array; - private _newPending: Set; + private _incrementalGraph: IncrementalGraph; private _incremental: Array; private _completed: Array; - // these are assigned within the Promise executor called synchronously within the constructor - private _signalled!: Promise; - private _resolve!: () => void; constructor(context: IncrementalPublisherContext) { this._context = context; this._nextId = 0; - this._pending = new Set(); - this._completedResultQueue = []; - this._newPending = new Set(); + this._incrementalGraph = new IncrementalGraph(); this._incremental = []; this._completed = []; - this._reset(); } buildResponse( @@ -83,10 +71,10 @@ class IncrementalPublisher { errors: ReadonlyArray | undefined, incrementalDataRecords: ReadonlyArray, ): ExperimentalIncrementalExecutionResults { - this._addIncrementalDataRecords(incrementalDataRecords); - this._pruneEmpty(); + this._incrementalGraph.addIncrementalDataRecords(incrementalDataRecords); + const newPending = this._incrementalGraph.getNewPending(); - const pending = this._pendingSourcesToResults(); + const pending = this._pendingSourcesToResults(newPending); const initialResult: InitialIncrementalExecutionResult = errors === undefined @@ -99,130 +87,12 @@ class IncrementalPublisher { }; } - private _addIncrementalDataRecords( - incrementalDataRecords: ReadonlyArray, - ): void { - for (const incrementalDataRecord of incrementalDataRecords) { - if (isDeferredGroupedFieldSetRecord(incrementalDataRecord)) { - for (const deferredFragmentRecord of incrementalDataRecord.deferredFragmentRecords) { - deferredFragmentRecord.expectedReconcilableResults++; - - this._addDeferredFragmentRecord(deferredFragmentRecord); - } - - const result = incrementalDataRecord.result; - if (isPromise(result)) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - result.then((resolved) => { - this._enqueueCompletedDeferredGroupedFieldSet(resolved); - }); - } else { - this._enqueueCompletedDeferredGroupedFieldSet(result); - } - - continue; - } - - const streamRecord = incrementalDataRecord.streamRecord; - if (streamRecord.id === undefined) { - this._newPending.add(streamRecord); - } - - const result = incrementalDataRecord.result; - if (isPromise(result)) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - result.then((resolved) => { - this._enqueueCompletedStreamItems(resolved); - }); - } else { - this._enqueueCompletedStreamItems(result); - } - } - } - - private _addDeferredFragmentRecord( - deferredFragmentRecord: DeferredFragmentRecord, - ): void { - const parent = deferredFragmentRecord.parent; - if (parent === undefined) { - // Below is equivalent and slightly faster version of: - // if (this._pending.has(deferredFragmentRecord)) { ... } - // as all released deferredFragmentRecords have ids. - if (deferredFragmentRecord.id !== undefined) { - return; - } - - this._newPending.add(deferredFragmentRecord); - return; - } - - if (parent.children.has(deferredFragmentRecord)) { - return; - } - - parent.children.add(deferredFragmentRecord); - - this._addDeferredFragmentRecord(parent); - } - - private _pruneEmpty() { - const maybeEmptyNewPending = this._newPending; - this._newPending = new Set(); - for (const node of maybeEmptyNewPending) { - if (isDeferredFragmentRecord(node)) { - if (node.expectedReconcilableResults) { - this._newPending.add(node); - continue; - } - for (const child of node.children) { - this._addNonEmptyNewPending(child); - } - } else { - this._newPending.add(node); - } - } - } - - private _addNonEmptyNewPending( - deferredFragmentRecord: DeferredFragmentRecord, - ): void { - if (deferredFragmentRecord.expectedReconcilableResults) { - this._newPending.add(deferredFragmentRecord); - return; - } - /* c8 ignore next 5 */ - // TODO: add test case for this, if when skipping an empty deferred fragment, the empty fragment has nested children. - for (const child of deferredFragmentRecord.children) { - this._addNonEmptyNewPending(child); - } - } - - private _enqueueCompletedDeferredGroupedFieldSet( - result: DeferredGroupedFieldSetResult, - ): void { - let hasPendingParent = false; - for (const deferredFragmentRecord of result.deferredFragmentRecords) { - if (deferredFragmentRecord.id !== undefined) { - hasPendingParent = true; - } - deferredFragmentRecord.results.push(result); - } - if (hasPendingParent) { - this._completedResultQueue.push(result); - this._trigger(); - } - } - - private _enqueueCompletedStreamItems(result: StreamItemsResult): void { - this._completedResultQueue.push(result); - this._trigger(); - } - - private _pendingSourcesToResults(): Array { + private _pendingSourcesToResults( + newPending: ReadonlyArray, + ): Array { const pendingResults: Array = []; - for (const pendingSource of this._newPending) { + for (const pendingSource of newPending) { const id = String(this._getNextId()); - this._pending.add(pendingSource); pendingSource.id = id; const pendingResult: PendingResult = { id, @@ -233,7 +103,6 @@ class IncrementalPublisher { } pendingResults.push(pendingResult); } - this._newPending.clear(); return pendingResults; } @@ -254,21 +123,19 @@ class IncrementalPublisher { while (!isDone) { let pending: Array = []; - let completedResult: IncrementalDataRecordResult | undefined; - while ( - (completedResult = this._completedResultQueue.shift()) !== undefined - ) { + for (const completedResult of this._incrementalGraph.completedResults()) { if (isDeferredGroupedFieldSetResult(completedResult)) { this._handleCompletedDeferredGroupedFieldSet(completedResult); } else { this._handleCompletedStreamItems(completedResult); } - pending = [...pending, ...this._pendingSourcesToResults()]; + const newPending = this._incrementalGraph.getNewPending(); + pending = [...pending, ...this._pendingSourcesToResults(newPending)]; } if (this._incremental.length > 0 || this._completed.length > 0) { - const hasNext = this._pending.size > 0; + const hasNext = this._incrementalGraph.hasNext(); if (!hasNext) { isDone = true; @@ -295,7 +162,7 @@ class IncrementalPublisher { } // eslint-disable-next-line no-await-in-loop - await this._signalled; + await this._incrementalGraph.newCompletedResultAvailable; } await returnStreamIterators().catch(() => { @@ -345,20 +212,6 @@ class IncrementalPublisher { }; } - private _trigger() { - this._resolve(); - this._reset(); - } - - private _reset() { - // promiseWithResolvers uses void only as a generic type parameter - // see: https://typescript-eslint.io/rules/no-invalid-void-type/ - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - const { promise: signalled, resolve } = promiseWithResolvers(); - this._resolve = resolve; - this._signalled = signalled; - } - private _handleCompletedDeferredGroupedFieldSet( deferredGroupedFieldSetResult: DeferredGroupedFieldSetResult, ): void { @@ -374,7 +227,9 @@ class IncrementalPublisher { id, errors: deferredGroupedFieldSetResult.errors, }); - this._pending.delete(deferredFragmentRecord); + this._incrementalGraph.removeSubsequentResultRecord( + deferredFragmentRecord, + ); } } return; @@ -388,7 +243,7 @@ class IncrementalPublisher { const incrementalDataRecords = deferredGroupedFieldSetResult.incrementalDataRecords; if (incrementalDataRecords !== undefined) { - this._addIncrementalDataRecords(incrementalDataRecords); + this._incrementalGraph.addIncrementalDataRecords(incrementalDataRecords); } for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { @@ -400,11 +255,9 @@ class IncrementalPublisher { if (id === undefined) { continue; } - const reconcilableResults = deferredFragmentRecord.reconcilableResults; - if ( - deferredFragmentRecord.expectedReconcilableResults !== - reconcilableResults.length - ) { + const reconcilableResults = + this._incrementalGraph.completeDeferredFragment(deferredFragmentRecord); + if (reconcilableResults === undefined) { continue; } for (const reconcilableResult of reconcilableResults) { @@ -427,14 +280,7 @@ class IncrementalPublisher { this._incremental.push(incrementalEntry); } this._completed.push({ id }); - this._pending.delete(deferredFragmentRecord); - for (const child of deferredFragmentRecord.children) { - this._newPending.add(child); - this._completedResultQueue.push(...child.results); - } } - - this._pruneEmpty(); } private _handleCompletedStreamItems( @@ -453,7 +299,7 @@ class IncrementalPublisher { id, errors: streamItemsResult.errors, }); - this._pending.delete(streamRecord); + this._incrementalGraph.removeSubsequentResultRecord(streamRecord); if (isCancellableStreamRecord(streamRecord)) { invariant(this._context.cancellableStreams !== undefined); this._context.cancellableStreams.delete(streamRecord); @@ -464,7 +310,7 @@ class IncrementalPublisher { } } else if (streamItemsResult.result === undefined) { this._completed.push({ id }); - this._pending.delete(streamRecord); + this._incrementalGraph.removeSubsequentResultRecord(streamRecord); if (isCancellableStreamRecord(streamRecord)) { invariant(this._context.cancellableStreams !== undefined); this._context.cancellableStreams.delete(streamRecord); @@ -478,10 +324,9 @@ class IncrementalPublisher { this._incremental.push(incrementalEntry); if (streamItemsResult.incrementalDataRecords !== undefined) { - this._addIncrementalDataRecords( + this._incrementalGraph.addIncrementalDataRecords( streamItemsResult.incrementalDataRecords, ); - this._pruneEmpty(); } } } diff --git a/src/execution/types.ts b/src/execution/types.ts index d2fd84827b..519d08ea46 100644 --- a/src/execution/types.ts +++ b/src/execution/types.ts @@ -188,7 +188,7 @@ export function isDeferredGroupedFieldSetResult( return 'deferredFragmentRecords' in subsequentResult; } -interface ReconcilableDeferredGroupedFieldSetResult { +export interface ReconcilableDeferredGroupedFieldSetResult { deferredFragmentRecords: ReadonlyArray; path: Array; result: BareDeferredGroupedFieldSetResult; From 86ea77c18ff98623939790285c9967662ac39c35 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 2 Jun 2024 14:40:05 +0300 Subject: [PATCH 11/26] refactor: move returnStreamIterators to method --- src/execution/IncrementalPublisher.ts | 34 +++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 92a3f199c3..8257bb2a8a 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -165,32 +165,18 @@ class IncrementalPublisher { await this._incrementalGraph.newCompletedResultAvailable; } - await returnStreamIterators().catch(() => { + await this._returnStreamIterators().catch(() => { // ignore errors }); return { value: undefined, done: true }; }; - const returnStreamIterators = async (): Promise => { - const cancellableStreams = this._context.cancellableStreams; - if (cancellableStreams === undefined) { - return; - } - const promises: Array> = []; - for (const streamRecord of cancellableStreams) { - if (streamRecord.earlyReturn !== undefined) { - promises.push(streamRecord.earlyReturn()); - } - } - await Promise.all(promises); - }; - const _return = async (): Promise< IteratorResult > => { isDone = true; - await returnStreamIterators(); + await this._returnStreamIterators(); return { value: undefined, done: true }; }; @@ -198,7 +184,7 @@ class IncrementalPublisher { error?: unknown, ): Promise> => { isDone = true; - await returnStreamIterators(); + await this._returnStreamIterators(); return Promise.reject(error); }; @@ -362,4 +348,18 @@ class IncrementalPublisher { subPath: subPath.length > 0 ? subPath : undefined, }; } + + private async _returnStreamIterators(): Promise { + const cancellableStreams = this._context.cancellableStreams; + if (cancellableStreams === undefined) { + return; + } + const promises: Array> = []; + for (const streamRecord of cancellableStreams) { + if (streamRecord.earlyReturn !== undefined) { + promises.push(streamRecord.earlyReturn()); + } + } + await Promise.all(promises); + } } From 95bf842566f123543c858a03efe6aa007494acbb Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 2 Jun 2024 15:00:10 +0300 Subject: [PATCH 12/26] refactor: use asyncIterator instead of extra promise --- src/execution/IncrementalGraph.ts | 104 +++++++++++++++++--------- src/execution/IncrementalPublisher.ts | 37 ++++++--- 2 files changed, 94 insertions(+), 47 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index b998af5dda..0d403ea5e7 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -7,7 +7,6 @@ import type { IncrementalDataRecord, IncrementalDataRecordResult, ReconcilableDeferredGroupedFieldSetResult, - StreamItemsResult, SubsequentResultRecord, } from './types.js'; import { @@ -19,19 +18,18 @@ import { * @internal */ export class IncrementalGraph { - // these are assigned within the Promise executor called synchronously within the constructor - newCompletedResultAvailable!: Promise; - private _resolve!: () => void; - private _pending: Set; private _newPending: Set; - private _completedResultQueue: Array; + private _completedQueue: Array; + private _nextQueue: Array< + (iterable: IteratorResult>) => void + >; constructor() { this._pending = new Set(); this._newPending = new Set(); - this._completedResultQueue = []; - this._reset(); + this._completedQueue = []; + this._nextQueue = []; } addIncrementalDataRecords( @@ -67,10 +65,10 @@ export class IncrementalGraph { if (isPromise(result)) { // eslint-disable-next-line @typescript-eslint/no-floating-promises result.then((resolved) => { - this._enqueueCompletedStreamItems(resolved); + this._enqueue(resolved); }); } else { - this._enqueueCompletedStreamItems(result); + this._enqueue(result); } } } @@ -97,13 +95,37 @@ export class IncrementalGraph { return newPending; } - *completedResults(): Generator { - let completedResult: IncrementalDataRecordResult | undefined; - while ( - (completedResult = this._completedResultQueue.shift()) !== undefined - ) { - yield completedResult; - } + completedIncrementalData() { + return { + [Symbol.asyncIterator]() { + return this; + }, + next: (): Promise< + IteratorResult> + > => { + const firstResult = this._completedQueue.shift(); + if (firstResult !== undefined) { + return Promise.resolve({ + value: this._yieldCurrentCompletedIncrementalData(firstResult), + done: false, + }); + } + const { promise, resolve } = + promiseWithResolvers< + IteratorResult> + >(); + this._nextQueue.push(resolve); + return promise; + }, + return: (): Promise< + IteratorResult> + > => { + for (const resolve of this._nextQueue) { + resolve({ value: undefined, done: true }); + } + return Promise.resolve({ value: undefined, done: true }); + }, + }; } hasNext(): boolean { @@ -120,10 +142,12 @@ export class IncrementalGraph { ) { return; } - this._pending.delete(deferredFragmentRecord); + this.removeSubsequentResultRecord(deferredFragmentRecord); for (const child of deferredFragmentRecord.children) { this._newPending.add(child); - this._completedResultQueue.push(...child.results); + for (const result of child.results) { + this._enqueue(result); + } } return reconcilableResults; } @@ -132,6 +156,11 @@ export class IncrementalGraph { subsequentResultRecord: SubsequentResultRecord, ): void { this._pending.delete(subsequentResultRecord); + if (this._pending.size === 0) { + for (const resolve of this._nextQueue) { + resolve({ value: undefined, done: true }); + } + } } private _addDeferredFragmentRecord( @@ -186,28 +215,29 @@ export class IncrementalGraph { deferredFragmentRecord.results.push(result); } if (hasPendingParent) { - this._completedResultQueue.push(result); - this._trigger(); + this._enqueue(result); } } - private _enqueueCompletedStreamItems(result: StreamItemsResult): void { - this._completedResultQueue.push(result); - this._trigger(); - } - - private _trigger() { - this._resolve(); - this._reset(); + private *_yieldCurrentCompletedIncrementalData( + first: IncrementalDataRecordResult, + ): Generator { + yield first; + let completed; + while ((completed = this._completedQueue.shift()) !== undefined) { + yield completed; + } } - private _reset() { - const { promise: newCompletedResultAvailable, resolve } = - // promiseWithResolvers uses void only as a generic type parameter - // see: https://typescript-eslint.io/rules/no-invalid-void-type/ - // eslint-disable-next-line @typescript-eslint/no-invalid-void-type - promiseWithResolvers(); - this._resolve = resolve; - this.newCompletedResultAvailable = newCompletedResultAvailable; + private _enqueue(completed: IncrementalDataRecordResult): void { + const next = this._nextQueue.shift(); + if (next !== undefined) { + next({ + value: this._yieldCurrentCompletedIncrementalData(completed), + done: false, + }); + return; + } + this._completedQueue.push(completed); } } diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 8257bb2a8a..89f0c3833e 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -120,10 +120,21 @@ class IncrementalPublisher { const _next = async (): Promise< IteratorResult > => { - while (!isDone) { + if (isDone) { + await this._returnAsyncIteratorsIgnoringErrors(); + return { value: undefined, done: true }; + } + + const completedIncrementalData = + this._incrementalGraph.completedIncrementalData(); + // use the raw iterator rather than 'for await ... of' so as not to trigger the + // '.return()' method on the iterator when exiting the loop with the next value + const asyncIterator = completedIncrementalData[Symbol.asyncIterator](); + let iteration = await asyncIterator.next(); + while (!iteration.done) { let pending: Array = []; - for (const completedResult of this._incrementalGraph.completedResults()) { + for (const completedResult of iteration.value) { if (isDeferredGroupedFieldSetResult(completedResult)) { this._handleCompletedDeferredGroupedFieldSet(completedResult); } else { @@ -138,6 +149,7 @@ class IncrementalPublisher { const hasNext = this._incrementalGraph.hasNext(); if (!hasNext) { + // eslint-disable-next-line require-atomic-updates isDone = true; } @@ -162,13 +174,10 @@ class IncrementalPublisher { } // eslint-disable-next-line no-await-in-loop - await this._incrementalGraph.newCompletedResultAvailable; + iteration = await asyncIterator.next(); } - await this._returnStreamIterators().catch(() => { - // ignore errors - }); - + await this._returnAsyncIteratorsIgnoringErrors(); return { value: undefined, done: true }; }; @@ -176,7 +185,7 @@ class IncrementalPublisher { IteratorResult > => { isDone = true; - await this._returnStreamIterators(); + await this._returnAsyncIterators(); return { value: undefined, done: true }; }; @@ -184,7 +193,7 @@ class IncrementalPublisher { error?: unknown, ): Promise> => { isDone = true; - await this._returnStreamIterators(); + await this._returnAsyncIterators(); return Promise.reject(error); }; @@ -349,7 +358,9 @@ class IncrementalPublisher { }; } - private async _returnStreamIterators(): Promise { + private async _returnAsyncIterators(): Promise { + await this._incrementalGraph.completedIncrementalData().return(); + const cancellableStreams = this._context.cancellableStreams; if (cancellableStreams === undefined) { return; @@ -362,4 +373,10 @@ class IncrementalPublisher { } await Promise.all(promises); } + + private async _returnAsyncIteratorsIgnoringErrors(): Promise { + await this._returnAsyncIterators().catch(() => { + // Ignore errors + }); + } } From 6fa7d55d2ea2a4a7a3396a1338d2c97dbae361d0 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 2 Jun 2024 15:49:23 +0300 Subject: [PATCH 13/26] refactor: convert IncrementalPublisher class members to method args --- src/execution/IncrementalPublisher.ts | 75 ++++++++++++++++----------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 89f0c3833e..5934f3a00b 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -12,6 +12,7 @@ import type { DeferredGroupedFieldSetResult, ExperimentalIncrementalExecutionResults, IncrementalDataRecord, + IncrementalDataRecordResult, IncrementalDeferResult, IncrementalResult, IncrementalStreamResult, @@ -45,6 +46,12 @@ interface IncrementalPublisherContext { cancellableStreams: Set | undefined; } +interface SubsequentIncrementalExecutionResultContext { + pending: Array; + incremental: Array; + completed: Array; +} + /** * This class is used to publish incremental results to the client, enabling semi-concurrent * execution while preserving result order. @@ -55,15 +62,11 @@ class IncrementalPublisher { private _context: IncrementalPublisherContext; private _nextId: number; private _incrementalGraph: IncrementalGraph; - private _incremental: Array; - private _completed: Array; constructor(context: IncrementalPublisherContext) { this._context = context; this._nextId = 0; this._incrementalGraph = new IncrementalGraph(); - this._incremental = []; - this._completed = []; } buildResponse( @@ -125,6 +128,12 @@ class IncrementalPublisher { return { value: undefined, done: true }; } + const context: SubsequentIncrementalExecutionResultContext = { + pending: [], + incremental: [], + completed: [], + }; + const completedIncrementalData = this._incrementalGraph.completedIncrementalData(); // use the raw iterator rather than 'for await ... of' so as not to trigger the @@ -132,20 +141,12 @@ class IncrementalPublisher { const asyncIterator = completedIncrementalData[Symbol.asyncIterator](); let iteration = await asyncIterator.next(); while (!iteration.done) { - let pending: Array = []; - for (const completedResult of iteration.value) { - if (isDeferredGroupedFieldSetResult(completedResult)) { - this._handleCompletedDeferredGroupedFieldSet(completedResult); - } else { - this._handleCompletedStreamItems(completedResult); - } - - const newPending = this._incrementalGraph.getNewPending(); - pending = [...pending, ...this._pendingSourcesToResults(newPending)]; + this._handleCompletedIncrementalData(completedResult, context); } - if (this._incremental.length > 0 || this._completed.length > 0) { + const { incremental, completed } = context; + if (incremental.length > 0 || completed.length > 0) { const hasNext = this._incrementalGraph.hasNext(); if (!hasNext) { @@ -156,20 +157,17 @@ class IncrementalPublisher { const subsequentIncrementalExecutionResult: SubsequentIncrementalExecutionResult = { hasNext }; + const pending = context.pending; if (pending.length > 0) { subsequentIncrementalExecutionResult.pending = pending; } - if (this._incremental.length > 0) { - subsequentIncrementalExecutionResult.incremental = - this._incremental; + if (incremental.length > 0) { + subsequentIncrementalExecutionResult.incremental = incremental; } - if (this._completed.length > 0) { - subsequentIncrementalExecutionResult.completed = this._completed; + if (completed.length > 0) { + subsequentIncrementalExecutionResult.completed = completed; } - this._incremental = []; - this._completed = []; - return { value: subsequentIncrementalExecutionResult, done: false }; } @@ -207,8 +205,25 @@ class IncrementalPublisher { }; } + private _handleCompletedIncrementalData( + completedIncrementalData: IncrementalDataRecordResult, + context: SubsequentIncrementalExecutionResultContext, + ): void { + if (isDeferredGroupedFieldSetResult(completedIncrementalData)) { + this._handleCompletedDeferredGroupedFieldSet( + completedIncrementalData, + context, + ); + } else { + this._handleCompletedStreamItems(completedIncrementalData, context); + } + const newPending = this._incrementalGraph.getNewPending(); + context.pending.push(...this._pendingSourcesToResults(newPending)); + } + private _handleCompletedDeferredGroupedFieldSet( deferredGroupedFieldSetResult: DeferredGroupedFieldSetResult, + context: SubsequentIncrementalExecutionResultContext, ): void { if ( isNonReconcilableDeferredGroupedFieldSetResult( @@ -218,7 +233,7 @@ class IncrementalPublisher { for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { const id = deferredFragmentRecord.id; if (id !== undefined) { - this._completed.push({ + context.completed.push({ id, errors: deferredGroupedFieldSetResult.errors, }); @@ -255,6 +270,7 @@ class IncrementalPublisher { if (reconcilableResults === undefined) { continue; } + const incremental = context.incremental; for (const reconcilableResult of reconcilableResults) { if (reconcilableResult.sent) { continue; @@ -272,14 +288,15 @@ class IncrementalPublisher { if (subPath !== undefined) { incrementalEntry.subPath = subPath; } - this._incremental.push(incrementalEntry); + incremental.push(incrementalEntry); } - this._completed.push({ id }); + context.completed.push({ id }); } } private _handleCompletedStreamItems( streamItemsResult: StreamItemsResult, + context: SubsequentIncrementalExecutionResultContext, ): void { const streamRecord = streamItemsResult.streamRecord; const id = streamRecord.id; @@ -290,7 +307,7 @@ class IncrementalPublisher { return; } if (streamItemsResult.errors !== undefined) { - this._completed.push({ + context.completed.push({ id, errors: streamItemsResult.errors, }); @@ -304,7 +321,7 @@ class IncrementalPublisher { }); } } else if (streamItemsResult.result === undefined) { - this._completed.push({ id }); + context.completed.push({ id }); this._incrementalGraph.removeSubsequentResultRecord(streamRecord); if (isCancellableStreamRecord(streamRecord)) { invariant(this._context.cancellableStreams !== undefined); @@ -316,7 +333,7 @@ class IncrementalPublisher { ...streamItemsResult.result, }; - this._incremental.push(incrementalEntry); + context.incremental.push(incrementalEntry); if (streamItemsResult.incrementalDataRecords !== undefined) { this._incrementalGraph.addIncrementalDataRecords( From 2b42b9103dd94ec595ca0e91961848c7ac5e7af4 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 2 Jun 2024 15:55:30 +0300 Subject: [PATCH 14/26] incremental: use invariant for checking id --- src/execution/IncrementalPublisher.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 5934f3a00b..9f233a8cb1 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -300,12 +300,7 @@ class IncrementalPublisher { ): void { const streamRecord = streamItemsResult.streamRecord; const id = streamRecord.id; - // TODO: Consider adding invariant or non-null assertion, as this should never happen. Since the stream is converted into a linked list - // for ordering purposes, if an entry errors, additional entries will not be processed. - /* c8 ignore next 3 */ - if (id === undefined) { - return; - } + invariant(id !== undefined); if (streamItemsResult.errors !== undefined) { context.completed.push({ id, From 28e079a3e82c8442a1db09f479feee2278b11a69 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 2 Jun 2024 17:07:11 +0300 Subject: [PATCH 15/26] refactor(IncrementalGraph): remove unnecessary method --- src/execution/IncrementalGraph.ts | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index 0d403ea5e7..49ebf4acd6 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -74,9 +74,8 @@ export class IncrementalGraph { } getNewPending(): ReadonlyArray { - const maybeEmptyNewPending = this._newPending; const newPending = []; - for (const node of maybeEmptyNewPending) { + for (const node of this._newPending) { if (isDeferredFragmentRecord(node)) { if (node.expectedReconcilableResults) { this._pending.add(node); @@ -84,7 +83,7 @@ export class IncrementalGraph { continue; } for (const child of node.children) { - this._addNonEmptyNewPending(child, newPending); + this._newPending.add(child); } } else { this._pending.add(node); @@ -188,22 +187,6 @@ export class IncrementalGraph { this._addDeferredFragmentRecord(parent); } - private _addNonEmptyNewPending( - deferredFragmentRecord: DeferredFragmentRecord, - newPending: Array, - ): void { - if (deferredFragmentRecord.expectedReconcilableResults) { - this._pending.add(deferredFragmentRecord); - newPending.push(deferredFragmentRecord); - return; - } - /* c8 ignore next 5 */ - // TODO: add test case for this, if when skipping an empty deferred fragment, the empty fragment has nested children. - for (const child of deferredFragmentRecord.children) { - this._addNonEmptyNewPending(child, newPending); - } - } - private _enqueueCompletedDeferredGroupedFieldSet( result: DeferredGroupedFieldSetResult, ): void { From 3bbbb0874e5f667efed19cfff71c648402c439b1 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 2 Jun 2024 17:37:03 +0300 Subject: [PATCH 16/26] refactor(IncrementalGraph): use Subsequent Result nodes to reduce mutation --- src/execution/IncrementalGraph.ts | 181 ++++++++++++++++++-------- src/execution/IncrementalPublisher.ts | 17 +-- src/execution/execute.ts | 10 +- src/execution/types.ts | 27 +--- 4 files changed, 142 insertions(+), 93 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index 49ebf4acd6..7cf722c29d 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -9,17 +9,41 @@ import type { ReconcilableDeferredGroupedFieldSetResult, SubsequentResultRecord, } from './types.js'; -import { - isDeferredFragmentRecord, - isDeferredGroupedFieldSetRecord, -} from './types.js'; +import { isDeferredGroupedFieldSetRecord } from './types.js'; + +interface DeferredFragmentNode { + deferredFragmentRecord: DeferredFragmentRecord; + expectedReconcilableResults: number; + results: Array; + reconcilableResults: Array; + children: Array; +} + +function isDeferredFragmentNode( + node: DeferredFragmentNode | undefined, +): node is DeferredFragmentNode { + return node !== undefined; +} + +function isStreamNode( + subsequentResultNode: SubsequentResultNode, +): subsequentResultNode is SubsequentResultRecord { + return 'path' in subsequentResultNode; +} + +type SubsequentResultNode = DeferredFragmentNode | SubsequentResultRecord; /** * @internal */ export class IncrementalGraph { - private _pending: Set; - private _newPending: Set; + private _pending: Set; + private _deferredFragmentNodes: Map< + DeferredFragmentRecord, + DeferredFragmentNode + >; + + private _newPending: Set; private _completedQueue: Array; private _nextQueue: Array< (iterable: IteratorResult>) => void @@ -27,6 +51,7 @@ export class IncrementalGraph { constructor() { this._pending = new Set(); + this._deferredFragmentNodes = new Map(); this._newPending = new Set(); this._completedQueue = []; this._nextQueue = []; @@ -38,9 +63,10 @@ export class IncrementalGraph { for (const incrementalDataRecord of incrementalDataRecords) { if (isDeferredGroupedFieldSetRecord(incrementalDataRecord)) { for (const deferredFragmentRecord of incrementalDataRecord.deferredFragmentRecords) { - deferredFragmentRecord.expectedReconcilableResults++; - - this._addDeferredFragmentRecord(deferredFragmentRecord); + const deferredFragmentNode = this._addDeferredFragmentNode( + deferredFragmentRecord, + ); + deferredFragmentNode.expectedReconcilableResults++; } const result = incrementalDataRecord.result; @@ -73,21 +99,33 @@ export class IncrementalGraph { } } + addCompletedReconcilableDeferredGroupedFieldSet( + reconcilableResult: ReconcilableDeferredGroupedFieldSetResult, + ): void { + const deferredFragmentNodes: Array = + reconcilableResult.deferredFragmentRecords + .map((deferredFragmentRecord) => + this._deferredFragmentNodes.get(deferredFragmentRecord), + ) + .filter(isDeferredFragmentNode); + for (const deferredFragmentNode of deferredFragmentNodes) { + deferredFragmentNode.reconcilableResults.push(reconcilableResult); + } + } + getNewPending(): ReadonlyArray { - const newPending = []; + const newPending: Array = []; for (const node of this._newPending) { - if (isDeferredFragmentRecord(node)) { - if (node.expectedReconcilableResults) { - this._pending.add(node); - newPending.push(node); - continue; - } + if (isStreamNode(node)) { + this._pending.add(node); + newPending.push(node); + } else if (node.expectedReconcilableResults) { + this._pending.add(node); + newPending.push(node.deferredFragmentRecord); + } else { for (const child of node.children) { this._newPending.add(child); } - } else { - this._pending.add(node); - newPending.push(node); } } this._newPending.clear(); @@ -134,15 +172,23 @@ export class IncrementalGraph { completeDeferredFragment( deferredFragmentRecord: DeferredFragmentRecord, ): Array | undefined { - const reconcilableResults = deferredFragmentRecord.reconcilableResults; + const deferredFragmentNode = this._deferredFragmentNodes.get( + deferredFragmentRecord, + ); + // TODO: add test case? + /* c8 ignore next 3 */ + if (deferredFragmentNode === undefined) { + return undefined; + } + const reconcilableResults = deferredFragmentNode.reconcilableResults; if ( - deferredFragmentRecord.expectedReconcilableResults !== + deferredFragmentNode.expectedReconcilableResults !== reconcilableResults.length ) { return; } - this.removeSubsequentResultRecord(deferredFragmentRecord); - for (const child of deferredFragmentRecord.children) { + this._removePending(deferredFragmentNode); + for (const child of deferredFragmentNode.children) { this._newPending.add(child); for (const result of child.results) { this._enqueue(result); @@ -151,10 +197,30 @@ export class IncrementalGraph { return reconcilableResults; } - removeSubsequentResultRecord( - subsequentResultRecord: SubsequentResultRecord, - ): void { - this._pending.delete(subsequentResultRecord); + removeDeferredFragment(deferredFragmentRecord: DeferredFragmentRecord): void { + const deferredFragmentNode = this._deferredFragmentNodes.get( + deferredFragmentRecord, + ); + // TODO: add test case? + /* c8 ignore next 3 */ + if (deferredFragmentNode === undefined) { + return; + } + this._removePending(deferredFragmentNode); + this._deferredFragmentNodes.delete(deferredFragmentRecord); + // TODO: add test case for an erroring deferred fragment with child defers + /* c8 ignore next 3 */ + for (const child of deferredFragmentNode.children) { + this.removeDeferredFragment(child.deferredFragmentRecord); + } + } + + removeStream(streamRecord: SubsequentResultRecord): void { + this._removePending(streamRecord); + } + + private _removePending(subsequentResultNode: SubsequentResultNode): void { + this._pending.delete(subsequentResultNode); if (this._pending.size === 0) { for (const resolve of this._nextQueue) { resolve({ value: undefined, done: true }); @@ -162,42 +228,55 @@ export class IncrementalGraph { } } - private _addDeferredFragmentRecord( + private _addDeferredFragmentNode( deferredFragmentRecord: DeferredFragmentRecord, - ): void { + ): DeferredFragmentNode { + let deferredFragmentNode = this._deferredFragmentNodes.get( + deferredFragmentRecord, + ); + if (deferredFragmentNode !== undefined) { + return deferredFragmentNode; + } + deferredFragmentNode = { + deferredFragmentRecord, + expectedReconcilableResults: 0, + results: [], + reconcilableResults: [], + children: [], + }; + this._deferredFragmentNodes.set( + deferredFragmentRecord, + deferredFragmentNode, + ); const parent = deferredFragmentRecord.parent; if (parent === undefined) { - // Below is equivalent and slightly faster version of: - // if (this._pending.has(deferredFragmentRecord)) { ... } - // as all released deferredFragmentRecords have ids. - if (deferredFragmentRecord.id !== undefined) { - return; - } - - this._newPending.add(deferredFragmentRecord); - return; + this._newPending.add(deferredFragmentNode); + return deferredFragmentNode; } - - if (parent.children.has(deferredFragmentRecord)) { - return; - } - - parent.children.add(deferredFragmentRecord); - - this._addDeferredFragmentRecord(parent); + const parentNode = this._addDeferredFragmentNode(parent); + parentNode.children.push(deferredFragmentNode); + return deferredFragmentNode; } private _enqueueCompletedDeferredGroupedFieldSet( result: DeferredGroupedFieldSetResult, ): void { - let hasPendingParent = false; + let isPending = false; for (const deferredFragmentRecord of result.deferredFragmentRecords) { - if (deferredFragmentRecord.id !== undefined) { - hasPendingParent = true; + const deferredFragmentNode = this._deferredFragmentNodes.get( + deferredFragmentRecord, + ); + // TODO: add test case? + /* c8 ignore next 3 */ + if (deferredFragmentNode === undefined) { + continue; + } + if (this._pending.has(deferredFragmentNode)) { + isPending = true; } - deferredFragmentRecord.results.push(result); + deferredFragmentNode.results.push(result); } - if (hasPendingParent) { + if (isPending) { this._enqueue(result); } } diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 9f233a8cb1..9499dfced4 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -237,18 +237,15 @@ class IncrementalPublisher { id, errors: deferredGroupedFieldSetResult.errors, }); - this._incrementalGraph.removeSubsequentResultRecord( - deferredFragmentRecord, - ); + this._incrementalGraph.removeDeferredFragment(deferredFragmentRecord); } } return; } - for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { - deferredFragmentRecord.reconcilableResults.push( - deferredGroupedFieldSetResult, - ); - } + + this._incrementalGraph.addCompletedReconcilableDeferredGroupedFieldSet( + deferredGroupedFieldSetResult, + ); const incrementalDataRecords = deferredGroupedFieldSetResult.incrementalDataRecords; @@ -306,7 +303,7 @@ class IncrementalPublisher { id, errors: streamItemsResult.errors, }); - this._incrementalGraph.removeSubsequentResultRecord(streamRecord); + this._incrementalGraph.removeStream(streamRecord); if (isCancellableStreamRecord(streamRecord)) { invariant(this._context.cancellableStreams !== undefined); this._context.cancellableStreams.delete(streamRecord); @@ -317,7 +314,7 @@ class IncrementalPublisher { } } else if (streamItemsResult.result === undefined) { context.completed.push({ id }); - this._incrementalGraph.removeSubsequentResultRecord(streamRecord); + this._incrementalGraph.removeStream(streamRecord); if (isCancellableStreamRecord(streamRecord)) { invariant(this._context.cancellableStreams !== undefined); this._context.cancellableStreams.delete(streamRecord); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index dfb7f36074..90e8ec5454 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -62,6 +62,7 @@ import { buildIncrementalResponse } from './IncrementalPublisher.js'; import { mapAsyncIterable } from './mapAsyncIterable.js'; import type { CancellableStreamRecord, + DeferredFragmentRecord, DeferredGroupedFieldSetRecord, DeferredGroupedFieldSetResult, ExecutionResult, @@ -71,10 +72,7 @@ import type { StreamItemsResult, SubsequentResultRecord, } from './types.js'; -import { - DeferredFragmentRecord, - isReconcilableStreamItemsResult, -} from './types.js'; +import { isReconcilableStreamItemsResult } from './types.js'; import { getArgumentValues, getDirectiveValues, @@ -1674,11 +1672,11 @@ function addNewDeferredFragments( : deferredFragmentRecordFromDeferUsage(parentDeferUsage, newDeferMap); // Instantiate the new record. - const deferredFragmentRecord = new DeferredFragmentRecord({ + const deferredFragmentRecord: DeferredFragmentRecord = { path, label: newDeferUsage.label, parent, - }); + }; // Update the map. newDeferMap.set(newDeferUsage, deferredFragmentRecord); diff --git a/src/execution/types.ts b/src/execution/types.ts index 519d08ea46..c8fc64ec07 100644 --- a/src/execution/types.ts +++ b/src/execution/types.ts @@ -166,12 +166,6 @@ export interface FormattedCompletedResult { errors?: ReadonlyArray; } -export function isDeferredFragmentRecord( - subsequentResultRecord: SubsequentResultRecord, -): subsequentResultRecord is DeferredFragmentRecord { - return 'parent' in subsequentResultRecord; -} - export function isDeferredGroupedFieldSetRecord( incrementalDataRecord: IncrementalDataRecord, ): incrementalDataRecord is DeferredGroupedFieldSetRecord { @@ -221,30 +215,11 @@ export interface SubsequentResultRecord { id?: string | undefined; } -/** @internal */ -export class DeferredFragmentRecord implements SubsequentResultRecord { +export interface DeferredFragmentRecord extends SubsequentResultRecord { path: Path | undefined; label: string | undefined; id?: string | undefined; parent: DeferredFragmentRecord | undefined; - expectedReconcilableResults: number; - results: Array; - reconcilableResults: Array; - children: Set; - - constructor(opts: { - path: Path | undefined; - label: string | undefined; - parent: DeferredFragmentRecord | undefined; - }) { - this.path = opts.path; - this.label = opts.label; - this.parent = opts.parent; - this.expectedReconcilableResults = 0; - this.results = []; - this.reconcilableResults = []; - this.children = new Set(); - } } export interface CancellableStreamRecord extends SubsequentResultRecord { From eceeb4ca5076740ab90c9d08925071fd6e69aa0a Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 3 Jun 2024 12:58:42 +0300 Subject: [PATCH 17/26] refactor(incremental): introduce StreamRecord type --- src/execution/IncrementalGraph.ts | 7 ++++--- src/execution/execute.ts | 18 +++++++++--------- src/execution/types.ts | 20 +++++++++++--------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index 7cf722c29d..5a4de46ff9 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -7,6 +7,7 @@ import type { IncrementalDataRecord, IncrementalDataRecordResult, ReconcilableDeferredGroupedFieldSetResult, + StreamRecord, SubsequentResultRecord, } from './types.js'; import { isDeferredGroupedFieldSetRecord } from './types.js'; @@ -27,11 +28,11 @@ function isDeferredFragmentNode( function isStreamNode( subsequentResultNode: SubsequentResultNode, -): subsequentResultNode is SubsequentResultRecord { +): subsequentResultNode is StreamRecord { return 'path' in subsequentResultNode; } -type SubsequentResultNode = DeferredFragmentNode | SubsequentResultRecord; +type SubsequentResultNode = DeferredFragmentNode | StreamRecord; /** * @internal @@ -215,7 +216,7 @@ export class IncrementalGraph { } } - removeStream(streamRecord: SubsequentResultRecord): void { + removeStream(streamRecord: StreamRecord): void { this._removePending(streamRecord); } diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 90e8ec5454..faac330f08 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -70,7 +70,7 @@ import type { IncrementalDataRecord, StreamItemsRecord, StreamItemsResult, - SubsequentResultRecord, + StreamRecord, } from './types.js'; import { isReconcilableStreamItemsResult } from './types.js'; import { @@ -1094,12 +1094,12 @@ async function completeAsyncIteratorValue( while (true) { if (streamUsage && index >= streamUsage.initialCount) { const returnFn = asyncIterator.return; - let streamRecord: SubsequentResultRecord | CancellableStreamRecord; + let streamRecord: StreamRecord | CancellableStreamRecord; if (returnFn === undefined) { streamRecord = { label: streamUsage.label, path, - } as SubsequentResultRecord; + } as StreamRecord; } else { streamRecord = { label: streamUsage.label, @@ -1266,7 +1266,7 @@ function completeIterableValue( const item = iteration.value; if (streamUsage && index >= streamUsage.initialCount) { - const streamRecord: SubsequentResultRecord = { + const streamRecord: StreamRecord = { label: streamUsage.label, path, }; @@ -2212,7 +2212,7 @@ function getDeferredFragmentRecords( } function firstSyncStreamItems( - streamRecord: SubsequentResultRecord, + streamRecord: StreamRecord, initialItem: PromiseOrValue, initialIndex: number, iterator: Iterator, @@ -2315,7 +2315,7 @@ function prependNextResolvedStreamItems( } function firstAsyncStreamItems( - streamRecord: SubsequentResultRecord, + streamRecord: StreamRecord, path: Path, initialIndex: number, asyncIterator: AsyncIterator, @@ -2341,7 +2341,7 @@ function firstAsyncStreamItems( } async function getNextAsyncStreamItemsResult( - streamRecord: SubsequentResultRecord, + streamRecord: StreamRecord, path: Path, index: number, asyncIterator: AsyncIterator, @@ -2395,7 +2395,7 @@ async function getNextAsyncStreamItemsResult( } function completeStreamItems( - streamRecord: SubsequentResultRecord, + streamRecord: StreamRecord, itemPath: Path, item: unknown, exeContext: ExecutionContext, @@ -2495,7 +2495,7 @@ function completeStreamItems( function buildStreamItemsResult( errors: ReadonlyArray | undefined, - streamRecord: SubsequentResultRecord, + streamRecord: StreamRecord, result: GraphQLWrappedResult, ): StreamItemsResult { return { diff --git a/src/execution/types.ts b/src/execution/types.ts index c8fc64ec07..e136388ced 100644 --- a/src/execution/types.ts +++ b/src/execution/types.ts @@ -209,20 +209,22 @@ export interface DeferredGroupedFieldSetRecord { result: PromiseOrValue; } -export interface SubsequentResultRecord { +export type SubsequentResultRecord = DeferredFragmentRecord | StreamRecord; + +export interface DeferredFragmentRecord { path: Path | undefined; label: string | undefined; id?: string | undefined; + parent: DeferredFragmentRecord | undefined; } -export interface DeferredFragmentRecord extends SubsequentResultRecord { - path: Path | undefined; +export interface StreamRecord { + path: Path; label: string | undefined; id?: string | undefined; - parent: DeferredFragmentRecord | undefined; } -export interface CancellableStreamRecord extends SubsequentResultRecord { +export interface CancellableStreamRecord extends StreamRecord { earlyReturn: () => Promise; } @@ -233,7 +235,7 @@ export function isCancellableStreamRecord( } interface ReconcilableStreamItemsResult { - streamRecord: SubsequentResultRecord; + streamRecord: StreamRecord; result: BareStreamItemsResult; incrementalDataRecords: ReadonlyArray | undefined; errors?: never; @@ -246,14 +248,14 @@ export function isReconcilableStreamItemsResult( } interface TerminatingStreamItemsResult { - streamRecord: SubsequentResultRecord; + streamRecord: StreamRecord; result?: never; incrementalDataRecords?: never; errors?: never; } interface NonReconcilableStreamItemsResult { - streamRecord: SubsequentResultRecord; + streamRecord: StreamRecord; errors: ReadonlyArray; result?: never; } @@ -264,7 +266,7 @@ export type StreamItemsResult = | NonReconcilableStreamItemsResult; export interface StreamItemsRecord { - streamRecord: SubsequentResultRecord; + streamRecord: StreamRecord; result: PromiseOrValue; } From 15ab731c914b2bf1e2dabf3468097b6cd9ae6eea Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 2 Jun 2024 17:51:32 +0300 Subject: [PATCH 18/26] refactor(IncrementalGraph): use set of pending deferred grouped field set results to reduce mutation --- src/execution/IncrementalGraph.ts | 48 +++++++++++++++++++-------- src/execution/IncrementalPublisher.ts | 13 ++++---- src/execution/execute.ts | 33 ++++++++++-------- src/execution/types.ts | 9 +++-- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index 5a4de46ff9..bf89d0f7e0 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -3,6 +3,7 @@ import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; import type { DeferredFragmentRecord, + DeferredGroupedFieldSetRecord, DeferredGroupedFieldSetResult, IncrementalDataRecord, IncrementalDataRecordResult, @@ -14,9 +15,9 @@ import { isDeferredGroupedFieldSetRecord } from './types.js'; interface DeferredFragmentNode { deferredFragmentRecord: DeferredFragmentRecord; - expectedReconcilableResults: number; + deferredGroupedFieldSetRecords: Set; results: Array; - reconcilableResults: Array; + reconcilableResults: Set; children: Array; } @@ -67,7 +68,9 @@ export class IncrementalGraph { const deferredFragmentNode = this._addDeferredFragmentNode( deferredFragmentRecord, ); - deferredFragmentNode.expectedReconcilableResults++; + deferredFragmentNode.deferredGroupedFieldSetRecords.add( + incrementalDataRecord, + ); } const result = incrementalDataRecord.result; @@ -104,13 +107,16 @@ export class IncrementalGraph { reconcilableResult: ReconcilableDeferredGroupedFieldSetResult, ): void { const deferredFragmentNodes: Array = - reconcilableResult.deferredFragmentRecords + reconcilableResult.deferredGroupedFieldSetRecord.deferredFragmentRecords .map((deferredFragmentRecord) => this._deferredFragmentNodes.get(deferredFragmentRecord), ) .filter(isDeferredFragmentNode); for (const deferredFragmentNode of deferredFragmentNodes) { - deferredFragmentNode.reconcilableResults.push(reconcilableResult); + deferredFragmentNode.deferredGroupedFieldSetRecords.delete( + reconcilableResult.deferredGroupedFieldSetRecord, + ); + deferredFragmentNode.reconcilableResults.add(reconcilableResult); } } @@ -120,7 +126,7 @@ export class IncrementalGraph { if (isStreamNode(node)) { this._pending.add(node); newPending.push(node); - } else if (node.expectedReconcilableResults) { + } else if (node.deferredGroupedFieldSetRecords.size > 0) { this._pending.add(node); newPending.push(node.deferredFragmentRecord); } else { @@ -181,13 +187,26 @@ export class IncrementalGraph { if (deferredFragmentNode === undefined) { return undefined; } - const reconcilableResults = deferredFragmentNode.reconcilableResults; - if ( - deferredFragmentNode.expectedReconcilableResults !== - reconcilableResults.length - ) { + if (deferredFragmentNode.deferredGroupedFieldSetRecords.size > 0) { return; } + const reconcilableResults = Array.from( + deferredFragmentNode.reconcilableResults, + ); + for (const reconcilableResult of reconcilableResults) { + for (const otherDeferredFragmentRecord of reconcilableResult + .deferredGroupedFieldSetRecord.deferredFragmentRecords) { + const otherDeferredFragmentNode = this._deferredFragmentNodes.get( + otherDeferredFragmentRecord, + ); + if (otherDeferredFragmentNode === undefined) { + continue; + } + otherDeferredFragmentNode.reconcilableResults.delete( + reconcilableResult, + ); + } + } this._removePending(deferredFragmentNode); for (const child of deferredFragmentNode.children) { this._newPending.add(child); @@ -240,9 +259,9 @@ export class IncrementalGraph { } deferredFragmentNode = { deferredFragmentRecord, - expectedReconcilableResults: 0, + deferredGroupedFieldSetRecords: new Set(), results: [], - reconcilableResults: [], + reconcilableResults: new Set(), children: [], }; this._deferredFragmentNodes.set( @@ -263,7 +282,8 @@ export class IncrementalGraph { result: DeferredGroupedFieldSetResult, ): void { let isPending = false; - for (const deferredFragmentRecord of result.deferredFragmentRecords) { + for (const deferredFragmentRecord of result.deferredGroupedFieldSetRecord + .deferredFragmentRecords) { const deferredFragmentNode = this._deferredFragmentNodes.get( deferredFragmentRecord, ); diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 9499dfced4..caf167d114 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -230,7 +230,8 @@ class IncrementalPublisher { deferredGroupedFieldSetResult, ) ) { - for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { + for (const deferredFragmentRecord of deferredGroupedFieldSetResult + .deferredGroupedFieldSetRecord.deferredFragmentRecords) { const id = deferredFragmentRecord.id; if (id !== undefined) { context.completed.push({ @@ -253,7 +254,8 @@ class IncrementalPublisher { this._incrementalGraph.addIncrementalDataRecords(incrementalDataRecords); } - for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { + for (const deferredFragmentRecord of deferredGroupedFieldSetResult + .deferredGroupedFieldSetRecord.deferredFragmentRecords) { const id = deferredFragmentRecord.id; // TODO: add test case for this. // Presumably, this can occur if an error causes a fragment to be completed early, @@ -269,10 +271,6 @@ class IncrementalPublisher { } const incremental = context.incremental; for (const reconcilableResult of reconcilableResults) { - if (reconcilableResult.sent) { - continue; - } - reconcilableResult.sent = true; const { bestId, subPath } = this._getBestIdAndSubPath( id, deferredFragmentRecord, @@ -343,7 +341,8 @@ class IncrementalPublisher { let maxLength = pathToArray(initialDeferredFragmentRecord.path).length; let bestId = initialId; - for (const deferredFragmentRecord of deferredGroupedFieldSetResult.deferredFragmentRecords) { + for (const deferredFragmentRecord of deferredGroupedFieldSetResult + .deferredGroupedFieldSetRecord.deferredFragmentRecords) { if (deferredFragmentRecord === initialDeferredFragmentRecord) { continue; } diff --git a/src/execution/execute.ts b/src/execution/execute.ts index faac330f08..a60fb0d7da 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -2089,9 +2089,14 @@ function executeDeferredGroupedFieldSets( deferMap, ); + const deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord = { + deferredFragmentRecords, + result: undefined as unknown as DeferredGroupedFieldSetResult, + }; + const executor = () => executeDeferredGroupedFieldSet( - deferredFragmentRecords, + deferredGroupedFieldSetRecord, exeContext, parentType, sourceValue, @@ -2104,12 +2109,12 @@ function executeDeferredGroupedFieldSets( deferMap, ); - const deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord = { - deferredFragmentRecords, - result: shouldDefer(parentDeferUsages, deferUsageSet) - ? Promise.resolve().then(executor) - : executor(), - }; + deferredGroupedFieldSetRecord.result = shouldDefer( + parentDeferUsages, + deferUsageSet, + ) + ? Promise.resolve().then(executor) + : executor(); newDeferredGroupedFieldSetRecords.push(deferredGroupedFieldSetRecord); } @@ -2134,7 +2139,7 @@ function shouldDefer( } function executeDeferredGroupedFieldSet( - deferredFragmentRecords: ReadonlyArray, + deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord, exeContext: ExecutionContext, parentType: GraphQLObjectType, sourceValue: unknown, @@ -2156,7 +2161,7 @@ function executeDeferredGroupedFieldSet( ); } catch (error) { return { - deferredFragmentRecords, + deferredGroupedFieldSetRecord, path: pathToArray(path), errors: withError(incrementalContext.errors, error), }; @@ -2167,12 +2172,12 @@ function executeDeferredGroupedFieldSet( (resolved) => buildDeferredGroupedFieldSetResult( incrementalContext.errors, - deferredFragmentRecords, + deferredGroupedFieldSetRecord, path, resolved, ), (error) => ({ - deferredFragmentRecords, + deferredGroupedFieldSetRecord, path: pathToArray(path), errors: withError(incrementalContext.errors, error), }), @@ -2181,7 +2186,7 @@ function executeDeferredGroupedFieldSet( return buildDeferredGroupedFieldSetResult( incrementalContext.errors, - deferredFragmentRecords, + deferredGroupedFieldSetRecord, path, result, ); @@ -2189,12 +2194,12 @@ function executeDeferredGroupedFieldSet( function buildDeferredGroupedFieldSetResult( errors: ReadonlyArray | undefined, - deferredFragmentRecords: ReadonlyArray, + deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord, path: Path | undefined, result: GraphQLWrappedResult>, ): DeferredGroupedFieldSetResult { return { - deferredFragmentRecords, + deferredGroupedFieldSetRecord, path: pathToArray(path), result: errors === undefined ? { data: result[0] } : { data: result[0], errors }, diff --git a/src/execution/types.ts b/src/execution/types.ts index e136388ced..343e3edf9b 100644 --- a/src/execution/types.ts +++ b/src/execution/types.ts @@ -179,22 +179,21 @@ export type DeferredGroupedFieldSetResult = export function isDeferredGroupedFieldSetResult( subsequentResult: DeferredGroupedFieldSetResult | StreamItemsResult, ): subsequentResult is DeferredGroupedFieldSetResult { - return 'deferredFragmentRecords' in subsequentResult; + return 'deferredGroupedFieldSetRecord' in subsequentResult; } export interface ReconcilableDeferredGroupedFieldSetResult { - deferredFragmentRecords: ReadonlyArray; + deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord; path: Array; result: BareDeferredGroupedFieldSetResult; incrementalDataRecords: ReadonlyArray | undefined; - sent?: true | undefined; errors?: never; } interface NonReconcilableDeferredGroupedFieldSetResult { - errors: ReadonlyArray; - deferredFragmentRecords: ReadonlyArray; + deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord; path: Array; + errors: ReadonlyArray; result?: never; } From 03cc5dc77ada0c03b348154558bf512a0e922f4a Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 3 Jun 2024 12:43:50 +0300 Subject: [PATCH 19/26] fix(incremental): emit only single completion when multiple deferred grouped field sets error --- src/execution/IncrementalGraph.ts | 7 +- src/execution/IncrementalPublisher.ts | 17 +-- src/execution/__tests__/defer-test.ts | 146 ++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 8 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index bf89d0f7e0..8e93b3eda4 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -217,14 +217,16 @@ export class IncrementalGraph { return reconcilableResults; } - removeDeferredFragment(deferredFragmentRecord: DeferredFragmentRecord): void { + removeDeferredFragment( + deferredFragmentRecord: DeferredFragmentRecord, + ): boolean { const deferredFragmentNode = this._deferredFragmentNodes.get( deferredFragmentRecord, ); // TODO: add test case? /* c8 ignore next 3 */ if (deferredFragmentNode === undefined) { - return; + return false; } this._removePending(deferredFragmentNode); this._deferredFragmentNodes.delete(deferredFragmentRecord); @@ -233,6 +235,7 @@ export class IncrementalGraph { for (const child of deferredFragmentNode.children) { this.removeDeferredFragment(child.deferredFragmentRecord); } + return true; } removeStream(streamRecord: StreamRecord): void { diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index caf167d114..87fe548628 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -233,13 +233,18 @@ class IncrementalPublisher { for (const deferredFragmentRecord of deferredGroupedFieldSetResult .deferredGroupedFieldSetRecord.deferredFragmentRecords) { const id = deferredFragmentRecord.id; - if (id !== undefined) { - context.completed.push({ - id, - errors: deferredGroupedFieldSetResult.errors, - }); - this._incrementalGraph.removeDeferredFragment(deferredFragmentRecord); + if ( + !this._incrementalGraph.removeDeferredFragment(deferredFragmentRecord) + ) { + // This can occur if multiple deferred grouped field sets error for a fragment. + continue; } + invariant(id !== undefined); + context.completed.push({ + id, + errors: deferredGroupedFieldSetResult.errors, + }); + this._incrementalGraph.removeDeferredFragment(deferredFragmentRecord); } return; } diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 71d86862f4..dec0982452 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -1334,6 +1334,152 @@ describe('Execute: defer directive', () => { ]); }); + it('Handles multiple erroring deferred grouped field sets', async () => { + const document = parse(` + query { + ... @defer { + a { + b { + c { + someError: nonNullErrorField + } + } + } + } + ... @defer { + a { + b { + c { + anotherError: nonNullErrorField + } + } + } + } + } + `); + const result = await complete(document, { + a: { + b: { c: { nonNullErrorField: null } }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: {}, + pending: [ + { id: '0', path: [] }, + { id: '1', path: [] }, + ], + hasNext: true, + }, + { + completed: [ + { + id: '0', + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 7, column: 17 }], + path: ['a', 'b', 'c', 'someError'], + }, + ], + }, + { + id: '1', + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 16, column: 17 }], + path: ['a', 'b', 'c', 'anotherError'], + }, + ], + }, + ], + hasNext: false, + }, + ]); + }); + + it('Handles multiple erroring deferred grouped field sets for the same fragment', async () => { + const document = parse(` + query { + ... @defer { + a { + b { + someC: c { + d: d + } + anotherC: c { + d: d + } + } + } + } + ... @defer { + a { + b { + someC: c { + someError: nonNullErrorField + } + anotherC: c { + anotherError: nonNullErrorField + } + } + } + } + } + `); + const result = await complete(document, { + a: { + b: { c: { d: 'd', nonNullErrorField: null } }, + }, + }); + expectJSON(result).toDeepEqual([ + { + data: {}, + pending: [ + { id: '0', path: [] }, + { id: '1', path: [] }, + ], + hasNext: true, + }, + { + incremental: [ + { + data: { a: { b: { someC: {}, anotherC: {} } } }, + id: '0', + }, + { + data: { d: 'd' }, + id: '0', + subPath: ['a', 'b', 'someC'], + }, + { + data: { d: 'd' }, + id: '0', + subPath: ['a', 'b', 'anotherC'], + }, + ], + completed: [ + { + id: '1', + errors: [ + { + message: + 'Cannot return null for non-nullable field c.nonNullErrorField.', + locations: [{ line: 19, column: 17 }], + path: ['a', 'b', 'someC', 'someError'], + }, + ], + }, + { id: '0' }, + ], + hasNext: false, + }, + ]); + }); + it('filters a payload with a null that cannot be merged', async () => { const document = parse(` query { From 0732a87c1f839b563ef195205e042c2ade388191 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Sun, 2 Jun 2024 22:02:05 +0300 Subject: [PATCH 20/26] refactor(incremental): enqueue only released records --- src/execution/IncrementalGraph.ts | 108 ++++++++++--------------- src/execution/__tests__/defer-test.ts | 28 +++++-- src/execution/__tests__/stream-test.ts | 24 +++--- 3 files changed, 78 insertions(+), 82 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index 8e93b3eda4..a706a8755b 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -4,10 +4,10 @@ import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; import type { DeferredFragmentRecord, DeferredGroupedFieldSetRecord, - DeferredGroupedFieldSetResult, IncrementalDataRecord, IncrementalDataRecordResult, ReconcilableDeferredGroupedFieldSetResult, + StreamItemsRecord, StreamRecord, SubsequentResultRecord, } from './types.js'; @@ -16,7 +16,6 @@ import { isDeferredGroupedFieldSetRecord } from './types.js'; interface DeferredFragmentNode { deferredFragmentRecord: DeferredFragmentRecord; deferredGroupedFieldSetRecords: Set; - results: Array; reconcilableResults: Set; children: Array; } @@ -46,6 +45,7 @@ export class IncrementalGraph { >; private _newPending: Set; + private _newIncrementalDataRecords: Set; private _completedQueue: Array; private _nextQueue: Array< (iterable: IteratorResult>) => void @@ -54,6 +54,7 @@ export class IncrementalGraph { constructor() { this._pending = new Set(); this._deferredFragmentNodes = new Map(); + this._newIncrementalDataRecords = new Set(); this._newPending = new Set(); this._completedQueue = []; this._nextQueue = []; @@ -64,41 +65,9 @@ export class IncrementalGraph { ): void { for (const incrementalDataRecord of incrementalDataRecords) { if (isDeferredGroupedFieldSetRecord(incrementalDataRecord)) { - for (const deferredFragmentRecord of incrementalDataRecord.deferredFragmentRecords) { - const deferredFragmentNode = this._addDeferredFragmentNode( - deferredFragmentRecord, - ); - deferredFragmentNode.deferredGroupedFieldSetRecords.add( - incrementalDataRecord, - ); - } - - const result = incrementalDataRecord.result; - if (isPromise(result)) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - result.then((resolved) => { - this._enqueueCompletedDeferredGroupedFieldSet(resolved); - }); - } else { - this._enqueueCompletedDeferredGroupedFieldSet(result); - } - - continue; - } - - const streamRecord = incrementalDataRecord.streamRecord; - if (streamRecord.id === undefined) { - this._newPending.add(streamRecord); - } - - const result = incrementalDataRecord.result; - if (isPromise(result)) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - result.then((resolved) => { - this._enqueue(resolved); - }); + this._addDeferredGroupedFieldSetRecord(incrementalDataRecord); } else { - this._enqueue(result); + this._addStreamItemsRecord(incrementalDataRecord); } } } @@ -127,6 +96,9 @@ export class IncrementalGraph { this._pending.add(node); newPending.push(node); } else if (node.deferredGroupedFieldSetRecords.size > 0) { + for (const deferredGroupedFieldSetNode of node.deferredGroupedFieldSetRecords) { + this._newIncrementalDataRecords.add(deferredGroupedFieldSetNode); + } this._pending.add(node); newPending.push(node.deferredFragmentRecord); } else { @@ -136,6 +108,18 @@ export class IncrementalGraph { } } this._newPending.clear(); + + for (const incrementalDataRecord of this._newIncrementalDataRecords) { + const result = incrementalDataRecord.result; + if (isPromise(result)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + result.then((resolved) => this._enqueue(resolved)); + } else { + this._enqueue(result); + } + } + this._newIncrementalDataRecords.clear(); + return newPending; } @@ -210,9 +194,6 @@ export class IncrementalGraph { this._removePending(deferredFragmentNode); for (const child of deferredFragmentNode.children) { this._newPending.add(child); - for (const result of child.results) { - this._enqueue(result); - } } return reconcilableResults; } @@ -251,6 +232,30 @@ export class IncrementalGraph { } } + private _addDeferredGroupedFieldSetRecord( + deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord, + ): void { + for (const deferredFragmentRecord of deferredGroupedFieldSetRecord.deferredFragmentRecords) { + const deferredFragmentNode = this._addDeferredFragmentNode( + deferredFragmentRecord, + ); + if (this._pending.has(deferredFragmentNode)) { + this._newIncrementalDataRecords.add(deferredGroupedFieldSetRecord); + } + deferredFragmentNode.deferredGroupedFieldSetRecords.add( + deferredGroupedFieldSetRecord, + ); + } + } + + private _addStreamItemsRecord(streamItemsRecord: StreamItemsRecord): void { + const streamRecord = streamItemsRecord.streamRecord; + if (!this._pending.has(streamRecord)) { + this._newPending.add(streamRecord); + } + this._newIncrementalDataRecords.add(streamItemsRecord); + } + private _addDeferredFragmentNode( deferredFragmentRecord: DeferredFragmentRecord, ): DeferredFragmentNode { @@ -263,7 +268,6 @@ export class IncrementalGraph { deferredFragmentNode = { deferredFragmentRecord, deferredGroupedFieldSetRecords: new Set(), - results: [], reconcilableResults: new Set(), children: [], }; @@ -281,30 +285,6 @@ export class IncrementalGraph { return deferredFragmentNode; } - private _enqueueCompletedDeferredGroupedFieldSet( - result: DeferredGroupedFieldSetResult, - ): void { - let isPending = false; - for (const deferredFragmentRecord of result.deferredGroupedFieldSetRecord - .deferredFragmentRecords) { - const deferredFragmentNode = this._deferredFragmentNodes.get( - deferredFragmentRecord, - ); - // TODO: add test case? - /* c8 ignore next 3 */ - if (deferredFragmentNode === undefined) { - continue; - } - if (this._pending.has(deferredFragmentNode)) { - isPending = true; - } - deferredFragmentNode.results.push(result); - } - if (isPending) { - this._enqueue(result); - } - } - private *_yieldCurrentCompletedIncrementalData( first: IncrementalDataRecordResult, ): Generator { diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index dec0982452..a79f211545 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -367,6 +367,12 @@ describe('Execute: defer directive', () => { }, id: '0', }, + ], + completed: [{ id: '0' }], + hasNext: true, + }, + { + incremental: [ { data: { friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], @@ -374,7 +380,7 @@ describe('Execute: defer directive', () => { id: '1', }, ], - completed: [{ id: '0' }, { id: '1' }], + completed: [{ id: '1' }], hasNext: false, }, ]); @@ -977,27 +983,37 @@ describe('Execute: defer directive', () => { hasNext: true, }, { - pending: [ - { id: '1', path: ['hero', 'nestedObject'] }, - { id: '2', path: ['hero', 'nestedObject', 'deeperObject'] }, - ], + pending: [{ id: '1', path: ['hero', 'nestedObject'] }], incremental: [ { data: { bar: 'bar' }, id: '0', subPath: ['nestedObject', 'deeperObject'], }, + ], + completed: [{ id: '0' }], + hasNext: true, + }, + { + pending: [{ id: '2', path: ['hero', 'nestedObject', 'deeperObject'] }], + incremental: [ { data: { baz: 'baz' }, id: '1', subPath: ['deeperObject'], }, + ], + completed: [{ id: '1' }], + hasNext: true, + }, + { + incremental: [ { data: { bak: 'bak' }, id: '2', }, ], - completed: [{ id: '0' }, { id: '1' }, { id: '2' }], + completed: [{ id: '2' }], hasNext: false, }, ]); diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index f0a103b935..332db58e11 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -1444,6 +1444,10 @@ describe('Execute: stream directive', () => { }, { incremental: [ + { + items: [{ name: 'Luke' }], + id: '1', + }, { data: { scalarField: null }, id: '0', @@ -1455,10 +1459,6 @@ describe('Execute: stream directive', () => { }, ], }, - { - items: [{ name: 'Luke' }], - id: '1', - }, ], completed: [{ id: '0' }], hasNext: true, @@ -1946,14 +1946,14 @@ describe('Execute: stream directive', () => { value: { pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], incremental: [ - { - data: { name: 'Luke' }, - id: '0', - }, { items: [{ id: '2' }], id: '1', }, + { + data: { name: 'Luke' }, + id: '0', + }, ], completed: [{ id: '0' }], hasNext: true, @@ -2047,14 +2047,14 @@ describe('Execute: stream directive', () => { value: { pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], incremental: [ - { - data: { name: 'Luke' }, - id: '0', - }, { items: [{ id: '2' }], id: '1', }, + { + data: { name: 'Luke' }, + id: '0', + }, ], completed: [{ id: '0' }], hasNext: true, From 062785e94353c4cab668423220ed37109e3ccd28 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 3 Jun 2024 05:28:19 +0300 Subject: [PATCH 21/26] refactor(incremental): introduce BoxedPromiseOrValue to save resolved promise results --- src/execution/IncrementalGraph.ts | 2 +- src/execution/__tests__/defer-test.ts | 60 +----- src/execution/__tests__/stream-test.ts | 96 +--------- src/execution/execute.ts | 172 ++++++++++-------- src/execution/types.ts | 6 +- src/jsutils/BoxedPromiseOrValue.ts | 26 +++ .../__tests__/BoxedPromiseOrValue-test.ts | 30 +++ 7 files changed, 168 insertions(+), 224 deletions(-) create mode 100644 src/jsutils/BoxedPromiseOrValue.ts create mode 100644 src/jsutils/__tests__/BoxedPromiseOrValue-test.ts diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index a706a8755b..d47f64c049 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -110,7 +110,7 @@ export class IncrementalGraph { this._newPending.clear(); for (const incrementalDataRecord of this._newIncrementalDataRecords) { - const result = incrementalDataRecord.result; + const result = incrementalDataRecord.result.value; if (isPromise(result)) { // eslint-disable-next-line @typescript-eslint/no-floating-promises result.then((resolved) => this._enqueue(resolved)); diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index a79f211545..537f875d37 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -367,12 +367,6 @@ describe('Execute: defer directive', () => { }, id: '0', }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - incremental: [ { data: { friends: [{ name: 'Han' }, { name: 'Leia' }, { name: 'C-3PO' }], @@ -380,7 +374,7 @@ describe('Execute: defer directive', () => { id: '1', }, ], - completed: [{ id: '1' }], + completed: [{ id: '0' }, { id: '1' }], hasNext: false, }, ]); @@ -732,12 +726,6 @@ describe('Execute: defer directive', () => { }, id: '0', }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - incremental: [ { data: { id: '1', @@ -745,7 +733,7 @@ describe('Execute: defer directive', () => { id: '1', }, ], - completed: [{ id: '1' }], + completed: [{ id: '0' }, { id: '1' }], hasNext: false, }, ]); @@ -909,12 +897,6 @@ describe('Execute: defer directive', () => { }, id: '0', }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - incremental: [ { data: { bar: 'bar', @@ -922,7 +904,7 @@ describe('Execute: defer directive', () => { id: '1', }, ], - completed: [{ id: '1' }], + completed: [{ id: '0' }, { id: '1' }], hasNext: false, }, ]); @@ -983,37 +965,27 @@ describe('Execute: defer directive', () => { hasNext: true, }, { - pending: [{ id: '1', path: ['hero', 'nestedObject'] }], + pending: [ + { id: '1', path: ['hero', 'nestedObject'] }, + { id: '2', path: ['hero', 'nestedObject', 'deeperObject'] }, + ], incremental: [ { data: { bar: 'bar' }, id: '0', subPath: ['nestedObject', 'deeperObject'], }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - pending: [{ id: '2', path: ['hero', 'nestedObject', 'deeperObject'] }], - incremental: [ { data: { baz: 'baz' }, id: '1', subPath: ['deeperObject'], }, - ], - completed: [{ id: '1' }], - hasNext: true, - }, - { - incremental: [ { data: { bak: 'bak' }, id: '2', }, ], - completed: [{ id: '2' }], + completed: [{ id: '0' }, { id: '1' }, { id: '2' }], hasNext: false, }, ]); @@ -2080,17 +2052,11 @@ describe('Execute: defer directive', () => { data: { name: 'slow', friends: [{}, {}, {}] }, id: '0', }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - incremental: [ { data: { name: 'Han' }, id: '1' }, { data: { name: 'Leia' }, id: '2' }, { data: { name: 'C-3PO' }, id: '3' }, ], - completed: [{ id: '1' }, { id: '2' }, { id: '3' }], + completed: [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }], hasNext: false, }, ]); @@ -2136,17 +2102,11 @@ describe('Execute: defer directive', () => { }, id: '0', }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - incremental: [ { data: { name: 'Han' }, id: '1' }, { data: { name: 'Leia' }, id: '2' }, { data: { name: 'C-3PO' }, id: '3' }, ], - completed: [{ id: '1' }, { id: '2' }, { id: '3' }], + completed: [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }], hasNext: false, }, ]); diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index 332db58e11..461eeb4f93 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -369,20 +369,10 @@ describe('Execute: stream directive', () => { items: [{ name: 'Luke', id: '1' }], id: '0', }, - ], - hasNext: true, - }, - { - incremental: [ { items: [{ name: 'Han', id: '2' }], id: '0', }, - ], - hasNext: true, - }, - { - incremental: [ { items: [{ name: 'Leia', id: '3' }], id: '0', @@ -527,11 +517,6 @@ describe('Execute: stream directive', () => { }, ], }, - ], - hasNext: true, - }, - { - incremental: [ { items: [{ name: 'Leia', id: '3' }], id: '0', @@ -572,11 +557,6 @@ describe('Execute: stream directive', () => { items: [{ name: 'Luke', id: '1' }], id: '0', }, - ], - hasNext: true, - }, - { - incremental: [ { items: [{ name: 'Han', id: '2' }], id: '0', @@ -591,9 +571,6 @@ describe('Execute: stream directive', () => { id: '0', }, ], - hasNext: true, - }, - { completed: [{ id: '0' }], hasNext: false, }, @@ -633,9 +610,6 @@ describe('Execute: stream directive', () => { id: '0', }, ], - hasNext: true, - }, - { completed: [{ id: '0' }], hasNext: false, }, @@ -946,11 +920,6 @@ describe('Execute: stream directive', () => { }, ], }, - ], - hasNext: true, - }, - { - incremental: [ { items: [{ nonNullName: 'Han' }], id: '0', @@ -997,11 +966,6 @@ describe('Execute: stream directive', () => { }, ], }, - ], - hasNext: true, - }, - { - incremental: [ { items: [{ nonNullName: 'Han' }], id: '0', @@ -1132,19 +1096,11 @@ describe('Execute: stream directive', () => { }, ], }, - ], - hasNext: true, - }, - { - incremental: [ { items: [{ nonNullName: 'Han' }], id: '0', }, ], - hasNext: true, - }, - { completed: [{ id: '0' }], hasNext: false, }, @@ -1460,11 +1416,7 @@ describe('Execute: stream directive', () => { ], }, ], - completed: [{ id: '0' }], - hasNext: true, - }, - { - completed: [{ id: '1' }], + completed: [{ id: '0' }, { id: '1' }], hasNext: false, }, ]); @@ -1570,9 +1522,6 @@ describe('Execute: stream directive', () => { ], }, ], - hasNext: true, - }, - { completed: [{ id: '0' }], hasNext: false, }, @@ -1724,9 +1673,6 @@ describe('Execute: stream directive', () => { id: '0', }, ], - hasNext: true, - }, - { completed: [{ id: '0' }], hasNext: false, }, @@ -1774,19 +1720,11 @@ describe('Execute: stream directive', () => { items: [{ id: '1', name: 'Luke' }], id: '0', }, - ], - hasNext: true, - }, - { - incremental: [ { items: [{ id: '2', name: 'Han' }], id: '0', }, ], - hasNext: true, - }, - { completed: [{ id: '0' }], hasNext: false, }, @@ -1844,48 +1782,22 @@ describe('Execute: stream directive', () => { data: { scalarField: 'slow', nestedFriendList: [] }, id: '0', }, - ], - completed: [{ id: '0' }], - hasNext: true, - }, - done: false, - }); - const result3 = await iterator.next(); - expectJSON(result3).toDeepEqual({ - value: { - incremental: [ { items: [{ name: 'Luke' }], id: '1', }, - ], - hasNext: true, - }, - done: false, - }); - const result4 = await iterator.next(); - expectJSON(result4).toDeepEqual({ - value: { - incremental: [ { items: [{ name: 'Han' }], id: '1', }, ], - hasNext: true, - }, - done: false, - }); - const result5 = await iterator.next(); - expectJSON(result5).toDeepEqual({ - value: { - completed: [{ id: '1' }], + completed: [{ id: '0' }, { id: '1' }], hasNext: false, }, done: false, }); - const result6 = await iterator.next(); - expectJSON(result6).toDeepEqual({ + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ value: undefined, done: true, }); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index a60fb0d7da..1c9a9024e2 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -1,3 +1,4 @@ +import { BoxedPromiseOrValue } from '../jsutils/BoxedPromiseOrValue.js'; import { inspect } from '../jsutils/inspect.js'; import { invariant } from '../jsutils/invariant.js'; import { isAsyncIterable } from '../jsutils/isAsyncIterable.js'; @@ -2091,7 +2092,8 @@ function executeDeferredGroupedFieldSets( const deferredGroupedFieldSetRecord: DeferredGroupedFieldSetRecord = { deferredFragmentRecords, - result: undefined as unknown as DeferredGroupedFieldSetResult, + result: + undefined as unknown as BoxedPromiseOrValue, }; const executor = () => @@ -2109,12 +2111,11 @@ function executeDeferredGroupedFieldSets( deferMap, ); - deferredGroupedFieldSetRecord.result = shouldDefer( - parentDeferUsages, - deferUsageSet, - ) - ? Promise.resolve().then(executor) - : executor(); + deferredGroupedFieldSetRecord.result = new BoxedPromiseOrValue( + shouldDefer(parentDeferUsages, deferUsageSet) + ? Promise.resolve().then(executor) + : executor(), + ); newDeferredGroupedFieldSetRecords.push(deferredGroupedFieldSetRecord); } @@ -2228,65 +2229,76 @@ function firstSyncStreamItems( ): StreamItemsRecord { return { streamRecord, - result: Promise.resolve().then(() => { - const path = streamRecord.path; - const initialPath = addPath(path, initialIndex, undefined); + result: new BoxedPromiseOrValue( + Promise.resolve().then(() => { + const path = streamRecord.path; + const initialPath = addPath(path, initialIndex, undefined); - let result = completeStreamItems( - streamRecord, - initialPath, - initialItem, - exeContext, - { errors: undefined }, - fieldGroup, - info, - itemType, - ); - const firstStreamItems = { result }; - let currentStreamItems = firstStreamItems; - let currentIndex = initialIndex; - let iteration = iterator.next(); - let erroredSynchronously = false; - while (!iteration.done) { - if (!isPromise(result) && !isReconcilableStreamItemsResult(result)) { - erroredSynchronously = true; - break; - } - const item = iteration.value; - currentIndex++; - const currentPath = addPath(path, currentIndex, undefined); - result = completeStreamItems( - streamRecord, - currentPath, - item, - exeContext, - { errors: undefined }, - fieldGroup, - info, - itemType, + let result = new BoxedPromiseOrValue( + completeStreamItems( + streamRecord, + initialPath, + initialItem, + exeContext, + { errors: undefined }, + fieldGroup, + info, + itemType, + ), ); + const firstStreamItems = { result }; + let currentStreamItems = firstStreamItems; + let currentIndex = initialIndex; + let iteration = iterator.next(); + let erroredSynchronously = false; + while (!iteration.done) { + const value = result.value; + if (!isPromise(value) && !isReconcilableStreamItemsResult(value)) { + erroredSynchronously = true; + break; + } + const item = iteration.value; + currentIndex++; + const currentPath = addPath(path, currentIndex, undefined); + result = new BoxedPromiseOrValue( + completeStreamItems( + streamRecord, + currentPath, + item, + exeContext, + { errors: undefined }, + fieldGroup, + info, + itemType, + ), + ); - const nextStreamItems: StreamItemsRecord = { streamRecord, result }; - currentStreamItems.result = prependNextStreamItems( - currentStreamItems.result, - nextStreamItems, - ); - currentStreamItems = nextStreamItems; + const nextStreamItems: StreamItemsRecord = { streamRecord, result }; + currentStreamItems.result = new BoxedPromiseOrValue( + prependNextStreamItems( + currentStreamItems.result.value, + nextStreamItems, + ), + ); + currentStreamItems = nextStreamItems; - iteration = iterator.next(); - } + iteration = iterator.next(); + } - // If a non-reconcilable stream items result was encountered, then the stream terminates in error. - // Otherwise, add a stream terminator. - if (!erroredSynchronously) { - currentStreamItems.result = prependNextStreamItems( - currentStreamItems.result, - { streamRecord, result: { streamRecord } }, - ); - } + // If a non-reconcilable stream items result was encountered, then the stream terminates in error. + // Otherwise, add a stream terminator. + if (!erroredSynchronously) { + currentStreamItems.result = new BoxedPromiseOrValue( + prependNextStreamItems(currentStreamItems.result.value, { + streamRecord, + result: new BoxedPromiseOrValue({ streamRecord }), + }), + ); + } - return firstStreamItems.result; - }), + return firstStreamItems.result.value; + }), + ), }; } @@ -2331,15 +2343,17 @@ function firstAsyncStreamItems( ): StreamItemsRecord { const firstStreamItems: StreamItemsRecord = { streamRecord, - result: getNextAsyncStreamItemsResult( - streamRecord, - path, - initialIndex, - asyncIterator, - exeContext, - fieldGroup, - info, - itemType, + result: new BoxedPromiseOrValue( + getNextAsyncStreamItemsResult( + streamRecord, + path, + initialIndex, + asyncIterator, + exeContext, + fieldGroup, + info, + itemType, + ), ), }; return firstStreamItems; @@ -2384,15 +2398,17 @@ async function getNextAsyncStreamItemsResult( const nextStreamItems: StreamItemsRecord = { streamRecord, - result: getNextAsyncStreamItemsResult( - streamRecord, - path, - index, - asyncIterator, - exeContext, - fieldGroup, - info, - itemType, + result: new BoxedPromiseOrValue( + getNextAsyncStreamItemsResult( + streamRecord, + path, + index, + asyncIterator, + exeContext, + fieldGroup, + info, + itemType, + ), ), }; diff --git a/src/execution/types.ts b/src/execution/types.ts index 343e3edf9b..5c44e6dea8 100644 --- a/src/execution/types.ts +++ b/src/execution/types.ts @@ -1,6 +1,6 @@ +import type { BoxedPromiseOrValue } from '../jsutils/BoxedPromiseOrValue.js'; import type { ObjMap } from '../jsutils/ObjMap.js'; import type { Path } from '../jsutils/Path.js'; -import type { PromiseOrValue } from '../jsutils/PromiseOrValue.js'; import type { GraphQLError, @@ -205,7 +205,7 @@ export function isNonReconcilableDeferredGroupedFieldSetResult( export interface DeferredGroupedFieldSetRecord { deferredFragmentRecords: ReadonlyArray; - result: PromiseOrValue; + result: BoxedPromiseOrValue; } export type SubsequentResultRecord = DeferredFragmentRecord | StreamRecord; @@ -266,7 +266,7 @@ export type StreamItemsResult = export interface StreamItemsRecord { streamRecord: StreamRecord; - result: PromiseOrValue; + result: BoxedPromiseOrValue; } export type IncrementalDataRecord = diff --git a/src/jsutils/BoxedPromiseOrValue.ts b/src/jsutils/BoxedPromiseOrValue.ts new file mode 100644 index 0000000000..7f6f758270 --- /dev/null +++ b/src/jsutils/BoxedPromiseOrValue.ts @@ -0,0 +1,26 @@ +import { isPromise } from './isPromise.js'; +import type { PromiseOrValue } from './PromiseOrValue.js'; + +/** + * A BoxedPromiseOrValue is a container for a value or promise where the value + * will be updated when the promise resolves. + * + * A BoxedPromiseOrValue may only be used with promises whose possible + * rejection has already been handled, otherwise this will lead to unhandled + * promise rejections. + * + * @internal + * */ +export class BoxedPromiseOrValue { + value: PromiseOrValue; + + constructor(value: PromiseOrValue) { + this.value = value; + if (isPromise(value)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + value.then((resolved) => { + this.value = resolved; + }); + } + } +} diff --git a/src/jsutils/__tests__/BoxedPromiseOrValue-test.ts b/src/jsutils/__tests__/BoxedPromiseOrValue-test.ts new file mode 100644 index 0000000000..19bc79a4bb --- /dev/null +++ b/src/jsutils/__tests__/BoxedPromiseOrValue-test.ts @@ -0,0 +1,30 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js'; + +import { BoxedPromiseOrValue } from '../BoxedPromiseOrValue.js'; + +describe('BoxedPromiseOrValue', () => { + it('can box a value', () => { + const boxed = new BoxedPromiseOrValue(42); + + expect(boxed.value).to.equal(42); + }); + + it('can box a promise', () => { + const promise = Promise.resolve(42); + const boxed = new BoxedPromiseOrValue(promise); + + expect(boxed.value).to.equal(promise); + }); + + it('resets the boxed value when the passed promise resolves', async () => { + const promise = Promise.resolve(42); + const boxed = new BoxedPromiseOrValue(promise); + + await resolveOnNextTick(); + + expect(boxed.value).to.equal(42); + }); +}); From 77e4a3dbb659addf776cb1f2b87495d0ea459c7c Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 4 Jun 2024 06:16:55 +0300 Subject: [PATCH 22/26] polish: remove outdated coverage ignore statement --- src/execution/IncrementalGraph.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index d47f64c049..e95da2f4af 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -204,8 +204,6 @@ export class IncrementalGraph { const deferredFragmentNode = this._deferredFragmentNodes.get( deferredFragmentRecord, ); - // TODO: add test case? - /* c8 ignore next 3 */ if (deferredFragmentNode === undefined) { return false; } From 07d50259b13fb22a1cbc6d2f5735a2b83eea4537 Mon Sep 17 00:00:00 2001 From: Benjie Date: Wed, 12 Jun 2024 17:04:46 +0100 Subject: [PATCH 23/26] Update release instructions in CONTRIBUTING.md (#4105) --- .github/CONTRIBUTING.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 58564acdaf..494302de1b 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -93,18 +93,22 @@ then use `npm version patch|minor|major` in order to increment the version in package.json and tag and commit a release. Then `git push && git push --tags` to sync this change with source control. Then `npm publish npmDist` to actually publish the release to NPM. -Once published, add [release notes](https://github.com/graphql/graphql-js/tags). +Once published, add [release notes](https://github.com/graphql/graphql-js/releases). Use [semver](https://semver.org/) to determine which version part to increment. Example for a patch release: ```sh +npm ci npm test npm version patch git push --follow-tags -npm publish npmDist +cd npmDist && npm publish +npm run changelog ``` +Then upload the changelog to [https://github.com/graphql/graphql-js/releases](https://github.com/graphql/graphql-js/releases). + ## License By contributing to graphql-js, you agree that your contributions will be From 89f9223f11329b2944554e9bf5ed5280df4782ad Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 12 Jun 2024 22:49:07 +0300 Subject: [PATCH 24/26] incremental: handle Stream as stream rather than linked list (#4098) The incremental graph can handle a stream as a stream, rather than creating a linked list where each incremental data record also includes the next record in addition to any new defers and/or streams. Enables easily batching all available stream items within the same incremental entry. Depends on #4094 --- src/execution/IncrementalGraph.ts | 97 +++++++-- src/execution/__tests__/stream-test.ts | 108 ++++------- src/execution/execute.ts | 259 +++++++++---------------- src/execution/types.ts | 54 ++---- 4 files changed, 229 insertions(+), 289 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index e95da2f4af..df41505204 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -1,13 +1,15 @@ import { isPromise } from '../jsutils/isPromise.js'; import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; +import type { GraphQLError } from '../error/GraphQLError.js'; + import type { DeferredFragmentRecord, DeferredGroupedFieldSetRecord, IncrementalDataRecord, IncrementalDataRecordResult, ReconcilableDeferredGroupedFieldSetResult, - StreamItemsRecord, + StreamItemRecord, StreamRecord, SubsequentResultRecord, } from './types.js'; @@ -27,9 +29,9 @@ function isDeferredFragmentNode( } function isStreamNode( - subsequentResultNode: SubsequentResultNode, -): subsequentResultNode is StreamRecord { - return 'path' in subsequentResultNode; + record: SubsequentResultNode | IncrementalDataRecord, +): record is StreamRecord { + return 'streamItemQueue' in record; } type SubsequentResultNode = DeferredFragmentNode | StreamRecord; @@ -67,7 +69,7 @@ export class IncrementalGraph { if (isDeferredGroupedFieldSetRecord(incrementalDataRecord)) { this._addDeferredGroupedFieldSetRecord(incrementalDataRecord); } else { - this._addStreamItemsRecord(incrementalDataRecord); + this._addStreamRecord(incrementalDataRecord); } } } @@ -95,6 +97,7 @@ export class IncrementalGraph { if (isStreamNode(node)) { this._pending.add(node); newPending.push(node); + this._newIncrementalDataRecords.add(node); } else if (node.deferredGroupedFieldSetRecords.size > 0) { for (const deferredGroupedFieldSetNode of node.deferredGroupedFieldSetRecords) { this._newIncrementalDataRecords.add(deferredGroupedFieldSetNode); @@ -110,12 +113,20 @@ export class IncrementalGraph { this._newPending.clear(); for (const incrementalDataRecord of this._newIncrementalDataRecords) { - const result = incrementalDataRecord.result.value; - if (isPromise(result)) { + if (isStreamNode(incrementalDataRecord)) { // eslint-disable-next-line @typescript-eslint/no-floating-promises - result.then((resolved) => this._enqueue(resolved)); + this._onStreamItems( + incrementalDataRecord, + incrementalDataRecord.streamItemQueue, + ); } else { - this._enqueue(result); + const result = incrementalDataRecord.result.value; + if (isPromise(result)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + result.then((resolved) => this._enqueue(resolved)); + } else { + this._enqueue(result); + } } } this._newIncrementalDataRecords.clear(); @@ -246,12 +257,8 @@ export class IncrementalGraph { } } - private _addStreamItemsRecord(streamItemsRecord: StreamItemsRecord): void { - const streamRecord = streamItemsRecord.streamRecord; - if (!this._pending.has(streamRecord)) { - this._newPending.add(streamRecord); - } - this._newIncrementalDataRecords.add(streamItemsRecord); + private _addStreamRecord(streamRecord: StreamRecord): void { + this._newPending.add(streamRecord); } private _addDeferredFragmentNode( @@ -283,6 +290,66 @@ export class IncrementalGraph { return deferredFragmentNode; } + private async _onStreamItems( + streamRecord: StreamRecord, + streamItemQueue: Array, + ): Promise { + let items: Array = []; + let errors: Array = []; + let incrementalDataRecords: Array = []; + let streamItemRecord: StreamItemRecord | undefined; + while ((streamItemRecord = streamItemQueue.shift()) !== undefined) { + let result = streamItemRecord.value; + if (isPromise(result)) { + if (items.length > 0) { + this._enqueue({ + streamRecord, + result: + // TODO add additional test case or rework for coverage + errors.length > 0 /* c8 ignore start */ + ? { items, errors } /* c8 ignore stop */ + : { items }, + incrementalDataRecords, + }); + items = []; + errors = []; + incrementalDataRecords = []; + } + // eslint-disable-next-line no-await-in-loop + result = await result; + // wait an additional tick to coalesce resolving additional promises + // within the queue + // eslint-disable-next-line no-await-in-loop + await Promise.resolve(); + } + if (result.item === undefined) { + if (items.length > 0) { + this._enqueue({ + streamRecord, + result: errors.length > 0 ? { items, errors } : { items }, + incrementalDataRecords, + }); + } + this._enqueue( + result.errors === undefined + ? { streamRecord } + : { + streamRecord, + errors: result.errors, + }, + ); + return; + } + items.push(result.item); + if (result.errors !== undefined) { + errors.push(...result.errors); + } + if (result.incrementalDataRecords !== undefined) { + incrementalDataRecords.push(...result.incrementalDataRecords); + } + } + } + private *_yieldCurrentCompletedIncrementalData( first: IncrementalDataRecordResult, ): Generator { diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index 461eeb4f93..d3d8749d7e 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -146,10 +146,7 @@ describe('Execute: stream directive', () => { hasNext: true, }, { - incremental: [ - { items: ['banana'], id: '0' }, - { items: ['coconut'], id: '0' }, - ], + incremental: [{ items: ['banana', 'coconut'], id: '0' }], completed: [{ id: '0' }], hasNext: false, }, @@ -169,11 +166,7 @@ describe('Execute: stream directive', () => { hasNext: true, }, { - incremental: [ - { items: ['apple'], id: '0' }, - { items: ['banana'], id: '0' }, - { items: ['coconut'], id: '0' }, - ], + incremental: [{ items: ['apple', 'banana', 'coconut'], id: '0' }], completed: [{ id: '0' }], hasNext: false, }, @@ -220,11 +213,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: ['banana'], - id: '0', - }, - { - items: ['coconut'], + items: ['banana', 'coconut'], id: '0', }, ], @@ -284,11 +273,10 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [['banana', 'banana', 'banana']], - id: '0', - }, - { - items: [['coconut', 'coconut', 'coconut']], + items: [ + ['banana', 'banana', 'banana'], + ['coconut', 'coconut', 'coconut'], + ], id: '0', }, ], @@ -366,15 +354,11 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [{ name: 'Luke', id: '1' }], - id: '0', - }, - { - items: [{ name: 'Han', id: '2' }], - id: '0', - }, - { - items: [{ name: 'Leia', id: '3' }], + items: [ + { name: 'Luke', id: '1' }, + { name: 'Han', id: '2' }, + { name: 'Leia', id: '3' }, + ], id: '0', }, ], @@ -507,7 +491,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [null], + items: [null, { name: 'Leia', id: '3' }], id: '0', errors: [ { @@ -517,10 +501,6 @@ describe('Execute: stream directive', () => { }, ], }, - { - items: [{ name: 'Leia', id: '3' }], - id: '0', - }, ], completed: [{ id: '0' }], hasNext: false, @@ -557,6 +537,11 @@ describe('Execute: stream directive', () => { items: [{ name: 'Luke', id: '1' }], id: '0', }, + ], + hasNext: true, + }, + { + incremental: [ { items: [{ name: 'Han', id: '2' }], id: '0', @@ -910,7 +895,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [null], + items: [null, { nonNullName: 'Han' }], id: '0', errors: [ { @@ -920,10 +905,6 @@ describe('Execute: stream directive', () => { }, ], }, - { - items: [{ nonNullName: 'Han' }], - id: '0', - }, ], completed: [{ id: '0' }], hasNext: false, @@ -956,7 +937,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [null], + items: [null, { nonNullName: 'Han' }], id: '0', errors: [ { @@ -966,10 +947,6 @@ describe('Execute: stream directive', () => { }, ], }, - { - items: [{ nonNullName: 'Han' }], - id: '0', - }, ], completed: [{ id: '0' }], hasNext: false, @@ -1086,7 +1063,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [null], + items: [null, { nonNullName: 'Han' }], id: '0', errors: [ { @@ -1096,10 +1073,6 @@ describe('Execute: stream directive', () => { }, ], }, - { - items: [{ nonNullName: 'Han' }], - id: '0', - }, ], completed: [{ id: '0' }], hasNext: false, @@ -1400,10 +1373,6 @@ describe('Execute: stream directive', () => { }, { incremental: [ - { - items: [{ name: 'Luke' }], - id: '1', - }, { data: { scalarField: null }, id: '0', @@ -1415,6 +1384,10 @@ describe('Execute: stream directive', () => { }, ], }, + { + items: [{ name: 'Luke' }], + id: '1', + }, ], completed: [{ id: '0' }, { id: '1' }], hasNext: false, @@ -1717,11 +1690,10 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [{ id: '1', name: 'Luke' }], - id: '0', - }, - { - items: [{ id: '2', name: 'Han' }], + items: [ + { id: '1', name: 'Luke' }, + { id: '2', name: 'Han' }, + ], id: '0', }, ], @@ -1783,11 +1755,7 @@ describe('Execute: stream directive', () => { id: '0', }, { - items: [{ name: 'Luke' }], - id: '1', - }, - { - items: [{ name: 'Han' }], + items: [{ name: 'Luke' }, { name: 'Han' }], id: '1', }, ], @@ -1858,14 +1826,14 @@ describe('Execute: stream directive', () => { value: { pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], incremental: [ - { - items: [{ id: '2' }], - id: '1', - }, { data: { name: 'Luke' }, id: '0', }, + { + items: [{ id: '2' }], + id: '1', + }, ], completed: [{ id: '0' }], hasNext: true, @@ -1959,14 +1927,14 @@ describe('Execute: stream directive', () => { value: { pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], incremental: [ - { - items: [{ id: '2' }], - id: '1', - }, { data: { name: 'Luke' }, id: '0', }, + { + items: [{ id: '2' }], + id: '1', + }, ], completed: [{ id: '0' }], hasNext: true, diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 1c9a9024e2..7b87d25060 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -69,11 +69,10 @@ import type { ExecutionResult, ExperimentalIncrementalExecutionResults, IncrementalDataRecord, - StreamItemsRecord, - StreamItemsResult, + StreamItemRecord, + StreamItemResult, StreamRecord, } from './types.js'; -import { isReconcilableStreamItemsResult } from './types.js'; import { getArgumentValues, getDirectiveValues, @@ -1094,17 +1093,29 @@ async function completeAsyncIteratorValue( // eslint-disable-next-line no-constant-condition while (true) { if (streamUsage && index >= streamUsage.initialCount) { + const streamItemQueue = buildAsyncStreamItemQueue( + index, + path, + asyncIterator, + exeContext, + streamUsage.fieldGroup, + info, + itemType, + ); + const returnFn = asyncIterator.return; let streamRecord: StreamRecord | CancellableStreamRecord; if (returnFn === undefined) { streamRecord = { label: streamUsage.label, path, - } as StreamRecord; + streamItemQueue, + }; } else { streamRecord = { label: streamUsage.label, path, + streamItemQueue, earlyReturn: returnFn.bind(asyncIterator), }; if (exeContext.cancellableStreams === undefined) { @@ -1113,18 +1124,7 @@ async function completeAsyncIteratorValue( exeContext.cancellableStreams.add(streamRecord); } - const firstStreamItems = firstAsyncStreamItems( - streamRecord, - path, - index, - asyncIterator, - exeContext, - streamUsage.fieldGroup, - info, - itemType, - ); - - addIncrementalDataRecords(graphqlWrappedResult, [firstStreamItems]); + addIncrementalDataRecords(graphqlWrappedResult, [streamRecord]); break; } @@ -1267,23 +1267,22 @@ function completeIterableValue( const item = iteration.value; if (streamUsage && index >= streamUsage.initialCount) { - const streamRecord: StreamRecord = { + const syncStreamRecord: StreamRecord = { label: streamUsage.label, path, + streamItemQueue: buildSyncStreamItemQueue( + item, + index, + path, + iterator, + exeContext, + streamUsage.fieldGroup, + info, + itemType, + ), }; - const firstStreamItems = firstSyncStreamItems( - streamRecord, - item, - index, - iterator, - exeContext, - streamUsage.fieldGroup, - info, - itemType, - ); - - addIncrementalDataRecords(graphqlWrappedResult, [firstStreamItems]); + addIncrementalDataRecords(graphqlWrappedResult, [syncStreamRecord]); break; } @@ -2217,26 +2216,22 @@ function getDeferredFragmentRecords( ); } -function firstSyncStreamItems( - streamRecord: StreamRecord, +function buildSyncStreamItemQueue( initialItem: PromiseOrValue, initialIndex: number, + streamPath: Path, iterator: Iterator, exeContext: ExecutionContext, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, -): StreamItemsRecord { - return { - streamRecord, - result: new BoxedPromiseOrValue( +): Array { + const streamItemQueue: Array = [ + new BoxedPromiseOrValue( Promise.resolve().then(() => { - const path = streamRecord.path; - const initialPath = addPath(path, initialIndex, undefined); - - let result = new BoxedPromiseOrValue( - completeStreamItems( - streamRecord, + const initialPath = addPath(streamPath, initialIndex, undefined); + const firstStreamItem = new BoxedPromiseOrValue( + completeStreamItem( initialPath, initialItem, exeContext, @@ -2246,25 +2241,23 @@ function firstSyncStreamItems( itemType, ), ); - const firstStreamItems = { result }; - let currentStreamItems = firstStreamItems; - let currentIndex = initialIndex; let iteration = iterator.next(); - let erroredSynchronously = false; + let currentIndex = initialIndex + 1; + let currentStreamItem = firstStreamItem; while (!iteration.done) { - const value = result.value; - if (!isPromise(value) && !isReconcilableStreamItemsResult(value)) { - erroredSynchronously = true; + // TODO: add test case for early sync termination + /* c8 ignore next 4 */ + const result = currentStreamItem.value; + if (!isPromise(result) && result.errors !== undefined) { break; } - const item = iteration.value; - currentIndex++; - const currentPath = addPath(path, currentIndex, undefined); - result = new BoxedPromiseOrValue( - completeStreamItems( - streamRecord, - currentPath, - item, + + const itemPath = addPath(streamPath, currentIndex, undefined); + + currentStreamItem = new BoxedPromiseOrValue( + completeStreamItem( + itemPath, + iteration.value, exeContext, { errors: undefined }, fieldGroup, @@ -2272,81 +2265,37 @@ function firstSyncStreamItems( itemType, ), ); - - const nextStreamItems: StreamItemsRecord = { streamRecord, result }; - currentStreamItems.result = new BoxedPromiseOrValue( - prependNextStreamItems( - currentStreamItems.result.value, - nextStreamItems, - ), - ); - currentStreamItems = nextStreamItems; + streamItemQueue.push(currentStreamItem); iteration = iterator.next(); + currentIndex = initialIndex + 1; } - // If a non-reconcilable stream items result was encountered, then the stream terminates in error. - // Otherwise, add a stream terminator. - if (!erroredSynchronously) { - currentStreamItems.result = new BoxedPromiseOrValue( - prependNextStreamItems(currentStreamItems.result.value, { - streamRecord, - result: new BoxedPromiseOrValue({ streamRecord }), - }), - ); - } + streamItemQueue.push(new BoxedPromiseOrValue({})); - return firstStreamItems.result.value; + return firstStreamItem.value; }), ), - }; -} - -function prependNextStreamItems( - result: PromiseOrValue, - nextStreamItems: StreamItemsRecord, -): PromiseOrValue { - if (isPromise(result)) { - return result.then((resolved) => - prependNextResolvedStreamItems(resolved, nextStreamItems), - ); - } - return prependNextResolvedStreamItems(result, nextStreamItems); -} + ]; -function prependNextResolvedStreamItems( - result: StreamItemsResult, - nextStreamItems: StreamItemsRecord, -): StreamItemsResult { - if (!isReconcilableStreamItemsResult(result)) { - return result; - } - const incrementalDataRecords = result.incrementalDataRecords; - return { - ...result, - incrementalDataRecords: - incrementalDataRecords === undefined - ? [nextStreamItems] - : [nextStreamItems, ...incrementalDataRecords], - }; + return streamItemQueue; } -function firstAsyncStreamItems( - streamRecord: StreamRecord, - path: Path, +function buildAsyncStreamItemQueue( initialIndex: number, + streamPath: Path, asyncIterator: AsyncIterator, exeContext: ExecutionContext, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, -): StreamItemsRecord { - const firstStreamItems: StreamItemsRecord = { - streamRecord, - result: new BoxedPromiseOrValue( - getNextAsyncStreamItemsResult( - streamRecord, - path, +): Array { + const streamItemQueue: Array = []; + streamItemQueue.push( + new BoxedPromiseOrValue( + getNextAsyncStreamItemResult( + streamItemQueue, + streamPath, initialIndex, asyncIterator, exeContext, @@ -2355,38 +2304,38 @@ function firstAsyncStreamItems( itemType, ), ), - }; - return firstStreamItems; + ); + return streamItemQueue; } -async function getNextAsyncStreamItemsResult( - streamRecord: StreamRecord, - path: Path, +async function getNextAsyncStreamItemResult( + streamItemQueue: Array, + streamPath: Path, index: number, asyncIterator: AsyncIterator, exeContext: ExecutionContext, fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, -): Promise { +): Promise { let iteration; try { iteration = await asyncIterator.next(); } catch (error) { return { - streamRecord, - errors: [locatedError(error, toNodes(fieldGroup), pathToArray(path))], + errors: [ + locatedError(error, toNodes(fieldGroup), pathToArray(streamPath)), + ], }; } if (iteration.done) { - return { streamRecord }; + return {}; } - const itemPath = addPath(path, index, undefined); + const itemPath = addPath(streamPath, index, undefined); - const result = completeStreamItems( - streamRecord, + const result = completeStreamItem( itemPath, iteration.value, exeContext, @@ -2396,12 +2345,11 @@ async function getNextAsyncStreamItemsResult( itemType, ); - const nextStreamItems: StreamItemsRecord = { - streamRecord, - result: new BoxedPromiseOrValue( - getNextAsyncStreamItemsResult( - streamRecord, - path, + streamItemQueue.push( + new BoxedPromiseOrValue( + getNextAsyncStreamItemResult( + streamItemQueue, + streamPath, index, asyncIterator, exeContext, @@ -2410,13 +2358,12 @@ async function getNextAsyncStreamItemsResult( itemType, ), ), - }; + ); - return prependNextStreamItems(result, nextStreamItems); + return result; } -function completeStreamItems( - streamRecord: StreamRecord, +function completeStreamItem( itemPath: Path, item: unknown, exeContext: ExecutionContext, @@ -2424,7 +2371,7 @@ function completeStreamItems( fieldGroup: FieldGroup, info: GraphQLResolveInfo, itemType: GraphQLOutputType, -): PromiseOrValue { +): PromiseOrValue { if (isPromise(item)) { return completePromisedValue( exeContext, @@ -2437,13 +2384,8 @@ function completeStreamItems( new Map(), ).then( (resolvedItem) => - buildStreamItemsResult( - incrementalContext.errors, - streamRecord, - resolvedItem, - ), + buildStreamItemResult(incrementalContext.errors, resolvedItem), (error) => ({ - streamRecord, errors: withError(incrementalContext.errors, error), }), ); @@ -2475,7 +2417,6 @@ function completeStreamItems( } } catch (error) { return { - streamRecord, errors: withError(incrementalContext.errors, error), }; } @@ -2495,39 +2436,23 @@ function completeStreamItems( }) .then( (resolvedItem) => - buildStreamItemsResult( - incrementalContext.errors, - streamRecord, - resolvedItem, - ), + buildStreamItemResult(incrementalContext.errors, resolvedItem), (error) => ({ - streamRecord, errors: withError(incrementalContext.errors, error), }), ); } - return buildStreamItemsResult( - incrementalContext.errors, - streamRecord, - result, - ); + return buildStreamItemResult(incrementalContext.errors, result); } -function buildStreamItemsResult( +function buildStreamItemResult( errors: ReadonlyArray | undefined, - streamRecord: StreamRecord, result: GraphQLWrappedResult, -): StreamItemsResult { +): StreamItemResult { return { - streamRecord, - result: - errors === undefined - ? { items: [result[0]] } - : { - items: [result[0]], - errors: [...errors], - }, + item: result[0], + errors, incrementalDataRecords: result[1], }; } diff --git a/src/execution/types.ts b/src/execution/types.ts index 5c44e6dea8..9340ab1b85 100644 --- a/src/execution/types.ts +++ b/src/execution/types.ts @@ -217,10 +217,26 @@ export interface DeferredFragmentRecord { parent: DeferredFragmentRecord | undefined; } +export interface StreamItemResult { + item?: unknown; + incrementalDataRecords?: ReadonlyArray | undefined; + errors?: ReadonlyArray | undefined; +} + +export type StreamItemRecord = BoxedPromiseOrValue; + export interface StreamRecord { path: Path; label: string | undefined; id?: string | undefined; + streamItemQueue: Array; +} + +export interface StreamItemsResult { + streamRecord: StreamRecord; + result?: BareStreamItemsResult | undefined; + incrementalDataRecords?: ReadonlyArray | undefined; + errors?: ReadonlyArray | undefined; } export interface CancellableStreamRecord extends StreamRecord { @@ -233,45 +249,9 @@ export function isCancellableStreamRecord( return 'earlyReturn' in subsequentResultRecord; } -interface ReconcilableStreamItemsResult { - streamRecord: StreamRecord; - result: BareStreamItemsResult; - incrementalDataRecords: ReadonlyArray | undefined; - errors?: never; -} - -export function isReconcilableStreamItemsResult( - streamItemsResult: StreamItemsResult, -): streamItemsResult is ReconcilableStreamItemsResult { - return streamItemsResult.result !== undefined; -} - -interface TerminatingStreamItemsResult { - streamRecord: StreamRecord; - result?: never; - incrementalDataRecords?: never; - errors?: never; -} - -interface NonReconcilableStreamItemsResult { - streamRecord: StreamRecord; - errors: ReadonlyArray; - result?: never; -} - -export type StreamItemsResult = - | ReconcilableStreamItemsResult - | TerminatingStreamItemsResult - | NonReconcilableStreamItemsResult; - -export interface StreamItemsRecord { - streamRecord: StreamRecord; - result: BoxedPromiseOrValue; -} - export type IncrementalDataRecord = | DeferredGroupedFieldSetRecord - | StreamItemsRecord; + | StreamRecord; export type IncrementalDataRecordResult = | DeferredGroupedFieldSetResult From 75dca3dd18e5be3031416965049fbde48a5adb20 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Thu, 13 Jun 2024 20:52:45 +0300 Subject: [PATCH 25/26] incremental: disable early execution by default (#4097) depends on #4098 --- src/execution/IncrementalGraph.ts | 13 +- src/execution/__tests__/defer-test.ts | 171 +++++++-- src/execution/__tests__/stream-test.ts | 469 ++++++++++++++++++++++--- src/execution/execute.ts | 179 ++++++---- src/execution/types.ts | 8 +- 5 files changed, 685 insertions(+), 155 deletions(-) diff --git a/src/execution/IncrementalGraph.ts b/src/execution/IncrementalGraph.ts index df41505204..cf5e95c285 100644 --- a/src/execution/IncrementalGraph.ts +++ b/src/execution/IncrementalGraph.ts @@ -1,3 +1,4 @@ +import { BoxedPromiseOrValue } from '../jsutils/BoxedPromiseOrValue.js'; import { isPromise } from '../jsutils/isPromise.js'; import { promiseWithResolvers } from '../jsutils/promiseWithResolvers.js'; @@ -120,7 +121,12 @@ export class IncrementalGraph { incrementalDataRecord.streamItemQueue, ); } else { - const result = incrementalDataRecord.result.value; + const deferredGroupedFieldSetResult = incrementalDataRecord.result; + const result = + deferredGroupedFieldSetResult instanceof BoxedPromiseOrValue + ? deferredGroupedFieldSetResult.value + : deferredGroupedFieldSetResult().value; + if (isPromise(result)) { // eslint-disable-next-line @typescript-eslint/no-floating-promises result.then((resolved) => this._enqueue(resolved)); @@ -299,7 +305,10 @@ export class IncrementalGraph { let incrementalDataRecords: Array = []; let streamItemRecord: StreamItemRecord | undefined; while ((streamItemRecord = streamItemQueue.shift()) !== undefined) { - let result = streamItemRecord.value; + let result = + streamItemRecord instanceof BoxedPromiseOrValue + ? streamItemRecord.value + : streamItemRecord().value; if (isPromise(result)) { if (items.length > 0) { this._enqueue({ diff --git a/src/execution/__tests__/defer-test.ts b/src/execution/__tests__/defer-test.ts index 537f875d37..e74aebb9ae 100644 --- a/src/execution/__tests__/defer-test.ts +++ b/src/execution/__tests__/defer-test.ts @@ -135,11 +135,16 @@ const query = new GraphQLObjectType({ const schema = new GraphQLSchema({ query }); -async function complete(document: DocumentNode, rootValue: unknown = { hero }) { +async function complete( + document: DocumentNode, + rootValue: unknown = { hero }, + enableEarlyExecution = false, +) { const result = await experimentalExecuteIncrementally({ schema, document, rootValue, + enableEarlyExecution, }); if ('initialResult' in result) { @@ -247,6 +252,118 @@ describe('Execute: defer directive', () => { }, ]); }); + it('Does not execute deferred fragments early when not specified', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `); + const order: Array = []; + const result = await complete(document, { + hero: { + ...hero, + id: async () => { + await resolveOnNextTick(); + await resolveOnNextTick(); + order.push('slow-id'); + return hero.id; + }, + name: () => { + order.push('fast-name'); + return hero.name; + }, + }, + }); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + pending: [{ id: '0', path: ['hero'] }], + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + expect(order).to.deep.equal(['slow-id', 'fast-name']); + }); + it('Does execute deferred fragments early when specified', async () => { + const document = parse(` + query HeroNameQuery { + hero { + id + ...NameFragment @defer + } + } + fragment NameFragment on Hero { + name + } + `); + const order: Array = []; + const result = await complete( + document, + { + hero: { + ...hero, + id: async () => { + await resolveOnNextTick(); + await resolveOnNextTick(); + order.push('slow-id'); + return hero.id; + }, + name: () => { + order.push('fast-name'); + return hero.name; + }, + }, + }, + true, + ); + + expectJSON(result).toDeepEqual([ + { + data: { + hero: { + id: '1', + }, + }, + pending: [{ id: '0', path: ['hero'] }], + hasNext: true, + }, + { + incremental: [ + { + data: { + name: 'Luke', + }, + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + expect(order).to.deep.equal(['fast-name', 'slow-id']); + }); it('Can defer fragments on the top level Query field', async () => { const document = parse(` query HeroNameQuery { @@ -1492,20 +1609,24 @@ describe('Execute: defer directive', () => { } } `); - const result = await complete(document, { - a: { - b: { - c: { - d: 'd', - nonNullErrorField: async () => { - await resolveOnNextTick(); - return null; + const result = await complete( + document, + { + a: { + b: { + c: { + d: 'd', + nonNullErrorField: async () => { + await resolveOnNextTick(); + return null; + }, }, }, + someField: 'someField', }, - someField: 'someField', }, - }); + true, + ); expectJSON(result).toDeepEqual([ { data: { @@ -1564,12 +1685,16 @@ describe('Execute: defer directive', () => { } } `); - const result = await complete(document, { - hero: { - ...hero, - nonNullName: () => null, + const result = await complete( + document, + { + hero: { + ...hero, + nonNullName: () => null, + }, }, - }); + true, + ); expectJSON(result).toDeepEqual({ data: { hero: null, @@ -1596,12 +1721,16 @@ describe('Execute: defer directive', () => { } } `); - const result = await complete(document, { - hero: { - ...hero, - nonNullName: () => null, + const result = await complete( + document, + { + hero: { + ...hero, + nonNullName: () => null, + }, }, - }); + true, + ); expectJSON(result).toDeepEqual([ { data: {}, diff --git a/src/execution/__tests__/stream-test.ts b/src/execution/__tests__/stream-test.ts index d3d8749d7e..15ad4028a5 100644 --- a/src/execution/__tests__/stream-test.ts +++ b/src/execution/__tests__/stream-test.ts @@ -84,11 +84,16 @@ const query = new GraphQLObjectType({ const schema = new GraphQLSchema({ query }); -async function complete(document: DocumentNode, rootValue: unknown = {}) { +async function complete( + document: DocumentNode, + rootValue: unknown = {}, + enableEarlyExecution = false, +) { const result = await experimentalExecuteIncrementally({ schema, document, rootValue, + enableEarlyExecution, }); if ('initialResult' in result) { @@ -354,11 +359,134 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [ - { name: 'Luke', id: '1' }, - { name: 'Han', id: '2' }, - { name: 'Leia', id: '3' }, - ], + items: [{ name: 'Luke', id: '1' }], + id: '0', + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Han', id: '2' }], + id: '0', + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + }); + it('Does not execute early if not specified', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + const order: Array = []; + const result = await complete(document, { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(i); + return f.id; + }, + })), + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + pending: [{ id: '0', path: ['friendList'] }], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '1' }], + id: '0', + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2' }], + id: '0', + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '3' }], + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + expect(order).to.deep.equal([0, 1, 2]); + }); + it('Executes early if specified', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + const order: Array = []; + const result = await complete( + document, + { + friendList: () => + friends.map((f, i) => ({ + id: async () => { + const slowness = 3 - i; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(i); + return f.id; + }, + })), + }, + true, + ); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + pending: [{ id: '0', path: ['friendList'] }], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '1' }, { id: '2' }, { id: '3' }], id: '0', }, ], @@ -366,6 +494,7 @@ describe('Execute: stream directive', () => { hasNext: false, }, ]); + expect(order).to.deep.equal([2, 1, 0]); }); it('Can stream a field that returns a list with nested promises', async () => { const document = parse(` @@ -491,7 +620,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [null, { name: 'Leia', id: '3' }], + items: [null], id: '0', errors: [ { @@ -502,6 +631,15 @@ describe('Execute: stream directive', () => { ], }, ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ name: 'Leia', id: '3' }], + id: '0', + }, + ], completed: [{ id: '0' }], hasNext: false, }, @@ -556,6 +694,9 @@ describe('Execute: stream directive', () => { id: '0', }, ], + hasNext: true, + }, + { completed: [{ id: '0' }], hasNext: false, }, @@ -595,6 +736,9 @@ describe('Execute: stream directive', () => { id: '0', }, ], + hasNext: true, + }, + { completed: [{ id: '0' }], hasNext: false, }, @@ -626,6 +770,128 @@ describe('Execute: stream directive', () => { }, }); }); + it('Does not execute early if not specified, when streaming from an async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + const order: Array = []; + // eslint-disable-next-line @typescript-eslint/require-await + const slowFriend = async (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(n); + return friends[n].id; + }, + }); + const result = await complete(document, { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + pending: [{ id: '0', path: ['friendList'] }], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '1' }], + id: '0', + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2' }], + id: '0', + }, + ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '3' }], + id: '0', + }, + ], + hasNext: true, + }, + { + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + expect(order).to.deep.equal([0, 1, 2]); + }); + it('Executes early if specified when streaming from an async iterable', async () => { + const document = parse(` + query { + friendList @stream(initialCount: 0) { + id + } + } + `); + const order: Array = []; + const slowFriend = (n: number) => ({ + id: async () => { + const slowness = (3 - n) * 10; + for (let j = 0; j < slowness; j++) { + // eslint-disable-next-line no-await-in-loop + await resolveOnNextTick(); + } + order.push(n); + return friends[n].id; + }, + }); + const result = await complete( + document, + { + async *friendList() { + yield await Promise.resolve(slowFriend(0)); + yield await Promise.resolve(slowFriend(1)); + yield await Promise.resolve(slowFriend(2)); + }, + }, + true, + ); + expectJSON(result).toDeepEqual([ + { + data: { + friendList: [], + }, + pending: [{ id: '0', path: ['friendList'] }], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '1' }, { id: '2' }, { id: '3' }], + id: '0', + }, + ], + completed: [{ id: '0' }], + hasNext: false, + }, + ]); + expect(order).to.deep.equal([2, 1, 0]); + }); it('Can handle concurrent calls to .next() without waiting', async () => { const document = parse(` query { @@ -895,7 +1161,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [null, { nonNullName: 'Han' }], + items: [null], id: '0', errors: [ { @@ -906,6 +1172,15 @@ describe('Execute: stream directive', () => { ], }, ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + id: '0', + }, + ], completed: [{ id: '0' }], hasNext: false, }, @@ -937,7 +1212,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [null, { nonNullName: 'Han' }], + items: [null], id: '0', errors: [ { @@ -948,6 +1223,15 @@ describe('Execute: stream directive', () => { ], }, ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + id: '0', + }, + ], completed: [{ id: '0' }], hasNext: false, }, @@ -1063,7 +1347,7 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [null, { nonNullName: 'Han' }], + items: [null], id: '0', errors: [ { @@ -1074,6 +1358,18 @@ describe('Execute: stream directive', () => { ], }, ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ nonNullName: 'Han' }], + id: '0', + }, + ], + hasNext: true, + }, + { completed: [{ id: '0' }], hasNext: false, }, @@ -1092,9 +1388,6 @@ describe('Execute: stream directive', () => { yield await Promise.resolve({ nonNullName: friends[0].name }); yield await Promise.resolve({ nonNullName: () => Promise.reject(new Error('Oops')), - }); - yield await Promise.resolve({ - nonNullName: friends[1].name, }); /* c8 ignore start */ } /* c8 ignore stop */, }); @@ -1149,17 +1442,12 @@ describe('Execute: stream directive', () => { nonNullName: () => Promise.reject(new Error('Oops')), }, }); - case 2: - return Promise.resolve({ - done: false, - value: { nonNullName: friends[1].name }, - }); // Not reached /* c8 ignore next 5 */ - case 3: + case 2: return Promise.resolve({ done: false, - value: { nonNullName: friends[2].name }, + value: { nonNullName: friends[1].name }, }); } }, @@ -1222,17 +1510,12 @@ describe('Execute: stream directive', () => { nonNullName: () => Promise.reject(new Error('Oops')), }, }); - case 2: - return Promise.resolve({ - done: false, - value: { nonNullName: friends[1].name }, - }); // Not reached /* c8 ignore next 5 */ - case 3: + case 2: return Promise.resolve({ done: false, - value: { nonNullName: friends[2].name }, + value: { nonNullName: friends[1].name }, }); } }, @@ -1373,6 +1656,10 @@ describe('Execute: stream directive', () => { }, { incremental: [ + { + items: [{ name: 'Luke' }], + id: '1', + }, { data: { scalarField: null }, id: '0', @@ -1384,12 +1671,12 @@ describe('Execute: stream directive', () => { }, ], }, - { - items: [{ name: 'Luke' }], - id: '1', - }, ], - completed: [{ id: '0' }, { id: '1' }], + completed: [{ id: '0' }], + hasNext: true, + }, + { + completed: [{ id: '1' }], hasNext: false, }, ]); @@ -1495,6 +1782,9 @@ describe('Execute: stream directive', () => { ], }, ], + hasNext: true, + }, + { completed: [{ id: '0' }], hasNext: false, }, @@ -1556,6 +1846,7 @@ describe('Execute: stream directive', () => { }, }, }, + enableEarlyExecution: true, }); assert('initialResult' in executeResult); const iterator = executeResult.subsequentResults[Symbol.asyncIterator](); @@ -1646,6 +1937,9 @@ describe('Execute: stream directive', () => { id: '0', }, ], + hasNext: true, + }, + { completed: [{ id: '0' }], hasNext: false, }, @@ -1690,13 +1984,22 @@ describe('Execute: stream directive', () => { { incremental: [ { - items: [ - { id: '1', name: 'Luke' }, - { id: '2', name: 'Han' }, - ], + items: [{ id: '1', name: 'Luke' }], id: '0', }, ], + hasNext: true, + }, + { + incremental: [ + { + items: [{ id: '2', name: 'Han' }], + id: '0', + }, + ], + hasNext: true, + }, + { completed: [{ id: '0' }], hasNext: false, }, @@ -1754,18 +2057,51 @@ describe('Execute: stream directive', () => { data: { scalarField: 'slow', nestedFriendList: [] }, id: '0', }, + ], + completed: [{ id: '0' }], + hasNext: true, + }, + done: false, + }); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + value: { + incremental: [ + { + items: [{ name: 'Luke' }], + id: '1', + }, + ], + hasNext: true, + }, + done: false, + }); + + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ + value: { + incremental: [ { - items: [{ name: 'Luke' }, { name: 'Han' }], + items: [{ name: 'Han' }], id: '1', }, ], - completed: [{ id: '0' }, { id: '1' }], + hasNext: true, + }, + done: false, + }); + + const result5 = await iterator.next(); + expectJSON(result5).toDeepEqual({ + value: { + completed: [{ id: '1' }], hasNext: false, }, done: false, }); - const result3 = await iterator.next(); - expectJSON(result3).toDeepEqual({ + const result6 = await iterator.next(); + expectJSON(result6).toDeepEqual({ value: undefined, done: true, }); @@ -1824,16 +2160,11 @@ describe('Execute: stream directive', () => { const result2 = await result2Promise; expectJSON(result2).toDeepEqual({ value: { - pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], incremental: [ { data: { name: 'Luke' }, id: '0', }, - { - items: [{ id: '2' }], - id: '1', - }, ], completed: [{ id: '0' }], hasNext: true, @@ -1846,13 +2177,27 @@ describe('Execute: stream directive', () => { const result3 = await result3Promise; expectJSON(result3).toDeepEqual({ value: { - completed: [{ id: '1' }], + pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], + incremental: [ + { + items: [{ id: '2' }], + id: '1', + }, + ], hasNext: true, }, done: false, }); const result4 = await iterator.next(); expectJSON(result4).toDeepEqual({ + value: { + completed: [{ id: '1' }], + hasNext: true, + }, + done: false, + }); + const result5 = await iterator.next(); + expectJSON(result5).toDeepEqual({ value: { incremental: [ { @@ -1865,8 +2210,8 @@ describe('Execute: stream directive', () => { }, done: false, }); - const result5 = await iterator.next(); - expectJSON(result5).toDeepEqual({ + const result6 = await iterator.next(); + expectJSON(result6).toDeepEqual({ value: undefined, done: true, }); @@ -1925,25 +2270,35 @@ describe('Execute: stream directive', () => { const result2 = await result2Promise; expectJSON(result2).toDeepEqual({ value: { - pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], incremental: [ { data: { name: 'Luke' }, id: '0', }, + ], + completed: [{ id: '0' }], + hasNext: true, + }, + done: false, + }); + + const result3 = await iterator.next(); + expectJSON(result3).toDeepEqual({ + value: { + pending: [{ id: '2', path: ['friendList', 1], label: 'DeferName' }], + incremental: [ { items: [{ id: '2' }], id: '1', }, ], - completed: [{ id: '0' }], hasNext: true, }, done: false, }); - const result3 = await iterator.next(); - expectJSON(result3).toDeepEqual({ + const result4 = await iterator.next(); + expectJSON(result4).toDeepEqual({ value: { incremental: [ { @@ -1957,10 +2312,10 @@ describe('Execute: stream directive', () => { done: false, }); - const result4Promise = iterator.next(); + const result5Promise = iterator.next(); resolveIterableCompletion(null); - const result4 = await result4Promise; - expectJSON(result4).toDeepEqual({ + const result5 = await result5Promise; + expectJSON(result5).toDeepEqual({ value: { completed: [{ id: '1' }], hasNext: false, @@ -1968,8 +2323,8 @@ describe('Execute: stream directive', () => { done: false, }); - const result5 = await iterator.next(); - expectJSON(result5).toDeepEqual({ + const result6 = await iterator.next(); + expectJSON(result6).toDeepEqual({ value: undefined, done: true, }); diff --git a/src/execution/execute.ts b/src/execution/execute.ts index 7b87d25060..d01b6ee768 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -140,6 +140,7 @@ export interface ExecutionContext { fieldResolver: GraphQLFieldResolver; typeResolver: GraphQLTypeResolver; subscribeFieldResolver: GraphQLFieldResolver; + enableEarlyExecution: boolean; errors: Array | undefined; cancellableStreams: Set | undefined; } @@ -159,6 +160,7 @@ export interface ExecutionArgs { fieldResolver?: Maybe>; typeResolver?: Maybe>; subscribeFieldResolver?: Maybe>; + enableEarlyExecution?: Maybe; } export interface StreamUsage { @@ -437,6 +439,7 @@ export function buildExecutionContext( fieldResolver, typeResolver, subscribeFieldResolver, + enableEarlyExecution, } = args; // If the schema used for execution is invalid, throw an error. @@ -500,6 +503,7 @@ export function buildExecutionContext( fieldResolver: fieldResolver ?? defaultFieldResolver, typeResolver: typeResolver ?? defaultTypeResolver, subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver, + enableEarlyExecution: enableEarlyExecution === true, errors: undefined, cancellableStreams: undefined, }; @@ -2110,12 +2114,17 @@ function executeDeferredGroupedFieldSets( deferMap, ); - deferredGroupedFieldSetRecord.result = new BoxedPromiseOrValue( - shouldDefer(parentDeferUsages, deferUsageSet) - ? Promise.resolve().then(executor) - : executor(), + const shouldDeferThisDeferUsageSet = shouldDefer( + parentDeferUsages, + deferUsageSet, ); + deferredGroupedFieldSetRecord.result = shouldDeferThisDeferUsageSet + ? exeContext.enableEarlyExecution + ? new BoxedPromiseOrValue(Promise.resolve().then(executor)) + : () => new BoxedPromiseOrValue(executor()) + : new BoxedPromiseOrValue(executor()); + newDeferredGroupedFieldSetRecords.push(deferredGroupedFieldSetRecord); } @@ -2226,57 +2235,74 @@ function buildSyncStreamItemQueue( info: GraphQLResolveInfo, itemType: GraphQLOutputType, ): Array { - const streamItemQueue: Array = [ - new BoxedPromiseOrValue( - Promise.resolve().then(() => { - const initialPath = addPath(streamPath, initialIndex, undefined); - const firstStreamItem = new BoxedPromiseOrValue( - completeStreamItem( - initialPath, - initialItem, - exeContext, - { errors: undefined }, - fieldGroup, - info, - itemType, - ), - ); - let iteration = iterator.next(); - let currentIndex = initialIndex + 1; - let currentStreamItem = firstStreamItem; - while (!iteration.done) { - // TODO: add test case for early sync termination - /* c8 ignore next 4 */ - const result = currentStreamItem.value; - if (!isPromise(result) && result.errors !== undefined) { - break; - } + const streamItemQueue: Array = []; - const itemPath = addPath(streamPath, currentIndex, undefined); + const enableEarlyExecution = exeContext.enableEarlyExecution; - currentStreamItem = new BoxedPromiseOrValue( - completeStreamItem( - itemPath, - iteration.value, - exeContext, - { errors: undefined }, - fieldGroup, - info, - itemType, - ), - ); - streamItemQueue.push(currentStreamItem); + const firstExecutor = () => { + const initialPath = addPath(streamPath, initialIndex, undefined); + const firstStreamItem = new BoxedPromiseOrValue( + completeStreamItem( + initialPath, + initialItem, + exeContext, + { errors: undefined }, + fieldGroup, + info, + itemType, + ), + ); - iteration = iterator.next(); - currentIndex = initialIndex + 1; + let iteration = iterator.next(); + let currentIndex = initialIndex + 1; + let currentStreamItem: + | BoxedPromiseOrValue + | (() => BoxedPromiseOrValue) = firstStreamItem; + while (!iteration.done) { + // TODO: add test case for early sync termination + /* c8 ignore next 6 */ + if (currentStreamItem instanceof BoxedPromiseOrValue) { + const result = currentStreamItem.value; + if (!isPromise(result) && result.errors !== undefined) { + break; } + } - streamItemQueue.push(new BoxedPromiseOrValue({})); + const itemPath = addPath(streamPath, currentIndex, undefined); - return firstStreamItem.value; - }), - ), - ]; + const value = iteration.value; + + const currentExecutor = () => + completeStreamItem( + itemPath, + value, + exeContext, + { errors: undefined }, + fieldGroup, + info, + itemType, + ); + + currentStreamItem = enableEarlyExecution + ? new BoxedPromiseOrValue(currentExecutor()) + : () => new BoxedPromiseOrValue(currentExecutor()); + + streamItemQueue.push(currentStreamItem); + + iteration = iterator.next(); + currentIndex = initialIndex + 1; + } + + streamItemQueue.push(new BoxedPromiseOrValue({})); + + return firstStreamItem.value; + }; + + streamItemQueue.push( + enableEarlyExecution + ? new BoxedPromiseOrValue(Promise.resolve().then(firstExecutor)) + : () => new BoxedPromiseOrValue(firstExecutor()), + ); return streamItemQueue; } @@ -2291,20 +2317,24 @@ function buildAsyncStreamItemQueue( itemType: GraphQLOutputType, ): Array { const streamItemQueue: Array = []; + const executor = () => + getNextAsyncStreamItemResult( + streamItemQueue, + streamPath, + initialIndex, + asyncIterator, + exeContext, + fieldGroup, + info, + itemType, + ); + streamItemQueue.push( - new BoxedPromiseOrValue( - getNextAsyncStreamItemResult( - streamItemQueue, - streamPath, - initialIndex, - asyncIterator, - exeContext, - fieldGroup, - info, - itemType, - ), - ), + exeContext.enableEarlyExecution + ? new BoxedPromiseOrValue(executor()) + : () => new BoxedPromiseOrValue(executor()), ); + return streamItemQueue; } @@ -2345,19 +2375,22 @@ async function getNextAsyncStreamItemResult( itemType, ); + const executor = () => + getNextAsyncStreamItemResult( + streamItemQueue, + streamPath, + index, + asyncIterator, + exeContext, + fieldGroup, + info, + itemType, + ); + streamItemQueue.push( - new BoxedPromiseOrValue( - getNextAsyncStreamItemResult( - streamItemQueue, - streamPath, - index, - asyncIterator, - exeContext, - fieldGroup, - info, - itemType, - ), - ), + exeContext.enableEarlyExecution + ? new BoxedPromiseOrValue(executor()) + : () => new BoxedPromiseOrValue(executor()), ); return result; diff --git a/src/execution/types.ts b/src/execution/types.ts index 9340ab1b85..50f9a083f8 100644 --- a/src/execution/types.ts +++ b/src/execution/types.ts @@ -203,9 +203,13 @@ export function isNonReconcilableDeferredGroupedFieldSetResult( return deferredGroupedFieldSetResult.errors !== undefined; } +type ThunkIncrementalResult = + | BoxedPromiseOrValue + | (() => BoxedPromiseOrValue); + export interface DeferredGroupedFieldSetRecord { deferredFragmentRecords: ReadonlyArray; - result: BoxedPromiseOrValue; + result: ThunkIncrementalResult; } export type SubsequentResultRecord = DeferredFragmentRecord | StreamRecord; @@ -223,7 +227,7 @@ export interface StreamItemResult { errors?: ReadonlyArray | undefined; } -export type StreamItemRecord = BoxedPromiseOrValue; +export type StreamItemRecord = ThunkIncrementalResult; export interface StreamRecord { path: Path; From e160b6f593ffb3b8078af1f3192d938bf0c7df62 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 19 Jun 2024 20:35:33 +0300 Subject: [PATCH 26/26] polish(IncrementalPublisher): remove unnecessary check and method call (#4106) polished IncrementalPublisher: = completeDeferredFragment will always return undefined if the deferredFragmentRecord has already been removed from the graph = removeDeferredFragment need not be called twice --- src/execution/IncrementalPublisher.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/execution/IncrementalPublisher.ts b/src/execution/IncrementalPublisher.ts index 87fe548628..b453bde0d3 100644 --- a/src/execution/IncrementalPublisher.ts +++ b/src/execution/IncrementalPublisher.ts @@ -244,7 +244,6 @@ class IncrementalPublisher { id, errors: deferredGroupedFieldSetResult.errors, }); - this._incrementalGraph.removeDeferredFragment(deferredFragmentRecord); } return; } @@ -261,19 +260,13 @@ class IncrementalPublisher { for (const deferredFragmentRecord of deferredGroupedFieldSetResult .deferredGroupedFieldSetRecord.deferredFragmentRecords) { - const id = deferredFragmentRecord.id; - // TODO: add test case for this. - // Presumably, this can occur if an error causes a fragment to be completed early, - // while an asynchronous deferred grouped field set result is enqueued. - /* c8 ignore next 3 */ - if (id === undefined) { - continue; - } const reconcilableResults = this._incrementalGraph.completeDeferredFragment(deferredFragmentRecord); if (reconcilableResults === undefined) { continue; } + const id = deferredFragmentRecord.id; + invariant(id !== undefined); const incremental = context.incremental; for (const reconcilableResult of reconcilableResults) { const { bestId, subPath } = this._getBestIdAndSubPath(