dynamo_mocker/loadgen/
arrival.rs1use anyhow::{Result, bail};
5use rand::rngs::StdRng;
6use rand::{Rng, SeedableRng};
7
8use super::types::ArrivalSpec;
9
10impl ArrivalSpec {
11 pub fn timestamps(&self, request_count: usize, seed: u64) -> Result<Vec<f64>> {
12 let mean_gap_ms = self.mean_gap_ms()?;
13 let mut rng = StdRng::seed_from_u64(seed);
14 let mut timestamps = Vec::with_capacity(request_count);
15 let mut next_arrival_ms = 0.0;
16
17 for request_idx in 0..request_count {
18 if request_idx > 0 {
19 next_arrival_ms += self.sample_gap_ms(mean_gap_ms, &mut rng)?;
20 }
21 timestamps.push(next_arrival_ms);
22 }
23
24 Ok(timestamps)
25 }
26
27 fn mean_gap_ms(&self) -> Result<f64> {
28 match self {
29 Self::Burst => Ok(0.0),
30 Self::ConstantQps { qps } | Self::PoissonQps { qps } | Self::GammaQps { qps, .. } => {
31 if !qps.is_finite() || *qps <= 0.0 {
32 bail!("qps must be a finite positive number, got {qps}");
33 }
34 Ok(1000.0 / qps)
35 }
36 }
37 }
38
39 fn sample_gap_ms(&self, mean_gap_ms: f64, rng: &mut StdRng) -> Result<f64> {
40 match self {
41 Self::Burst => Ok(0.0),
42 Self::ConstantQps { .. } => Ok(mean_gap_ms),
43 Self::PoissonQps { .. } => Ok(sample_exponential_ms(mean_gap_ms, rng)),
44 Self::GammaQps { smoothness, .. } => {
45 if !smoothness.is_finite() || *smoothness <= 0.0 {
46 bail!("gamma smoothness must be a finite positive number, got {smoothness}");
47 }
48 Ok(sample_gamma_ms(*smoothness, mean_gap_ms / smoothness, rng))
49 }
50 }
51 }
52}
53
54fn sample_exponential_ms(mean_ms: f64, rng: &mut StdRng) -> f64 {
55 if mean_ms == 0.0 {
56 return 0.0;
57 }
58 let u = (1.0 - rng.random::<f64>()).clamp(f64::MIN_POSITIVE, 1.0);
59 -mean_ms * u.ln()
60}
61
62fn sample_gamma_ms(shape: f64, scale: f64, rng: &mut StdRng) -> f64 {
63 if scale == 0.0 {
64 return 0.0;
65 }
66 if shape < 1.0 {
67 let u = (1.0 - rng.random::<f64>()).clamp(f64::MIN_POSITIVE, 1.0);
68 return sample_gamma_ms(shape + 1.0, scale, rng) * u.powf(1.0 / shape);
69 }
70
71 let d = shape - 1.0 / 3.0;
72 let c = (1.0 / (9.0 * d)).sqrt();
73 loop {
74 let u1 = (1.0 - rng.random::<f64>()).clamp(f64::MIN_POSITIVE, 1.0);
75 let u2 = rng.random::<f64>();
76 let z = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
77 let v = (1.0 + c * z).powi(3);
78 if v <= 0.0 {
79 continue;
80 }
81 let u = rng.random::<f64>();
82 if u < 1.0 - 0.0331 * z.powi(4) {
83 return d * v * scale;
84 }
85 if u.ln() < 0.5 * z * z + d * (1.0 - v + v.ln()) {
86 return d * v * scale;
87 }
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 fn gap_moments(timestamps: &[f64]) -> (f64, f64) {
96 let gaps = timestamps
97 .windows(2)
98 .map(|values| values[1] - values[0])
99 .collect::<Vec<_>>();
100 assert!(gaps.iter().all(|gap| *gap > 0.0));
101
102 let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
103 let variance = gaps
104 .iter()
105 .map(|gap| {
106 let delta = gap - mean;
107 delta * delta
108 })
109 .sum::<f64>()
110 / gaps.len() as f64;
111 (mean, variance.sqrt())
112 }
113
114 #[test]
115 fn fixed_schedule_has_exact_cadence() {
116 assert_eq!(ArrivalSpec::Burst.timestamps(4, 17).unwrap(), vec![0.0; 4]);
117 let fixed = ArrivalSpec::ConstantQps { qps: 10.0 }
118 .timestamps(4, 17)
119 .unwrap();
120 assert_eq!(fixed, vec![0.0, 100.0, 200.0, 300.0]);
121 }
122
123 #[test]
124 fn poisson_schedule_is_seeded_and_matches_exponential_moments() {
125 let poisson = ArrivalSpec::PoissonQps { qps: 10.0 }
126 .timestamps(100_001, 17)
127 .unwrap();
128 let repeated = ArrivalSpec::PoissonQps { qps: 10.0 }
129 .timestamps(100_001, 17)
130 .unwrap();
131 assert_eq!(poisson, repeated);
132
133 let (mean_gap_ms, stddev_gap_ms) = gap_moments(&poisson);
134 assert!(
135 (mean_gap_ms - 100.0).abs() < 1.0,
136 "expected a 100ms mean gap, got {mean_gap_ms}"
137 );
138 assert!(
139 (stddev_gap_ms - 100.0).abs() < 2.0,
140 "expected a 100ms gap standard deviation, got {stddev_gap_ms}"
141 );
142 }
143
144 #[test]
145 fn gamma_schedule_is_seeded_and_matches_requested_moments() {
146 let spec = ArrivalSpec::GammaQps {
147 qps: 20.0,
148 smoothness: 4.0,
149 };
150 let timestamps = spec.timestamps(100_001, 23).unwrap();
151 assert_eq!(timestamps, spec.timestamps(100_001, 23).unwrap());
152
153 let (mean_gap_ms, stddev_gap_ms) = gap_moments(×tamps);
154 assert!(
155 (mean_gap_ms - 50.0).abs() < 0.5,
156 "expected a 50ms mean gap, got {mean_gap_ms}"
157 );
158 assert!(
159 (stddev_gap_ms - 25.0).abs() < 0.5,
160 "expected a 25ms gap standard deviation, got {stddev_gap_ms}"
161 );
162 }
163
164 #[test]
165 fn arrival_parameters_are_validated_before_sampling() {
166 for qps in [0.0, -1.0, f64::NAN, f64::INFINITY] {
167 assert!(
168 ArrivalSpec::PoissonQps { qps }
169 .timestamps(2, 42)
170 .unwrap_err()
171 .to_string()
172 .contains("qps must be")
173 );
174 }
175 for smoothness in [0.0, -1.0, f64::NAN, f64::INFINITY] {
176 assert!(
177 ArrivalSpec::GammaQps {
178 qps: 1.0,
179 smoothness,
180 }
181 .timestamps(2, 42)
182 .unwrap_err()
183 .to_string()
184 .contains("gamma smoothness")
185 );
186 }
187 }
188}