Skip to main content

lobe_core/engine/
burst.rs

1//! Latency-under-contention probe: hold N requests in flight while pushing a
2//! fixed total through, each measured with the normal phase decomposition.
3//! Deliberately not a throughput benchmark — the output is the latency
4//! distribution per concurrency level, not requests/second.
5
6use std::sync::Arc;
7use std::time::Instant;
8
9use tokio::sync::Semaphore;
10
11use crate::engine::bimodal::{detect_bimodal, BimodalDetection};
12use crate::engine::proxy::SimpleProbe;
13use crate::error::Result;
14use crate::models::{NewProbeResult, ProbeStatus};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct PhasePercentiles {
18    pub p50_ms: u64,
19    pub p95_ms: u64,
20}
21
22#[derive(Debug, Clone)]
23pub struct BurstSummary {
24    pub concurrency: usize,
25    pub total_requests: usize,
26    pub succeeded: usize,
27    pub failed: usize,
28    pub dns: PhasePercentiles,
29    pub tcp: PhasePercentiles,
30    pub tls: PhasePercentiles,
31    pub ttfb: PhasePercentiles,
32    pub total: PhasePercentiles,
33    /// Fast/slow cohort split on TTFB across successful requests, when the
34    /// distribution is bimodal (e.g. connection-pool exhaustion).
35    pub bimodal_ttfb: Option<BimodalDetection>,
36    /// First error message observed, for surfacing failures without dumping
37    /// every result.
38    pub first_error: Option<String>,
39    pub elapsed_ms: u64,
40}
41
42/// Run `total_requests` probes against `target`, holding at most
43/// `concurrency` in flight, and aggregate the phase distributions.
44pub async fn run_burst(
45    probe: &SimpleProbe,
46    target: &str,
47    method: &str,
48    concurrency: usize,
49    total_requests: usize,
50) -> Result<BurstSummary> {
51    let semaphore = Arc::new(Semaphore::new(concurrency.max(1)));
52    let started = Instant::now();
53
54    let mut handles = Vec::with_capacity(total_requests);
55    for _ in 0..total_requests {
56        let semaphore = Arc::clone(&semaphore);
57        let probe = probe.clone();
58        let target = target.to_string();
59        let method = method.to_string();
60
61        handles.push(tokio::spawn(async move {
62            let _permit = semaphore.acquire_owned().await;
63            probe.probe_with_method(&target, &method).await
64        }));
65    }
66
67    let mut results = Vec::with_capacity(total_requests);
68    for handle in handles {
69        match handle.await {
70            Ok(result) => results.push(result?),
71            Err(join_error) => {
72                return Err(crate::error::TloxError::Io(std::io::Error::other(
73                    join_error.to_string(),
74                )))
75            }
76        }
77    }
78
79    Ok(summarize_burst(
80        concurrency,
81        &results,
82        started.elapsed().as_millis() as u64,
83    ))
84}
85
86fn summarize_burst(
87    concurrency: usize,
88    results: &[NewProbeResult],
89    elapsed_ms: u64,
90) -> BurstSummary {
91    let succeeded: Vec<&NewProbeResult> = results
92        .iter()
93        .filter(|result| result.status == ProbeStatus::Succeeded)
94        .collect();
95    let failed = results.len() - succeeded.len();
96    let first_error = results
97        .iter()
98        .find_map(|result| result.error_message.clone());
99
100    let phase = |selector: fn(&NewProbeResult) -> u64| {
101        let values: Vec<u64> = succeeded.iter().map(|result| selector(result)).collect();
102        PhasePercentiles {
103            p50_ms: percentile_u64(&values, 0.50),
104            p95_ms: percentile_u64(&values, 0.95),
105        }
106    };
107
108    let ttfb_values: Vec<u64> = succeeded
109        .iter()
110        .map(|result| result.report.ttfb_ms)
111        .collect();
112
113    BurstSummary {
114        concurrency,
115        total_requests: results.len(),
116        succeeded: succeeded.len(),
117        failed,
118        dns: phase(|result| result.report.dns_ms),
119        tcp: phase(|result| result.report.tcp_ms),
120        tls: phase(|result| result.report.tls_ms),
121        ttfb: phase(|result| result.report.ttfb_ms),
122        total: phase(|result| result.report.total_ms),
123        bimodal_ttfb: detect_bimodal(&ttfb_values),
124        first_error,
125        elapsed_ms,
126    }
127}
128
129fn percentile_u64(values: &[u64], percentile: f64) -> u64 {
130    if values.is_empty() {
131        return 0;
132    }
133
134    let mut sorted = values.to_vec();
135    sorted.sort_unstable();
136    let index = ((sorted.len() - 1) as f64 * percentile).round() as usize;
137    sorted[index]
138}
139
140#[cfg(test)]
141mod tests {
142    use crate::models::{NewProbeResult, ProbeStatus, TimingReport};
143
144    use super::{percentile_u64, summarize_burst};
145
146    fn result_with_ttfb(ttfb_ms: u64, status: ProbeStatus) -> NewProbeResult {
147        NewProbeResult {
148            target: "http://example.test".to_string(),
149            request_path: "/".to_string(),
150            report: TimingReport {
151                ttfb_ms,
152                total_ms: ttfb_ms + 5,
153                ..TimingReport::default()
154            },
155            status,
156            response_status_code: (status == ProbeStatus::Succeeded).then_some(200),
157            response_status_text: None,
158            response_bytes: None,
159            error_message: (status == ProbeStatus::Failed)
160                .then(|| "connection refused".to_string()),
161            tls: None,
162        }
163    }
164
165    #[test]
166    fn percentile_u64_handles_empty_and_single_values() {
167        assert_eq!(percentile_u64(&[], 0.95), 0);
168        assert_eq!(percentile_u64(&[42], 0.50), 42);
169        assert_eq!(percentile_u64(&[42], 0.95), 42);
170    }
171
172    #[test]
173    fn summarize_burst_excludes_failures_from_percentiles() {
174        let mut results: Vec<NewProbeResult> = (0..10)
175            .map(|i| result_with_ttfb(40 + i, ProbeStatus::Succeeded))
176            .collect();
177        results.push(result_with_ttfb(0, ProbeStatus::Failed));
178
179        let summary = summarize_burst(5, &results, 1000);
180
181        assert_eq!(summary.total_requests, 11);
182        assert_eq!(summary.succeeded, 10);
183        assert_eq!(summary.failed, 1);
184        assert!(summary.ttfb.p50_ms >= 40);
185        assert_eq!(summary.first_error.as_deref(), Some("connection refused"));
186    }
187
188    #[test]
189    fn summarize_burst_flags_bimodal_ttfb() {
190        let mut results: Vec<NewProbeResult> = (0..20)
191            .map(|i| result_with_ttfb(38 + (i % 6), ProbeStatus::Succeeded))
192            .collect();
193        results.extend((0..40).map(|i| result_with_ttfb(590 + (i % 30), ProbeStatus::Succeeded)));
194
195        let summary = summarize_burst(50, &results, 5000);
196
197        let detection = summary.bimodal_ttfb.expect("split should be detected");
198        assert!(detection.fast.median_ms < 50);
199        assert!(detection.slow.median_ms > 500);
200    }
201}