Skip to content

persist: hedge slow blob gets to absorb dead-connection stalls - #38077

Merged
ggevay merged 4 commits into
MaterializeInc:mainfrom
ggevay:hedged-blob-gets
Aug 25, 2026
Merged

persist: hedge slow blob gets to absorb dead-connection stalls#38077
ggevay merged 4 commits into
MaterializeInc:mainfrom
ggevay:hedged-blob-gets

Conversation

@ggevay

@ggevay ggevay commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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:

  • HedgedBlob sits below Tasked/MetricsBlob in the blob stack, so loser cancellation is real and a hedged get is one blob_get at the winner's latency.
  • open_hedge_sibling encodes per-backend sibling construction (Isolated for S3/Azure, SharedWithPrimary for file/mem/turmoil, best-effort degradation to Unavailable).
  • Amplification is bounded by a concurrency cap (default 2, sized to the warm-socket count and the fetch semaphore's blindness to hedge bytes) and a token bucket (32-hedge burst, 1% long-run ratio).
  • Enabling hedging inverts the old detection signals: mz_persist_s3_read_timeouts and the SDK poisoning log lines go quiet and hedges_won replaces them as the detector for this class.
  • Flag state: initially off in production, on across CI with a 10ms delay (randomized 0s variant hedges every get); parallel-workload flips the flags at runtime; benchmark harnesses pin the production-enablement configuration (on, 2s, 0.01) so benchmark history accumulates ahead of rollout.

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_test conformance 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:

@ggevay ggevay added the A-PERSIST Area: persistence of (meta)data label Aug 6, 2026
@ggevay
ggevay force-pushed the hedged-blob-gets branch from ded91ae to 76f55c1 Compare August 6, 2026 12:56

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

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"]

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.

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

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: 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.

Comment thread src/persist/src/hedge.rs Outdated
Comment on lines +151 to +161
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))

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.

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.

Suggested change
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)
}

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. Kept the early return for the no-slot case, otherwise as suggested.

Comment thread src/persist/src/hedge.rs Outdated
}
let _ = self
.micro_tokens
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |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.

Relaxed ordering here too

Suggested change
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |t| {
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |t| {

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 for all the budget atomics (acquire, replenish, guard drop).

Comment thread src/persist/src/hedge.rs Outdated

impl Drop for HedgeGuard<'_> {
fn drop(&mut self) {
self.0.concurrent.fetch_sub(1, Ordering::SeqCst);

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.

Suggested change
self.0.concurrent.fetch_sub(1, Ordering::SeqCst);
self.0.concurrent.fetch_sub(1, Ordering::Relaxed);

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 (part of the sweep above).

Comment thread src/persist/src/hedge.rs Outdated
// 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

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.

There is a dedicated method for this https://docs.rs/tokio/latest/tokio/time/fn.timeout.html

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

Comment thread src/persist/src/hedge.rs Outdated
Ok(res)
}
Either::Left((Err(primary_err), hedge)) => {
// The primary failed after the hedge fired. If the hedge is

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.

Why isn't this branch symmetric to the one below? i.e I would expect this to be just hedge.await plus the metrics

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 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 in retry_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.

ggevay and others added 3 commits August 23, 2026 16:46
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>
@ggevay
ggevay marked this pull request as ready for review August 23, 2026 15:37
@ggevay
ggevay requested review from a team as code owners August 23, 2026 15:37
@ggevay

ggevay commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

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.

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>
@def-

def- commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- The concurrency guard does not bound hedge memory

src/persist/src/hedge.rs:148

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.

Details

The 128 MiB persist_blob_target_size used to justify the default of two slots is explicitly best-effort and does not apply to an individual update (src/persist-client/src/batch.rs:415). The columnar representation permits key/value buffers up to i32::MAX bytes (src/persist/src/indexed/columnar.rs:46). Rollups and hollow runs are also read through this wrapper without the batch fetch semaphore. For batch reads, the caller acquires permits once for the logical blob's encoded size before calling blob.get (src/persist-client/src/fetch.rs:228), but the hedge can concurrently build another complete SegmentedBytes that those permits do not cover. The full initial 32-token bucket admits this immediately, with up to two oversized duplicates in flight. A large incompressible row or large state blob that takes longer than the two-second delay can therefore add hundreds of MiB or more beyond the memory budget. This needs byte-aware admission, a hard hedge-size limit, or chunk-level hedging. Counting calls cannot establish the claimed memory or egress bound.

@ggevay

ggevay commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

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: Blob::get(key) carries no size metadata, so the wrapper learns the size only when the bytes arrive, and plumbing a size hint through would extend the correctness-critical Blob trait (rejected in Alternatives). The remedy that genuinely bounds bytes is per-chunk hedging, which is the recorded follow-up. Sustained duplication of large parts is separately throttled by the budget (one percent of gets).

@ggevay

ggevay commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the review!

@ggevay
ggevay merged commit db15d3b into MaterializeInc:main Aug 25, 2026
83 checks passed
ggevay added a commit that referenced this pull request Aug 25, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-PERSIST Area: persistence of (meta)data

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants