Skip to main content

dev_chaos/
latency.rs

1//! Latency injection: simulate slow-but-not-failing operations.
2//!
3//! `LatencyInjector` produces a deterministic delay per attempt
4//! according to a [`LatencyProfile`]. It composes with
5//! `FailureSchedule` (see [`LatencyInjector::compose_with_schedule`]):
6//! inject latency on every call, inject failures on a subset.
7
8use std::time::Duration;
9
10/// Per-attempt latency profile.
11///
12/// All variants are deterministic.
13#[derive(Debug, Clone)]
14pub enum LatencyProfile {
15    /// Constant delay on every attempt.
16    Constant(Duration),
17    /// Linear ramp: `start + (attempt - 1) * step`.
18    LinearRamp {
19        /// Delay applied to attempt 1.
20        start: Duration,
21        /// Delay added to each subsequent attempt.
22        step: Duration,
23    },
24    /// Step function: piecewise-constant by `boundaries`. Each entry
25    /// `(attempt_threshold, delay)` means "use `delay` while attempt
26    /// is `<= attempt_threshold`". The list MUST be sorted ascending
27    /// by `attempt_threshold`. Attempts beyond the last threshold use
28    /// the final entry's `delay`.
29    StepSchedule(Vec<(usize, Duration)>),
30}
31
32/// Computes per-attempt delays from a [`LatencyProfile`].
33///
34/// `LatencyInjector` is intentionally side-effect-free: it returns
35/// the delay it *would* sleep, leaving the actual `thread::sleep`
36/// (or `tokio::time::sleep`) up to the caller. This keeps the type
37/// usable in both sync and async contexts.
38///
39/// # Example
40///
41/// ```
42/// use dev_chaos::latency::{LatencyInjector, LatencyProfile};
43/// use std::time::Duration;
44///
45/// let inj = LatencyInjector::new(LatencyProfile::Constant(Duration::from_millis(5)));
46/// assert_eq!(inj.delay_for(1), Duration::from_millis(5));
47/// assert_eq!(inj.delay_for(100), Duration::from_millis(5));
48/// ```
49pub struct LatencyInjector {
50    profile: LatencyProfile,
51}
52
53impl LatencyInjector {
54    /// Build an injector from a profile.
55    pub fn new(profile: LatencyProfile) -> Self {
56        Self { profile }
57    }
58
59    /// Compute the delay that would be applied at `attempt` (1-indexed).
60    pub fn delay_for(&self, attempt: usize) -> Duration {
61        match &self.profile {
62            LatencyProfile::Constant(d) => *d,
63            LatencyProfile::LinearRamp { start, step } => {
64                let n = attempt.saturating_sub(1) as u32;
65                *start + step.saturating_mul(n)
66            }
67            LatencyProfile::StepSchedule(boundaries) => {
68                if boundaries.is_empty() {
69                    return Duration::ZERO;
70                }
71                for (threshold, delay) in boundaries.iter() {
72                    if attempt <= *threshold {
73                        return *delay;
74                    }
75                }
76                boundaries.last().unwrap().1
77            }
78        }
79    }
80
81    /// Apply the delay synchronously by sleeping the calling thread.
82    ///
83    /// Equivalent to `std::thread::sleep(self.delay_for(attempt))`.
84    pub fn apply_blocking(&self, attempt: usize) {
85        std::thread::sleep(self.delay_for(attempt));
86    }
87
88    /// Bind this injector to a [`FailureSchedule`](crate::FailureSchedule)
89    /// so a single call applies latency *and* checks for failure injection.
90    ///
91    /// Returns a [`LatencyAndFailure`] composer:
92    /// - On every attempt, the latency is applied (sync sleep).
93    /// - On scheduled-failure attempts, the call returns
94    ///   `Err(InjectedFailure)` after the latency has been applied
95    ///   (so the test observes both the slowdown *and* the failure).
96    ///
97    /// # Example
98    ///
99    /// ```
100    /// use dev_chaos::{
101    ///     latency::{LatencyInjector, LatencyProfile},
102    ///     FailureMode, FailureSchedule,
103    /// };
104    /// use std::time::Duration;
105    ///
106    /// let inj = LatencyInjector::new(LatencyProfile::Constant(Duration::ZERO));
107    /// let schedule = FailureSchedule::on_attempts(&[2], FailureMode::Timeout);
108    /// let composed = inj.compose_with_schedule(schedule);
109    ///
110    /// assert!(composed.apply_blocking(1).is_ok());
111    /// assert!(composed.apply_blocking(2).is_err());
112    /// ```
113    pub fn compose_with_schedule(self, schedule: crate::FailureSchedule) -> LatencyAndFailure {
114        LatencyAndFailure {
115            injector: self,
116            schedule,
117        }
118    }
119}
120
121/// A latency injector composed with a [`FailureSchedule`](crate::FailureSchedule).
122///
123/// Built via [`LatencyInjector::compose_with_schedule`]. Each
124/// `apply_blocking` call sleeps for the latency profile's delay and
125/// then either returns `Ok(())` or `Err(InjectedFailure)` based on
126/// the schedule.
127pub struct LatencyAndFailure {
128    injector: LatencyInjector,
129    schedule: crate::FailureSchedule,
130}
131
132impl LatencyAndFailure {
133    /// Apply the latency for `attempt`, then check the schedule.
134    ///
135    /// Returns `Ok(())` if the schedule does not fire, or
136    /// `Err(InjectedFailure)` if it does. The latency is applied in
137    /// either case — the failure is appended *after* the slowdown.
138    pub fn apply_blocking(&self, attempt: usize) -> Result<(), crate::InjectedFailure> {
139        self.injector.apply_blocking(attempt);
140        self.schedule.maybe_fail(attempt)
141    }
142
143    /// Compute the delay for `attempt` without sleeping or consuming
144    /// schedule attempts. Useful for diagnostics.
145    pub fn delay_for(&self, attempt: usize) -> Duration {
146        self.injector.delay_for(attempt)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn constant_profile_returns_same_duration() {
156        let inj = LatencyInjector::new(LatencyProfile::Constant(Duration::from_micros(50)));
157        for attempt in 1..=10 {
158            assert_eq!(inj.delay_for(attempt), Duration::from_micros(50));
159        }
160    }
161
162    #[test]
163    fn linear_ramp_increases() {
164        let inj = LatencyInjector::new(LatencyProfile::LinearRamp {
165            start: Duration::from_micros(10),
166            step: Duration::from_micros(5),
167        });
168        assert_eq!(inj.delay_for(1), Duration::from_micros(10));
169        assert_eq!(inj.delay_for(2), Duration::from_micros(15));
170        assert_eq!(inj.delay_for(5), Duration::from_micros(30));
171    }
172
173    #[test]
174    fn step_schedule_picks_correct_band() {
175        let inj = LatencyInjector::new(LatencyProfile::StepSchedule(vec![
176            (10, Duration::from_micros(1)),
177            (20, Duration::from_micros(5)),
178            (50, Duration::from_micros(20)),
179        ]));
180        assert_eq!(inj.delay_for(1), Duration::from_micros(1));
181        assert_eq!(inj.delay_for(10), Duration::from_micros(1));
182        assert_eq!(inj.delay_for(11), Duration::from_micros(5));
183        assert_eq!(inj.delay_for(20), Duration::from_micros(5));
184        assert_eq!(inj.delay_for(21), Duration::from_micros(20));
185        assert_eq!(inj.delay_for(100), Duration::from_micros(20));
186    }
187
188    #[test]
189    fn empty_step_schedule_yields_zero() {
190        let inj = LatencyInjector::new(LatencyProfile::StepSchedule(vec![]));
191        assert_eq!(inj.delay_for(1), Duration::ZERO);
192    }
193
194    #[test]
195    fn apply_blocking_sleeps_at_least_the_delay() {
196        let inj = LatencyInjector::new(LatencyProfile::Constant(Duration::from_millis(10)));
197        let start = std::time::Instant::now();
198        inj.apply_blocking(1);
199        assert!(start.elapsed() >= Duration::from_millis(10));
200    }
201}