persist: hedge slow blob gets to absorb dead-connection stalls - #38077
Conversation
ded91ae to
76f55c1
Compare
petrosagg
left a comment
There was a problem hiding this comment.
Overall shape looks good!
| # Full refill so the delay=0s variant keeps hedging instead of | ||
| # draining the budget after the first few gets. | ||
| VariableSystemParameter( | ||
| "persist_blob_hedged_get_budget_ratio", "1.0", ["1.0", "0.01"] |
There was a problem hiding this comment.
If the 10ms setting above is low enough to cause ~every request to hedge then setting this to one means CI will be doing double the network traffic, potentially messing up with other measurements and results. I'd keep this ratio to the production value of 0.01 in CI
There was a problem hiding this comment.
Done: the CI default is 0.01 now. Hedges still fire in every run (budget-capped at 1% of gets), and 1.0 stays in the variant list so randomized runs can pair a full budget with delay=0s.
| let took_token = self | ||
| .micro_tokens | ||
| .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |t| { | ||
| t.checked_sub(HEDGE_COST_MICRO_TOKENS) | ||
| }) | ||
| .is_ok(); | ||
| if !took_token { | ||
| self.concurrent.fetch_sub(1, Ordering::SeqCst); | ||
| return Err(HedgeRefused::Budget); | ||
| } | ||
| Ok(HedgeGuard(self)) |
There was a problem hiding this comment.
Since the guard is responsible for encoding the restoration of the slot counter you can construct it as soon as we successfully get the slot and rely on its drop for cleanup.
| let took_token = self | |
| .micro_tokens | |
| .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |t| { | |
| t.checked_sub(HEDGE_COST_MICRO_TOKENS) | |
| }) | |
| .is_ok(); | |
| if !took_token { | |
| self.concurrent.fetch_sub(1, Ordering::SeqCst); | |
| return Err(HedgeRefused::Budget); | |
| } | |
| Ok(HedgeGuard(self)) | |
| let guard = HedgeGuard(self); | |
| let token_res = self | |
| .micro_tokens | |
| .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |t| { | |
| t.checked_sub(HEDGE_COST_MICRO_TOKENS) | |
| }); | |
| match token_res { | |
| Ok(_) => Ok(guard), | |
| Err(_) => Err(HedgeRefused::Budget) | |
| } |
There was a problem hiding this comment.
Done. Kept the early return for the no-slot case, otherwise as suggested.
| } | ||
| let _ = self | ||
| .micro_tokens | ||
| .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |t| { |
There was a problem hiding this comment.
Relaxed ordering here too
| .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |t| { | |
| .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |t| { |
There was a problem hiding this comment.
Done for all the budget atomics (acquire, replenish, guard drop).
|
|
||
| impl Drop for HedgeGuard<'_> { | ||
| fn drop(&mut self) { | ||
| self.0.concurrent.fetch_sub(1, Ordering::SeqCst); |
There was a problem hiding this comment.
| self.0.concurrent.fetch_sub(1, Ordering::SeqCst); | |
| self.0.concurrent.fetch_sub(1, Ordering::Relaxed); |
There was a problem hiding this comment.
Done (part of the sweep above).
| // with the hedge is never miscredited as a hedge win. tokio::select! | ||
| // does NOT have this property unless marked `biased`. | ||
| if let Either::Left((res, _sleep)) = | ||
| select(primary.as_mut(), std::pin::pin!(tokio::time::sleep(delay))).await |
There was a problem hiding this comment.
There is a dedicated method for this https://docs.rs/tokio/latest/tokio/time/fn.timeout.html
There was a problem hiding this comment.
Done. Timeout polls the wrapped future before checking the deadline, so a primary ready exactly at the boundary still wins without firing a hedge; the NOTE here covers this now.
| Ok(res) | ||
| } | ||
| Either::Left((Err(primary_err), hedge)) => { | ||
| // The primary failed after the hedge fired. If the hedge is |
There was a problem hiding this comment.
Why isn't this branch symmetric to the one below? i.e I would expect this to be just hedge.await plus the metrics
There was a problem hiding this comment.
The asymmetry is deliberate. The principle: the primary's outcome is authoritative, and the hedge is opportunistic, invisible unless it wins. Two consequences here:
- Bounded rather than plain
hedge.await: the window is only there to let an already-healthy hedge win, which takes about one round trip. Beyond that, returning the error puts us on the known-good recovery path (callers wrap gets inretry_external, whose retry on a fresh connection recovers this failure class promptly). An unbounded wait gambles that on the hedge leg's health, up to its 90s attempt timeout in a correlated both-legs event, and pins one of the (default 2) hedge slots for that long. - The primary's error rather than the hedge's outcome: it is exactly what the caller would have seen without hedging. Surfacing the hedge's error would make the error surface depend on whether a hedge happened to fire (and the hedge's failure is already logged).
The mirror branch has no such shortcut: when the hedge errors, we hold no outcome for the caller's request, so awaiting the primary (bounded by its own timeouts) is the only transparent option.
I've expanded the code comments to spell this out; primary_error_hedge_timeout pins the timing.
Established pooled connections to the blob store occasionally die in ways that surface only after a multi-second hang, well before any client timeout fires, and one hung get on a hot shard starves everything downstream of it. Add HedgedBlob, a Blob decorator that races a second get on an isolated connection pool (fresh client, fresh DNS, independently warmed) once a get has been in flight for a configurable delay, taking whichever succeeds first. Amplification is bounded by a concurrency cap and a token bucket, and the caller-visible error surface is unchanged. Ships dark: persist_blob_hedged_get_enabled defaults off in production and on in CI (with a 10ms delay so hedges exercise in every run), and benchmarks pin the planned production-enablement configuration. Design doc: doc/developer/design/20260806_hedged_blob_gets.md Linear: PER-57 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address the first round of review comments: * Keep the CI default for persist_blob_hedged_get_budget_ratio at the production value 0.01. With the 10ms CI delay a full refill would hedge nearly every get and double CI blob traffic. The 1.0 variant stays available to randomized runs. * Construct the HedgeGuard as soon as the concurrency slot is won and let its drop release the slot on the budget-refusal path. * Relax the budget atomics to Ordering::Relaxed. Both counters are self-contained: every access is a read-modify-write, and no other memory is published through them. * Use tokio::time::timeout for the primary-vs-delay race. Timeout polls the wrapped future before checking the deadline, which preserves the property that a primary ready exactly at the boundary wins without firing a hedge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The primary-error branch waits only a bounded grace window for the hedge instead of awaiting it unbounded. Spell out why at the decision point (no-hedging baseline, caller-side retries, slot pinning), and record in the module doc that no branch of the race assumes callers retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
76f55c1 to
35ed62b
Compare
|
Thank you for the review! I've addressed the comments, ready for the next review round. Edit: I've also added one more commit, which tweaks the design doc. |
35ed62b to
7b761b0
Compare
Capture the warm-interval analysis (the purge-window mechanism, why abort-and-restart warming probes better than a patient handshake, and why the interval stays at 20 seconds pending evidence), the tuning trade-offs of the remaining parameters (the delay's rescue floor vs its false-fire population, the bucket-capacity sizing rationale, the dead-socket slot-pinning interaction), and open Alternatives with the naive options (the primary's own pool, just lowering the timeouts), each alternative in its own subsection. Calibrate the pool-isolation motivation to the evidence along the way, in the doc and the hedge.rs module doc: same-pool traffic mostly survived the observed events, so the case for isolation is the correlated residual plus what a shared pool forecloses, not blanket fate sharing. Retire the informal "retry ladder" term in favor of naming retry_external. Finish with a conciseness and consistency pass over the whole document: deduplicate facts so one section owns each (the signal handoff, the backstop, the disabled-sibling state, the evidence recaps), drop framing sentences and glosses the audience does not need, and fix writing issues, including that the Blob trait has five methods, not six. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7b761b0 to
73efe56
Compare
QA LLM Review1. MEDIUM -- The concurrency guard does not bound hedge memory
The guard charges every hedge one slot regardless of response size, so it does not provide the advertised memory bound. A slow oversized batch part, hollow run, or rollup can therefore allocate a second full response outside the existing fetch accounting and OOM a replica when hedging is enabled. DetailsThe 128 MiB |
|
The mechanism is the documented trade rather than a missed one: the design doc's "Bounding amplification" section states that the hedge leg's buffers are invisible to the fetch-path accounting and that the concurrency cap is what bounds the unaccounted transient buffers, to about two parts. Two refinements here are fair, though: that bound inherits the best-effort nature of the 128 MiB target (a single oversized update raises the ceiling with it), and rollup and hollow-run gets sit outside the fetch semaphore for the primary leg too, so hedging doubles an already-unaccounted read there, still capped at two in flight. Byte-aware admission is not implementable at this layer: |
|
Thank you for the review! |
…#38448) #38180 made the scope a required argument of `Config::new`. It landed after #38077's last CI run and before its squash merge, so `main` currently fails to compile `mz-persist` (five E0061 errors in `hedge.rs`, [test build 132566](https://buildkite.com/materialize/test/builds/132566)). The hedge configs are `Environment`-scoped like every other persist config, since the same client code runs in `environmentd` and `clusterd`. No functional change. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Persist reads batch parts, rollups, and the txns shard through
Blob::get. We characterized a failure class where established pooled S3 connections die by TCP reset after a 5-15s hang, before any client timeout fires, and a single hung get on a hot shard starves a whole replica. This PR adds hedged blob gets: after a dyncfg delay (default 2s), a second request for the same key fires on a fully isolated sibling client (own pool, DNS, credentials, kept warm while enabled) and the first success wins.Design doc (rendered) - motivation and evidence, the race and its error semantics, pool isolation per backend, the two amplification guards, correctness argument, observability, and rejected alternatives.
Highlights for review:
HedgedBlobsits belowTasked/MetricsBlobin the blob stack, so loser cancellation is real and a hedged get is oneblob_getat the winner's latency.open_hedge_siblingencodes per-backend sibling construction (Isolatedfor S3/Azure,SharedWithPrimaryfor file/mem/turmoil, best-effort degradation toUnavailable).mz_persist_s3_read_timeoutsand the SDK poisoning log lines go quiet andhedges_wonreplaces them as the detector for this class.Tests added: a unit suite driving the race on tokio's paused clock (win/cancellation timing, error asymmetry incl. the grace window, budget exhaustion and refill, concurrency-slot release on dropped gets, warmer gating and cadence), a
blob_impl_testconformance run with a hedge firing and winning on every get, and an env-gated variant against real S3 with two independent clients.Rollout note: enablement is per environment via LaunchDarkly and requires the LD flags to be created first (the five dyncfgs are allowlisted in the LD-consistency test until then).
Fixes PER-57
Nightly: