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