1use crate::{erlang, error::PoolsimError, types::WorkloadConfig};
6
7#[derive(Debug, Clone, PartialEq)]
9pub struct AcquisitionEstimate {
10 pool_size: u32,
11 utilisation_rho: f64,
12 mean_acquisition_wait_ms: f64,
13 p99_acquisition_wait_ms: f64,
14 acquisition_timeout_ms: f64,
15 timeout_risk: bool,
16}
17
18impl AcquisitionEstimate {
19 pub fn pool_size(&self) -> u32 {
21 self.pool_size
22 }
23
24 pub fn utilisation_rho(&self) -> f64 {
26 self.utilisation_rho
27 }
28
29 pub fn mean_acquisition_wait_ms(&self) -> f64 {
31 self.mean_acquisition_wait_ms
32 }
33
34 pub fn p99_acquisition_wait_ms(&self) -> f64 {
36 self.p99_acquisition_wait_ms
37 }
38
39 pub fn acquisition_timeout_ms(&self) -> f64 {
41 self.acquisition_timeout_ms
42 }
43
44 pub fn timeout_risk(&self) -> bool {
46 self.timeout_risk
47 }
48}
49
50pub fn estimate_acquisition_wait(
57 workload: &WorkloadConfig,
58 pool_size: u32,
59 acquisition_timeout_ms: f64,
60) -> Result<AcquisitionEstimate, PoolsimError> {
61 workload.validate()?;
62 if pool_size == 0 {
63 return Err(PoolsimError::invalid_input(
64 "INVALID_POOL_SIZE",
65 "pool_size must be greater than 0",
66 None,
67 ));
68 }
69 if acquisition_timeout_ms <= 0.0 || !acquisition_timeout_ms.is_finite() {
70 return Err(PoolsimError::invalid_input(
71 "INVALID_ACQUISITION_TIMEOUT",
72 "acquisition_timeout_ms must be finite and greater than 0",
73 None,
74 ));
75 }
76
77 let lambda = workload.requests_per_second;
78 let service_mean_ms =
79 (workload.latency_p50_ms + workload.latency_p95_ms + workload.latency_p99_ms) / 3.0;
80 let mu = 1_000.0 / service_mean_ms;
81 let rho = erlang::utilisation(lambda, mu, pool_size);
82 let mean = erlang::mean_queue_wait_ms(lambda, mu, pool_size)?;
83 let p99 = erlang::queue_wait_percentile_ms(lambda, mu, pool_size, 0.99)?;
84
85 Ok(AcquisitionEstimate {
86 pool_size,
87 utilisation_rho: rho,
88 mean_acquisition_wait_ms: mean,
89 p99_acquisition_wait_ms: p99,
90 acquisition_timeout_ms,
91 timeout_risk: p99 >= acquisition_timeout_ms,
92 })
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub struct TransactionClass {
98 name: String,
99 requests_per_second: f64,
100 latency_p50_ms: f64,
101 latency_p95_ms: f64,
102 latency_p99_ms: f64,
103}
104
105impl TransactionClass {
106 pub fn new(
108 name: impl Into<String>,
109 requests_per_second: f64,
110 latency_p50_ms: f64,
111 latency_p95_ms: f64,
112 latency_p99_ms: f64,
113 ) -> Self {
114 Self {
115 name: name.into(),
116 requests_per_second,
117 latency_p50_ms,
118 latency_p95_ms,
119 latency_p99_ms,
120 }
121 }
122
123 pub fn name(&self) -> &str {
125 &self.name
126 }
127
128 pub fn requests_per_second(&self) -> f64 {
130 self.requests_per_second
131 }
132
133 fn validate(&self) -> Result<(), PoolsimError> {
134 if self.name.trim().is_empty() {
135 return Err(PoolsimError::invalid_input(
136 "INVALID_TRANSACTION_NAME",
137 "transaction class name must not be empty",
138 None,
139 ));
140 }
141 WorkloadConfig {
142 requests_per_second: self.requests_per_second,
143 latency_p50_ms: self.latency_p50_ms,
144 latency_p95_ms: self.latency_p95_ms,
145 latency_p99_ms: self.latency_p99_ms,
146 raw_samples_ms: None,
147 step_load_profile: None,
148 }
149 .validate()
150 }
151}
152
153#[derive(Debug, Clone, PartialEq)]
155pub struct TransactionMix {
156 classes: Vec<TransactionClass>,
157}
158
159impl TransactionMix {
160 pub fn new(classes: Vec<TransactionClass>) -> Result<Self, PoolsimError> {
166 if classes.is_empty() {
167 return Err(PoolsimError::invalid_input(
168 "INVALID_TRANSACTION_MIX",
169 "transaction mix must contain at least one class",
170 None,
171 ));
172 }
173 for class in &classes {
174 class.validate()?;
175 }
176 Ok(Self { classes })
177 }
178
179 pub fn classes(&self) -> &[TransactionClass] {
181 &self.classes
182 }
183
184 pub fn aggregate_workload(&self) -> WorkloadConfig {
186 let total_rps: f64 = self
187 .classes
188 .iter()
189 .map(|class| class.requests_per_second)
190 .sum();
191 let weighted = |latency: fn(&TransactionClass) -> f64| {
192 self.classes
193 .iter()
194 .map(|class| latency(class) * class.requests_per_second / total_rps)
195 .sum()
196 };
197
198 WorkloadConfig {
199 requests_per_second: total_rps,
200 latency_p50_ms: weighted(|class| class.latency_p50_ms),
201 latency_p95_ms: weighted(|class| class.latency_p95_ms),
202 latency_p99_ms: weighted(|class| class.latency_p99_ms),
203 raw_samples_ms: None,
204 step_load_profile: None,
205 }
206 }
207}
208
209#[derive(Debug, Clone, PartialEq)]
211pub struct LeakSimulation {
212 initial_pool_size: u32,
213 final_available_connections: u32,
214 leaked_connections: u32,
215 minutes_to_exhaustion: Option<u32>,
216}
217
218impl LeakSimulation {
219 pub fn initial_pool_size(&self) -> u32 {
221 self.initial_pool_size
222 }
223
224 pub fn final_available_connections(&self) -> u32 {
226 self.final_available_connections
227 }
228
229 pub fn leaked_connections(&self) -> u32 {
231 self.leaked_connections
232 }
233
234 pub fn minutes_to_exhaustion(&self) -> Option<u32> {
236 self.minutes_to_exhaustion
237 }
238}
239
240pub fn simulate_connection_leak(
246 initial_pool_size: u32,
247 leak_rate_per_minute: f64,
248 duration_minutes: u32,
249) -> Result<LeakSimulation, PoolsimError> {
250 if initial_pool_size == 0 {
251 return Err(PoolsimError::invalid_input(
252 "INVALID_POOL_SIZE",
253 "initial_pool_size must be greater than 0",
254 None,
255 ));
256 }
257 if leak_rate_per_minute < 0.0 || !leak_rate_per_minute.is_finite() {
258 return Err(PoolsimError::invalid_input(
259 "INVALID_LEAK_RATE",
260 "leak_rate_per_minute must be finite and non-negative",
261 None,
262 ));
263 }
264
265 let leaked = (leak_rate_per_minute * f64::from(duration_minutes)).floor() as u32;
266 let capped_leaked = leaked.min(initial_pool_size);
267 let minutes_to_exhaustion = if leak_rate_per_minute > 0.0 {
268 Some((f64::from(initial_pool_size) / leak_rate_per_minute).ceil() as u32)
269 .filter(|minutes| *minutes <= duration_minutes)
270 } else {
271 None
272 };
273
274 Ok(LeakSimulation {
275 initial_pool_size,
276 final_available_connections: initial_pool_size.saturating_sub(capped_leaked),
277 leaked_connections: capped_leaked,
278 minutes_to_exhaustion,
279 })
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 fn workload() -> WorkloadConfig {
287 WorkloadConfig {
288 requests_per_second: 100.0,
289 latency_p50_ms: 5.0,
290 latency_p95_ms: 20.0,
291 latency_p99_ms: 40.0,
292 raw_samples_ms: None,
293 step_load_profile: None,
294 }
295 }
296
297 #[test]
298 fn acquisition_wait_estimates_timeout_risk() {
299 let estimate = estimate_acquisition_wait(&workload(), 4, 100.0).expect("estimate");
300 assert_eq!(estimate.pool_size(), 4);
301 assert!(estimate.utilisation_rho().is_finite());
302 assert!(estimate.p99_acquisition_wait_ms() >= 0.0);
303 assert_eq!(
304 estimate.timeout_risk(),
305 estimate.p99_acquisition_wait_ms() >= 100.0
306 );
307 }
308
309 #[test]
310 fn transaction_mix_aggregates_weighted_workload() {
311 let mix = TransactionMix::new(vec![
312 TransactionClass::new("fast-read", 80.0, 4.0, 12.0, 30.0),
313 TransactionClass::new("slow-write", 20.0, 20.0, 80.0, 160.0),
314 ])
315 .expect("valid mix");
316 assert_eq!(mix.classes()[0].name(), "fast-read");
317 let workload = mix.aggregate_workload();
318 assert_eq!(workload.requests_per_second, 100.0);
319 assert!(workload.latency_p99_ms > 30.0);
320 }
321
322 #[test]
323 fn connection_leak_reports_exhaustion() {
324 let leak = simulate_connection_leak(10, 2.0, 6).expect("leak simulation");
325 assert_eq!(leak.initial_pool_size(), 10);
326 assert_eq!(leak.leaked_connections(), 10);
327 assert_eq!(leak.final_available_connections(), 0);
328 assert_eq!(leak.minutes_to_exhaustion(), Some(5));
329 }
330
331 #[test]
332 fn invalid_advanced_inputs_are_rejected() {
333 assert!(estimate_acquisition_wait(&workload(), 0, 100.0).is_err());
334 assert!(TransactionMix::new(Vec::new()).is_err());
335 assert!(TransactionMix::new(vec![TransactionClass::new("", 1.0, 1.0, 2.0, 3.0)]).is_err());
336 assert!(simulate_connection_leak(1, f64::NAN, 10).is_err());
337 }
338}