ndn_discovery/
backoff.rs

1//! Exponential backoff with jitter for hello/probe scheduling.
2//!
3//! Used by neighbor discovery to schedule retransmits and probes without
4//! creating correlated bursts across multiple nodes (thundering-herd effect).
5//!
6//! The algorithm:
7//! 1. Start at `initial_interval`.
8//! 2. On each failure, double the interval (capped at `max_interval`).
9//! 3. Add uniform random jitter of ±`jitter_fraction` of the current interval.
10//! 4. On success, reset to `initial_interval`.
11
12use std::time::Duration;
13
14/// Static configuration for a backoff strategy.
15///
16/// Construct once and pass by reference; per-instance mutable state lives
17/// in [`BackoffState`].
18#[derive(Clone, Debug)]
19pub struct BackoffConfig {
20    /// Initial retry interval.
21    pub initial_interval: Duration,
22    /// Maximum retry interval (backoff ceiling).
23    pub max_interval: Duration,
24    /// Jitter fraction in [0.0, 1.0].  `0.25` means ±25 % of the current
25    /// interval is added as random noise.
26    pub jitter_fraction: f64,
27}
28
29impl Default for BackoffConfig {
30    fn default() -> Self {
31        Self {
32            initial_interval: Duration::from_millis(100),
33            max_interval: Duration::from_secs(30),
34            jitter_fraction: 0.25,
35        }
36    }
37}
38
39impl BackoffConfig {
40    /// Backoff profile suitable for link-local neighbor discovery hellos.
41    ///
42    /// Short initial interval for fast bootstrap; caps at 10 s to avoid
43    /// stale neighbor entries persisting too long.
44    pub fn for_neighbor_hello() -> Self {
45        Self {
46            initial_interval: Duration::from_millis(500),
47            max_interval: Duration::from_secs(10),
48            jitter_fraction: 0.3,
49        }
50    }
51
52    /// Backoff profile for SWIM indirect probing.
53    ///
54    /// Shorter intervals to match SWIM's failure-detection SLA.
55    pub fn for_swim_probe() -> Self {
56        Self {
57            initial_interval: Duration::from_millis(200),
58            max_interval: Duration::from_secs(5),
59            jitter_fraction: 0.2,
60        }
61    }
62}
63
64/// Per-instance mutable backoff state.
65///
66/// Carries the current interval and a lightweight pseudo-random seed
67/// so that no external RNG dependency is needed.
68#[derive(Clone, Debug)]
69pub struct BackoffState {
70    current: Duration,
71    /// Xorshift32 seed for jitter; never zero.
72    rng: u32,
73}
74
75impl BackoffState {
76    /// Create fresh state, seeded from the given value.
77    ///
78    /// Use a per-peer value (e.g. truncated FaceId or timestamp) as the seed
79    /// to decorrelate hellos across nodes.
80    pub fn new(seed: u32) -> Self {
81        Self {
82            current: Duration::ZERO,
83            rng: if seed == 0 { 0xdeadbeef } else { seed },
84        }
85    }
86
87    /// Compute the next wait duration and update internal state.
88    ///
89    /// Call this after a *failed* probe to get the retry delay.
90    pub fn next_failure(&mut self, cfg: &BackoffConfig) -> Duration {
91        if self.current.is_zero() {
92            self.current = cfg.initial_interval;
93        } else {
94            self.current = (self.current * 2).min(cfg.max_interval);
95        }
96        self.apply_jitter(cfg)
97    }
98
99    /// Reset to the initial interval after a *successful* exchange.
100    ///
101    /// Clears the current interval so the next [`next_failure`] call
102    /// starts fresh from `cfg.initial_interval`.
103    pub fn reset(&mut self, _cfg: &BackoffConfig) {
104        self.current = Duration::ZERO;
105    }
106
107    /// Current interval without modification (peek).
108    pub fn current(&self) -> Duration {
109        self.current
110    }
111
112    fn apply_jitter(&mut self, cfg: &BackoffConfig) -> Duration {
113        let base_ms = self.current.as_millis() as u64;
114        if base_ms == 0 || cfg.jitter_fraction <= 0.0 {
115            return self.current;
116        }
117        // Xorshift32 for lightweight deterministic noise.
118        self.rng ^= self.rng << 13;
119        self.rng ^= self.rng >> 17;
120        self.rng ^= self.rng << 5;
121        let range_ms = (base_ms as f64 * cfg.jitter_fraction) as u64;
122        let jitter_ms = if range_ms > 0 {
123            (self.rng as u64 % (2 * range_ms)) as i64 - range_ms as i64
124        } else {
125            0
126        };
127        let result_ms = (base_ms as i64 + jitter_ms).max(1) as u64;
128        Duration::from_millis(result_ms)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn doubles_on_failure() {
138        let cfg = BackoffConfig {
139            initial_interval: Duration::from_millis(100),
140            max_interval: Duration::from_secs(10),
141            jitter_fraction: 0.0, // no jitter for determinism
142        };
143        let mut state = BackoffState::new(1);
144        let d1 = state.next_failure(&cfg);
145        let d2 = state.next_failure(&cfg);
146        let d3 = state.next_failure(&cfg);
147        assert_eq!(d1, Duration::from_millis(100));
148        assert_eq!(d2, Duration::from_millis(200));
149        assert_eq!(d3, Duration::from_millis(400));
150    }
151
152    #[test]
153    fn capped_at_max() {
154        let cfg = BackoffConfig {
155            initial_interval: Duration::from_millis(100),
156            max_interval: Duration::from_millis(300),
157            jitter_fraction: 0.0,
158        };
159        let mut state = BackoffState::new(1);
160        for _ in 0..10 {
161            state.next_failure(&cfg);
162        }
163        assert_eq!(state.current(), Duration::from_millis(300));
164    }
165
166    #[test]
167    fn reset_goes_to_zero() {
168        let cfg = BackoffConfig {
169            initial_interval: Duration::from_millis(100),
170            max_interval: Duration::from_secs(30),
171            jitter_fraction: 0.0, // no jitter for determinism
172        };
173        let mut state = BackoffState::new(42);
174        state.next_failure(&cfg);
175        state.next_failure(&cfg);
176        state.reset(&cfg);
177        // After reset, current is zero; next_failure will start from initial.
178        assert_eq!(state.current(), Duration::ZERO);
179        let d = state.next_failure(&cfg);
180        assert_eq!(d, cfg.initial_interval);
181    }
182
183    #[test]
184    fn jitter_stays_in_range() {
185        let cfg = BackoffConfig {
186            initial_interval: Duration::from_millis(1000),
187            max_interval: Duration::from_secs(60),
188            jitter_fraction: 0.25,
189        };
190        let mut state = BackoffState::new(999);
191        for _ in 0..50 {
192            let d = state.next_failure(&cfg);
193            // 1000ms ± 25% → [750ms, 1250ms]
194            assert!(d >= Duration::from_millis(750), "too low: {d:?}");
195            assert!(d <= Duration::from_millis(1250), "too high: {d:?}");
196            state.reset(&cfg);
197        }
198    }
199}