Repository navigation
fix(Spanner): retry and resume BatchWrite streams on UNAVAILABLE - #9758
alkatrivedi wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements a resilient batch write stream mechanism (batchWriteStream) for Cloud Spanner, allowing batchWriteAtLeastOnce to transparently retry and resume on transient failures (such as UNAVAILABLE or premature stream termination) for only the unacknowledged mutation groups while correctly translating response indexes. It also updates metrics tracing and request stream coordination to support deferred operation completion. The feedback recommends avoiding the override of the private _destroy method on instantiated Node.js streams in both batch-write-stream.ts and database.ts, suggesting instead the use of standard public stream events like 'close' and 'error' for cleanup.
f8ace3c to
d9d9733
Compare
d9d9733 to
04d2439
Compare
olavloite
left a comment
There was a problem hiding this comment.
Can we consider adding these tests as well:
-
Trailing gRPC error after all mutation groups are acknowledged (
remaining === 0)- Scenario: Attempt 1 streams responses acknowledging all mutation groups (0..N-1), but the underlying gRPC stream terminates with an error (e.g.
UNAVAILABLEor connection reset) rather than a clean EOF /end. - Expected behavior: The stream should finalize with
grpc.status.OKand emitendonuserStreamwithout error or attempting another retry (batch-write-stream.ts:L335-L340).
- Scenario: Attempt 1 streams responses acknowledging all mutation groups (0..N-1), but the underlying gRPC stream terminates with an error (e.g.
-
Total timeout / deadline exceeded during retries (
DeadlineError)- Scenario: An attempt fails with
UNAVAILABLE, butelapsed + delayMs >= timeout(or elapsed time already exceedstimeout). - Expected behavior: The client should short-circuit the backoff timer, finalize with
grpc.status.DEADLINE_EXCEEDED, and destroyuserStreamwith aDeadlineErrorwrapping the underlying error (batch-write-stream.ts:L222-L227). Currently, no tests assertDeadlineError.
- Scenario: An attempt fails with
-
Fatal error after a partial retry
- Scenario: Attempt 1 acknowledges group 0, but fails with
UNAVAILABLEbefore acknowledging group 1. Attempt 2 is issued with[group1], but fails with a non-retryable error (e.g.PERMISSION_DENIED). - Expected behavior:
userStreamemits thePERMISSION_DENIEDerror, final status isPERMISSION_DENIED, and no further retry is attempted. (Currently, every retry test intest/batch-write-stream.tseventually succeeds; testing eventual failure after partial progress is missing).
- Scenario: Attempt 1 acknowledges group 0, but fails with
-
Exhaustion of
maxRetries- Scenario: Configure
backoffSettings.maxRetries: 2and fail consecutive attempts withUNAVAILABLE. - Expected behavior: Retries cease after the 2nd retry attempt, and the user stream emits the final
UNAVAILABLEerror (batch-write-stream.ts:L216).
- Scenario: Configure
-
Corrupted
RetryInfometadata decode fallback- Scenario: Unit test
getNextRetryDelayMswith malformed binary payload ingoogle.rpc.retryinfo-bin. - Expected behavior:
getNextRetryDelayMsgracefully catches the decode exception and falls back to jittered exponential backoff rather than throwing an unhandled exception (batch-write-stream.ts:L73-L75).
- Scenario: Unit test
| }); | ||
| }); | ||
|
|
||
| it('should fail withINTERNAL error and not retry when server returns an out-of-bounds index', done => { |
There was a problem hiding this comment.
nit: add space with INTERNAL
| import {DeadlineError, isRetryableInternalError} from './transaction-runner'; | ||
| import {getRetryInfo} from './protos'; | ||
|
|
||
| const RETRY_INFO_BIN = 'google.rpc.retryinfo-bin'; |
There was a problem hiding this comment.
nit: This constant seems to be duplicated throughout the client in multiple places. Can we rather export it from one of the 'central' util files like src/proto.ts or src/common.tsx and import it here?
| * Extracts server-supplied retry delay (in ms) from `google.rpc.retryinfo-bin` | ||
| * trailing metadata if present, otherwise computes jittered exponential backoff. | ||
| */ | ||
| export function getNextRetryDelayMs( |
There was a problem hiding this comment.
nit: Can we move this function to common.ts? In follow-up PRs, we can update other places that implement similar functionality (but in a less correct way) to reuse this method.
| (retryableCodes.includes(grpc.status.UNAVAILABLE) && | ||
| isRetryableInternalError(err)) |
There was a problem hiding this comment.
nit: I had to read over this a few times to understand why exactly we are doing this here. Maybe rewrite to this to make it easier to understand:
const allowsNetworkRetries = retryableCodes.includes(grpc.status.UNAVAILABLE);
return retryableCodes.includes(err.code) || (allowsNetworkRetries && isRetryableInternalError(err));| * retries and resumes on transient stream failures (`UNAVAILABLE`, retryable `INTERNAL`, | ||
| * and premature stream `end`) for only the unresolved mutation groups. | ||
| */ | ||
| export function batchWriteStream( |
There was a problem hiding this comment.
Can we split this function into smaller parts? It is almost 300 lines long, and therefore hard to read.
| // Validate all response indexes before mutating state. | ||
| for (let i = 0; i < respIndexes.length; i++) { | ||
| const idx = Number(respIndexes[i]); | ||
| if ( | ||
| !Number.isInteger(idx) || | ||
| idx < 0 || | ||
| idx >= streamIndexes.length | ||
| ) { | ||
| cleanupActiveRequestStream(); | ||
| const msg = `BatchWrite index ${idx} out of bounds for current request size ${streamIndexes.length}`; | ||
| const internalErr = Object.assign(new Error(msg), { | ||
| code: grpc.status.INTERNAL, | ||
| details: msg, | ||
| metadata: new grpc.Metadata(), | ||
| } as grpc.ServiceError); | ||
| finalize(grpc.status.INTERNAL); | ||
| userStream.destroy(internalErr); | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| // Translate retry-local indexes back to original caller indexes in-place | ||
| // and mark each acknowledged group as resolved (nulling reference for GC). | ||
| for (let i = 0; i < respIndexes.length; i++) { | ||
| const retryIdx = Number(respIndexes[i]); | ||
| const origIdx = streamIndexes[retryIdx]; | ||
| respIndexes[i] = origIdx; | ||
| if (pending[origIdx] !== null) { | ||
| pending[origIdx] = null; | ||
| remaining--; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Forward the response with translated indexes and respect downstream backpressure. | ||
| if (!userStream.write(response)) { | ||
| currentStream.pause(); | ||
| } |
There was a problem hiding this comment.
Could we for example extract this into a separate method?
| if (typeof response !== 'object' || response === null) { | ||
| sawNonObjectChunk = true; | ||
| if (!userStream.write(response)) { | ||
| currentStream.pause(); | ||
| } | ||
| return; | ||
| } |
There was a problem hiding this comment.
I think that this is only needed for a test case, and will never be used in real life, right? If so, can we get rid of this by modifying the test case to send a real object into the stream?
| ? [...mutationGroups] | ||
| : []; | ||
| let remaining = totalGroups; | ||
| let sawNonObjectChunk = false; |
There was a problem hiding this comment.
I think that this is only used to handle specific test cases. Can we get rid of it?
| * }); | ||
| * ``` | ||
| */ | ||
| batchWriteAtLeastOnce( |
There was a problem hiding this comment.
Can we try to split up this method also? It is relatively long and heavily indented, making it very hard to read and understand what is going on.
| headers: {[k: string]: string}; | ||
| metricsTracer?: MetricsTracer; | ||
| metricsTracer?: MetricsTracer | null; | ||
| deferOperationCompletion?: boolean; |
There was a problem hiding this comment.
I assume that deferOperationCompletion was added here for multi-attempt streaming RPCs like BatchWrite, we don't want RequestStreamCoordinator to prematurely finalize the operation metric when Attempt 1 fails with UNAVAILABLE, because the overall operation is still ongoing and will be retried.
However, adding deferOperationCompletion?: boolean to the generic RequestConfig feels like a leaky abstraction:
- It leaks an internal coordination detail of the OpenTelemetry metrics subsystem into the generic gRPC request configuration interface (
RequestConfig), even though it is only used in this one place. - In
database.ts, it leads to a non-obvious side-effect dance: Attempt 1 passesmetricsTracer: undefined, waits forrequestStreamto mutateconfig.metricsTracerin-place, and then captures it for Attempt 2+. The only reason Attempt 1 doesn't finalize the operation metric is becausedeferOperationCompletion: trueis passed alongsidemetricsTracer: undefined.
Proposed Alternative: Ownership by Injection
We can drop deferOperationCompletion completely by establishing a clean ownership rule:
If the caller provides
config.metricsTracer, the caller owns the operation lifecycle.
Instead of letting requestStream lazily create the tracer during Attempt 1:
database.tsinitializesmetricsTracerupfront at operation start (viathis._getSpanner()._initMetricsTracer(...)) and passes it intoconfig.metricsTracerfor all attempts (1, 2, ... N).- In
requestStream, we check ifconfig.metricsTracer !== undefined. If so, we passisExternalTracer = truetoRequestStreamCoordinator. - In
RequestStreamCoordinator.prototype.completeOperation:
completeOperation(): void {
if (this.operationCompleted || this.isExternalTracer) {
return;
}
this.operationCompleted = true;
this.metricsTracer?.recordOperationCompletion();
}This achieves the exact same multi-attempt behavior without needing deferOperationCompletion on RequestConfig, eliminates the in-place mutation of config, and establishes a clean pattern that streaming queries/reads (partial-result-stream.ts) can also adopt in the future when retrying with resume tokens.
batchWriteAtLeastOnce previously failed immediately on any stream error including transient UNAVAILABLE errors and treated streams that closed prematurely as complete. This PR introduce automatic retries and resumes interrupted BatchWrite streams for only the remaining unacknowledged mutation groups, while translating response indexes back to their original positions.