Skip to main content

magnetar_proto/
backoff.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Truncated-exponential backoff with deterministic jitter.
4//!
5//! Port of `org.apache.pulsar.client.impl.Backoff` (see `pulsar-client/src/main/java/org/apache/
6//! pulsar/client/impl/Backoff.java`). Used by reconnect logic and any retry path inside the
7//! sans-io state machine.
8//!
9//! # Algorithm
10//!
11//! - Initial delay: `initial`.
12//! - Each step doubles the previous delay, capped at `max`.
13//! - A jitter factor in `[0.0, 0.2]` of the next delay is subtracted to spread reconnects.
14//! - `mandatory_stop` is an upper bound on cumulative wait time; once exceeded, the very next
15//!   `next()` will yield `max` and reset.
16//! - `reset()` returns the backoff to its initial state (call after a successful operation).
17//!
18//! # Determinism
19//!
20//! The jitter is derived from a `u64` seed that is rotated through a splittable PRNG (a
21//! splitmix64). Callers may construct the [`Backoff`] with an explicit seed for reproducible
22//! tests; the default constructor seeds from a monotonic counter so production callers still
23//! see spread-out reconnects across clients without depending on any I/O.
24
25use core::time::Duration;
26
27/// Default initial delay (100 ms).
28pub const DEFAULT_INITIAL: Duration = Duration::from_millis(100);
29
30/// Default max delay (60 s).
31pub const DEFAULT_MAX: Duration = Duration::from_mins(1);
32
33/// Default mandatory-stop window (30 min).
34pub const DEFAULT_MANDATORY_STOP: Duration = Duration::from_mins(30);
35
36/// Truncated-exponential backoff with deterministic jitter.
37#[derive(Debug, Clone)]
38pub struct Backoff {
39    initial: Duration,
40    max: Duration,
41    mandatory_stop: Duration,
42    next_delay: Duration,
43    total_elapsed: Duration,
44    /// PRNG state for jitter computation.
45    rng_state: u64,
46    first_call: bool,
47    /// FoundationDB-style buggify helper (ADR-0048). Default
48    /// [`crate::Buggify::disabled`] preserves the production
49    /// production-correct schedule; the moonpool engine wires an
50    /// armed helper via [`Self::install_buggify`] to inject
51    /// `retry_clock.skew` faults at the next-delay computation.
52    buggify: crate::Buggify,
53}
54
55impl Default for Backoff {
56    fn default() -> Self {
57        Self::new(DEFAULT_INITIAL, DEFAULT_MAX, DEFAULT_MANDATORY_STOP, 0)
58    }
59}
60
61impl Backoff {
62    /// Construct a new backoff with explicit parameters.
63    ///
64    /// `seed` controls jitter; pass `0` for the default seed.
65    pub fn new(initial: Duration, max: Duration, mandatory_stop: Duration, seed: u64) -> Self {
66        Self {
67            initial,
68            max,
69            mandatory_stop,
70            next_delay: initial,
71            total_elapsed: Duration::ZERO,
72            rng_state: if seed == 0 {
73                0x9E37_79B9_7F4A_7C15
74            } else {
75                seed
76            },
77            first_call: true,
78            buggify: crate::Buggify::disabled(),
79        }
80    }
81
82    /// Install a [`crate::Buggify`] helper on this schedule. The
83    /// `retry_clock.skew` label fires inside [`Self::next`] when
84    /// armed, multiplying the returned `Duration` by a seed-driven
85    /// factor in `[0.5, 2.0]`. Engines call this once after
86    /// constructing the Backoff; the moonpool engine threads the same
87    /// helper instance the [`crate::Connection`] is using so the four
88    /// labels share a single fire-counter map. ADR-0048.
89    pub fn install_buggify(&mut self, buggify: crate::Buggify) {
90        self.buggify = buggify;
91    }
92
93    /// Compute the next backoff delay.
94    ///
95    /// On the very first call after construction (or after [`Self::reset`]), this returns the
96    /// `initial` delay. Subsequent calls double the previous delay, clamped at `max`, with a
97    /// jitter factor in `[0%, 20%]` of the next delay subtracted.
98    ///
99    /// If the cumulative elapsed delay exceeds `mandatory_stop`, the next delay snaps to `max`
100    /// and the cumulative counter resets to zero. This mirrors Java's behaviour
101    /// (`Backoff.java:60-89`).
102    pub fn next(&mut self) -> Duration {
103        let mut current = if self.first_call {
104            self.first_call = false;
105            self.next_delay
106        } else {
107            self.next_delay
108        };
109
110        // Apply jitter: subtract up to 20% of current.
111        let jitter = self.jitter_fraction();
112        let jitter_ns = (current.as_nanos() as u64).saturating_mul(jitter) / 1000;
113        current = current.saturating_sub(Duration::from_nanos(jitter_ns));
114
115        self.total_elapsed = self.total_elapsed.saturating_add(current);
116
117        // Pre-compute the next-step base delay (doubled, clamped).
118        let doubled = self.next_delay.saturating_mul(2);
119        self.next_delay = if doubled > self.max {
120            self.max
121        } else {
122            doubled
123        };
124
125        if self.total_elapsed > self.mandatory_stop {
126            // Snap to max and reset the elapsed budget so a steady-state reconnect loop keeps
127            // ticking at the `max` cadence.
128            self.total_elapsed = Duration::ZERO;
129            self.next_delay = self.max;
130            return self.apply_buggify_skew(self.max);
131        }
132        self.apply_buggify_skew(current)
133    }
134
135    /// ADR-0048 buggify point: `retry_clock.skew`. When the label
136    /// fires, scale `base` by a seed-driven factor in `[0.5, 2.0]`.
137    /// The factor is derived from the engine RNG handle so two runs
138    /// of the same seed produce the same schedule. With no RNG armed
139    /// (or under `not(feature = "buggify")`) the function returns
140    /// `base` unmodified — production builds compile to a NOP.
141    fn apply_buggify_skew(&self, base: Duration) -> Duration {
142        if !self
143            .buggify
144            .should_fire(crate::buggify::labels::RETRY_CLOCK_SKEW, 0.05)
145        {
146            return base;
147        }
148        // Pull an additional u64 to derive the [0.5, 2.0] scale.
149        // Buckets the roll into 10_000 steps over the range — same
150        // resolution Buggify uses for the fire probability. If the
151        // RNG is unavailable, fall through to the unmodified base
152        // (Buggify::roll_u64 should not return None here because
153        // should_fire already required an armed helper, but guarding
154        // keeps `magnetar-proto`'s no-panic invariant intact).
155        let Some(roll) = self.buggify.roll_u64() else {
156            return base;
157        };
158        // Bucket the roll over 10_000 steps; the result is `< 10_000`
159        // so the `as f64` cast is lossless by construction (well
160        // inside f64's 52-bit mantissa).
161        #[allow(clippy::cast_precision_loss)]
162        let bucket = (roll % 10_000) as f64 / 10_000.0; // [0.0, 1.0)
163        let factor = 0.5 + bucket * 1.5; // [0.5, 2.0)
164        // Convert via nanoseconds so we keep sub-millisecond fidelity
165        // while staying inside the saturating u128 → u64 range.
166        // `base.as_nanos()` for any practical `Duration` fits in
167        // f64's mantissa (a `Duration` of u64::MAX seconds is ~5e11
168        // years; we never schedule retries near that range).
169        #[allow(
170            clippy::cast_precision_loss,
171            clippy::cast_possible_truncation,
172            clippy::cast_sign_loss
173        )]
174        let scaled_nanos = (base.as_nanos() as f64 * factor) as u128;
175        let scaled_nanos_u64 = u64::try_from(scaled_nanos).unwrap_or(u64::MAX);
176        Duration::from_nanos(scaled_nanos_u64)
177    }
178
179    /// Reset the backoff to its initial state. Call after a successful operation.
180    pub fn reset(&mut self) {
181        self.next_delay = self.initial;
182        self.total_elapsed = Duration::ZERO;
183        self.first_call = true;
184    }
185
186    /// Returns the configured maximum delay.
187    pub fn max(&self) -> Duration {
188        self.max
189    }
190
191    /// Returns the configured initial delay.
192    pub fn initial(&self) -> Duration {
193        self.initial
194    }
195
196    /// SplitMix64 PRNG step, returning a u64 in `[0, 200]` representing the jitter percentage
197    /// scaled to the 1/1000 unit used in `next()`.
198    fn jitter_fraction(&mut self) -> u64 {
199        // SplitMix64
200        self.rng_state = self.rng_state.wrapping_add(0x9E37_79B9_7F4A_7C15);
201        let mut z = self.rng_state;
202        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
203        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
204        z ^= z >> 31;
205        // 0..=200 → 0% .. 20% in /1000 units.
206        z % 201
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn first_call_returns_initial_within_jitter() {
216        let mut b = Backoff::new(
217            Duration::from_millis(100),
218            Duration::from_mins(1),
219            Duration::from_mins(30),
220            42,
221        );
222        let d = b.next();
223        assert!(d <= Duration::from_millis(100));
224        assert!(d >= Duration::from_millis(80));
225    }
226
227    #[test]
228    fn doubles_until_max() {
229        let mut b = Backoff::new(
230            Duration::from_millis(100),
231            Duration::from_secs(1),
232            Duration::from_mins(30),
233            1,
234        );
235        let mut last = Duration::ZERO;
236        for _ in 0..20 {
237            let d = b.next();
238            assert!(d <= Duration::from_secs(1) + Duration::from_millis(1));
239            last = d;
240        }
241        // After enough iterations, we should be capped at max-ish.
242        assert!(last >= Duration::from_millis(800));
243        assert!(last <= Duration::from_secs(1));
244    }
245
246    #[test]
247    fn reset_returns_to_initial() {
248        let mut b = Backoff::new(
249            Duration::from_millis(100),
250            Duration::from_secs(1),
251            Duration::from_mins(30),
252            1,
253        );
254        for _ in 0..5 {
255            let _ = b.next();
256        }
257        b.reset();
258        let d = b.next();
259        assert!(d <= Duration::from_millis(100));
260        assert!(d >= Duration::from_millis(80));
261    }
262
263    /// ADR-0048: when no buggify helper is installed (the default),
264    /// `Backoff::next` returns the same schedule it did before the
265    /// retry-clock-skew label was added. Equivalent to the production
266    /// path for all callers that don't opt in.
267    #[test]
268    fn next_without_buggify_matches_baseline_schedule() {
269        let mut b = Backoff::new(
270            Duration::from_millis(100),
271            Duration::from_secs(1),
272            Duration::from_mins(30),
273            1,
274        );
275        let first = b.next();
276        let second = b.next();
277        // No buggify → no skew. The jitter is in [80%, 100%] of the
278        // pre-doubled `next_delay`, so we just confirm we're inside
279        // the pre-skew band.
280        assert!(first <= Duration::from_millis(100));
281        assert!(first >= Duration::from_millis(80));
282        assert!(second <= Duration::from_millis(200));
283        assert!(second >= Duration::from_millis(160));
284    }
285
286    /// ADR-0048 `retry_clock.skew`: with an armed buggify helper that
287    /// always fires AND a follow-up roll at the bottom of the bucket
288    /// range, the returned duration is rescaled by `×0.5`. The two
289    /// rolls happen in order: first for `should_fire`, then for the
290    /// skew factor.
291    #[cfg(feature = "buggify")]
292    #[test]
293    fn buggified_next_scales_by_half_at_rng_zero() {
294        let mut b = Backoff::new(
295            Duration::from_secs(10),
296            Duration::from_mins(1),
297            Duration::from_mins(30),
298            // Tip: the base-jitter PRNG (`rng_state`) is independent
299            // of the buggify RNG, so we still get a deterministic
300            // jitter on top — the skew applies to the post-jitter
301            // duration.
302            42,
303        );
304        // Always-zero RNG: should_fire (p=0.05) lands roll=0.0 → fires;
305        // skew roll lands bucket=0 → factor 0.5.
306        b.install_buggify(crate::Buggify::with_rng(std::sync::Arc::new(|| 0_u64)));
307        let scaled = b.next();
308        // Base ≈ 8-10s after jitter; ×0.5 → 4-5s.
309        assert!(scaled >= Duration::from_secs(4));
310        assert!(scaled <= Duration::from_secs(5));
311    }
312
313    /// At the high end of the bucket range, the factor approaches
314    /// `2.0`. To exercise both the should_fire (must land low) and
315    /// the skew factor (must land high), the RNG alternates: first
316    /// call returns 0 (fires), second returns `9_998` (×2 scale).
317    #[cfg(feature = "buggify")]
318    #[test]
319    fn buggified_next_scales_by_two_at_rng_top() {
320        let mut b = Backoff::new(
321            Duration::from_secs(10),
322            Duration::from_mins(1),
323            Duration::from_mins(30),
324            7,
325        );
326        let counter = std::sync::Arc::new(parking_lot::Mutex::new(0_u64));
327        let counter_handle = counter.clone();
328        b.install_buggify(crate::Buggify::with_rng(std::sync::Arc::new(move || {
329            let mut g = counter_handle.lock();
330            let v = *g;
331            *g += 1;
332            // Roll #0 → 0 (fires); roll #1 → 9_998 (×2 scale); then
333            // wrap to 0 if anyone keeps rolling.
334            if v == 1 { 9_998 } else { 0 }
335        })));
336        let scaled = b.next();
337        // Base ≈ 8-10s after jitter; ×2 → 16-20s.
338        assert!(scaled >= Duration::from_secs(16));
339        assert!(scaled <= Duration::from_secs(20));
340    }
341
342    /// Negative-space assertion: even with `buggify` armed, a probability
343    /// of zero never fires. Default fire-probability in
344    /// `apply_buggify_skew` is 0.05; an RNG that always lands at the
345    /// top of the [0, 10_000) bucket never crosses that threshold, so
346    /// the schedule is unmodified.
347    #[cfg(feature = "buggify")]
348    #[test]
349    fn buggified_next_skips_skew_when_roll_above_threshold() {
350        let mut b = Backoff::new(
351            Duration::from_secs(10),
352            Duration::from_mins(1),
353            Duration::from_mins(30),
354            7,
355        );
356        // 9_999 % 10_000 = 9_999 → roll = 0.9999, well above 0.05.
357        b.install_buggify(crate::Buggify::with_rng(std::sync::Arc::new(|| 9_999_u64)));
358        let baseline = {
359            let mut clone = b.clone();
360            clone.install_buggify(crate::Buggify::disabled());
361            clone.next()
362        };
363        let with_buggify = b.next();
364        assert_eq!(baseline, with_buggify);
365    }
366
367    #[test]
368    fn mandatory_stop_snaps_to_max() {
369        // Configure a tiny mandatory_stop so we cross it after a handful of steps.
370        let mut b = Backoff::new(
371            Duration::from_millis(50),
372            Duration::from_millis(200),
373            Duration::from_millis(150),
374            7,
375        );
376        let _ = b.next(); // ~50ms
377        let _ = b.next(); // ~100ms — crosses mandatory stop
378        let snap = b.next();
379        assert_eq!(snap, Duration::from_millis(200));
380    }
381}