Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a defensive workaround to intercept redundant .end() calls on HTTP/2 streams, preventing unnecessary NodeError and stack trace allocations that degrade performance. The workaround is applied in the Spanner constructor and is accompanied by comprehensive unit tests. The review feedback highlights two important improvements: replacing a while loop with a for loop when uncorking to avoid potential infinite loops if uncork is mocked, and adding a defensive check for this to prevent a TypeError if the patched end method is called unbound.
6f9b5ed to
ef3c9d2
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a defensive workaround to address a performance issue in @grpc/grpc-js where redundant .end() calls on already-ended or destroyed HTTP/2 streams cause unnecessary NodeError allocations and V8 stack trace captures. The workaround patches stream.Duplex.prototype.end to short-circuit these redundant calls when no chunk or callback is provided, and is integrated into the Spanner client constructor. Comprehensive unit tests have been added to verify the workaround's behavior, including environment variable toggles, idempotency, and safety checks. I have no feedback to provide as there are no review comments.
In @grpc/grpc-js, destroyHttp2Stream() calls http2Stream.end() when the server finishes a call, even if the stream was already ended by halfClose(). Calling .end() on an already ended or destroyed Node stream without a callback causes Node core to construct an ERR_STREAM_ALREADY_FINISHED or ERR_STREAM_DESTROYED error with a full V8 stack capture and immediately discard it, creating unnecessary CPU overhead on high-throughput workloads. This adds a workaround on stream.Duplex.prototype.end scoped to HTTP/2 streams that returns early when called on an already ended or destroyed stream without data or a callback. The workaround can be disabled with SPANNER_DISABLE_HTTP2_STREAM_END_WORKAROUND=true. This is a temporary client-side workaround for an upstream issue in @grpc/grpc-js, tracked in grpc/grpc-node#3082. It can be removed once Spanner requires a grpc-js release that includes that fix.
ef3c9d2 to
bcf286e
Compare
Summary of the Workaround
This PR introduces a targeted workaround in
@google-cloud/spannerfor an upstream issue in@grpc/grpc-js(grpc/grpc-node#3082) where redundant.end()calls are issued on finished HTTP/2 streams.The Problem
@grpc/grpc-js, when executing unary and server-streaming RPCs, the client marks the request half-closed (halfClose()) after sending the request payload, which setsthis.http2Stream.writableEnded = true.@grpc/grpc-jsinvokesdestroyHttp2Stream(), which callsthis.http2Stream.end().node:internal/streams/writable), calling.end()on a stream that already haswritableEnded = truewithout providing a callback causes Node to instantiatenew ERR_STREAM_ALREADY_FINISHED('end')with a full synchronous V8 stack trace capture. Because no callback was provided, the constructed error is immediately discarded.The Implementation
Rather than modifying global Node.js stream prototypes (such as
stream.Duplex.prototype), the workaround targets only@grpc/grpc-jsinternals:Http2SubchannelCall.prototype.destroyHttp2StreamandHttp2SubchannelCall.prototype.halfClosein@grpc/grpc-js:destroyHttp2Stream(): returns early ifthis.http2Stream.destroyedis already true, or ifthis.serverEndedCall && this.http2Stream.writableEndedis true (avoiding the second.end()call).halfClose(): returns early ifthis.http2Stream.destroyedorthis.http2Stream.writableEndedis already true.stream.Duplex.prototype,net.Socket,tls.TLSSocket, and other Node.js stream types untouched.SymbolonHttp2SubchannelCall.prototypeso it is installed at most once.try / catchto fail open safely if@grpc/grpc-jsmodule layout differs or changes in future releases.SPANNER_DISABLE_HTTP2_STREAM_END_WORKAROUND=true.Removal Path
This workaround is temporary and will be removed once upstream PR grpc/grpc-node#3082 is merged, released in
@grpc/grpc-js, and adopted as a minimum dependency.Benchmark Results
To evaluate the effect of avoiding redundant HTTP/2 stream closures in
@grpc/grpc-js, we ran 30-minute side-by-side benchmarks on Google Compute Engine VMs against Cloud Spanner comparingmainagainst this branch.Test Environment & Workloads
n2-standardVMs located ineurope-north1-aconnecting to Cloud Spanner instancespring-data-jpavia sidecar workload generator.n2-standard-2(2 vCPUs).n2-standard-4(4 vCPUs) across 3 Node.js worker threads.workload.googleapis.com/spanner_client_benchmarks/latencyexplicit histogram distribution).1. Steady Point-Select Benchmark (100 TPS, 30m)
mainLatency Distribution
mainCount (%)2. Bursty Channel-Scaling Benchmark (2,000 TPS baseline, 7,600 TPS bursts, 30m)
mainLatency Distribution
mainCount (%)Summary of Findings
main).ERR_STREAM_ALREADY_FINISHEDerrors. This keeps the Node.js event loop responsive during bursts, reducing P90 latency from 81.45 ms to 19.47 ms and reducing requests queued