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}