mockforge_bench/preflight.rs
1//! Pre-flight latency probe for sizing `--vus` against `--rps`.
2//!
3//! Issue #79 round 8 — Srikanth's reply on 0.3.137 flagged that the
4//! `--vus * 10 < --rps` warning's "1 VU sustains ~10 req/s at 100ms latency"
5//! rule of thumb is wrong for fast targets (~2ms). At 2ms response time, one
6//! VU can drive ~500 req/s, so `--vus 5` is enough for `--rps 1000` but the
7//! static rule incorrectly warns "bump to --vus 100".
8//!
9//! Instead, do a tiny (1-3 request) HTTP probe of the actual target to
10//! measure baseline latency, then derive a more accurate "VUs needed to
11//! sustain rate" estimate. Skip the warning entirely if the measured rate
12//! comfortably covers the requested rate.
13
14use std::time::{Duration, Instant};
15
16/// Result of a pre-flight latency probe.
17#[derive(Debug, Clone, Copy)]
18pub struct ProbeResult {
19 /// Observed average request latency.
20 pub avg_latency: Duration,
21 /// Number of successful probe requests.
22 pub samples: u32,
23}
24
25impl ProbeResult {
26 /// Required VUs to sustain `target_rps` end-to-end with `num_operations`
27 /// per iteration.
28 ///
29 /// Why `num_operations` matters: k6's `constant-arrival-rate` executor
30 /// (set by `--rps`) targets ITERATIONS per second, not requests. Each
31 /// iteration runs every operation in the generated script sequentially.
32 /// So sustaining `--rps R` with a spec of `N` operations actually needs
33 /// `R × N × latency_secs` VUs, not `R × latency_secs`.
34 ///
35 /// Issue #79 round 9 — Srikanth saw "Pre-flight probe: --vus 5 is
36 /// sufficient" followed by k6 emitting "Insufficient VUs" mid-run.
37 /// Cause: he had a ~12-operation spec, so the real iteration time
38 /// was ~12 × measured latency, not 1 ×.
39 ///
40 /// Formula: `rps × num_operations × latency_secs + 1` (safety margin).
41 /// Returns at least 1.
42 pub fn required_vus(&self, target_rps: u32, num_operations: u32) -> u32 {
43 let lat_secs = self.avg_latency.as_secs_f64().max(0.001);
44 let ops = num_operations.max(1) as f64;
45 let raw = (target_rps as f64 * ops * lat_secs).ceil() as u32;
46 raw.saturating_add(1).max(1)
47 }
48}
49
50/// Probe `target` with up to `samples` quick HEAD/GET requests and report
51/// the average successful-response latency. Each probe has a 5s timeout;
52/// failed probes are excluded from the average. Returns `None` if no
53/// probe succeeded.
54///
55/// Used pre-flight to size the `--vus` warning. We deliberately *don't*
56/// fail the bench if probes fail — the target might require auth or be
57/// strict about HEADs. Falling back to the static heuristic is fine.
58pub async fn probe_target_latency(
59 target: &str,
60 samples: u32,
61 skip_tls_verify: bool,
62) -> Option<ProbeResult> {
63 let client = reqwest::Client::builder()
64 .timeout(Duration::from_secs(5))
65 .danger_accept_invalid_certs(skip_tls_verify)
66 .build()
67 .ok()?;
68
69 let mut total = Duration::ZERO;
70 let mut count: u32 = 0;
71 for _ in 0..samples {
72 let start = Instant::now();
73 // HEAD first — cheaper for the target. Fall back to GET if HEAD
74 // fails (some servers / WAFs reject HEAD).
75 let head = client.head(target).send().await;
76 let elapsed = match head {
77 Ok(_) => start.elapsed(),
78 Err(_) => {
79 let start = Instant::now();
80 match client.get(target).send().await {
81 Ok(_) => start.elapsed(),
82 Err(_) => continue,
83 }
84 }
85 };
86 total += elapsed;
87 count = count.saturating_add(1);
88 }
89
90 if count == 0 {
91 return None;
92 }
93
94 Some(ProbeResult {
95 avg_latency: total / count,
96 samples: count,
97 })
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn required_vus_scales_with_latency() {
106 let fast = ProbeResult {
107 avg_latency: Duration::from_millis(2),
108 samples: 3,
109 };
110 // Single-op spec: 1000 rps × 1 op × 2ms = 2 VU + 1 margin = 3
111 assert_eq!(fast.required_vus(1000, 1), 3);
112
113 let slow = ProbeResult {
114 avg_latency: Duration::from_millis(100),
115 samples: 3,
116 };
117 // Single-op: 100 rps × 1 op × 100ms = 10 VU + 1 margin = 11
118 assert_eq!(slow.required_vus(100, 1), 11);
119 }
120
121 #[test]
122 fn required_vus_scales_with_operation_count() {
123 // Issue #79 round 9 — Srikanth saw "vus 5 is sufficient" then k6
124 // hit Insufficient VUs because his spec had ~12 operations per
125 // iteration. With 15ms baseline × 12 ops × 100 rps = 18 VUs.
126 let probe = ProbeResult {
127 avg_latency: Duration::from_millis(15),
128 samples: 3,
129 };
130 assert_eq!(probe.required_vus(100, 1), 3); // single op
131 assert_eq!(probe.required_vus(100, 12), 19); // 12 ops + 1 margin
132 }
133
134 #[test]
135 fn required_vus_clamps_to_one() {
136 let fast = ProbeResult {
137 avg_latency: Duration::from_micros(50),
138 samples: 1,
139 };
140 // (1 × 0.001s clamp) × 1 op × 1 rps = 1, +1 margin = 2
141 assert!(fast.required_vus(1, 1) >= 1);
142 }
143
144 #[test]
145 fn required_vus_treats_zero_operations_as_one() {
146 let probe = ProbeResult {
147 avg_latency: Duration::from_millis(10),
148 samples: 1,
149 };
150 // num_operations=0 should clamp to 1 so we never divide-by-zero
151 // upstream or report an impossible "0 VUs" recommendation.
152 assert_eq!(probe.required_vus(100, 0), probe.required_vus(100, 1));
153 }
154
155 #[tokio::test]
156 async fn probe_returns_none_for_unreachable() {
157 // Reserved-for-docs port on a non-routable address — should error
158 // fast (no DNS lookup) without hanging.
159 let result = probe_target_latency("http://127.0.0.1:1/", 1, false).await;
160 assert!(result.is_none());
161 }
162}