Skip to main content

mockforge_bench/
chunked_bench.rs

1//! Native Rust chunked-encoding traffic generator.
2//!
3//! `mockforge bench --native-chunked` bypasses k6 entirely. Each worker opens
4//! its own HTTP connection and sends POST/PUT/PATCH requests with bodies
5//! streamed via `reqwest::Body::wrap_stream`. Because the body has no known
6//! `Content-Length`, hyper transports it as `Transfer-Encoding: chunked` —
7//! guaranteed, unlike the k6/Go path where the runtime decides based on body
8//! type.
9//!
10//! This is a small benchmark intended to exercise the *server's* chunked
11//! handling (slow consumers, max body size, partial-response chaos against
12//! chunked uploads). Not a k6 replacement for general load testing.
13//!
14//! ```no_run
15//! # use mockforge_bench::chunked_bench::{ChunkedBenchConfig, run};
16//! # use std::time::Duration;
17//! # use std::collections::HashMap;
18//! # async fn x() -> anyhow::Result<()> {
19//! let result = run(ChunkedBenchConfig {
20//!     target_url: "http://localhost:3000/upload".into(),
21//!     method: reqwest::Method::POST,
22//!     concurrency: 10,
23//!     duration: Duration::from_secs(60),
24//!     chunk_size_bytes: 1024,
25//!     total_size_bytes: 1024 * 1024,
26//!     chunk_interval_ms: 0,
27//!     headers: HashMap::new(),
28//!     skip_tls_verify: false,
29//!     rps: None,
30//!     no_keep_alive: false,
31//! }).await?;
32//! println!("{} req/s", result.req_per_sec);
33//! # Ok(()) }
34//! ```
35
36use async_stream::stream;
37use futures::StreamExt;
38use std::{
39    collections::HashMap,
40    sync::{
41        atomic::{AtomicU64, Ordering},
42        Arc,
43    },
44    time::{Duration, Instant},
45};
46use tokio::sync::Mutex;
47
48/// Configuration for the native chunked-encoding bench.
49#[derive(Debug, Clone)]
50pub struct ChunkedBenchConfig {
51    /// Target URL (e.g. `http://localhost:3000/upload`).
52    pub target_url: String,
53    /// HTTP method. POST/PUT/PATCH make sense; GET/HEAD don't take a body.
54    pub method: reqwest::Method,
55    /// Number of concurrent workers (each holds its own connection / future).
56    pub concurrency: u32,
57    /// Total run duration.
58    pub duration: Duration,
59    /// Bytes per chunk emitted into the request body stream.
60    pub chunk_size_bytes: usize,
61    /// Total body size per request, in bytes.
62    pub total_size_bytes: usize,
63    /// Sleep between chunks, in milliseconds. 0 = back-to-back.
64    pub chunk_interval_ms: u64,
65    /// Extra headers to attach to every request. `Transfer-Encoding: chunked`
66    /// is set automatically by hyper because the body has no Content-Length.
67    pub headers: HashMap<String, String>,
68    /// Skip TLS certificate verification (useful for test self-signed certs).
69    pub skip_tls_verify: bool,
70    /// Cap on request *starts* per second, shared by all workers. `None` =
71    /// each worker starts its next request as soon as the previous one ends.
72    /// The achieved rate can't exceed `concurrency / request_duration`.
73    pub rps: Option<u32>,
74    /// Open a fresh TCP/TLS connection for every request (no pooling), so the
75    /// connections-per-second rate equals the request rate.
76    pub no_keep_alive: bool,
77}
78
79/// Hands out evenly spaced request start times across all workers.
80struct Pacer {
81    interval: Duration,
82    next: Mutex<Instant>,
83}
84
85impl Pacer {
86    fn new(rps: u32) -> Self {
87        Self {
88            interval: Duration::from_secs_f64(1.0 / f64::from(rps)),
89            next: Mutex::new(Instant::now()),
90        }
91    }
92
93    /// Reserve the next start slot. Slots are never handed out in the past,
94    /// so a stall doesn't cause a burst to "catch up".
95    async fn reserve(&self) -> Instant {
96        let mut next = self.next.lock().await;
97        let slot = (*next).max(Instant::now());
98        *next = slot + self.interval;
99        slot
100    }
101}
102
103/// One captured non-2xx response — used to surface *who* sent the error
104/// (mockforge? a proxy in front?) and *why*. Critical for diagnosing
105/// "I see 503 from the bench but the server log shows 200" — almost
106/// always an upstream proxy timing out on a slow chunked upload.
107#[derive(Debug, Clone)]
108pub struct ErrorSample {
109    pub status: u16,
110    /// `Server` response header, when present. Often reveals the
111    /// proxy: `nginx/1.21.0`, `cloudflare`, `envoy`, `awselb/2.0`, etc.
112    pub server_header: Option<String>,
113    /// First N bytes of the response body, lossy-UTF8'd. Trimmed.
114    pub body_excerpt: String,
115}
116
117/// Aggregate result from a chunked bench run.
118#[derive(Debug, Clone)]
119pub struct ChunkedBenchResult {
120    pub total_requests: u64,
121    pub successful: u64,
122    pub failed: u64,
123    pub bytes_sent: u64,
124    pub elapsed: Duration,
125    pub req_per_sec: f64,
126    pub latencies_ms: Vec<u64>,
127    pub avg_latency_ms: f64,
128    pub p50_ms: u64,
129    pub p95_ms: u64,
130    pub p99_ms: u64,
131    pub status_counts: HashMap<u16, u64>,
132    /// First N captured non-2xx responses (status, body excerpt, Server
133    /// header). Empty when every request succeeded.
134    pub error_samples: Vec<ErrorSample>,
135}
136
137/// How many distinct error responses to capture body+headers for.
138const MAX_ERROR_SAMPLES: usize = 5;
139/// How many bytes of error response body to keep per sample.
140const ERROR_BODY_EXCERPT_BYTES: usize = 256;
141
142/// Run the chunked-traffic bench. Spawns `concurrency` worker tasks that send
143/// chunked POSTs back-to-back until `duration` elapses, then aggregates stats.
144pub async fn run(cfg: ChunkedBenchConfig) -> anyhow::Result<ChunkedBenchResult> {
145    if cfg.chunk_size_bytes == 0 {
146        anyhow::bail!("chunk_size_bytes must be > 0");
147    }
148    if cfg.total_size_bytes == 0 {
149        anyhow::bail!("total_size_bytes must be > 0");
150    }
151    if cfg.concurrency == 0 {
152        anyhow::bail!("concurrency must be >= 1");
153    }
154    if cfg.rps == Some(0) {
155        anyhow::bail!("rps must be >= 1");
156    }
157
158    let mut builder = reqwest::Client::builder().danger_accept_invalid_certs(cfg.skip_tls_verify);
159    if cfg.no_keep_alive {
160        builder = builder.pool_max_idle_per_host(0);
161    }
162    let client = builder.build()?;
163    let pacer = cfg.rps.map(|n| Arc::new(Pacer::new(n)));
164
165    let total_requests = Arc::new(AtomicU64::new(0));
166    let successful = Arc::new(AtomicU64::new(0));
167    let failed = Arc::new(AtomicU64::new(0));
168    let bytes_sent = Arc::new(AtomicU64::new(0));
169    let latencies: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(Vec::with_capacity(8192)));
170    let status_counts: Arc<Mutex<HashMap<u16, u64>>> = Arc::new(Mutex::new(HashMap::new()));
171    let error_samples: Arc<Mutex<Vec<ErrorSample>>> = Arc::new(Mutex::new(Vec::new()));
172
173    let deadline = Instant::now() + cfg.duration;
174    let started_at = Instant::now();
175
176    let mut workers = Vec::with_capacity(cfg.concurrency as usize);
177    for _ in 0..cfg.concurrency {
178        let cfg = cfg.clone();
179        let client = client.clone();
180        let total_requests = total_requests.clone();
181        let successful = successful.clone();
182        let failed = failed.clone();
183        let bytes_sent = bytes_sent.clone();
184        let latencies = latencies.clone();
185        let status_counts = status_counts.clone();
186        let error_samples = error_samples.clone();
187        let pacer = pacer.clone();
188
189        workers.push(tokio::spawn(async move {
190            while Instant::now() < deadline {
191                if let Some(pacer) = &pacer {
192                    let slot = pacer.reserve().await;
193                    if slot >= deadline {
194                        break;
195                    }
196                    tokio::time::sleep_until(slot.into()).await;
197                }
198                let req_started = Instant::now();
199                match send_one_chunked_request(&client, &cfg).await {
200                    Ok(SendResult { status, sample }) => {
201                        successful.fetch_add(1, Ordering::Relaxed);
202                        bytes_sent.fetch_add(cfg.total_size_bytes as u64, Ordering::Relaxed);
203                        let elapsed_ms = req_started.elapsed().as_millis() as u64;
204                        latencies.lock().await.push(elapsed_ms);
205                        *status_counts.lock().await.entry(status).or_insert(0) += 1;
206                        if let Some(s) = sample {
207                            let mut g = error_samples.lock().await;
208                            if g.len() < MAX_ERROR_SAMPLES {
209                                g.push(s);
210                            }
211                        }
212                    }
213                    Err(_e) => {
214                        failed.fetch_add(1, Ordering::Relaxed);
215                    }
216                }
217                total_requests.fetch_add(1, Ordering::Relaxed);
218            }
219        }));
220    }
221
222    for w in workers {
223        let _ = w.await;
224    }
225
226    let elapsed = started_at.elapsed();
227    let total = total_requests.load(Ordering::Relaxed);
228    let mut samples: Vec<u64> = {
229        let mut g = latencies.lock().await;
230        std::mem::take(&mut *g)
231    };
232    let final_status_counts: HashMap<u16, u64> = {
233        let mut g = status_counts.lock().await;
234        std::mem::take(&mut *g)
235    };
236    let final_error_samples: Vec<ErrorSample> = {
237        let mut g = error_samples.lock().await;
238        std::mem::take(&mut *g)
239    };
240    samples.sort_unstable();
241    let avg = if samples.is_empty() {
242        0.0
243    } else {
244        samples.iter().copied().sum::<u64>() as f64 / samples.len() as f64
245    };
246    let p = |q: f64| -> u64 {
247        if samples.is_empty() {
248            return 0;
249        }
250        let idx = ((samples.len() as f64 - 1.0) * q).round() as usize;
251        samples[idx]
252    };
253
254    Ok(ChunkedBenchResult {
255        total_requests: total,
256        successful: successful.load(Ordering::Relaxed),
257        failed: failed.load(Ordering::Relaxed),
258        bytes_sent: bytes_sent.load(Ordering::Relaxed),
259        elapsed,
260        req_per_sec: if elapsed.as_secs_f64() > 0.0 {
261            total as f64 / elapsed.as_secs_f64()
262        } else {
263            0.0
264        },
265        avg_latency_ms: avg,
266        p50_ms: p(0.50),
267        p95_ms: p(0.95),
268        p99_ms: p(0.99),
269        latencies_ms: samples,
270        status_counts: final_status_counts,
271        error_samples: final_error_samples,
272    })
273}
274
275/// Per-request outcome from `send_one_chunked_request`. Carries an
276/// `ErrorSample` only for non-2xx responses (and only until the caller
277/// has accumulated `MAX_ERROR_SAMPLES`).
278struct SendResult {
279    status: u16,
280    sample: Option<ErrorSample>,
281}
282
283async fn send_one_chunked_request(
284    client: &reqwest::Client,
285    cfg: &ChunkedBenchConfig,
286) -> anyhow::Result<SendResult> {
287    let chunk_size = cfg.chunk_size_bytes;
288    let total = cfg.total_size_bytes;
289    let interval_ms = cfg.chunk_interval_ms;
290
291    // Build a stream that yields fixed-size chunks until `total` bytes are
292    // emitted. No Content-Length is set on the request, so hyper transports
293    // the body as Transfer-Encoding: chunked.
294    let body_stream = stream! {
295        let mut sent: usize = 0;
296        let payload = vec![b'X'; chunk_size];
297        while sent < total {
298            // Wait *between* chunks: the first chunk goes out immediately.
299            if interval_ms > 0 && sent > 0 {
300                tokio::time::sleep(Duration::from_millis(interval_ms)).await;
301            }
302            let next = std::cmp::min(chunk_size, total - sent);
303            let chunk = payload[..next].to_vec();
304            sent += next;
305            yield Ok::<_, std::io::Error>(chunk);
306        }
307    };
308
309    let body = reqwest::Body::wrap_stream(body_stream.boxed());
310
311    let mut req = client.request(cfg.method.clone(), &cfg.target_url).body(body);
312    for (k, v) in &cfg.headers {
313        req = req.header(k, v);
314    }
315    let resp = req.send().await?;
316    let status = resp.status().as_u16();
317
318    // For non-2xx responses, capture a small excerpt + the Server header so
319    // the user can tell at a glance whether the error came from MockForge,
320    // an upstream proxy, a CDN, etc. This is the most useful diagnostic for
321    // the "503 from bench, 200 in TUI" pattern (proxy upstream timeout).
322    let sample = if !(200..300).contains(&status) {
323        let server_header = resp
324            .headers()
325            .get(reqwest::header::SERVER)
326            .and_then(|v| v.to_str().ok())
327            .map(str::to_owned);
328        let bytes = resp.bytes().await.unwrap_or_default();
329        let take = std::cmp::min(bytes.len(), ERROR_BODY_EXCERPT_BYTES);
330        let body_excerpt = String::from_utf8_lossy(&bytes[..take]).trim().to_owned();
331        Some(ErrorSample {
332            status,
333            server_header,
334            body_excerpt,
335        })
336    } else {
337        None
338    };
339
340    Ok(SendResult { status, sample })
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn error_sample_struct_holds_diagnostic_fields() {
349        // Schema sanity: ErrorSample must carry the three pieces a user
350        // needs to diagnose "where did this 503 come from?".
351        let s = ErrorSample {
352            status: 503,
353            server_header: Some("nginx/1.21.0".into()),
354            body_excerpt: "upstream timed out".into(),
355        };
356        assert_eq!(s.status, 503);
357        assert_eq!(s.server_header.as_deref(), Some("nginx/1.21.0"));
358        assert_eq!(s.body_excerpt, "upstream timed out");
359    }
360
361    #[tokio::test]
362    async fn rejects_zero_concurrency() {
363        let cfg = ChunkedBenchConfig {
364            target_url: "http://127.0.0.1:1".into(),
365            method: reqwest::Method::POST,
366            concurrency: 0,
367            duration: Duration::from_millis(10),
368            chunk_size_bytes: 1024,
369            total_size_bytes: 4096,
370            chunk_interval_ms: 0,
371            headers: HashMap::new(),
372            skip_tls_verify: false,
373            rps: None,
374            no_keep_alive: false,
375        };
376        assert!(run(cfg).await.is_err());
377    }
378
379    #[tokio::test]
380    async fn rejects_zero_chunk_size() {
381        let cfg = ChunkedBenchConfig {
382            target_url: "http://127.0.0.1:1".into(),
383            method: reqwest::Method::POST,
384            concurrency: 1,
385            duration: Duration::from_millis(10),
386            chunk_size_bytes: 0,
387            total_size_bytes: 4096,
388            chunk_interval_ms: 0,
389            headers: HashMap::new(),
390            skip_tls_verify: false,
391            rps: None,
392            no_keep_alive: false,
393        };
394        assert!(run(cfg).await.is_err());
395    }
396
397    #[tokio::test]
398    async fn pacer_spaces_slots_evenly() {
399        let pacer = Pacer::new(10);
400        let a = pacer.reserve().await;
401        let b = pacer.reserve().await;
402        let c = pacer.reserve().await;
403        assert_eq!(b - a, Duration::from_millis(100));
404        assert_eq!(c - b, Duration::from_millis(100));
405    }
406
407    #[tokio::test]
408    async fn rps_caps_request_starts_across_workers() {
409        let mut server = mockito::Server::new_async().await;
410        let _m = server
411            .mock("POST", "/upload")
412            .with_status(200)
413            .expect_at_least(1)
414            .create_async()
415            .await;
416        let cfg = ChunkedBenchConfig {
417            target_url: format!("{}/upload", server.url()),
418            method: reqwest::Method::POST,
419            concurrency: 8,
420            duration: Duration::from_millis(1000),
421            chunk_size_bytes: 64,
422            total_size_bytes: 256,
423            chunk_interval_ms: 0,
424            headers: HashMap::new(),
425            skip_tls_verify: false,
426            rps: Some(5),
427            no_keep_alive: true,
428        };
429        let r = run(cfg).await.unwrap();
430        // 1s at 5 rps = slots at 0, 200, 400, 600, 800ms. Unpaced, 8 workers
431        // against a local server would do hundreds.
432        assert!((4..=6).contains(&r.total_requests), "got {} requests", r.total_requests);
433        assert_eq!(r.failed, 0);
434    }
435
436    #[tokio::test]
437    async fn rejects_zero_rps() {
438        let cfg = ChunkedBenchConfig {
439            target_url: "http://127.0.0.1:1".into(),
440            method: reqwest::Method::POST,
441            concurrency: 1,
442            duration: Duration::from_millis(10),
443            chunk_size_bytes: 1024,
444            total_size_bytes: 4096,
445            chunk_interval_ms: 0,
446            headers: HashMap::new(),
447            skip_tls_verify: false,
448            rps: Some(0),
449            no_keep_alive: false,
450        };
451        assert!(run(cfg).await.is_err());
452    }
453}