Skip to content

fix: back off Consul CAS retries during KV outages - #7893

Open
mvanhorn wants to merge 5 commits into
cortexproject:masterfrom
mvanhorn:fix/4697-cortex-consul-cas-error-backoff
Open

mvanhorn wants to merge 5 commits into
cortexproject:masterfrom
mvanhorn:fix/4697-cortex-consul-cas-error-backoff

Conversation

@mvanhorn

Copy link
Copy Markdown

Motivation

During Consul outages, distributors can send rapid bursts of retries that amplify load and prolong rate limiting, delaying recovery.

Failed reads and writes in the Consul CAS loop immediately continued to the next attempt without error backoff. The optional retry delay also used an unconditional sleep that could not be interrupted by context cancellation.

What this changes

CAS now waits with the existing backoff configuration before retrying failed reads or writes, preserving the backoff across successful reads when writes continue to fail. Conflicts and callback retries do not trigger error backoff. Retry waits honor context cancellation, with additional cancellation checks before reads, writes, and the final error return. A regression test checks that two reads receiving HTTP 429 are separated by at least one second and never invoke the callback.

How it was tested

gofmt -l pkg/ring/kv/consul/client.go pkg/ring/kv/consul/client_test.go reported no formatting differences. go vet ./pkg/ring/kv/consul/... reported no diagnostics. go test ./pkg/ring/kv/consul/... passed. repo_lint ran three times with no output shown. make BUILD_IN_CONTAINER=false lint ran with no output shown. make BUILD_IN_CONTAINER=false mod-check ran with no output shown. make BUILD_IN_CONTAINER=false check-protos ran with no output shown. make BUILD_IN_CONTAINER=false check-doc ran with no output shown. make BUILD_IN_CONTAINER=false check-white-noise ran with no output shown. The tests in pkg/ring/kv/consul/client_test.go fail without the fix in the cortex change (checked by reverting the source changes).

Fixes #4697

AI was used for assistance.

CAS now waits with the existing backoff configuration before retrying
failed reads or writes, preserving the backoff across successful reads
when writes continue to fail. Conflicts and callback retries do not
trigger error backoff. Retry waits honor context cancellation, with
additional cancellation checks before reads, writes, and the final error
return. A regression test checks that two reads receiving HTTP 429 are
separated by at least one second and never invoke the callback.

Fixes cortexproject#4697

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn
mvanhorn requested a review from a team as a code owner October 25, 2026 07:47
@mvanhorn
mvanhorn requested a review from yeya24 October 25, 2026 07:47

@sandy2008 sandy2008 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.

Thanks for picking up #4697. The backoff and ctx handling work as described, and TestCASBackoff fails without the change. My main concern is how long a CAS can now take; details inline.

Comment thread pkg/ring/kv/consul/client.go Outdated
index := uint64(0)
// Keep error backoff across attempts: a successful read doesn't imply
// that writes have recovered. Conflicts and callback retries don't wait.
errorBackoff := backoff.New(ctx, backoffConfig)

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.

backoffConfig is the 1s→1m backoff meant for the endless watch loops. Inside CAS, with the default 10 attempts, it adds up to 159–302s of waiting when Consul fails fast: against an httptest server that always returns 429, one CAS took 3m27s here vs ~2ms before. Several CAS callers pass context.Background() (Lifecycler.stopping for LEAVING/heartbeats/unregister, BasicLifecycler.stopping, autoJoin, initRing), so the new ctx handling doesn't bound them, and a shutdown during a KV outage can hang for minutes per call. A heartbeat can also still be in a 32–60s wait after Consul recovers, which is close to the default 1m heartbeat timeout.

Could CAS get its own much shorter backoff (e.g. sub-second min and a few seconds max, or a total budget around the heartbeat period)? Throttling just the HA tracker path, which is what #4697 is about, would be another option.

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.

Agreed, 5996434 gives CAS its own backoff: 100ms min, 2s max. The watch loops keep the 1s to 1m backoffConfig. At the default 10 attempts that is at most 9 waits, roughly 9.5s to 13s in total, well under the 1m heartbeat timeout. That bounds the context.Background() callers you listed too, and a heartbeat is never more than 2s behind a recovered Consul.

