Skip to main content

poolsim_core/
optimizer.rs

1//! Pool-size optimization routines.
2//!
3//! This module evaluates the configured candidate range and chooses the
4//! smallest pool size that satisfies both:
5//!
6//! - `max_acceptable_rho`
7//! - `target_wait_p99_ms`
8//!
9//! If no candidate satisfies both constraints, the optimizer falls back to
10//! `max_pool_size` and emits advisory warnings in
11//! [`crate::optimizer::OptimalResult`].
12
13use crate::{
14    distribution::LatencyDistribution,
15    erlang,
16    error::PoolsimError,
17    monte_carlo,
18    types::{PoolConfig, QueueModel, SimulationOptions, WorkloadConfig},
19};
20
21/// Optimization output for the selected pool size.
22#[derive(Debug, Clone)]
23pub struct OptimalResult {
24    /// Selected pool size.
25    pub pool_size: u32,
26    /// Confidence interval around the selected pool size.
27    pub confidence_interval: (u32, u32),
28    /// Utilization ratio (`rho`) for `pool_size`.
29    pub utilisation_rho: f64,
30    /// Mean queue wait in milliseconds for `pool_size`.
31    pub mean_queue_wait_ms: f64,
32    /// p99 queue wait in milliseconds for `pool_size`.
33    pub p99_queue_wait_ms: f64,
34    /// Human-readable advisory notes from the optimizer.
35    pub warnings: Vec<String>,
36}
37
38/// Finds the smallest pool size that satisfies target constraints.
39///
40/// Falls back to `pool.max_pool_size` when no candidate satisfies both
41/// utilization and p99 wait targets.
42///
43/// # Errors
44///
45/// Returns propagated distribution/simulation/queue-model errors.
46pub fn find_optimal(
47    workload: &WorkloadConfig,
48    pool: &PoolConfig,
49    dist: &LatencyDistribution,
50    opts: &SimulationOptions,
51) -> Result<OptimalResult, PoolsimError> {
52    let lambda = workload.requests_per_second;
53    let mu = 1_000.0 / (dist.mean_ms() + pool.connection_overhead_ms);
54
55    let mut candidate = None;
56    let mut warnings = Vec::new();
57    if opts.queue_model == QueueModel::MDC {
58        warnings.push("MDC mode uses Monte Carlo probe estimates for candidate search".to_string());
59    }
60
61    for size in pool.min_pool_size..=pool.max_pool_size {
62        let rho = erlang::utilisation(lambda, mu, size);
63        if rho >= 1.0 {
64            continue;
65        }
66
67        let p99 = match opts.queue_model {
68            QueueModel::MMC => erlang::queue_wait_percentile_ms(lambda, mu, size, 0.99)?,
69            QueueModel::MDC => mdc_probe_p99(workload, pool, dist, opts, size)?,
70        };
71        if rho < opts.max_acceptable_rho && p99 <= opts.target_wait_p99_ms {
72            candidate = Some(size);
73            break;
74        }
75    }
76
77    let chosen = candidate.unwrap_or(pool.max_pool_size);
78    if candidate.is_none() {
79        warnings.push(
80            "No candidate pool size met target constraints; using max_pool_size fallback"
81                .to_string(),
82        );
83    }
84
85    let mc =
86        monte_carlo::run_with_overhead(workload, chosen, pool.connection_overhead_ms, dist, opts)?;
87
88    let rho = erlang::utilisation(lambda, mu, chosen);
89    let ci = bootstrap_ci(chosen, pool, &mc.wait_times_ms, opts.target_wait_p99_ms);
90
91    Ok(OptimalResult {
92        pool_size: chosen,
93        confidence_interval: ci,
94        utilisation_rho: rho,
95        mean_queue_wait_ms: mc.mean,
96        p99_queue_wait_ms: mc.p99,
97        warnings,
98    })
99}
100
101fn mdc_probe_p99(
102    workload: &WorkloadConfig,
103    pool: &PoolConfig,
104    dist: &LatencyDistribution,
105    opts: &SimulationOptions,
106    size: u32,
107) -> Result<f64, PoolsimError> {
108    let probe_opts = mdc_probe_options(opts, size);
109    let probe = monte_carlo::run_with_overhead(
110        workload,
111        size,
112        pool.connection_overhead_ms,
113        dist,
114        &probe_opts,
115    )?;
116    Ok(probe.p99)
117}
118
119fn mdc_probe_options(opts: &SimulationOptions, size: u32) -> SimulationOptions {
120    let mut probe_opts = opts.clone();
121    probe_opts.iterations = (opts.iterations / 4).clamp(400, 2_500);
122    if let Some(seed) = opts.seed {
123        probe_opts.seed = Some(seed ^ ((size as u64 + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15)));
124    }
125    probe_opts
126}
127
128fn bootstrap_ci(
129    chosen: u32,
130    pool: &PoolConfig,
131    wait_times: &[f64],
132    target_wait_p99_ms: f64,
133) -> (u32, u32) {
134    if wait_times.is_empty() {
135        return (chosen, chosen);
136    }
137
138    let mean = wait_times.iter().sum::<f64>() / wait_times.len() as f64;
139    let variance = wait_times
140        .iter()
141        .map(|v| {
142            let d = v - mean;
143            d * d
144        })
145        .sum::<f64>()
146        / wait_times.len() as f64;
147
148    let stddev = variance.sqrt();
149    let mut width = (stddev / target_wait_p99_ms).ceil() as u32;
150    width = width.clamp(1, 5);
151
152    (
153        chosen.saturating_sub(width).max(pool.min_pool_size),
154        chosen.saturating_add(width).min(pool.max_pool_size),
155    )
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn bootstrap_ci_returns_degenerate_interval_for_empty_waits() {
164        let pool = PoolConfig {
165            max_server_connections: 100,
166            connection_overhead_ms: 2.0,
167            idle_timeout_ms: None,
168            min_pool_size: 2,
169            max_pool_size: 20,
170        };
171        assert_eq!(bootstrap_ci(7, &pool, &[], 40.0), (7, 7));
172    }
173}