Skip to content

fix(storage): follow-ups for read stall retry - #20577

Merged
cpriti-os merged 2 commits into
googleapis:mainfrom
cpriti-os:read-stall-followups
Sep 28, 2026
Merged

cpriti-os merged 2 commits into
googleapis:mainfrom
cpriti-os:read-stall-followups

Conversation

@cpriti-os

Copy link
Copy Markdown
Contributor

Follow-ups to #20525.

1. XML reads: don't leak invocation headers across retries.
newRangeReaderXML reuses one *http.Request for every attempt, and setHeadersFromCtx merges x-goog-api-client values 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 TestXMLReaderRetryInvocationHeaders checks that each of 3 attempts carries exactly one gccl-attempt-count/N (the correct N) and exactly one gccl-invocation-id.

2. Emulator stall tests: cover BidiReadObject, and assert the stall was injected.

  • TestGRPCRetryReadStallEmulated now runs for both ReadObject and BidiReadObject (WithGRPCBidiReads()).
  • The HTTP and gRPC stall tests now call a new helper, checkRetryTestCompleted, which reuses emulatorTest.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.
  • Requires storage-testbench v0.64.0 (gRPC read stall support, feat: Add gRPC read stall support for Go SDK storage-testbench#819).

3. Stall metric description.
The description of gcp.storage.client.stall.duration said "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 openStream finished just as the stall timer fired, select could 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 whether openStream has 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.
  • TestXMLReaderRetryInvocationHeaders fails without fix 1 (2 or 3 gccl-attempt-count tokens on attempts 2 and 3) and passes with it.
  • Emulator tests against storage-testbench v0.64.0: TestRetryReadStallEmulated, TestGRPCRetryReadStallEmulated/ReadObject and TestGRPCRetryReadStallEmulated/BidiReadObject all pass (~0.3s each: stall, then retry).
  • The same tests against v0.63.0 fail with test not completed; unused instructions: map[storage.objects.get:[stall-for-10s-after-0K]], which confirms the new assertion works.

@cpriti-os
cpriti-os requested review from a team as code owners September 25, 2026 07:24
@product-auto-label product-auto-label Bot added the api: storage Issues related to the Cloud Storage API. label Sep 25, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread storage/dynamic_delay.go Outdated
Comment on lines +285 to +287
if innerErr == nil {
cancel = nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread storage/client_test.go
}),
}, tc.opts...)

client, err := NewGRPCClient(ctx, opts...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
client, err := NewGRPCClient(ctx, opts...)
client, err := NewGRPCClient(context.Background(), opts...)
References
  1. 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread storage/http_client_test.go Outdated
)
hc, closeServer := newTestServer(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
gotHeaders = append(gotHeaders, r.Header.Get(xGoogHeaderKey))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
gotHeaders = append(gotHeaders, r.Header.Get(xGoogHeaderKey))
gotHeaders = append(gotHeaders, strings.Join(r.Header.Values(xGoogHeaderKey), " "))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, switched to strings.Join(r.Header.Values(...), " ").

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.
Comment thread storage/dynamic_delay.go Outdated
Comment on lines +285 to +287
if innerErr == nil {
cancel = nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Consider extracting this to a single place to avoid missing changes in both the locations in the future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@cpriti-os
cpriti-os merged commit 24eacd5 into googleapis:main Sep 28, 2026
17 checks passed
sahusneha2004 pushed a commit that referenced this pull request Oct 1, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api: storage Issues related to the Cloud Storage API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants