Repository navigation
fix(storage): follow-ups for read stall retry - #20577
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces several improvements and fixes to the storage client. In storage/dynamic_delay.go, it prevents false-positive stall reports by checking if the stream completed at the same time the timer fired. In storage/http_client.go, it snapshots and restores the x-goog-api-client header to prevent attempt-specific tokens from leaking across retries. It also updates tests to cover both standard and bidirectional gRPC reads, and adds a test verifying XML reader retry invocation headers. Feedback on the changes highlights a potential issue in storage/dynamic_delay.go where setting cancel = nil to prevent deferred cancellation may not work if defer cancel() is evaluated immediately rather than wrapped in a closure. Additionally, it is recommended to use a long-lived context for NewGRPCClient in tests to avoid premature cancellation of background tasks, and to use r.Header.Values instead of r.Header.Get in tests to thoroughly assert against leaked headers.
| if innerErr == nil { | ||
| cancel = nil | ||
| } |
There was a problem hiding this comment.
Setting cancel = nil to prevent deferred cancellation only works if the deferred cancel is wrapped in a closure (e.g., defer func() { if cancel != nil { cancel() } }()). If the function instead uses a direct defer cancel(), the function value is evaluated immediately at the defer statement, and the original cancel function will still execute upon return, prematurely canceling the successful stream. If the direct defer cancel() pattern is used elsewhere in this function, it should be refactored to a closure.
There was a problem hiding this comment.
The deferred cancel is already a closure (defer func() { if cancel != nil { cancel() } }(), L253-257), so setting cancel = nil prevents it from running. No change needed.
| }), | ||
| }, tc.opts...) | ||
|
|
||
| client, err := NewGRPCClient(ctx, opts...) |
There was a problem hiding this comment.
Passing a short-lived context ctx (with a 5-second timeout) to the client constructor NewGRPCClient violates the best practice of using a long-lived context for client initialization. Since the client may spawn background goroutines for connection monitoring and session maintenance, a short-lived context risks premature cancellation of these background tasks once the timeout expires. Passing a long-lived context like context.Background() to NewGRPCClient instead, and restricting the use of the timeout context ctx to individual RPC calls, avoids this issue.
| client, err := NewGRPCClient(ctx, opts...) | |
| client, err := NewGRPCClient(context.Background(), opts...) |
References
- When initializing clients or channels (constructors) that may spawn background goroutines (e.g., for session maintenance or connection monitoring), pass a long-lived context directly instead of a short-lived or bounded context. Do not wrap constructors in short-lived timeout helpers if the constructor itself doesn't use that timeout context, as it provides no hang protection and risks premature cancellation of background tasks. Instead, apply bounded timeouts only to discrete RPCs.
There was a problem hiding this comment.
The context only covers client setup and the test's RPCs, and client.Close() runs before cancel(), so nothing outlives it. Keeping it consistent with TestRetryReadStallEmulated.
| ) | ||
| hc, closeServer := newTestServer(func(w http.ResponseWriter, r *http.Request) { | ||
| mu.Lock() | ||
| gotHeaders = append(gotHeaders, r.Header.Get(xGoogHeaderKey)) |
There was a problem hiding this comment.
Using r.Header.Get(xGoogHeaderKey) only retrieves the first value of the header. If the client implementation leaks headers by appending them as separate header fields (via Header.Add), those leaked values will reside in subsequent elements of the header slice and will be silently ignored by Header.Get. To thoroughly assert against any leaked headers, retrieve all values using r.Header.Values(xGoogHeaderKey) and join them.
| gotHeaders = append(gotHeaders, r.Header.Get(xGoogHeaderKey)) | |
| gotHeaders = append(gotHeaders, strings.Join(r.Header.Values(xGoogHeaderKey), " ")) |
There was a problem hiding this comment.
Good catch, switched to strings.Join(r.Header.Values(...), " ").
0b2f211 to
7286bdd
Compare
Follow-ups to googleapis#20525: - XML reads: restore the base x-goog-api-client value on each attempt so gccl-attempt-count tokens from earlier attempts don't leak into retries (the *http.Request is reused across attempts). - Emulator stall tests: cover BidiReadObject and assert that the testbench consumed the stall instruction (requires storage-testbench v0.64.0). - Reword the gcp.storage.client.stall.duration description; "first byte" is only accurate for HTTP. - executeWithReadStallTimeout: if openStream completed at the same time the stall timer fired, prefer the real result over reporting a stall.
7286bdd to
3985cdd
Compare
| if innerErr == nil { | ||
| cancel = nil | ||
| } |
There was a problem hiding this comment.
nit: Consider extracting this to a single place to avoid missing changes in both the locations in the future.
🤖 I have created a release *beep* *boop* --- ## [1.69.0](storage/v1.68.0...storage/v1.69.0) (2026-10-01) ### Features * **all:** Update supported go versions ([#20520](#20520)) ([90413d3](90413d3)) * **storage:** Add read stall retry for gRPC storage client ([#20525](#20525)) ([8bfc486](8bfc486)) ### Bug Fixes * **storage:** Add App Hub storage.googleapis.com prefix to destination.id ([#20569](#20569)) ([2bca92a](2bca92a)) * **storage:** Follow-ups for read stall retry ([#20577](#20577)) ([24eacd5](24eacd5)) * **storage:** Update PartSize documentation and optimize cleanup ([#19930](#19930)) ([3d09abf](3d09abf)) * **various:** Address format directive issues ([#20547](#20547)) ([e2e1047](e2e1047)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com>
Follow-ups to #20525.
1. XML reads: don't leak invocation headers across retries.
newRangeReaderXMLreuses one*http.Requestfor every attempt, andsetHeadersFromCtxmergesx-goog-api-clientvalues into whatever the header already contains. As a result, retried requests carried the attempt counts from all earlier attempts, e.g. on attempt 3:gccl-invocation-id/… gccl-attempt-count/3 gl-go/… gccl/… gccl-attempt-count/2 gccl-attempt-count/1.The fix snapshots the header value before the first attempt and restores it at the start of each attempt, before the per-attempt context headers are applied. The new test
TestXMLReaderRetryInvocationHeaderschecks that each of 3 attempts carries exactly onegccl-attempt-count/N(the correct N) and exactly onegccl-invocation-id.2. Emulator stall tests: cover BidiReadObject, and assert the stall was injected.
TestGRPCRetryReadStallEmulatednow runs for bothReadObjectandBidiReadObject(WithGRPCBidiReads()).checkRetryTestCompleted, which reusesemulatorTest.check(). It fails the test if the testbench didn't use the stall instruction, so the tests can no longer pass without a stall ever happening.3. Stall metric description.
The description of
gcp.storage.client.stall.durationsaid "waiting for the first byte", which is only accurate for HTTP. It now says: stall timeout after which a read attempt was aborted while waiting for the initial response (response headers for HTTP, first response message for gRPC).4.
executeWithReadStallTimeout: prefer the real result when it arrives at the same moment the timer fires.If
openStreamfinished just as the stall timer fired,selectcould pick the timer case at random. That threw away a real success (or a real error such as NotFound) and reported a stall instead. The timer case now first checks whetheropenStreamhas already finished and, if so, returns its result.Testing
go build ./...,go vet .,gofmt -l .: clean.go test -short -race -count=1 .: pass.go test -short -race -count=50 -run TestExecuteWithReadStallTimeout .: pass.TestXMLReaderRetryInvocationHeadersfails without fix 1 (2 or 3gccl-attempt-counttokens on attempts 2 and 3) and passes with it.TestRetryReadStallEmulated,TestGRPCRetryReadStallEmulated/ReadObjectandTestGRPCRetryReadStallEmulated/BidiReadObjectall pass (~0.3s each: stall, then retry).test not completed; unused instructions: map[storage.objects.get:[stall-for-10s-after-0K]], which confirms the new assertion works.