Skip to content

Commit ed9caee

Browse files
committed
fix(incremental): pass through errors from return functions that throw
Early execution may result in non-completed streams after publishing is completed -- these streams must be closed using their return methods. When this occurs, we should pass through any error that occurs in the clean-up function instead of swallowing errors. Swallowing errors is a bad practice that could lead to memory leaks. The argument in favor of swallowing the error might be that because the stream was "executed early" and the error does not impact any of the returned data, there is no "place" to forward the error. But there is a way to forward the error, and that's on the next() call that returns { value: undefined, done: true } to end the stream. We will therefore appropriately send all the data and be able to pass through an error. Servers processing our stream should be made aware of this behavior and put in place error handling procedures that allow them to forward the data to clients when they see a payload with { hasNext: false } and then filter any further errors from clients (versus holding that { hasNext: false } until the clean-up has been performed, which would be up to servers.
1 parent 18d284c commit ed9caee

File tree

2 files changed

+44
-30
lines changed

2 files changed

+44
-30
lines changed

src/execution/IncrementalPublisher.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ interface SubsequentIncrementalExecutionResultContext {
5252
completed: Array<CompletedResult>;
5353
}
5454

55+
/**
56+
* The IncrementalPublisherState Enum tracks the state of the IncrementalPublisher, which is initialized to
57+
* "Started". When there are no more incremental results to publish, the state is set to "Completed". On the
58+
* next call to next, clean-up is potentially performed and the state is set to "Finished".
59+
*
60+
* If the IncrementalPublisher is ended early, it may be advanced directly from "Started" to "Finished".
61+
*/
62+
enum IncrementalPublisherState {
63+
Started = 1,
64+
Completed = 2,
65+
Finished = 3,
66+
}
67+
5568
/**
5669
* This class is used to publish incremental results to the client, enabling semi-concurrent
5770
* execution while preserving result order.
@@ -119,14 +132,29 @@ class IncrementalPublisher {
119132
void,
120133
void
121134
> {
122-
let isDone = false;
135+
let incrementalPublisherState: IncrementalPublisherState =
136+
IncrementalPublisherState.Started;
137+
138+
const _finish = async (): Promise<void> => {
139+
incrementalPublisherState = IncrementalPublisherState.Finished;
140+
this._incrementalGraph.abort();
141+
await this._returnAsyncIterators();
142+
};
123143

124144
const _next = async (): Promise<
125145
IteratorResult<SubsequentIncrementalExecutionResult, void>
126146
> => {
127-
if (isDone) {
128-
await this._returnAsyncIteratorsIgnoringErrors();
129-
return { value: undefined, done: true };
147+
switch (incrementalPublisherState) {
148+
case IncrementalPublisherState.Finished: {
149+
return { value: undefined, done: true };
150+
}
151+
case IncrementalPublisherState.Completed: {
152+
await _finish();
153+
return { value: undefined, done: true };
154+
}
155+
case IncrementalPublisherState.Started: {
156+
// continue
157+
}
130158
}
131159

132160
const context: SubsequentIncrementalExecutionResultContext = {
@@ -147,7 +175,7 @@ class IncrementalPublisher {
147175
const hasNext = this._incrementalGraph.hasNext();
148176

149177
if (!hasNext) {
150-
isDone = true;
178+
incrementalPublisherState = IncrementalPublisherState.Completed;
151179
}
152180

153181
const subsequentIncrementalExecutionResult: SubsequentIncrementalExecutionResult =
@@ -171,25 +199,20 @@ class IncrementalPublisher {
171199
batch = await this._incrementalGraph.nextCompletedBatch();
172200
} while (batch !== undefined);
173201

174-
await this._returnAsyncIteratorsIgnoringErrors();
175202
return { value: undefined, done: true };
176203
};
177204

178205
const _return = async (): Promise<
179206
IteratorResult<SubsequentIncrementalExecutionResult, void>
180207
> => {
181-
isDone = true;
182-
this._incrementalGraph.abort();
183-
await this._returnAsyncIterators();
208+
await _finish();
184209
return { value: undefined, done: true };
185210
};
186211

187212
const _throw = async (
188213
error?: unknown,
189214
): Promise<IteratorResult<SubsequentIncrementalExecutionResult, void>> => {
190-
isDone = true;
191-
this._incrementalGraph.abort();
192-
await this._returnAsyncIterators();
215+
await _finish();
193216
return Promise.reject(error);
194217
};
195218

@@ -372,10 +395,4 @@ class IncrementalPublisher {
372395
}
373396
await Promise.all(promises);
374397
}
375-
376-
private async _returnAsyncIteratorsIgnoringErrors(): Promise<void> {
377-
await this._returnAsyncIterators().catch(() => {
378-
// Ignore errors
379-
});
380-
}
381398
}

src/execution/__tests__/stream-test.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { assert, expect } from 'chai';
22
import { describe, it } from 'mocha';
33

44
import { expectJSON } from '../../__testUtils__/expectJSON.js';
5+
import { expectPromise } from '../../__testUtils__/expectPromise.js';
56
import { resolveOnNextTick } from '../../__testUtils__/resolveOnNextTick.js';
67

78
import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.js';
@@ -1791,7 +1792,7 @@ describe('Execute: stream directive', () => {
17911792
]);
17921793
});
17931794

1794-
it('Returns iterator and ignores errors when stream payloads are filtered', async () => {
1795+
it('Returns iterator and passes through errors when stream payloads are filtered', async () => {
17951796
let returned = false;
17961797
let requested = false;
17971798
const iterable = {
@@ -1814,7 +1815,7 @@ describe('Execute: stream directive', () => {
18141815
},
18151816
return: () => {
18161817
returned = true;
1817-
// Ignores errors from return.
1818+
// This error should be passed through.
18181819
return Promise.reject(new Error('Oops'));
18191820
},
18201821
}),
@@ -1889,8 +1890,8 @@ describe('Execute: stream directive', () => {
18891890
},
18901891
});
18911892

1892-
const result3 = await iterator.next();
1893-
expectJSON(result3).toDeepEqual({ done: true, value: undefined });
1893+
const result3Promise = iterator.next();
1894+
await expectPromise(result3Promise).toRejectWith('Oops');
18941895

18951896
assert(returned);
18961897
});
@@ -2339,6 +2340,8 @@ describe('Execute: stream directive', () => {
23392340
}),
23402341
return: () => {
23412342
returned = true;
2343+
// This error should be passed through.
2344+
return Promise.reject(new Error('Oops'));
23422345
},
23432346
}),
23442347
};
@@ -2378,7 +2381,7 @@ describe('Execute: stream directive', () => {
23782381
done: true,
23792382
value: undefined,
23802383
});
2381-
await returnPromise;
2384+
await expectPromise(returnPromise).toRejectWith('Oops');
23822385
assert(returned);
23832386
});
23842387
it('Can return async iterable when underlying iterable does not have a return method', async () => {
@@ -2498,13 +2501,7 @@ describe('Execute: stream directive', () => {
24982501
done: true,
24992502
value: undefined,
25002503
});
2501-
try {
2502-
await throwPromise; /* c8 ignore start */
2503-
// Not reachable, always throws
2504-
/* c8 ignore stop */
2505-
} catch (e) {
2506-
// ignore error
2507-
}
2504+
await expectPromise(throwPromise).toRejectWith('bad');
25082505
assert(returned);
25092506
});
25102507
});

0 commit comments

Comments
 (0)