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//!     body: None,
32//! }).await?;
33//! println!("{} req/s", result.req_per_sec);
34//! # Ok(()) }
35//! ```
36
37use async_stream::stream;
38use futures::StreamExt;
39use std::{
40    collections::HashMap,
41    sync::{
42        atomic::{AtomicU64, Ordering},
43        Arc,
44    },
45    time::{Duration, Instant},
46};
47use tokio::sync::Mutex;
48
49/// Configuration for the native chunked-encoding bench.
50#[derive(Debug, Clone)]
51pub struct ChunkedBenchConfig {
52    /// Target URL (e.g. `http://localhost:3000/upload`).
53    pub target_url: String,
54    /// HTTP method. POST/PUT/PATCH make sense; GET/HEAD don't take a body.
55    pub method: reqwest::Method,
56    /// Number of concurrent workers (each holds its own connection / future).
57    pub concurrency: u32,
58    /// Total run duration.
59    pub duration: Duration,
60    /// Bytes per chunk emitted into the request body stream.
61    pub chunk_size_bytes: usize,
62    /// Total body size per request, in bytes.
63    pub total_size_bytes: usize,
64    /// Sleep between chunks, in milliseconds. 0 = back-to-back.
65    pub chunk_interval_ms: u64,
66    /// Extra headers to attach to every request. `Transfer-Encoding: chunked`
67    /// is set automatically by hyper because the body has no Content-Length.
68    pub headers: HashMap<String, String>,
69    /// Skip TLS certificate verification (useful for test self-signed certs).
70    pub skip_tls_verify: bool,
71    /// Cap on request *starts* per second, shared by all workers. `None` =
72    /// each worker starts its next request as soon as the previous one ends.
73    /// The achieved rate can't exceed `concurrency / request_duration`.
74    pub rps: Option<u32>,
75    /// Open a fresh TCP/TLS connection for every request (no pooling), so the
76    /// connections-per-second rate equals the request rate.
77    pub no_keep_alive: bool,
78    /// Pre-built request body to stream instead of `X` filler (e.g. a JSON
79    /// document from [`build_json_body`]). When set, its length replaces
80    /// `total_size_bytes` as the per-request body size.
81    pub body: Option<Arc<Vec<u8>>>,
82}
83
84impl ChunkedBenchConfig {
85    /// Bytes each request actually sends.
86    fn body_len(&self) -> usize {
87        self.body.as_ref().map_or(self.total_size_bytes, |b| b.len())
88    }
89}
90
91/// Field name used to pad JSON bodies up to the requested size.
92pub const JSON_PADDING_FIELD: &str = "_padding";
93
94/// True when a `Content-Type` header (any casing) names a JSON media type:
95/// `application/json` or any `+json` suffix type.
96pub fn is_json_content_type(headers: &HashMap<String, String>) -> bool {
97    headers.iter().any(|(k, v)| {
98        if !k.eq_ignore_ascii_case("content-type") {
99            return false;
100        }
101        let media = v.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
102        media == "application/json" || media.ends_with("+json")
103    })
104}
105
106/// Build a valid JSON document of exactly `total` bytes.
107///
108/// `seed` (typically the spec-generated request body) supplies the fields; a
109/// string field named [`JSON_PADDING_FIELD`] is added and filled with `X`s so
110/// the serialized size lands on `total`. A non-object seed is wrapped as
111/// `{"data": seed}`. If `total` is smaller than the smallest valid document,
112/// that smallest document is returned (and is longer than `total`).
113pub fn build_json_body(seed: Option<&serde_json::Value>, total: usize) -> Vec<u8> {
114    let mut obj = match seed {
115        Some(serde_json::Value::Object(m)) => m.clone(),
116        Some(other) => {
117            let mut m = serde_json::Map::new();
118            m.insert("data".to_string(), other.clone());
119            m
120        }
121        None => serde_json::Map::new(),
122    };
123    obj.insert(JSON_PADDING_FIELD.to_string(), serde_json::Value::String(String::new()));
124    let base = serde_json::to_vec(&obj).unwrap_or_default().len();
125    // `X` serializes as one byte, so the padding length maps 1:1 to output size.
126    let pad = total.saturating_sub(base);
127    obj.insert(JSON_PADDING_FIELD.to_string(), serde_json::Value::String("X".repeat(pad)));
128    serde_json::to_vec(&obj).unwrap_or_default()
129}
130
131/// Hands out evenly spaced request start times across all workers.
132struct Pacer {
133    interval: Duration,
134    next: Mutex<Instant>,
135}
136
137impl Pacer {
138    fn new(rps: u32) -> Self {
139        Self {
140            interval: Duration::from_secs_f64(1.0 / f64::from(rps)),
141            next: Mutex::new(Instant::now()),
142        }
143    }
144
145    /// Reserve the next start slot. Slots are never handed out in the past,
146    /// so a stall doesn't cause a burst to "catch up".
147    async fn reserve(&self) -> Instant {
148        let mut next = self.next.lock().await;
149        let slot = (*next).max(Instant::now());
150        *next = slot + self.interval;
151        slot
152    }
153}
154
155/// One captured non-2xx response — used to surface *who* sent the error
156/// (mockforge? a proxy in front?) and *why*. Critical for diagnosing
157/// "I see 503 from the bench but the server log shows 200" — almost
158/// always an upstream proxy timing out on a slow chunked upload.
159#[derive(Debug, Clone)]
160pub struct ErrorSample {
161    pub status: u16,
162    /// `Server` response header, when present. Often reveals the
163    /// proxy: `nginx/1.21.0`, `cloudflare`, `envoy`, `awselb/2.0`, etc.
164    pub server_header: Option<String>,
165    /// First N bytes of the response body, lossy-UTF8'd. Trimmed.
166    pub body_excerpt: String,
167}
168
169/// Aggregate result from a chunked bench run.
170#[derive(Debug, Clone)]
171pub struct ChunkedBenchResult {
172    pub total_requests: u64,
173    pub successful: u64,
174    pub failed: u64,
175    pub bytes_sent: u64,
176    pub elapsed: Duration,
177    /// Completed attempts over `elapsed`, which includes waiting for the
178    /// requests still in flight when the load window closed.
179    pub req_per_sec: f64,
180    /// The window during which new requests could start (`--duration`).
181    pub load_window: Duration,
182    /// Attempts over `load_window`: the rate requests were started, which is
183    /// what `--rps` caps. Unlike `req_per_sec`, short runs aren't diluted by
184    /// the final in-flight drain.
185    pub start_rate_per_sec: f64,
186    /// Upload bandwidth in megabits/s: `bytes_sent * 8 / elapsed / 1e6`.
187    pub upload_mbps: f64,
188    pub latencies_ms: Vec<u64>,
189    pub avg_latency_ms: f64,
190    pub p50_ms: u64,
191    pub p95_ms: u64,
192    pub p99_ms: u64,
193    pub status_counts: HashMap<u16, u64>,
194    /// First N captured non-2xx responses (status, body excerpt, Server
195    /// header). Empty when every request succeeded.
196    pub error_samples: Vec<ErrorSample>,
197}
198
199/// How many distinct error responses to capture body+headers for.
200const MAX_ERROR_SAMPLES: usize = 5;
201/// How many bytes of error response body to keep per sample.
202const ERROR_BODY_EXCERPT_BYTES: usize = 256;
203
204/// Run the chunked-traffic bench. Spawns `concurrency` worker tasks that send
205/// chunked POSTs back-to-back until `duration` elapses, then aggregates stats.
206pub async fn run(cfg: ChunkedBenchConfig) -> anyhow::Result<ChunkedBenchResult> {
207    if cfg.chunk_size_bytes == 0 {
208        anyhow::bail!("chunk_size_bytes must be > 0");
209    }
210    if cfg.total_size_bytes == 0 {
211        anyhow::bail!("total_size_bytes must be > 0");
212    }
213    if cfg.concurrency == 0 {
214        anyhow::bail!("concurrency must be >= 1");
215    }
216    if cfg.rps == Some(0) {
217        anyhow::bail!("rps must be >= 1");
218    }
219
220    let mut builder = reqwest::Client::builder().danger_accept_invalid_certs(cfg.skip_tls_verify);
221    if cfg.no_keep_alive {
222        builder = builder.pool_max_idle_per_host(0);
223    }
224    let client = builder.build()?;
225    let pacer = cfg.rps.map(|n| Arc::new(Pacer::new(n)));
226
227    let total_requests = Arc::new(AtomicU64::new(0));
228    let successful = Arc::new(AtomicU64::new(0));
229    let failed = Arc::new(AtomicU64::new(0));
230    let bytes_sent = Arc::new(AtomicU64::new(0));
231    let latencies: Arc<Mutex<Vec<u64>>> = Arc::new(Mutex::new(Vec::with_capacity(8192)));
232    let status_counts: Arc<Mutex<HashMap<u16, u64>>> = Arc::new(Mutex::new(HashMap::new()));
233    let error_samples: Arc<Mutex<Vec<ErrorSample>>> = Arc::new(Mutex::new(Vec::new()));
234
235    let deadline = Instant::now() + cfg.duration;
236    let started_at = Instant::now();
237
238    let mut workers = Vec::with_capacity(cfg.concurrency as usize);
239    for _ in 0..cfg.concurrency {
240        let cfg = cfg.clone();
241        let client = client.clone();
242        let total_requests = total_requests.clone();
243        let successful = successful.clone();
244        let failed = failed.clone();
245        let bytes_sent = bytes_sent.clone();
246        let latencies = latencies.clone();
247        let status_counts = status_counts.clone();
248        let error_samples = error_samples.clone();
249        let pacer = pacer.clone();
250
251        workers.push(tokio::spawn(async move {
252            while Instant::now() < deadline {
253                if let Some(pacer) = &pacer {
254                    let slot = pacer.reserve().await;
255                    if slot >= deadline {
256                        break;
257                    }
258                    tokio::time::sleep_until(slot.into()).await;
259                }
260                let req_started = Instant::now();
261                match send_one_chunked_request(&client, &cfg).await {
262                    Ok(SendResult { status, sample }) => {
263                        successful.fetch_add(1, Ordering::Relaxed);
264                        bytes_sent.fetch_add(cfg.body_len() as u64, Ordering::Relaxed);
265                        let elapsed_ms = req_started.elapsed().as_millis() as u64;
266                        latencies.lock().await.push(elapsed_ms);
267                        *status_counts.lock().await.entry(status).or_insert(0) += 1;
268                        if let Some(s) = sample {
269                            let mut g = error_samples.lock().await;
270                            if g.len() < MAX_ERROR_SAMPLES {
271                                g.push(s);
272                            }
273                        }
274                    }
275                    Err(_e) => {
276                        failed.fetch_add(1, Ordering::Relaxed);
277                    }
278                }
279                total_requests.fetch_add(1, Ordering::Relaxed);
280            }
281        }));
282    }
283
284    for w in workers {
285        let _ = w.await;
286    }
287
288    let elapsed = started_at.elapsed();
289    let load_window = cfg.duration;
290    let total = total_requests.load(Ordering::Relaxed);
291    let sent = bytes_sent.load(Ordering::Relaxed);
292    let mut samples: Vec<u64> = {
293        let mut g = latencies.lock().await;
294        std::mem::take(&mut *g)
295    };
296    let final_status_counts: HashMap<u16, u64> = {
297        let mut g = status_counts.lock().await;
298        std::mem::take(&mut *g)
299    };
300    let final_error_samples: Vec<ErrorSample> = {
301        let mut g = error_samples.lock().await;
302        std::mem::take(&mut *g)
303    };
304    samples.sort_unstable();
305    let avg = if samples.is_empty() {
306        0.0
307    } else {
308        samples.iter().copied().sum::<u64>() as f64 / samples.len() as f64
309    };
310    let p = |q: f64| -> u64 {
311        if samples.is_empty() {
312            return 0;
313        }
314        let idx = ((samples.len() as f64 - 1.0) * q).round() as usize;
315        samples[idx]
316    };
317
318    Ok(ChunkedBenchResult {
319        total_requests: total,
320        successful: successful.load(Ordering::Relaxed),
321        failed: failed.load(Ordering::Relaxed),
322        bytes_sent: sent,
323        elapsed,
324        req_per_sec: per_second(total as f64, elapsed),
325        load_window,
326        start_rate_per_sec: per_second(total as f64, load_window),
327        upload_mbps: per_second(sent as f64 * 8.0, elapsed) / 1_000_000.0,
328        avg_latency_ms: avg,
329        p50_ms: p(0.50),
330        p95_ms: p(0.95),
331        p99_ms: p(0.99),
332        latencies_ms: samples,
333        status_counts: final_status_counts,
334        error_samples: final_error_samples,
335    })
336}
337
338pub(crate) fn per_second(amount: f64, window: Duration) -> f64 {
339    if window.is_zero() {
340        0.0
341    } else {
342        amount / window.as_secs_f64()
343    }
344}
345
346/// Per-request outcome from `send_one_chunked_request`. Carries an
347/// `ErrorSample` only for non-2xx responses (and only until the caller
348/// has accumulated `MAX_ERROR_SAMPLES`).
349struct SendResult {
350    status: u16,
351    sample: Option<ErrorSample>,
352}
353
354async fn send_one_chunked_request(
355    client: &reqwest::Client,
356    cfg: &ChunkedBenchConfig,
357) -> anyhow::Result<SendResult> {
358    let chunk_size = cfg.chunk_size_bytes;
359    let total = cfg.body_len();
360    let interval_ms = cfg.chunk_interval_ms;
361    let prebuilt = cfg.body.clone();
362
363    // Build a stream that yields fixed-size chunks until `total` bytes are
364    // emitted, slicing the pre-built body when there is one and `X` filler
365    // otherwise. No Content-Length is set on the request, so hyper transports
366    // the body as Transfer-Encoding: chunked.
367    let body_stream = stream! {
368        let mut sent: usize = 0;
369        let filler = vec![b'X'; chunk_size];
370        while sent < total {
371            // Wait *between* chunks: the first chunk goes out immediately.
372            if interval_ms > 0 && sent > 0 {
373                tokio::time::sleep(Duration::from_millis(interval_ms)).await;
374            }
375            let next = std::cmp::min(chunk_size, total - sent);
376            let chunk = match &prebuilt {
377                Some(b) => b[sent..sent + next].to_vec(),
378                None => filler[..next].to_vec(),
379            };
380            sent += next;
381            yield Ok::<_, std::io::Error>(chunk);
382        }
383    };
384
385    let body = reqwest::Body::wrap_stream(body_stream.boxed());
386
387    let mut req = client.request(cfg.method.clone(), &cfg.target_url).body(body);
388    for (k, v) in &cfg.headers {
389        req = req.header(k, v);
390    }
391    let resp = req.send().await?;
392    let status = resp.status().as_u16();
393
394    // For non-2xx responses, capture a small excerpt + the Server header so
395    // the user can tell at a glance whether the error came from MockForge,
396    // an upstream proxy, a CDN, etc. This is the most useful diagnostic for
397    // the "503 from bench, 200 in TUI" pattern (proxy upstream timeout).
398    let sample = if !(200..300).contains(&status) {
399        let server_header = resp
400            .headers()
401            .get(reqwest::header::SERVER)
402            .and_then(|v| v.to_str().ok())
403            .map(str::to_owned);
404        let bytes = resp.bytes().await.unwrap_or_default();
405        let take = std::cmp::min(bytes.len(), ERROR_BODY_EXCERPT_BYTES);
406        let body_excerpt = String::from_utf8_lossy(&bytes[..take]).trim().to_owned();
407        Some(ErrorSample {
408            status,
409            server_header,
410            body_excerpt,
411        })
412    } else {
413        None
414    };
415
416    Ok(SendResult { status, sample })
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn error_sample_struct_holds_diagnostic_fields() {
425        // Schema sanity: ErrorSample must carry the three pieces a user
426        // needs to diagnose "where did this 503 come from?".
427        let s = ErrorSample {
428            status: 503,
429            server_header: Some("nginx/1.21.0".into()),
430            body_excerpt: "upstream timed out".into(),
431        };
432        assert_eq!(s.status, 503);
433        assert_eq!(s.server_header.as_deref(), Some("nginx/1.21.0"));
434        assert_eq!(s.body_excerpt, "upstream timed out");
435    }
436
437    #[tokio::test]
438    async fn rejects_zero_concurrency() {
439        let cfg = ChunkedBenchConfig {
440            target_url: "http://127.0.0.1:1".into(),
441            method: reqwest::Method::POST,
442            concurrency: 0,
443            duration: Duration::from_millis(10),
444            chunk_size_bytes: 1024,
445            total_size_bytes: 4096,
446            chunk_interval_ms: 0,
447            headers: HashMap::new(),
448            skip_tls_verify: false,
449            rps: None,
450            no_keep_alive: false,
451            body: None,
452        };
453        assert!(run(cfg).await.is_err());
454    }
455
456    #[tokio::test]
457    async fn rejects_zero_chunk_size() {
458        let cfg = ChunkedBenchConfig {
459            target_url: "http://127.0.0.1:1".into(),
460            method: reqwest::Method::POST,
461            concurrency: 1,
462            duration: Duration::from_millis(10),
463            chunk_size_bytes: 0,
464            total_size_bytes: 4096,
465            chunk_interval_ms: 0,
466            headers: HashMap::new(),
467            skip_tls_verify: false,
468            rps: None,
469            no_keep_alive: false,
470            body: None,
471        };
472        assert!(run(cfg).await.is_err());
473    }
474
475    #[tokio::test]
476    async fn pacer_spaces_slots_evenly() {
477        let pacer = Pacer::new(10);
478        let a = pacer.reserve().await;
479        let b = pacer.reserve().await;
480        let c = pacer.reserve().await;
481        assert_eq!(b - a, Duration::from_millis(100));
482        assert_eq!(c - b, Duration::from_millis(100));
483    }
484
485    #[tokio::test]
486    async fn rps_caps_request_starts_across_workers() {
487        let mut server = mockito::Server::new_async().await;
488        let _m = server
489            .mock("POST", "/upload")
490            .with_status(200)
491            .expect_at_least(1)
492            .create_async()
493            .await;
494        let cfg = ChunkedBenchConfig {
495            target_url: format!("{}/upload", server.url()),
496            method: reqwest::Method::POST,
497            concurrency: 8,
498            duration: Duration::from_millis(1000),
499            chunk_size_bytes: 64,
500            total_size_bytes: 256,
501            chunk_interval_ms: 0,
502            headers: HashMap::new(),
503            skip_tls_verify: false,
504            rps: Some(5),
505            no_keep_alive: true,
506            body: None,
507        };
508        let r = run(cfg).await.unwrap();
509        // 1s at 5 rps = slots at 0, 200, 400, 600, 800ms. Unpaced, 8 workers
510        // against a local server would do hundreds.
511        assert!((4..=6).contains(&r.total_requests), "got {} requests", r.total_requests);
512        assert_eq!(r.failed, 0);
513        // The start rate divides by the 1s load window, not the drain.
514        assert_eq!(r.load_window, Duration::from_millis(1000));
515        assert!((r.start_rate_per_sec - r.total_requests as f64).abs() < 1e-9);
516        assert!(r.start_rate_per_sec <= 5.0 + 1e-9);
517    }
518
519    #[tokio::test]
520    async fn rejects_zero_rps() {
521        let cfg = ChunkedBenchConfig {
522            target_url: "http://127.0.0.1:1".into(),
523            method: reqwest::Method::POST,
524            concurrency: 1,
525            duration: Duration::from_millis(10),
526            chunk_size_bytes: 1024,
527            total_size_bytes: 4096,
528            chunk_interval_ms: 0,
529            headers: HashMap::new(),
530            skip_tls_verify: false,
531            rps: Some(0),
532            no_keep_alive: false,
533            body: None,
534        };
535        assert!(run(cfg).await.is_err());
536    }
537
538    #[test]
539    fn json_body_is_valid_and_exact_size() {
540        let seed = serde_json::json!({"name": "widget", "count": 3});
541        for total in [64usize, 4096, 1_048_576] {
542            let body = build_json_body(Some(&seed), total);
543            assert_eq!(body.len(), total);
544            let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
545            assert_eq!(v["name"], "widget");
546            assert_eq!(v["count"], 3);
547        }
548    }
549
550    #[test]
551    fn json_body_without_seed_or_non_object_seed() {
552        let body = build_json_body(None, 100);
553        assert_eq!(body.len(), 100);
554        assert!(serde_json::from_slice::<serde_json::Value>(&body).unwrap().is_object());
555
556        let body = build_json_body(Some(&serde_json::json!([1, 2])), 100);
557        assert_eq!(body.len(), 100);
558        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
559        assert_eq!(v["data"], serde_json::json!([1, 2]));
560    }
561
562    #[test]
563    fn json_body_smaller_than_minimum_stays_valid() {
564        let body = build_json_body(None, 1);
565        assert_eq!(body, br#"{"_padding":""}"#);
566    }
567
568    #[test]
569    fn detects_json_content_types() {
570        let h = |v: &str| HashMap::from([("content-type".to_string(), v.to_string())]);
571        assert!(is_json_content_type(&h("application/json")));
572        assert!(is_json_content_type(&h("Application/JSON; charset=utf-8")));
573        assert!(is_json_content_type(&h("application/vnd.api+json")));
574        assert!(!is_json_content_type(&h("application/octet-stream")));
575        assert!(!is_json_content_type(&HashMap::new()));
576        let upper = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
577        assert!(is_json_content_type(&upper));
578    }
579
580    #[tokio::test]
581    async fn streams_prebuilt_json_body_intact() {
582        let mut server = mockito::Server::new_async().await;
583        let m = server
584            .mock("POST", "/items")
585            .match_body(mockito::Matcher::PartialJson(serde_json::json!({"name": "widget"})))
586            .with_status(201)
587            .expect_at_least(1)
588            .create_async()
589            .await;
590        let body = build_json_body(Some(&serde_json::json!({"name": "widget"})), 10_000);
591        let cfg = ChunkedBenchConfig {
592            target_url: format!("{}/items", server.url()),
593            method: reqwest::Method::POST,
594            concurrency: 1,
595            duration: Duration::from_millis(300),
596            chunk_size_bytes: 777,
597            total_size_bytes: 10_000,
598            chunk_interval_ms: 0,
599            headers: HashMap::from([("Content-Type".into(), "application/json".into())]),
600            skip_tls_verify: false,
601            rps: None,
602            no_keep_alive: false,
603            body: Some(Arc::new(body)),
604        };
605        let r = run(cfg).await.unwrap();
606        assert_eq!(r.failed, 0);
607        assert!(r.status_counts.contains_key(&201), "statuses: {:?}", r.status_counts);
608        assert_eq!(r.bytes_sent, r.successful * 10_000);
609        m.assert_async().await;
610    }
611}