Comment thread CHANGELOG.md Outdated

## master / unreleased

* [BUGFIX] Consul: Back off CAS retries after failed reads or writes to reduce request amplification during outages, and honor context cancellation while waiting to retry.

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.

Could you move this below the existing [BUGFIX] entries (the order is CHANGE, FEATURE, ENHANCEMENT, BUGFIX), end it with #7893, and drop the extra blank line after it?

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.

Done in 5996434: the entry is now the last [BUGFIX] item, ends with #7893, and the extra blank line is gone.

return nil
}

func TestCASBackoff(t *testing.T) {

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.

This only covers failed reads. With the same httptest server it'd be cheap to also cover failed writes after a successful read (the delay should keep growing), ctx cancellation during the wait (CAS should return context.Canceled well before the backoff elapses), and a CAS conflict being retried without waiting.

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.

Added in 5996434, all against the same httptest server:

  • TestCASBackoffAfterFailedWrites: reads succeed, writes return 429. It checks the gaps between writes grow (at least 100ms, 200ms, 400ms).
  • TestCASBackoffContextCanceledDuringWait: with a 1m CAS backoff, cancelling 200ms in returns context.Canceled in about 0.2s after one request.
  • TestCASConflictRetriedWithoutBackoff: three conflicts and then success complete without waiting, under a 1m backoff.

go test ./pkg/ring/kv/consul/ -race passes, and the CAS tests passed 5 runs in a row with -race.

The 1s to 1m backoff is meant for the endless watch loops. Inside CAS,
with the default 10 attempts, it could add minutes of waiting when Consul
fails fast, and several CAS callers (lifecycler shutdown, heartbeats,
autoJoin, initRing) pass context.Background(), so the context does not
bound it.

CAS now uses its own backoff (100ms min, 2s max). With the default 10
attempts there are at most 9 waits, totalling less than 13s, well below
the default 1m ring heartbeat timeout. The watch loops keep the existing
backoff.

Add tests for failed writes after successful reads (delay keeps growing),
context cancellation during the wait, and CAS conflicts being retried
without waiting. Move the CHANGELOG entry below the existing BUGFIX
entries and reference the PR.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@pull-request-size pull-request-size Bot added size/L and removed size/M labels Oct 28, 2026

@sandy2008 sandy2008 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.

Re-checked at 5996434. CAS now has its own 100ms to 2s backoff, so with the default 10 attempts the worst case is 9 waits adding up to 9.5–13s: against a server that always returns 429 I measured about 11s per CAS (it was 3m27s before), and a heartbeat-style 5s deadline returns context deadline exceeded at 5s. The CHANGELOG entry is now the last BUGFIX under master / unreleased and ends with #7893. Of the new tests, TestCASBackoff, the failed-writes test and the cancellation test fail with the CAS changes reverted, the conflict test guards against over-waiting, and the package passes with -race -count=3. One tiny nit on a comment inline.

Comment thread pkg/ring/kv/consul/client.go Resolved
…onsul-cas-error-backoff

Signed-off-by: Matt Van Horn <mvanhorn@gmail.com>

# Conflicts:
#	CHANGELOG.md
The jitter range starts at [100ms, 200ms) and doubles, so the upper
bounds of the first four waits are 200ms, 400ms, 800ms and 1.6s, then
2s for the remaining five, which totals 13s.

Signed-off-by: Matt Van Horn <mvanhorn@gmail.com>
@mvanhorn

Copy link
Copy Markdown
Author

Merged master into the branch to clear the CHANGELOG conflict. The Consul entry stays as the last BUGFIX under master / unreleased, after the new querier entry. Also took the comment nit in 0a53279. go test ./pkg/ring/kv/consul/ passes and go vet is clean on the merged head.

…onsul-cas-error-backoff

Signed-off-by: Matt Van Horn <mvanhorn@gmail.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HA Tracker KV Store usage not rate-limited

2 participants