Skip to main content

mz_persist/
hedge.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! A [Blob] decorator that hedges slow `get` requests.
11//!
12//! Established connections to the blob store occasionally die in ways that
13//! surface only after multiple seconds (a TCP reset after a hang, or a black
14//! hole), well before any client timeout fires. A `get` riding such a
15//! connection stalls everything downstream of it, while other connections on
16//! the same process serve the same store normally. The mitigation, endorsed by
17//! the major object stores for idempotent reads, is a hedged request: if the
18//! first `get` has not completed within a short delay, race a second one on a
19//! connection the first cannot have poisoned, and take whichever succeeds
20//! first.
21//!
22//! Only `get` is hedged. All other [Blob] methods are forwarded to the
23//! primary handle untouched: writes, deletes, and restores have side
24//! effects, and lists are not latency-critical enough to justify racing a
25//! streaming interface. Extending hedging to any of them is forbidden.
26//!
27//! The hedge handle must not share a connection pool (or DNS state) with the
28//! primary, otherwise the hedge can be handed a connection dying in the same
29//! event that stalled the primary, exactly when a hedge matters most. See
30//! [crate::cfg::open_hedge_sibling] for how that isolation is constructed
31//! per backend.
32//!
33//! Hedging operates within a single `retry_external` attempt, before any
34//! failure surfaces. The retrying in `retry_external`, which is what
35//! recovers this failure class when hedging is off (at the cost of the full
36//! hang), stays untouched as the backstop. The governing principle for
37//! every race below: the primary's outcome is authoritative, and the hedge
38//! is opportunistic, invisible unless it wins. Nothing here assumes callers
39//! retry: every branch of the race degrades to the outcome of the un-hedged
40//! get, delayed by at most one hedge delay, so a caller that treats a get
41//! error as fatal sees the same error it would have seen without hedging,
42//! at most that one delay later.
43//!
44//! NOTE: enabling hedging largely suppresses the old fingerprints of the
45//! dead-connection class (client timeout counters, the SDK's
46//! connection-poisoning log lines), because the hung request is cancelled
47//! before they trigger. The `hedges_won` counter is the replacement signal.
48
49use std::sync::Arc;
50use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
51use std::time::{Duration, Instant};
52
53use async_trait::async_trait;
54use bytes::Bytes;
55use futures_util::future::{Either, select};
56use mz_dyncfg::{Config, ConfigSet, ParameterScope};
57use mz_ore::bytes::SegmentedBytes;
58use mz_ore::cast::CastLossy;
59use mz_ore::task::AbortOnDropHandle;
60use tracing::{debug, warn};
61
62use crate::location::{BLOB_GET_LIVENESS_KEY, Blob, BlobMetadata, ExternalError};
63use crate::metrics::BlobHedgeMetrics;
64
65pub(crate) const BLOB_HEDGED_GET_ENABLED: Config<bool> = Config::new(
66    "persist_blob_hedged_get_enabled",
67    false,
68    "Whether to hedge slow blob gets with a second request on a separate \
69    connection pool (Materialize).",
70    ParameterScope::Environment,
71);
72
73pub(crate) const BLOB_HEDGED_GET_DELAY: Config<Duration> = Config::new(
74    "persist_blob_hedged_get_delay",
75    Duration::from_secs(2),
76    "How long a blob get may be in flight before a hedge request is fired \
77    (Materialize).",
78    ParameterScope::Environment,
79);
80
81pub(crate) const BLOB_HEDGED_GET_MAX_CONCURRENT: Config<usize> = Config::new(
82    "persist_blob_hedged_get_max_concurrent",
83    // Bounds the extra in-flight bytes (which the fetch memory semaphore
84    // cannot see) to about two batch parts. The warmer holds this many
85    // sockets open, so every admitted hedge can be served warm.
86    2,
87    "Maximum concurrent hedge requests per blob handle, bounding the memory \
88    held by raced gets and the number of warm sockets (Materialize).",
89    ParameterScope::Environment,
90);
91
92pub(crate) const BLOB_HEDGED_GET_BUDGET_RATIO: Config<f64> = Config::new(
93    "persist_blob_hedged_get_budget_ratio",
94    0.01,
95    "Long-run bound on hedge requests as a fraction of blob gets \
96    (Materialize).",
97    ParameterScope::Environment,
98);
99
100// NOTE: the warmer only runs while hedging is enabled, so `enabled` stops
101// its traffic too. Setting this knob to 0 additionally stops the warmer
102// while keeping hedging on, which is why it must be changeable at runtime.
103pub(crate) const BLOB_HEDGED_GET_WARM_INTERVAL: Config<Duration> = Config::new(
104    "persist_blob_hedged_get_warm_interval",
105    Duration::from_secs(20),
106    "How often to issue liveness gets that keep the hedge connection pool \
107    warm, 0 disables warming without disabling hedging (Materialize).",
108    ParameterScope::Environment,
109);
110
111/// The cost of one hedge in bucket tokens. Micro-token granularity keeps
112/// small `budget_ratio` values (down to 1e-6) from rounding to "never
113/// refill".
114const HEDGE_COST_MICRO_TOKENS: u64 = 1_000_000;
115
116/// Token-bucket capacity: 32 hedges.
117///
118/// The bucket's shape, not an operational lever: the tuning lever is
119/// `persist_blob_hedged_get_budget_ratio` and the kill switch is
120/// `persist_blob_hedged_get_enabled`. NOTE: because the bucket starts full,
121/// `budget_ratio = 0` still permits ~32 banked hedges before draining. It is
122/// not an instant stop, `enabled` is.
123const BUDGET_BURST_MICRO_TOKENS: u64 = 32 * HEDGE_COST_MICRO_TOKENS;
124
125/// Why a hedge was not fired for a get that exceeded the delay.
126enum HedgeRefused {
127    Concurrency,
128    Budget,
129}
130
131/// Bounds hedge amplification with two independent guards: the concurrency
132/// cap bounds memory held by raced gets, the token bucket bounds long-run
133/// request-rate/egress amplification (e.g. a store-wide brownout making
134/// every get slow, or large gets that legitimately exceed the delay, must
135/// not settle into hedging every request).
136#[derive(Debug)]
137struct HedgeBudget {
138    concurrent: AtomicUsize,
139    micro_tokens: AtomicU64,
140}
141
142impl HedgeBudget {
143    fn new() -> Self {
144        HedgeBudget {
145            concurrent: AtomicUsize::new(0),
146            micro_tokens: AtomicU64::new(BUDGET_BURST_MICRO_TOKENS),
147        }
148    }
149
150    /// Attempts to acquire both guards. The returned guard releases the
151    /// concurrency slot on drop. Spent tokens come back only via
152    /// [HedgeBudget::replenish].
153    fn try_acquire(&self, max_concurrent: usize) -> Result<HedgeGuard<'_>, HedgeRefused> {
154        let got_slot = self
155            .concurrent
156            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |c| {
157                (c < max_concurrent).then_some(c + 1)
158            })
159            .is_ok();
160        if !got_slot {
161            return Err(HedgeRefused::Concurrency);
162        }
163        // Constructed before the token take so its drop releases the slot
164        // on the budget-refusal path.
165        let guard = HedgeGuard(self);
166        let took_token = self
167            .micro_tokens
168            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |t| {
169                t.checked_sub(HEDGE_COST_MICRO_TOKENS)
170            })
171            .is_ok();
172        if took_token {
173            Ok(guard)
174        } else {
175            Err(HedgeRefused::Budget)
176        }
177    }
178
179    /// Adds `ratio` tokens, called once per completed get (hedged or not),
180    /// so under sustained slowness hedging settles at `ratio` of traffic.
181    fn replenish(&self, ratio: f64) {
182        let add = u64::cast_lossy(ratio.clamp(0.0, 1.0) * f64::cast_lossy(HEDGE_COST_MICRO_TOKENS));
183        if add == 0 {
184            return;
185        }
186        let _ = self
187            .micro_tokens
188            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |t| {
189                Some((t + add).min(BUDGET_BURST_MICRO_TOKENS))
190            });
191    }
192}
193
194struct HedgeGuard<'a>(&'a HedgeBudget);
195
196impl Drop for HedgeGuard<'_> {
197    fn drop(&mut self) {
198        self.0.concurrent.fetch_sub(1, Ordering::Relaxed);
199    }
200}
201
202/// The sibling handle a [HedgedBlob] runs hedge requests on, produced by
203/// [crate::cfg::open_hedge_sibling].
204#[derive(Debug)]
205pub enum HedgeSibling {
206    /// A handle onto the same durable store with fully separate connection
207    /// state, kept warm by the wrapper.
208    Isolated(Arc<dyn Blob>),
209    /// The backend has no connection state to isolate (or a second open
210    /// would observe a different store): hedge on the primary instance
211    /// itself, with nothing to warm.
212    SharedWithPrimary,
213    /// Opening the sibling failed: hedging is unavailable for this process
214    /// lifetime.
215    Unavailable,
216}
217
218/// A [Blob] decorator that hedges slow `get` requests, per the module docs.
219#[derive(Debug)]
220pub struct HedgedBlob {
221    primary: Arc<dyn Blob>,
222    /// The handle hedge requests run on. `None` = hedging unavailable,
223    /// visible as `hedges_skipped{reason="unavailable"}`.
224    hedge: Option<Arc<dyn Blob>>,
225    cfg: Arc<ConfigSet>,
226    metrics: BlobHedgeMetrics,
227    budget: HedgeBudget,
228    _warmer: Option<AbortOnDropHandle<()>>,
229}
230
231/// Keeps the sibling's connection pool warm with periodic concurrent
232/// liveness gets, while hedging is enabled. A cold hedge can stall up to
233/// the connect timeout, which during correlated connection events is
234/// exactly when it must not. While hedging is disabled the warmer idles
235/// and the sibling sees no traffic at all, so a freshly enabled flag can
236/// find a cold pool for up to one warm interval plus a handshake. Hedges
237/// in that window are merely no better than no hedge, never worse.
238fn spawn_warmer(
239    hedge: Arc<dyn Blob>,
240    cfg: Arc<ConfigSet>,
241    metrics: BlobHedgeMetrics,
242) -> AbortOnDropHandle<()> {
243    mz_ore::task::spawn(|| "persist::blob_hedge_warmer", async move {
244        loop {
245            let interval = BLOB_HEDGED_GET_WARM_INTERVAL.get(&cfg);
246            if !BLOB_HEDGED_GET_ENABLED.get(&cfg) || interval == Duration::ZERO {
247                // Nothing to keep warm. Re-check at the configured cadence
248                // (or its default while warming is set to 0), so a dyncfg
249                // flip takes effect without a restart.
250                let recheck = if interval == Duration::ZERO {
251                    *BLOB_HEDGED_GET_WARM_INTERVAL.default()
252                } else {
253                    interval
254                };
255                tokio::time::sleep(recheck).await;
256                continue;
257            }
258            // Ping first, sleep second, so the pool is warm from process
259            // start. As many concurrent pings as hedges can run at once
260            // (HTTP/1.1 allows one in-flight request per connection, so N
261            // concurrent pings force N warm sockets).
262            let start = Instant::now();
263            let sockets = BLOB_HEDGED_GET_MAX_CONCURRENT.get(&cfg);
264            let pings = (0..sockets).map(|_| hedge.get(BLOB_GET_LIVENESS_KEY));
265            let pings = futures_util::future::join_all(pings);
266            // Bound the cycle: an unbounded hung ping would block warming
267            // past hyper's pool idle eviction, going cold exactly during the
268            // correlated events warming exists for. The timeout also drops
269            // the hung request, which closes its dying socket.
270            match tokio::time::timeout(interval, pings).await {
271                Ok(results) if results.iter().all(|r| r.is_ok()) => {
272                    metrics.rtt_latency.set(start.elapsed().as_secs_f64());
273                }
274                Ok(_) | Err(_) => {
275                    // A failing or hung warm path means hedges cannot be
276                    // trusted to be fast. Surface it, and do not update the
277                    // gauge: a fast failure must not report as a fast
278                    // healthy path.
279                    metrics.warm_errors.inc();
280                }
281            }
282            tokio::time::sleep(interval).await;
283        }
284    })
285    .abort_on_drop()
286}
287
288impl HedgedBlob {
289    /// Returns a new [HedgedBlob].
290    ///
291    /// Must be called from within a tokio runtime: it spawns the sibling
292    /// warming task.
293    pub fn new(
294        primary: Arc<dyn Blob>,
295        sibling: HedgeSibling,
296        cfg: Arc<ConfigSet>,
297        metrics: BlobHedgeMetrics,
298    ) -> HedgedBlob {
299        let (hedge, warmer) = match sibling {
300            HedgeSibling::Isolated(h) => {
301                let warmer = spawn_warmer(Arc::clone(&h), Arc::clone(&cfg), metrics.clone());
302                (Some(h), Some(warmer))
303            }
304            HedgeSibling::SharedWithPrimary => (Some(Arc::clone(&primary)), None),
305            HedgeSibling::Unavailable => (None, None),
306        };
307        metrics.armed.set(i64::from(hedge.is_some()));
308        HedgedBlob {
309            primary,
310            hedge,
311            cfg,
312            metrics,
313            budget: HedgeBudget::new(),
314            _warmer: warmer,
315        }
316    }
317
318    /// Returns the sibling handle and a budget guard, or `None` (having
319    /// already recorded why) if this get must not hedge.
320    fn admit(&self) -> Option<(&Arc<dyn Blob>, HedgeGuard<'_>)> {
321        let Some(hedge_blob) = &self.hedge else {
322            self.metrics.skipped_unavailable.inc();
323            return None;
324        };
325        match self
326            .budget
327            .try_acquire(BLOB_HEDGED_GET_MAX_CONCURRENT.get(&self.cfg))
328        {
329            Ok(guard) => Some((hedge_blob, guard)),
330            Err(HedgeRefused::Concurrency) => {
331                self.metrics.skipped_concurrency.inc();
332                None
333            }
334            Err(HedgeRefused::Budget) => {
335                self.metrics.skipped_budget.inc();
336                None
337            }
338        }
339    }
340
341    fn record_win(&self, key: &str, start: Instant) {
342        self.metrics.won.inc();
343        self.metrics
344            .won_seconds
345            .observe(start.elapsed().as_secs_f64());
346        debug!(%key, elapsed = ?start.elapsed(), "blob get won by hedge request");
347    }
348
349    async fn get_hedged(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
350        let start = Instant::now();
351        let delay = BLOB_HEDGED_GET_DELAY.get(&self.cfg);
352        let mut primary = std::pin::pin!(self.primary.get(key));
353        // NOTE: `Timeout` polls the wrapped future before checking the
354        // deadline, so a primary that is ready exactly at the delay
355        // boundary wins here without firing a hedge. A deadline-first
356        // combinator would not be incorrect, just wasteful: it would fire
357        // a redundant hedge (spending budget and skewing metrics) whenever
358        // the primary completes right at the boundary.
359        if let Ok(res) = tokio::time::timeout(delay, primary.as_mut()).await {
360            // A fast error is returned verbatim: hedging targets hangs,
361            // not failures.
362            return res;
363        }
364        let Some((hedge_blob, guard)) = self.admit() else {
365            return primary.await;
366        };
367        self.metrics.fired.inc();
368        let mut hedge = std::pin::pin!(hedge_blob.get(key));
369        // The losing future is dropped, which cancels the request in flight
370        // (there is no task boundary between here and the backend). An error
371        // on one leg does not end the race: the slow leg is expected to be a
372        // hung request, and a fast-failing hedge must not convert a get that
373        // was about to succeed into an error.
374        // NOTE: `select` polls its first argument first, so a primary that
375        // is ready simultaneously with the hedge is never miscredited as a
376        // hedge win, which matters because hedges_won is the detection
377        // signal that replaces the suppressed timeout counters (see the
378        // module doc). tokio::select! does NOT have this property unless
379        // marked `biased`.
380        match select(primary.as_mut(), hedge.as_mut()).await {
381            Either::Left((Ok(res), _hedge)) => Ok(res),
382            Either::Right((Ok(res), _primary)) => {
383                self.record_win(key, start);
384                Ok(res)
385            }
386            Either::Left((Err(primary_err), hedge)) => {
387                // The primary failed after the hedge fired. Give the hedge
388                // a bounded grace window before returning the error: the
389                // window is only there to let an already-healthy hedge win,
390                // which takes about one round trip; reusing the hedge delay
391                // as its length caps the added latency of every branch at
392                // one delay (see the module doc). Beyond that, returning the
393                // primary's error is the better move: it is the un-hedged
394                // outcome, and the callers that wrap gets in retry_external
395                // recover this failure class promptly on a fresh
396                // connection, while an unbounded wait would gamble that
397                // recovery on the hedge leg's health, holding the get and
398                // its hedge slot for up to the blob client's per-attempt
399                // timeout when both legs are unhealthy. The guard stays
400                // held across the window on purpose: the hedge is still in
401                // flight, so the slot still bounds real work (contrast the
402                // hedge-error branch below).
403                match tokio::time::timeout(delay, hedge).await {
404                    Ok(Ok(res)) => {
405                        self.record_win(key, start);
406                        Ok(res)
407                    }
408                    Ok(Err(hedge_err)) => {
409                        self.metrics.errors.inc();
410                        warn!(%key, %hedge_err, "hedged blob get: both requests failed");
411                        // Do not attach the hedge error as context (the
412                        // warning above records it): the error surface
413                        // must not depend on whether a hedge fired (see
414                        // the module doc), and hedge text mentioning
415                        // timeouts could make a string-matching consumer
416                        // like ExternalError::is_timeout misclassify a
417                        // non-timeout error.
418                        Err(primary_err)
419                    }
420                    Err(_elapsed) => {
421                        warn!(%key, "hedged blob get: primary failed, hedge still pending");
422                        Err(primary_err)
423                    }
424                }
425            }
426            Either::Right((Err(hedge_err), primary)) => {
427                self.metrics.errors.inc();
428                warn!(%key, %hedge_err, "hedge request failed, awaiting primary");
429                // The hedge leg is gone, so the concurrency slot no longer
430                // bounds any in-flight memory. Release it rather than
431                // pinning it for the primary's remaining hang, which could
432                // starve other gets of their hedges during exactly the
433                // events hedging exists for.
434                drop(guard);
435                primary.await
436            }
437        }
438    }
439}
440
441#[async_trait]
442impl Blob for HedgedBlob {
443    async fn get(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
444        if !BLOB_HEDGED_GET_ENABLED.get(&self.cfg) {
445            return self.primary.get(key).await;
446        }
447        let res = self.get_hedged(key).await;
448        self.budget
449            .replenish(BLOB_HEDGED_GET_BUDGET_RATIO.get(&self.cfg));
450        res
451    }
452
453    async fn list_keys_and_metadata(
454        &self,
455        key_prefix: &str,
456        f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
457    ) -> Result<(), ExternalError> {
458        self.primary.list_keys_and_metadata(key_prefix, f).await
459    }
460
461    async fn set(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
462        self.primary.set(key, value).await
463    }
464
465    async fn delete(&self, key: &str) -> Result<Option<usize>, ExternalError> {
466        self.primary.delete(key).await
467    }
468
469    async fn restore(&self, key: &str) -> Result<(), ExternalError> {
470        self.primary.restore(key).await
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use std::sync::atomic::{AtomicUsize, Ordering};
477
478    use anyhow::anyhow;
479    use mz_dyncfg::ConfigUpdates;
480    use mz_ore::metrics::MetricsRegistry;
481
482    use crate::location::tests::blob_impl_test;
483    use crate::mem::MemMultiRegistry;
484
485    use super::*;
486
487    /// A test [Blob] whose `get` sleeps a fixed delay and then returns a
488    /// fixed outcome, counting calls.
489    #[derive(Debug)]
490    struct TestBlob {
491        delay: Duration,
492        outcome: Result<Option<&'static str>, &'static str>,
493        gets: AtomicUsize,
494    }
495
496    impl TestBlob {
497        fn new(
498            delay: Duration,
499            outcome: Result<Option<&'static str>, &'static str>,
500        ) -> Arc<TestBlob> {
501            Arc::new(TestBlob {
502                delay,
503                outcome,
504                gets: AtomicUsize::new(0),
505            })
506        }
507    }
508
509    #[async_trait]
510    impl Blob for TestBlob {
511        async fn get(&self, _key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
512            self.gets.fetch_add(1, Ordering::SeqCst);
513            tokio::time::sleep(self.delay).await;
514            match self.outcome {
515                Ok(x) => Ok(x.map(|x| SegmentedBytes::from(Bytes::from(x)))),
516                Err(msg) => Err(ExternalError::from(anyhow!(msg))),
517            }
518        }
519
520        async fn list_keys_and_metadata(
521            &self,
522            _key_prefix: &str,
523            _f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
524        ) -> Result<(), ExternalError> {
525            unreachable!("test blob only supports get")
526        }
527
528        async fn set(&self, _key: &str, _value: Bytes) -> Result<(), ExternalError> {
529            unreachable!("test blob only supports get")
530        }
531
532        async fn delete(&self, _key: &str) -> Result<Option<usize>, ExternalError> {
533            unreachable!("test blob only supports get")
534        }
535
536        async fn restore(&self, _key: &str) -> Result<(), ExternalError> {
537            unreachable!("test blob only supports get")
538        }
539    }
540
541    fn test_cfg(customize: impl FnOnce(&mut ConfigUpdates)) -> Arc<ConfigSet> {
542        let cfg = crate::cfg::all_dyn_configs(ConfigSet::default());
543        let mut updates = ConfigUpdates::default();
544        updates.add(&BLOB_HEDGED_GET_ENABLED, true);
545        customize(&mut updates);
546        updates.apply(&cfg);
547        Arc::new(cfg)
548    }
549
550    fn metrics() -> BlobHedgeMetrics {
551        BlobHedgeMetrics::new(&MetricsRegistry::new())
552    }
553
554    fn hedged(primary: &Arc<TestBlob>, hedge: &Arc<TestBlob>, cfg: Arc<ConfigSet>) -> HedgedBlob {
555        let primary: Arc<dyn Blob> = Arc::<TestBlob>::clone(primary);
556        let hedge: Arc<dyn Blob> = Arc::<TestBlob>::clone(hedge);
557        HedgedBlob::new(primary, HedgeSibling::Isolated(hedge), cfg, metrics())
558    }
559
560    const SECS: fn(u64) -> Duration = Duration::from_secs;
561
562    #[mz_ore::test(tokio::test(start_paused = true))]
563    async fn fast_primary_no_hedge() {
564        let primary = TestBlob::new(SECS(0), Ok(Some("x")));
565        let hedge = TestBlob::new(SECS(0), Ok(Some("x")));
566        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
567        assert!(blob.get("k").await.unwrap().is_some());
568        assert_eq!(hedge.gets.load(Ordering::SeqCst), 0);
569        assert_eq!(blob.metrics.fired.get(), 0);
570    }
571
572    #[mz_ore::test(tokio::test(start_paused = true))]
573    async fn hedge_wins_and_cancels_primary() {
574        let primary = TestBlob::new(SECS(3600), Ok(Some("slow")));
575        let hedge = TestBlob::new(SECS(0), Ok(Some("fast")));
576        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
577        let start = tokio::time::Instant::now();
578        let res = blob.get("k").await.unwrap().expect("some");
579        // The hedge's value won, and it won at exactly the hedge delay, not
580        // at the primary's 3600s: the primary was cancelled while pending.
581        assert_eq!(res.into_contiguous(), b"fast".to_vec());
582        assert_eq!(start.elapsed(), SECS(2));
583        assert_eq!(blob.metrics.fired.get(), 1);
584        assert_eq!(blob.metrics.won.get(), 1);
585        assert_eq!(blob.metrics.won_seconds.get_sample_count(), 1);
586    }
587
588    #[mz_ore::test(tokio::test(start_paused = true))]
589    async fn primary_wins_after_hedge_fired() {
590        let primary = TestBlob::new(SECS(3), Ok(Some("primary")));
591        let hedge = TestBlob::new(SECS(3600), Ok(Some("hedge")));
592        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
593        let res = blob.get("k").await.unwrap().expect("some");
594        assert_eq!(res.into_contiguous(), b"primary".to_vec());
595        assert_eq!(blob.metrics.fired.get(), 1);
596        assert_eq!(blob.metrics.won.get(), 0);
597    }
598
599    #[mz_ore::test(tokio::test(start_paused = true))]
600    async fn hedge_error_does_not_fail_get() {
601        let primary = TestBlob::new(SECS(5), Ok(Some("primary")));
602        let hedge = TestBlob::new(SECS(0), Err("hedge boom"));
603        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
604        // First success wins, not first completion: the hedge fails fast at
605        // the 2s mark but the primary's later success is returned.
606        let res = blob.get("k").await.unwrap().expect("some");
607        assert_eq!(res.into_contiguous(), b"primary".to_vec());
608        assert_eq!(blob.metrics.errors.get(), 1);
609    }
610
611    #[mz_ore::test(tokio::test(start_paused = true))]
612    async fn primary_error_then_hedge_success() {
613        let primary = TestBlob::new(SECS(3), Err("primary boom"));
614        let hedge = TestBlob::new(SECS(2), Ok(Some("hedge")));
615        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
616        let res = blob.get("k").await.unwrap().expect("some");
617        assert_eq!(res.into_contiguous(), b"hedge".to_vec());
618        assert_eq!(blob.metrics.won.get(), 1);
619    }
620
621    #[mz_ore::test(tokio::test(start_paused = true))]
622    async fn primary_error_then_hedge_error() {
623        // Both legs fail with the primary failing first: the hedge's error
624        // within the grace window is counted, the primary's error returned.
625        let primary = TestBlob::new(SECS(3), Err("primary boom"));
626        let hedge = TestBlob::new(Duration::from_millis(1500), Err("hedge boom"));
627        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
628        let err = blob.get("k").await.unwrap_err();
629        assert!(err.to_string().contains("primary boom"), "{}", err);
630        assert!(!err.to_string().contains("hedge boom"), "{}", err);
631        assert_eq!(blob.metrics.errors.get(), 1);
632    }
633
634    #[mz_ore::test(tokio::test(start_paused = true))]
635    async fn primary_error_hedge_timeout() {
636        // The primary fails after the hedge fired, and the hedge is slow:
637        // the get returns the primary's error after a bounded extra wait
638        // instead of holding on the hedge indefinitely.
639        let primary = TestBlob::new(SECS(3), Err("primary boom"));
640        let hedge = TestBlob::new(SECS(3600), Ok(Some("hedge")));
641        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
642        let start = tokio::time::Instant::now();
643        let err = blob.get("k").await.unwrap_err();
644        assert!(err.to_string().contains("primary boom"), "{}", err);
645        // Primary error at 3s plus the delay-sized grace window.
646        assert_eq!(start.elapsed(), SECS(5));
647    }
648
649    #[mz_ore::test(tokio::test(start_paused = true))]
650    async fn dropped_get_releases_concurrency_slot() {
651        // Dropping a hedged get mid-race must release the concurrency slot,
652        // else abandoned gets would permanently disable hedging.
653        let primary = TestBlob::new(SECS(3600), Ok(Some("slow")));
654        let hedge = TestBlob::new(SECS(3600), Ok(Some("slow")));
655        let cfg = test_cfg(|u| u.add(&BLOB_HEDGED_GET_MAX_CONCURRENT, 1));
656        let blob = hedged(&primary, &hedge, cfg);
657        for expected_fired in [1, 2] {
658            let res = tokio::time::timeout(SECS(10), blob.get("k")).await;
659            assert!(res.is_err(), "get should still be pending at timeout");
660            assert_eq!(blob.metrics.fired.get(), expected_fired);
661        }
662        assert_eq!(blob.metrics.skipped_concurrency.get(), 0);
663    }
664
665    #[mz_ore::test(tokio::test(start_paused = true))]
666    async fn hedge_error_then_primary_error() {
667        // Both legs fail with the hedge failing first: the get falls back to
668        // awaiting the primary and returns the primary's error.
669        let primary = TestBlob::new(SECS(3), Err("primary boom"));
670        let hedge = TestBlob::new(SECS(0), Err("hedge boom"));
671        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
672        let err = blob.get("k").await.unwrap_err();
673        assert!(err.to_string().contains("primary boom"), "{}", err);
674        assert!(!err.to_string().contains("hedge boom"), "{}", err);
675        assert!(!err.is_timeout());
676    }
677
678    #[mz_ore::test(tokio::test(start_paused = true))]
679    async fn fast_primary_error_passthrough() {
680        let primary = TestBlob::new(SECS(0), Err("fast fail"));
681        let hedge = TestBlob::new(SECS(0), Ok(Some("hedge")));
682        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
683        assert!(blob.get("k").await.is_err());
684        assert_eq!(hedge.gets.load(Ordering::SeqCst), 0);
685        assert_eq!(blob.metrics.fired.get(), 0);
686    }
687
688    #[mz_ore::test(tokio::test(start_paused = true))]
689    async fn ok_none_wins() {
690        let primary = TestBlob::new(SECS(3600), Ok(None));
691        let hedge = TestBlob::new(SECS(0), Ok(None));
692        let blob = hedged(&primary, &hedge, test_cfg(|_| {}));
693        assert!(blob.get("k").await.unwrap().is_none());
694        assert_eq!(blob.metrics.won.get(), 1);
695    }
696
697    #[mz_ore::test(tokio::test(start_paused = true))]
698    async fn disabled_passthrough() {
699        let primary = TestBlob::new(SECS(0), Ok(Some("x")));
700        let hedge = TestBlob::new(SECS(0), Ok(Some("x")));
701        let cfg = test_cfg(|u| u.add(&BLOB_HEDGED_GET_ENABLED, false));
702        let blob = hedged(&primary, &hedge, cfg);
703        assert!(blob.get("k").await.unwrap().is_some());
704        assert_eq!(hedge.gets.load(Ordering::SeqCst), 0);
705        assert_eq!(blob.metrics.fired.get(), 0);
706    }
707
708    #[mz_ore::test(tokio::test(start_paused = true))]
709    async fn unavailable_sibling() {
710        let primary = TestBlob::new(SECS(3), Ok(Some("x")));
711        let primary_blob: Arc<dyn Blob> = Arc::<TestBlob>::clone(&primary);
712        let blob = HedgedBlob::new(
713            primary_blob,
714            HedgeSibling::Unavailable,
715            test_cfg(|_| {}),
716            metrics(),
717        );
718        assert_eq!(blob.metrics.armed.get(), 0);
719        assert!(blob.get("k").await.unwrap().is_some());
720        assert_eq!(blob.metrics.skipped_unavailable.get(), 1);
721    }
722
723    #[mz_ore::test(tokio::test(start_paused = true))]
724    async fn budget_exhausts_and_refills() {
725        let primary = TestBlob::new(SECS(10), Ok(Some("slow")));
726        let hedge = TestBlob::new(SECS(0), Ok(Some("fast")));
727        // No refill, so the bucket only ever drains.
728        let cfg = test_cfg(|u| u.add(&BLOB_HEDGED_GET_BUDGET_RATIO, 0.0));
729        let blob = hedged(&primary, &hedge, Arc::clone(&cfg));
730        for _ in 0..32 {
731            assert!(blob.get("k").await.unwrap().is_some());
732        }
733        assert_eq!(blob.metrics.fired.get(), 32);
734        assert!(blob.get("k").await.unwrap().is_some());
735        assert_eq!(blob.metrics.fired.get(), 32);
736        assert_eq!(blob.metrics.skipped_budget.get(), 1);
737        // Turn refill up to one token per completed get. The next get still
738        // finds an empty bucket (refill lands at completion), the one after
739        // hedges again.
740        let mut updates = ConfigUpdates::default();
741        updates.add(&BLOB_HEDGED_GET_BUDGET_RATIO, 1.0);
742        updates.apply(&cfg);
743        assert!(blob.get("k").await.unwrap().is_some());
744        assert_eq!(blob.metrics.skipped_budget.get(), 2);
745        assert!(blob.get("k").await.unwrap().is_some());
746        assert_eq!(blob.metrics.fired.get(), 33);
747    }
748
749    #[mz_ore::test(tokio::test(start_paused = true))]
750    async fn concurrency_cap() {
751        let primary = TestBlob::new(SECS(10), Ok(Some("slow")));
752        let hedge = TestBlob::new(SECS(5), Ok(Some("fast")));
753        let cfg = test_cfg(|u| u.add(&BLOB_HEDGED_GET_MAX_CONCURRENT, 1));
754        let blob = hedged(&primary, &hedge, cfg);
755        let (a, b) = tokio::join!(blob.get("k1"), blob.get("k2"));
756        assert!(a.is_ok() && b.is_ok());
757        assert_eq!(blob.metrics.fired.get(), 1);
758        assert_eq!(blob.metrics.skipped_concurrency.get(), 1);
759    }
760
761    #[mz_ore::test(tokio::test(start_paused = true))]
762    async fn warmer_pings_isolated_sibling() {
763        let primary = TestBlob::new(SECS(0), Ok(None));
764        let hedge = TestBlob::new(SECS(0), Ok(None));
765        let cfg = test_cfg(|_| {});
766        let sockets = BLOB_HEDGED_GET_MAX_CONCURRENT.get(&cfg);
767        let blob = hedged(&primary, &hedge, cfg);
768        // The warmer pings immediately at start, then every 20s, holding as
769        // many sockets as hedges can run at once.
770        tokio::time::sleep(SECS(1)).await;
771        tokio::task::yield_now().await;
772        assert_eq!(hedge.gets.load(Ordering::SeqCst), sockets);
773        tokio::time::sleep(SECS(20)).await;
774        tokio::task::yield_now().await;
775        assert_eq!(hedge.gets.load(Ordering::SeqCst), 2 * sockets);
776        drop(blob);
777    }
778
779    #[mz_ore::test(tokio::test(start_paused = true))]
780    async fn warmer_gated_on_enabled() {
781        let primary = TestBlob::new(SECS(0), Ok(None));
782        let hedge = TestBlob::new(SECS(0), Ok(None));
783        let cfg = test_cfg(|u| u.add(&BLOB_HEDGED_GET_ENABLED, false));
784        let blob = hedged(&primary, &hedge, Arc::clone(&cfg));
785        // Disabled: the warmer idles, the sibling sees no traffic.
786        tokio::time::sleep(SECS(120)).await;
787        tokio::task::yield_now().await;
788        assert_eq!(hedge.gets.load(Ordering::SeqCst), 0);
789        // Enabling at runtime starts warming within one warm interval.
790        let mut updates = ConfigUpdates::default();
791        updates.add(&BLOB_HEDGED_GET_ENABLED, true);
792        updates.apply(&cfg);
793        tokio::time::sleep(*BLOB_HEDGED_GET_WARM_INTERVAL.default() + SECS(1)).await;
794        tokio::task::yield_now().await;
795        assert!(hedge.gets.load(Ordering::SeqCst) > 0);
796        drop(blob);
797    }
798
799    #[mz_ore::test(tokio::test(start_paused = true))]
800    async fn shared_sibling_gets_no_warmer() {
801        let primary = TestBlob::new(SECS(0), Ok(None));
802        let primary_blob: Arc<dyn Blob> = Arc::<TestBlob>::clone(&primary);
803        let blob = HedgedBlob::new(
804            primary_blob,
805            HedgeSibling::SharedWithPrimary,
806            test_cfg(|_| {}),
807            metrics(),
808        );
809        assert!(blob._warmer.is_none());
810        assert_eq!(blob.metrics.armed.get(), 1);
811        tokio::time::sleep(SECS(60)).await;
812        assert_eq!(primary.gets.load(Ordering::SeqCst), 0);
813    }
814
815    /// A test [Blob] that delays gets so the hedge (delay 0) fires and wins
816    /// on every get in the conformance run below. Non-get methods pass
817    /// through undelayed.
818    #[derive(Debug)]
819    struct SlowGetBlob(Arc<dyn Blob>);
820
821    #[async_trait]
822    impl Blob for SlowGetBlob {
823        async fn get(&self, key: &str) -> Result<Option<SegmentedBytes>, ExternalError> {
824            tokio::time::sleep(Duration::from_millis(2)).await;
825            self.0.get(key).await
826        }
827
828        async fn list_keys_and_metadata(
829            &self,
830            key_prefix: &str,
831            f: &mut (dyn FnMut(BlobMetadata) + Send + Sync),
832        ) -> Result<(), ExternalError> {
833            self.0.list_keys_and_metadata(key_prefix, f).await
834        }
835
836        async fn set(&self, key: &str, value: Bytes) -> Result<(), ExternalError> {
837            self.0.set(key, value).await
838        }
839
840        async fn delete(&self, key: &str) -> Result<Option<usize>, ExternalError> {
841            self.0.delete(key).await
842        }
843
844        async fn restore(&self, key: &str) -> Result<(), ExternalError> {
845            self.0.restore(key).await
846        }
847    }
848
849    /// Runs the full [Blob] conformance suite with a hedge racing on every
850    /// single get: the primary's gets are artificially delayed while the
851    /// hedge reads the same underlying store undelayed, so the hedge fires
852    /// and wins throughout.
853    #[mz_ore::test(tokio::test)]
854    #[cfg_attr(miri, ignore)] // unsupported operation: returning ready events from epoll_wait is not yet implemented
855    async fn hedged_blob_conformance() {
856        let registry = Arc::new(tokio::sync::Mutex::new(MemMultiRegistry::new(false)));
857        let cfg = test_cfg(|u| {
858            u.add(&BLOB_HEDGED_GET_DELAY, Duration::ZERO);
859            u.add(&BLOB_HEDGED_GET_BUDGET_RATIO, 1.0);
860        });
861        let metrics = metrics();
862        let metrics_check = metrics.clone();
863        blob_impl_test(move |path| {
864            let path = path.to_owned();
865            let registry = Arc::clone(&registry);
866            let cfg = Arc::clone(&cfg);
867            let metrics = metrics.clone();
868            async move {
869                let store: Arc<dyn Blob> = Arc::new(registry.lock().await.blob(&path));
870                let primary: Arc<dyn Blob> = Arc::new(SlowGetBlob(Arc::clone(&store)));
871                Ok(HedgedBlob::new(
872                    primary,
873                    HedgeSibling::Isolated(store),
874                    cfg,
875                    metrics,
876                ))
877            }
878        })
879        .await
880        .expect("conformance");
881        assert!(metrics_check.fired.get() > 0, "no hedge ever fired");
882        assert!(metrics_check.won.get() > 0, "no hedge ever won");
883    }
884}