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}