Skip to main content

poolsim_core/
lib.rs

1#![doc = include_str!("../README.md")]
2#![doc(html_root_url = "https://docs.rs/poolsim-core/0.2.1")]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4#![deny(missing_docs)]
5
6/// Distribution fitting and sampling utilities.
7pub mod distribution;
8/// Erlang-C queueing formulas.
9pub mod erlang;
10/// Error type and helpers.
11pub mod error;
12/// Monte Carlo queue simulation engine.
13pub mod monte_carlo;
14/// Pool-size optimization routines.
15pub mod optimizer;
16/// Sensitivity analysis routines.
17pub mod sensitivity;
18/// Telemetry import and recommendation-diff routines.
19pub mod telemetry;
20/// Public input/output data models.
21pub mod types;
22
23use distribution::LatencyDistribution;
24use error::PoolsimError;
25use optimizer::find_optimal;
26use types::{
27    EvaluationResult, PoolConfig, SaturationLevel, SensitivityRow, SimulationOptions,
28    SimulationReport, StepLoadResult, WorkloadConfig,
29};
30
31/// Re-exported distribution model enum.
32pub use types::DistributionModel;
33/// Re-exported queue model enum.
34pub use types::QueueModel;
35/// Re-exported risk-level enum.
36pub use types::RiskLevel;
37
38/// Minimum iteration floor used by full simulation for stable estimates.
39pub const MIN_FULL_SIMULATION_ITERATIONS: u32 = 10_000;
40/// Performance warning text emitted by benchmark/helpers when threshold is exceeded.
41pub const PERFORMANCE_CONTRACT_WARNING: &str = "performance contract not met: expected <= 200ms";
42
43/// Emits the performance contract warning when elapsed time exceeds threshold.
44pub fn emit_performance_contract_warning(elapsed_ms: u128, threshold_ms: u128) {
45    if elapsed_ms > threshold_ms {
46        eprintln!("{PERFORMANCE_CONTRACT_WARNING}");
47    }
48}
49
50/// Runs full pool-size optimization and returns a simulation report.
51///
52/// # Errors
53///
54/// Returns [`error::PoolsimError`] for invalid inputs, distribution fitting failures,
55/// or queue/simulation failures.
56pub fn simulate(
57    workload: &WorkloadConfig,
58    pool: &PoolConfig,
59    opts: &SimulationOptions,
60) -> Result<SimulationReport, PoolsimError> {
61    workload.validate()?;
62    pool.validate()?;
63    opts.validate()?;
64
65    let mut effective_opts = opts.clone();
66    let mut warnings = Vec::new();
67    if effective_opts.iterations < MIN_FULL_SIMULATION_ITERATIONS {
68        effective_opts.iterations = MIN_FULL_SIMULATION_ITERATIONS;
69        warnings.push(format!(
70            "iterations increased to {} for full simulation fidelity",
71            MIN_FULL_SIMULATION_ITERATIONS
72        ));
73    }
74
75    let dist = LatencyDistribution::fit(workload, effective_opts.distribution)?;
76    let optimal = find_optimal(workload, pool, &dist, &effective_opts)?;
77    let sensitivity = sensitivity::sweep_with_options(workload, pool, &effective_opts)?;
78    let cold_start_min_pool_size =
79        recommend_cold_start_pool_size(workload, pool, &dist, &effective_opts, optimal.pool_size);
80
81    let mut step_opts = effective_opts.clone();
82    if workload.step_load_profile.is_some() {
83        let reduced = (effective_opts.iterations / 4).clamp(1_500, 5_000);
84        if reduced < effective_opts.iterations {
85            step_opts.iterations = reduced;
86            warnings.push(format!(
87                "step-load analysis used {} iterations per step for responsiveness",
88                reduced
89            ));
90        }
91    }
92    let step_load_analysis = build_step_load_analysis(workload, optimal.pool_size, &step_opts)?;
93
94    let saturation = SaturationLevel::from_rho(optimal.utilisation_rho);
95    warnings.extend(optimal.warnings);
96    if saturation != SaturationLevel::Ok {
97        warnings.push(format!(
98            "System utilisation is high at the recommended size (rho={:.3})",
99            optimal.utilisation_rho
100        ));
101    }
102
103    Ok(SimulationReport {
104        optimal_pool_size: optimal.pool_size,
105        confidence_interval: optimal.confidence_interval,
106        cold_start_min_pool_size,
107        utilisation_rho: optimal.utilisation_rho,
108        mean_queue_wait_ms: optimal.mean_queue_wait_ms,
109        p99_queue_wait_ms: optimal.p99_queue_wait_ms,
110        saturation,
111        sensitivity,
112        step_load_analysis,
113        warnings,
114    })
115}
116
117/// Evaluates a fixed pool size against the workload/options.
118///
119/// # Errors
120///
121/// Returns [`error::PoolsimError`] for invalid inputs or queue/simulation failures.
122pub fn evaluate(
123    workload: &WorkloadConfig,
124    pool_size: u32,
125    opts: &SimulationOptions,
126) -> Result<EvaluationResult, PoolsimError> {
127    workload.validate()?;
128    opts.validate()?;
129
130    if pool_size == 0 {
131        return Err(PoolsimError::invalid_input(
132            "INVALID_POOL_SIZE",
133            "pool_size must be greater than 0",
134            None,
135        ));
136    }
137
138    let dist = LatencyDistribution::fit(workload, opts.distribution)?;
139    let mc = monte_carlo::run(workload, pool_size, &dist, opts)?;
140
141    let lambda = workload.requests_per_second;
142    let mu = 1_000.0 / dist.mean_ms();
143    let rho = erlang::utilisation(lambda, mu, pool_size);
144    let mean_wait = match opts.queue_model {
145        QueueModel::MMC => erlang::mean_queue_wait_ms(lambda, mu, pool_size).unwrap_or(mc.mean),
146        QueueModel::MDC => mc.mean,
147    };
148
149    let saturation = SaturationLevel::from_rho(rho);
150    let mut warnings = Vec::new();
151    if saturation != SaturationLevel::Ok {
152        warnings.push(format!("utilisation is elevated (rho={:.3})", rho));
153    }
154
155    Ok(EvaluationResult {
156        pool_size,
157        utilisation_rho: rho,
158        mean_queue_wait_ms: mean_wait,
159        p99_queue_wait_ms: mc.p99,
160        saturation,
161        warnings,
162    })
163}
164
165/// Generates a sensitivity table using default simulation options.
166///
167/// # Errors
168///
169/// Returns [`error::PoolsimError`] for invalid inputs or queue/simulation failures.
170pub fn sweep(
171    workload: &WorkloadConfig,
172    pool: &PoolConfig,
173) -> Result<Vec<SensitivityRow>, PoolsimError> {
174    sweep_with_options(workload, pool, &SimulationOptions::default())
175}
176
177/// Generates a sensitivity table using explicit simulation options.
178///
179/// # Errors
180///
181/// Returns [`error::PoolsimError`] for invalid inputs or queue/simulation failures.
182pub fn sweep_with_options(
183    workload: &WorkloadConfig,
184    pool: &PoolConfig,
185    opts: &SimulationOptions,
186) -> Result<Vec<SensitivityRow>, PoolsimError> {
187    workload.validate()?;
188    pool.validate()?;
189    opts.validate()?;
190    sensitivity::sweep_with_options(workload, pool, opts)
191}
192
193fn recommend_cold_start_pool_size(
194    workload: &WorkloadConfig,
195    pool: &PoolConfig,
196    dist: &LatencyDistribution,
197    opts: &SimulationOptions,
198    recommended_pool_size: u32,
199) -> u32 {
200    let peak_rps = workload
201        .step_load_profile
202        .as_ref()
203        .and_then(|profile| {
204            profile
205                .iter()
206                .map(|point| point.requests_per_second)
207                .max_by(|a, b| a.total_cmp(b))
208        })
209        .map(|peak| peak.max(workload.requests_per_second))
210        .unwrap_or(workload.requests_per_second);
211
212    let mu = 1_000.0 / (dist.mean_ms() + pool.connection_overhead_ms);
213    if !mu.is_finite() || mu <= 0.0 {
214        return pool.min_pool_size.min(recommended_pool_size);
215    }
216
217    let warm_rho_target = opts.max_acceptable_rho.clamp(0.35, 0.70);
218    let required = (peak_rps / (mu * warm_rho_target)).ceil().max(1.0) as u32;
219    required.max(pool.min_pool_size).min(recommended_pool_size)
220}
221
222fn build_step_load_analysis(
223    workload: &WorkloadConfig,
224    pool_size: u32,
225    opts: &SimulationOptions,
226) -> Result<Vec<StepLoadResult>, PoolsimError> {
227    let Some(profile) = &workload.step_load_profile else {
228        return Ok(Vec::new());
229    };
230
231    let mut rows = Vec::with_capacity(profile.len());
232    for point in profile {
233        let mut step_workload = workload.clone();
234        step_workload.requests_per_second = point.requests_per_second;
235        step_workload.step_load_profile = None;
236
237        let step = evaluate(&step_workload, pool_size, opts)?;
238        rows.push(StepLoadResult {
239            time_s: point.time_s,
240            requests_per_second: point.requests_per_second,
241            utilisation_rho: step.utilisation_rho,
242            p99_queue_wait_ms: step.p99_queue_wait_ms,
243            saturation: step.saturation,
244        });
245    }
246
247    Ok(rows)
248}