Skip to main content

simulacra/net/
latency.rs

1//! Latency and jitter models for network simulation.
2
3use crate::rng::SimRng;
4use crate::time::Duration;
5
6/// A model for computing message transmission latency.
7pub trait LatencyModel {
8    /// Computes the latency for a message given the base link latency.
9    fn compute(&mut self, base_latency: Duration, rng: &mut SimRng) -> Duration;
10}
11
12/// Fixed latency with no jitter.
13///
14/// Messages always take exactly the base latency.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct FixedLatency;
17
18impl LatencyModel for FixedLatency {
19    fn compute(&mut self, base_latency: Duration, _rng: &mut SimRng) -> Duration {
20        base_latency
21    }
22}
23
24/// Uniform jitter model.
25///
26/// Adds uniformly distributed jitter in the range [-max_jitter, +max_jitter]
27/// to the base latency. Result is clamped to be non-negative.
28#[derive(Debug, Clone, Copy)]
29pub struct UniformJitter {
30    /// Maximum jitter to add or subtract.
31    pub max_jitter: Duration,
32}
33
34impl UniformJitter {
35    /// Creates a new uniform jitter model.
36    pub fn new(max_jitter: Duration) -> Self {
37        UniformJitter { max_jitter }
38    }
39}
40
41impl LatencyModel for UniformJitter {
42    fn compute(&mut self, base_latency: Duration, rng: &mut SimRng) -> Duration {
43        rng.duration_with_jitter(base_latency, self.max_jitter)
44    }
45}
46
47/// Percentage-based jitter model.
48///
49/// Adds jitter as a percentage of the base latency.
50/// For example, 10% jitter on 100ms base = jitter in [-10ms, +10ms].
51#[derive(Debug, Clone, Copy)]
52pub struct PercentageJitter {
53    /// Jitter as a fraction (0.0 to 1.0).
54    pub fraction: f64,
55}
56
57impl PercentageJitter {
58    /// Creates a new percentage jitter model.
59    ///
60    /// # Arguments
61    /// * `percent` - Jitter percentage (e.g., 10.0 for 10%)
62    pub fn new(percent: f64) -> Self {
63        PercentageJitter {
64            fraction: percent / 100.0,
65        }
66    }
67
68    /// Creates from a fraction (0.0 to 1.0).
69    pub fn from_fraction(fraction: f64) -> Self {
70        PercentageJitter { fraction }
71    }
72}
73
74impl LatencyModel for PercentageJitter {
75    fn compute(&mut self, base_latency: Duration, rng: &mut SimRng) -> Duration {
76        let max_jitter_nanos = (base_latency.as_nanos() as f64 * self.fraction) as u64;
77        let max_jitter = Duration::from_nanos(max_jitter_nanos);
78        rng.duration_with_jitter(base_latency, max_jitter)
79    }
80}
81
82/// Compound latency model that adds a fixed overhead plus jitter.
83#[derive(Debug, Clone, Copy)]
84pub struct OverheadPlusJitter {
85    /// Fixed overhead added to every message.
86    pub overhead: Duration,
87    /// Maximum jitter.
88    pub max_jitter: Duration,
89}
90
91impl OverheadPlusJitter {
92    /// Creates a new overhead plus jitter model.
93    pub fn new(overhead: Duration, max_jitter: Duration) -> Self {
94        OverheadPlusJitter {
95            overhead,
96            max_jitter,
97        }
98    }
99}
100
101impl LatencyModel for OverheadPlusJitter {
102    fn compute(&mut self, base_latency: Duration, rng: &mut SimRng) -> Duration {
103        let base_with_overhead = base_latency.saturating_add(self.overhead);
104        rng.duration_with_jitter(base_with_overhead, self.max_jitter)
105    }
106}
107
108/// Injects occasional delay spikes on top of an inner latency model.
109///
110/// On each `compute()` call, the inner model is consulted first. With
111/// probability `spike_probability`, an additional uniformly random spike
112/// in `[0, spike_max]` is added. Useful for simulating tail-latency fault
113/// storms, GC pauses, or congested links without a full queueing model.
114#[derive(Debug, Clone, Copy)]
115pub struct SpikyLatency<L: LatencyModel> {
116    /// Inner latency model.
117    pub inner: L,
118    /// Probability of a spike on any given message, in `[0.0, 1.0]`.
119    pub spike_probability: f64,
120    /// Maximum additional spike duration.
121    pub spike_max: Duration,
122}
123
124impl<L: LatencyModel> SpikyLatency<L> {
125    /// Creates a new spiky latency wrapper around `inner`.
126    pub fn new(inner: L, spike_probability: f64, spike_max: Duration) -> Self {
127        SpikyLatency {
128            inner,
129            spike_probability,
130            spike_max,
131        }
132    }
133}
134
135impl<L: LatencyModel> LatencyModel for SpikyLatency<L> {
136    fn compute(&mut self, base_latency: Duration, rng: &mut SimRng) -> Duration {
137        let base = self.inner.compute(base_latency, rng);
138        if self.spike_probability > 0.0
139            && self.spike_max.as_nanos() > 0
140            && rng.bool(self.spike_probability)
141        {
142            let spike_nanos = rng.u64(self.spike_max.as_nanos() + 1);
143            base.saturating_add(Duration::from_nanos(spike_nanos))
144        } else {
145            base
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn fixed_latency() {
156        let mut model = FixedLatency;
157        let mut rng = SimRng::new(42);
158        let base = Duration::from_millis(100);
159
160        for _ in 0..100 {
161            assert_eq!(model.compute(base, &mut rng), base);
162        }
163    }
164
165    #[test]
166    fn uniform_jitter_bounds() {
167        let mut model = UniformJitter::new(Duration::from_millis(10));
168        let mut rng = SimRng::new(42);
169        let base = Duration::from_millis(100);
170
171        for _ in 0..1000 {
172            let latency = model.compute(base, &mut rng);
173            assert!(latency.as_millis() >= 90);
174            assert!(latency.as_millis() <= 110);
175        }
176    }
177
178    #[test]
179    fn uniform_jitter_clamps_negative() {
180        let mut model = UniformJitter::new(Duration::from_millis(100));
181        let mut rng = SimRng::new(42);
182        let base = Duration::from_millis(10); // Small base, large jitter
183
184        for _ in 0..1000 {
185            let latency = model.compute(base, &mut rng);
186            // `as_nanos()` returns u64, so non-negative is implicit;
187            // just assert the call succeeds.
188            let _ = latency.as_nanos();
189        }
190    }
191
192    #[test]
193    fn percentage_jitter() {
194        let mut model = PercentageJitter::new(10.0); // 10%
195        let mut rng = SimRng::new(42);
196        let base = Duration::from_millis(100);
197
198        for _ in 0..1000 {
199            let latency = model.compute(base, &mut rng);
200            assert!(latency.as_millis() >= 90);
201            assert!(latency.as_millis() <= 110);
202        }
203    }
204
205    #[test]
206    fn overhead_plus_jitter() {
207        let mut model = OverheadPlusJitter::new(Duration::from_millis(5), Duration::from_millis(2));
208        let mut rng = SimRng::new(42);
209        let base = Duration::from_millis(100);
210
211        for _ in 0..1000 {
212            let latency = model.compute(base, &mut rng);
213            // 100 + 5 = 105, with +/- 2ms jitter
214            assert!(latency.as_millis() >= 103);
215            assert!(latency.as_millis() <= 107);
216        }
217    }
218
219    #[test]
220    fn deterministic_jitter() {
221        let mut model1 = UniformJitter::new(Duration::from_millis(10));
222        let mut model2 = UniformJitter::new(Duration::from_millis(10));
223        let mut rng1 = SimRng::new(42);
224        let mut rng2 = SimRng::new(42);
225        let base = Duration::from_millis(100);
226
227        for _ in 0..100 {
228            assert_eq!(
229                model1.compute(base, &mut rng1),
230                model2.compute(base, &mut rng2)
231            );
232        }
233    }
234}