lobe_core/engine/
burst.rs1use 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 pub bimodal_ttfb: Option<BimodalDetection>,
36 pub first_error: Option<String>,
39 pub elapsed_ms: u64,
40}
41
42pub 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}