Skip to main content

sisyphus/
jitter.rs

1//! Jitter / randomness abstraction.
2//!
3//! Adding randomness to backoff delays is the standard defence against the
4//! *thundering herd*: if a thousand peers all fail at the same instant and all
5//! back off by exactly the same amount, they will all retry at the same instant
6//! too. Jitter spreads them out.
7//!
8//! Crucially, the randomness is **injected**, not pulled from a global RNG.
9//! Inject [`NoJitter`] for fully deterministic behaviour, a seeded
10//! [`SplitMix64`] for reproducible-yet-spread-out behaviour (ideal for P2P
11//! simulation and consensus tests), or your own host RNG in production.
12
13use core::time::Duration;
14
15use crate::util::saturating_mul_f64;
16
17/// A source of uniform randomness used to perturb backoff delays.
18///
19/// The only required method is [`next_unit_f64`], which yields a value in
20/// `[0.0, 1.0)`. The provided [`apply`] method turns that into a symmetric
21/// jitter around a base delay.
22///
23/// [`next_unit_f64`]: Jitter::next_unit_f64
24/// [`apply`]: Jitter::apply
25pub trait Jitter {
26    /// Return the next pseudo-random value in the half-open range `[0.0, 1.0)`.
27    fn next_unit_f64(&mut self) -> f64;
28
29    /// Apply symmetric jitter of `factor` around `base`.
30    ///
31    /// With `factor == 0.0` the `base` is returned unchanged. With
32    /// `factor == r` the result is uniformly distributed in
33    /// `[base * (1 - r), base * (1 + r))`. `factor` is clamped to `[0.0, 1.0]`.
34    ///
35    /// The conversion is saturating and therefore panic-free.
36    #[inline]
37    fn apply(&mut self, base: Duration, factor: f64) -> Duration {
38        if factor <= 0.0 || base.is_zero() {
39            return base;
40        }
41        let factor = factor.min(1.0);
42        let r = self.next_unit_f64();
43        // scale ranges over [1 - factor, 1 + factor).
44        let scale = (1.0 - factor) + r * (2.0 * factor);
45        saturating_mul_f64(base, scale)
46    }
47}
48
49impl<J: Jitter + ?Sized> Jitter for &mut J {
50    #[inline]
51    fn next_unit_f64(&mut self) -> f64 {
52        (**self).next_unit_f64()
53    }
54}
55
56/// A [`Jitter`] that adds no randomness at all.
57///
58/// [`apply`](Jitter::apply) always returns the base delay unchanged, so a
59/// policy using `NoJitter` is fully deterministic. This is the default jitter
60/// for [`ExponentialBackoff`].
61///
62/// [`ExponentialBackoff`]: crate::ExponentialBackoff
63#[derive(Debug, Clone, Copy, Default)]
64pub struct NoJitter;
65
66impl Jitter for NoJitter {
67    /// Returns the midpoint `0.5`, so any symmetric jitter collapses to the
68    /// base delay (`(1 - f) + 0.5 * 2f == 1`).
69    #[inline]
70    fn next_unit_f64(&mut self) -> f64 {
71        0.5
72    }
73}
74
75/// A tiny, fast, deterministic PRNG (Steele et al.'s SplitMix64).
76///
77/// This is **not** cryptographically secure, but it is excellent for jitter:
78/// it is allocation-free, `const`-constructible, has good statistical spread,
79/// and is perfectly reproducible from a seed. That reproducibility is the whole
80/// point in P2P/consensus testing.
81///
82/// # Example
83///
84/// ```
85/// use sisyphus::{Jitter, SplitMix64};
86///
87/// let mut a = SplitMix64::new(42);
88/// let mut b = SplitMix64::new(42);
89/// // Same seed => identical stream => reproducible tests.
90/// assert_eq!(a.next_unit_f64(), b.next_unit_f64());
91/// ```
92#[derive(Debug, Clone, Copy)]
93pub struct SplitMix64 {
94    state: u64,
95}
96
97impl SplitMix64 {
98    /// Create a new generator from a 64-bit seed.
99    #[inline]
100    #[must_use]
101    pub const fn new(seed: u64) -> Self {
102        Self { state: seed }
103    }
104
105    /// Advance the generator and return the next raw 64-bit value.
106    #[inline]
107    pub fn next_u64(&mut self) -> u64 {
108        self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
109        let mut z = self.state;
110        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
111        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
112        z ^ (z >> 31)
113    }
114}
115
116impl Default for SplitMix64 {
117    #[inline]
118    fn default() -> Self {
119        // An arbitrary non-zero default seed.
120        Self::new(0x2545_F491_4F6C_DD1D)
121    }
122}
123
124impl Jitter for SplitMix64 {
125    #[inline]
126    fn next_unit_f64(&mut self) -> f64 {
127        // Use the top 53 bits to fill an f64 mantissa, giving a uniform
128        // value in [0, 1). 2^-53 is exactly representable.
129        const SCALE: f64 = 1.0 / (1u64 << 53) as f64;
130        (self.next_u64() >> 11) as f64 * SCALE
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn no_jitter_is_identity() {
140        let mut j = NoJitter;
141        let base = Duration::from_millis(100);
142        assert_eq!(j.apply(base, 0.5), base);
143        assert_eq!(j.apply(base, 1.0), base);
144    }
145
146    #[test]
147    fn unit_values_are_in_range() {
148        let mut rng = SplitMix64::new(7);
149        for _ in 0..10_000 {
150            let v = rng.next_unit_f64();
151            assert!((0.0..1.0).contains(&v), "value out of range: {v}");
152        }
153    }
154
155    #[test]
156    fn jitter_stays_within_symmetric_bounds() {
157        let mut rng = SplitMix64::new(99);
158        let base = Duration::from_millis(1000);
159        for _ in 0..10_000 {
160            let d = rng.apply(base, 0.5);
161            assert!(d >= Duration::from_millis(500));
162            assert!(d < Duration::from_millis(1500));
163        }
164    }
165
166    #[test]
167    fn seeded_streams_are_reproducible() {
168        let mut a = SplitMix64::new(123);
169        let mut b = SplitMix64::new(123);
170        for _ in 0..100 {
171            assert_eq!(a.next_u64(), b.next_u64());
172        }
173    }
174}