pulses 0.2.0

A robust, high-performance background job processing library for Rust.
Documentation
//! Exponential backoff with decorrelated jitter.

use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;

use crate::config::BackoffConfig;

/// Computes retry delays as `initial * factor^(attempt-1)`, capped at `max`,
/// and randomized within `±jitter`.
///
/// The jitter source is a `SplitMix64` generator seeded once at construction
/// and advanced per call, so it neither re-reads the clock on every retry nor
/// collapses to zero when the clock is unavailable.
#[derive(Debug)]
pub(crate) struct Backoff {
    config: BackoffConfig,
    state: AtomicU64,
}

impl Backoff {
    pub(crate) fn new(config: BackoffConfig) -> Self {
        let seed = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|elapsed| elapsed.as_nanos() as u64)
            .unwrap_or(0x9E37_79B9_7F4A_7C15);
        // Ensure a non-zero seed.
        Self { config, state: AtomicU64::new(seed | 1) }
    }

    /// Delay for the given 1-based attempt number.
    pub(crate) fn delay(&self, attempt: u32) -> Duration {
        let initial = self.config.initial.as_millis().max(1) as f64;
        let max = (self.config.max.as_millis().max(1) as f64).max(initial);
        let factor = self.config.factor.max(1.0);

        let growth = factor.powi(attempt.saturating_sub(1) as i32);
        let base = (initial * growth).min(max);

        let jitter = self.config.jitter.clamp(0.0, 1.0);
        if jitter == 0.0 {
            return Duration::from_millis(base.round() as u64);
        }

        let lower = base * (1.0 - jitter);
        let upper = base * (1.0 + jitter);
        let randomized = lower + self.next_unit() * (upper - lower);
        Duration::from_millis(randomized.max(0.0).round() as u64)
    }

    /// Next pseudo-random value in `[0, 1)` via SplitMix64.
    fn next_unit(&self) -> f64 {
        let mut z = self.state.fetch_add(0x9E37_79B9_7F4A_7C15, Ordering::Relaxed).wrapping_add(0x9E37_79B9_7F4A_7C15);
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        z ^= z >> 31;
        // Use the top 53 bits for a uniform double in [0, 1).
        (z >> 11) as f64 / (1u64 << 53) as f64
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn grows_geometrically_and_caps() {
        let backoff = Backoff::new(BackoffConfig {
            initial: Duration::from_millis(10),
            max: Duration::from_millis(100),
            factor: 2.0,
            jitter: 0.0,
        });
        assert_eq!(backoff.delay(1), Duration::from_millis(10));
        assert_eq!(backoff.delay(2), Duration::from_millis(20));
        assert_eq!(backoff.delay(3), Duration::from_millis(40));
        // Capped at max.
        assert_eq!(backoff.delay(10), Duration::from_millis(100));
    }

    #[test]
    fn jitter_stays_within_bounds() {
        let backoff = Backoff::new(BackoffConfig {
            initial: Duration::from_millis(100),
            max: Duration::from_millis(100),
            factor: 2.0,
            jitter: 0.5,
        });
        for _ in 0..1_000 {
            let delay = backoff.delay(1).as_millis();
            assert!((50..=150).contains(&delay), "delay {delay} out of jittered bounds");
        }
    }
}