Skip to content

gcs: retry Delete on transient 5xx - #261

Open
llarco wants to merge 4 commits into
thanos-io:mainfrom
llarco:gcs-retry-delete
Open

llarco wants to merge 4 commits into
thanos-io:mainfrom
llarco:gcs-retry-delete

Conversation

@llarco

@llarco llarco commented May 31, 2026

Copy link
Copy Markdown

The default RetryIdempotent policy doesn't cover Delete without an IfGenerationMatch precondition, so a single 503 from GCS bubbles up to the caller. Switch Delete to RetryAlways. Also apply MaxRetries (default 3 when unset) to all operations so the cap is no longer opt-in.

See https://docs.cloud.google.com/storage/docs/retry-strategy.

  • I added CHANGELOG entry for this change.
  • Change is not relevant to the end user.

Changes

  • Delete now calls bkt.Object(name).Retryer(storage.WithPolicy(storage.RetryAlways)).Delete(ctx). Without RetryAlways, the SDK skips retries entirely for plain Delete because the default RetryIdempotent policy treats unconditional Delete as non-idempotent.
  • newBucket now always sets WithMaxAttempts(MaxRetries) on the bucket handle. Previously this was only applied when the user set max_retries > 0. The ObjectHandle inherits the bucket's retry config, so the new Delete retry inherits the cap.
  • DefaultConfig.MaxRetries = 3 sets the new default. newBucket falls back to this when gc.MaxRetries == 0, covering both the YAML config path (via DefaultConfig) and direct NewBucketWithConfig callers.
  • Config.MaxRetries doc updated. MaxRetries: 1 to disable retries still works.

Verification

Added TestBucket_Delete_RetriesOnTransient5xx in providers/gcs/gcs_test.go. Stands up an httptest.NewServer that returns 503 on the first 2 DELETE requests and 204 on the third. Asserts bkt.Delete returns nil and the server saw exactly 3 attempts.

Context: this surfaced as recurring crashes in the Thanos compactor's BlocksCleaner.DeleteMarkedBlocks when GCS returned a transient 503 on a single delete during the cleanup pass. Discussion: thanos-io/thanos#8838 (now closed in favor of fixing the issue here in objstore).

@llarco

llarco commented Oct 2, 2026

Copy link
Copy Markdown
Author

Ran into this again in prod — a transient 503 on a meta.json delete took down my compactor (Thanos 0.37.1):

googleapi: Error 503: We encountered an internal error. Please try again., backendError
delete 01KTYYMM90S20K6MA099K1VRKE/meta.json
pkg/compact.(*BlocksCleaner).DeleteMarkedBlocks

The default RetryIdempotent policy skips Delete when there's no IfGenerationMatch, so it never retries.

Rebased on main. PTAL at your earliest convenience — happy to split the MaxRetries default into a separate PR if that's easier to review.

Comment thread providers/gcs/gcs.go Resolved
Comment thread providers/gcs/gcs_test.go
}))
defer srv.Close()

t.Setenv("STORAGE_EMULATOR_HOST", srv.Listener.Addr().String())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What does this STORAGE_EMULATOR_HOST mean? Maybe there's a constant for it defined somewhere in some module that provides more context?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It's the GCS SDK's documented emulator override: https://cloud.google.com/storage/docs/emulator. It makes it so that the GCS client library points at a local endpoint instead of the real API. It's already used in other places in this file. There's no constant for it today, happy to add a constant but didn't want to touch more lines in this PR.

Comment thread providers/gcs/gcs_test.go Resolved
Comment thread CHANGELOG.md Resolved
Comment thread providers/gcs/gcs_test.go Resolved
llarco added 3 commits October 5, 2026 22:53
The default RetryIdempotent policy doesn't cover Delete without an
IfGenerationMatch precondition, so a single 503 from GCS bubbles up
to the caller. Switch Delete to RetryAlways. Also apply MaxRetries
(default 3 when unset) to all operations so the cap is no longer
opt-in.

See https://docs.cloud.google.com/storage/docs/retry-strategy.

Signed-off-by: Luis Larco <langelo91@gmail.com>
Signed-off-by: Luis Larco <langelo91@gmail.com>
- Drop the hardcoded default from the MaxRetries doc comment and the
  CHANGELOG entry; both duplicated DefaultConfig and would drift from it.
- t.Cleanup over defer, and t.Context() over context.Background(), in the
  new retry test.

Signed-off-by: Luis Larco <langelo91@gmail.com>
Comment thread providers/gcs/gcs.go
// https://docs.cloud.google.com/storage/docs/retry-strategy for what
// counts as transient and how the SDK handles backoff.
maxAttempts := gc.MaxRetries
if maxAttempts == 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If maxAttempts can only be 0 or above then maybe it could be a uint?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think the idiomatic/standard convention in golang is to use int for this kind of variables while keeping uint and other variations of ints for specific operations (eg. bitwise operations, bitmasking, etc) and let the code logic enforce/validate the the value explicitly.

Also, golang does not have implicit type casting, so if for whatever reason we wanted to compare this value to another one line for i := 0; i < maxAttempts; i++, we would actually have to typecast it: for i := 0; i < int(maxAttempts); i++.

I found an interested thread about this topic: https://stackoverflow.com/questions/63105394/in-go-when-should-you-use-uint-vs-int

And also this quote "When you need an integer value you should use int unless you have a specific reason to use a sized or unsigned integer type" from https://go.dev/tour/basics/11.

I looked at the rest of the repo, and there are many cases where int is being used for non-negative numbers (eg. counters) rather than unit, so I figured I'd stick to majority of the code style across the repo.

Either way, if you want me to switch it to an uint, let me know and I'll make the change

Comment thread providers/gcs/gcs.go Outdated
// Delete removes the object with the given name.
// Delete removes the object with the given name. RetryAlways overrides the
// default RetryIdempotent policy, which would otherwise exclude Delete because
// we don't pass IfGenerationMatch.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's IfGenerationMatch?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

IfGenerationMatch is GCS's way of handling race conditions for operations that would otherwise require atomicity (see https://docs.cloud.google.com/storage/docs/request-preconditions#precondition_criteria). Since we are not passing such configuration, the default policy is to not retry 5xx errors for delete operations.

I've updated the comment to explain this inline rather than just naming it.

The previous wording named IfGenerationMatch without saying what it is,
so the reason RetryAlways is needed wasn't self-contained.

Signed-off-by: Luis Larco <langelo91@gmail.com>
@llarco
llarco requested a review from GiedriusS October 6, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants