-
Notifications
You must be signed in to change notification settings - Fork 704
perf: optimize stream pipeline by eliminating events-intercept #9221
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,18 +16,15 @@ | |
|
|
||
| import {GrpcService} from './common-grpc/service'; | ||
| import * as checkpointStream from 'checkpoint-stream'; | ||
| import * as eventsIntercept from 'events-intercept'; | ||
| import mergeStream = require('merge-stream'); | ||
| import {common as p} from 'protobufjs'; | ||
| import {Readable, Transform} from 'stream'; | ||
| import {PassThrough, Readable, Transform} from 'stream'; | ||
| import * as streamEvents from 'stream-events'; | ||
| import {grpc, CallOptions} from 'google-gax'; | ||
| import {DeadlineError, isRetryableInternalError} from './transaction-runner'; | ||
|
|
||
| import {codec, JSONOptions, Json, Field, Value} from './codec'; | ||
| import {protos} from '@google-cloud/spanner-api'; | ||
| import google = protos.google; | ||
| import * as stream from 'stream'; | ||
| import {isDefined, isEmpty, isString} from './helper'; | ||
|
|
||
| const originalDecode = codec.decode; | ||
|
|
@@ -253,7 +250,7 @@ | |
| this._options.columnsMetadata, | ||
| name, | ||
| ) | ||
| ? (this._options.columnsMetadata as any)[name] | ||
| : undefined; | ||
| if (codec.decode !== originalDecode) { | ||
| return val => | ||
|
|
@@ -578,17 +575,18 @@ | |
| const maxQueued = 10; | ||
| let lastResumeToken: ResumeToken; | ||
| let lastRequestStream: Readable; | ||
| let errorListener: (err: grpc.ServiceError) => void; | ||
| const startTime = Date.now(); | ||
| const timeout = options?.gaxOptions?.timeout ?? Infinity; | ||
|
|
||
| // mergeStream allows multiple streams to be connected into one. This is good; | ||
| // requestsStream allows multiple streams to be connected into one. This is good; | ||
| // if we need to retry a request and pipe more data to the user's stream. | ||
| // We also add an additional stream that can be used to flush any remaining | ||
| // items in the checkpoint stream that have been received, and that did not | ||
| // contain a resume token. | ||
| const requestsStream = mergeStream(); | ||
| const flushStream = new stream.PassThrough({objectMode: true}); | ||
| requestsStream.add(flushStream); | ||
| const requestsStream = new PassThrough({objectMode: true}); | ||
| const flushStream = new PassThrough({objectMode: true}); | ||
| flushStream.pipe(requestsStream); | ||
| const partialRSStream = new PartialResultStream(options); | ||
| const userStream = streamEvents(partialRSStream); | ||
| // We keep track of the number of PartialResultSets that did not include a | ||
|
|
@@ -617,7 +615,6 @@ | |
| // then push `null` to end the stream. | ||
| flushStream.push({resumeToken: '_'}); | ||
| flushStream.push(null); | ||
| requestsStream.end(); | ||
| }); | ||
| }; | ||
| const makeRequest = (): void => { | ||
|
|
@@ -626,7 +623,11 @@ | |
| } | ||
| lastRequestStream = requestFn(lastResumeToken); | ||
| lastRequestStream.on('end', endListener); | ||
| requestsStream.add(lastRequestStream); | ||
| errorListener = (err: grpc.ServiceError) => { | ||
| setImmediate(() => retry(err)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deferring the retry here with Verification test cases: it('should successfully retry when the failed stream emits an error followed by end', done => {
const fakeCheckpointStream = through.obj();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fakeCheckpointStream as any).reset = () => {};
sandbox.stub(checkpointStream, 'obj').returns(fakeCheckpointStream);
const firstStream = through.obj();
const secondStream = through.obj();
const requestFnStub = sandbox.stub();
// First request fails with UNAVAILABLE and immediately ends
requestFnStub.onCall(0).callsFake(() => {
setImmediate(() => {
firstStream.emit('error', {
code: grpc.status.UNAVAILABLE,
message: 'Unavailable',
} as grpc.ServiceError);
firstStream.end();
});
return firstStream;
});
// Retried request succeeds and delivers data
requestFnStub.onCall(1).callsFake(() => {
setImmediate(() => {
secondStream.push(RESULT_WITH_TOKEN);
fakeCheckpointStream.emit('checkpoint', RESULT_WITH_TOKEN);
secondStream.end();
});
return secondStream;
});
const receivedRows: Row[] = [];
partialResultStream(requestFnStub)
.on('data', row => receivedRows.push(row))
.on('error', done)
.on('end', () => {
try {
assert.strictEqual(requestFnStub.callCount, 2, 'Should have retried once');
assert.strictEqual(receivedRows.length, 1, 'Should receive data from retried stream');
done();
} catch (e) {
done(e);
}
});
});
it('should only spawn a single retry when multiple errors are emitted in rapid succession', done => {
const fakeCheckpointStream = through.obj();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fakeCheckpointStream as any).reset = () => {};
sandbox.stub(checkpointStream, 'obj').returns(fakeCheckpointStream);
const firstStream = through.obj();
const secondStream = through.obj();
const requestFnStub = sandbox.stub();
// First request emits two error events synchronously
requestFnStub.onCall(0).callsFake(() => {
setImmediate(() => {
const err = {
code: grpc.status.UNAVAILABLE,
message: 'Unavailable',
} as grpc.ServiceError;
firstStream.emit('error', err);
firstStream.emit('error', err);
});
return firstStream;
});
// Second request succeeds
requestFnStub.onCall(1).callsFake(() => {
setImmediate(() => {
secondStream.push(RESULT_WITH_TOKEN);
fakeCheckpointStream.emit('checkpoint', RESULT_WITH_TOKEN);
secondStream.end();
});
return secondStream;
});
partialResultStream(requestFnStub)
.on('error', done)
.pipe(
concat(rows => {
try {
// Exactly 1 initial request + 1 retry request = 2 calls total
assert.strictEqual(requestFnStub.callCount, 2, 'Should only trigger one retry request');
assert.strictEqual(rows.length, 1);
done();
} catch (e) {
done(e);
}
}),
);
});Suggested fix: |
||
| }; | ||
| lastRequestStream.on('error', errorListener); | ||
| lastRequestStream.pipe(requestsStream, {end: false}); | ||
| }; | ||
|
|
||
| const retry = (err: grpc.ServiceError): void => { | ||
|
|
@@ -659,6 +660,8 @@ | |
|
|
||
| if (lastRequestStream) { | ||
| lastRequestStream.removeListener('end', endListener); | ||
| lastRequestStream.removeAllListeners('error'); | ||
| lastRequestStream.on('error', () => {}); // Prevent unhandled exception crash | ||
| lastRequestStream.destroy(); | ||
| } | ||
|
Comment on lines
661
to
666
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This cleanup code is skipped if the early return above for non-retriable errors is used (the one on line 657). That causes the Verification test: it('should destroy the request stream and detach listeners on non-retryable errors', done => {
const fakeCheckpointStream = through.obj();
sandbox.stub(checkpointStream, 'obj').returns(fakeCheckpointStream);
const fakeStream = through.obj();
const destroySpy = sandbox.spy(fakeStream, 'destroy');
const requestFnStub = sandbox.stub().callsFake(() => {
setImmediate(() => {
fakeStream.emit('error', {
code: grpc.status.INVALID_ARGUMENT,
message: 'Invalid query argument.',
} as grpc.ServiceError);
});
return fakeStream;
});
partialResultStream(requestFnStub)
.on('data', () => {})
.on('error', err => {
try {
assert.strictEqual(err.code, grpc.status.INVALID_ARGUMENT);
assert.strictEqual(destroySpy.called, true, 'Request stream should be destroyed on non-retryable error');
assert.strictEqual(fakeStream.listenerCount('end'), 0, 'endListener should be removed');
assert.strictEqual(fakeStream.listenerCount('error'), 0, 'errorListener should be removed');
done();
} catch (e) {
done(e);
}
});
});Suggested fix: |
||
| // Delay the retry until all the values that are already in the stream | ||
|
|
@@ -674,15 +677,6 @@ | |
| }; | ||
|
|
||
| userStream.once('reading', makeRequest); | ||
| eventsIntercept.patch(requestsStream); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the application calls Verification test case: it('should destroy the underlying request stream when the user destroys the returned stream', done => {
const fakeStream = through.obj();
const destroySpy = sandbox.spy(fakeStream, 'destroy');
const requestFnStub = sandbox.stub().returns(fakeStream);
const stream = partialResultStream(requestFnStub);
// Read first row and immediately destroy stream
stream.on('data', () => {
stream.destroy();
});
stream.on('close', () => {
setImmediate(() => {
try {
assert.strictEqual(
destroySpy.called,
true,
'Underlying request stream must be destroyed when user cancels the stream',
);
done();
} catch (e) {
done(e);
}
});
});
fakeStream.push(RESULT_WITH_TOKEN);
});Suggested fix: |
||
|
|
||
| // need types for events-intercept | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| (requestsStream as any).intercept('error', err => | ||
| // Retry __after__ all pending data has been processed to ensure that the | ||
| // checkpoint stream is reset at the correct position. | ||
| setImmediate(() => retry(err)), | ||
| ); | ||
|
|
||
| return ( | ||
| requestsStream | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This actually also imports
events-interceptas a transitive dependency. So the removal of the import below does not actually remove it entirely from this file. AndcheckpointStreamalso monkey-patches theemitmethod. So while this PR gets rid of some of the monkey-patching of the query stream, it does not get rid of all of it.Test:
We could instead implement our own specific 'checkpointStream' without monkey-patching and remove the entire dependency on
checkpointStreamhere: