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(
59            "MDC mode uses Monte Carlo probe estimates for candidate search".to_string(),
60        );
61    }
62
63    for size in pool.min_pool_size..=pool.max_pool_size {
64        let rho = erlang::utilisation(lambda, mu, size);
65        if rho >= 1.0 {
66            continue;
67        }
68
69        let p99 = match opts.queue_model {
70            QueueModel::MMC => erlang::queue_wait_percentile_ms(lambda, mu, size, 0.99)?,
71            QueueModel::MDC => mdc_probe_p99(workload, pool, dist, opts, size)?,
72        };
73        if rho < opts.max_acceptable_rho && p99 <= opts.target_wait_p99_ms {
74            candidate = Some(size);
75            break;
76        }
77    }
78
79    let chosen = candidate.unwrap_or(pool.max_pool_size);
80    if candidate.is_none() {
81        warnings.push(
82            "No candidate pool size met target constraints; using max_pool_size fallback".to_string(),
83        );
84    }
85
86    let mc = 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 =
110        monte_carlo::run_with_overhead(workload, size, pool.connection_overhead_ms, dist, &probe_opts)?;
111    Ok(probe.p99)
112}
113
114fn mdc_probe_options(opts: &SimulationOptions, size: u32) -> SimulationOptions {
115    let mut probe_opts = opts.clone();
116    probe_opts.iterations = (opts.iterations / 4).clamp(400, 2_500);
117    if let Some(seed) = opts.seed {
118        probe_opts.seed = Some(seed ^ ((size as u64 + 1).wrapping_mul(0x9E37_79B9_7F4A_7C15)));
119    }
120    probe_opts
121}
122
123fn bootstrap_ci(chosen: u32, pool: &PoolConfig, wait_times: &[f64], target_wait_p99_ms: f64) -> (u32, u32) {
124    if wait_times.is_empty() {
125        return (chosen, chosen);
126    }
127
128    let mean = wait_times.iter().sum::<f64>() / wait_times.len() as f64;
129    let variance = wait_times
130        .iter()
131        .map(|v| {
132            let d = v - mean;
133            d * d
134        })
135        .sum::<f64>()
136        / wait_times.len() as f64;
137
138    let stddev = variance.sqrt();
139    let mut width = (stddev / target_wait_p99_ms).ceil() as u32;
140    width = width.clamp(1, 5);
141
142    (
143        chosen.saturating_sub(width).max(pool.min_pool_size),
144        chosen.saturating_add(width).min(pool.max_pool_size),
145    )
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn bootstrap_ci_returns_degenerate_interval_for_empty_waits() {
154        let pool = PoolConfig {
155            max_server_connections: 100,
156            connection_overhead_ms: 2.0,
157            idle_timeout_ms: None,
158            min_pool_size: 2,
159            max_pool_size: 20,
160        };
161        assert_eq!(bootstrap_ci(7, &pool, &[], 40.0), (7, 7));
162    }
163}