Skip to main content

mockforge_bench/
chunked_command.rs

1//! `mockforge bench-chunked` driver: single-target, spec-driven, and
2//! multi-target (`--targets-file`) chunked-encoding runs.
3//!
4//! [`crate::chunked_bench::run`] drives one URL. This module layers the CLI
5//! modes on top of it:
6//!
7//! - **Single target** (`--target URL`, no `--spec`): one run against the URL.
8//! - **Spec-driven** (`--spec`): one run per POST/PUT/PATCH operation, with
9//!   `--target` as the base URL.
10//! - **Multi-target** (`--targets-file`, issue #79): every target in the file
11//!   runs the single-target or spec-driven flow in parallel (bounded by
12//!   `--max-concurrency`). It uses the same file format as
13//!   `mockforge bench --targets-file`, including per-target `auth`, `headers`
14//!   and `spec`. Per-target artifacts go to `<output>/target_<N>/`, and a
15//!   roll-up goes to `<output>/chunked-multi-target-summary.json`.
16
17use crate::chunked_bench::{run, ChunkedBenchConfig, ChunkedBenchResult};
18use crate::request_gen::RequestGenerator;
19use crate::spec_parser::SpecParser;
20use crate::target_parser::{parse_targets_file, TargetConfig};
21use anyhow::{bail, Context};
22use futures::StreamExt;
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27/// Options shared by every `bench-chunked` mode.
28#[derive(Debug, Clone)]
29pub struct ChunkedCommand {
30    /// Single target URL. Mutually exclusive with `targets_file`.
31    pub target: Option<String>,
32    /// File with multiple targets (same format as `bench --targets-file`).
33    pub targets_file: Option<PathBuf>,
34    /// Max targets running at once in multi-target mode.
35    pub max_concurrency: u32,
36    pub spec: Option<PathBuf>,
37    pub base_path: Option<String>,
38    pub operation_id: Option<String>,
39    pub method: String,
40    pub concurrency: u32,
41    pub duration: Duration,
42    pub chunk_size_bytes: usize,
43    pub total_size_bytes: usize,
44    pub chunk_interval_ms: u64,
45    pub headers: HashMap<String, String>,
46    pub insecure: bool,
47    pub validate_requests: bool,
48    pub export_requests: bool,
49    pub output: PathBuf,
50}
51
52/// One target's resolved run plan.
53#[derive(Debug, Clone)]
54struct TargetRun {
55    /// `""` in single-target mode, `"[target_N] "` in multi-target mode.
56    prefix: String,
57    url: String,
58    headers: HashMap<String, String>,
59    spec: Option<PathBuf>,
60    output: PathBuf,
61}
62
63/// Roll-up of one target's run(s).
64#[derive(Debug, Clone, Default)]
65struct TargetOutcome {
66    total_requests: u64,
67    successful: u64,
68    failed: u64,
69    bytes_sent: u64,
70    status_counts: HashMap<u16, u64>,
71    /// An operation errored out entirely (vs. individual requests failing).
72    run_failed: bool,
73}
74
75impl TargetOutcome {
76    fn absorb(&mut self, r: &ChunkedBenchResult) {
77        self.total_requests += r.total_requests;
78        self.successful += r.successful;
79        self.failed += r.failed;
80        self.bytes_sent += r.bytes_sent;
81        for (code, n) in &r.status_counts {
82            *self.status_counts.entry(*code).or_insert(0) += n;
83        }
84    }
85}
86
87impl ChunkedCommand {
88    /// Run the bench. Returns `Ok(true)` when every run completed, `Ok(false)`
89    /// when at least one operation or target failed to run.
90    pub async fn execute(&self) -> anyhow::Result<bool> {
91        match (&self.target, &self.targets_file) {
92            (Some(_), Some(_)) => bail!("--target and --targets-file are mutually exclusive"),
93            (None, None) => bail!("either --target or --targets-file is required"),
94            (Some(target), None) => {
95                let run = TargetRun {
96                    prefix: String::new(),
97                    url: target.clone(),
98                    headers: self.headers.clone(),
99                    spec: self.spec.clone(),
100                    output: self.output.clone(),
101                };
102                Ok(!self.run_target(&run).await?.run_failed)
103            }
104            (None, Some(file)) => self.execute_multi_target(file).await,
105        }
106    }
107
108    async fn execute_multi_target(&self, file: &Path) -> anyhow::Result<bool> {
109        let targets = parse_targets_file(file)
110            .map_err(|e| anyhow::anyhow!("{e}"))
111            .with_context(|| format!("failed to read targets file {}", file.display()))?;
112        if targets.is_empty() {
113            bail!("no targets found in {}", file.display());
114        }
115        let runs = plan_target_runs(targets, &self.headers, self.spec.as_ref(), &self.output);
116        let max_concurrency = self.max_concurrency.max(1) as usize;
117        println!(
118            "→ Chunked bench across {} targets from {:?} (max {} in parallel)",
119            runs.len(),
120            file,
121            max_concurrency
122        );
123
124        let mut outcomes: Vec<(usize, TargetRun, anyhow::Result<TargetOutcome>)> =
125            futures::stream::iter(runs.into_iter().enumerate())
126                .map(|(i, run)| async move {
127                    let res = self.run_target(&run).await;
128                    (i, run, res)
129                })
130                .buffer_unordered(max_concurrency)
131                .collect()
132                .await;
133        outcomes.sort_by_key(|(i, _, _)| *i);
134
135        println!();
136        println!("Multi-target chunked summary:");
137        let mut all_ok = true;
138        let mut rows = Vec::with_capacity(outcomes.len());
139        for (i, run, res) in &outcomes {
140            let name = format!("target_{}", i + 1);
141            match res {
142                Ok(o) => {
143                    if o.run_failed {
144                        all_ok = false;
145                    }
146                    println!(
147                        "  {:<10} {}  requests={} ok={} failed={} bytes={}{}",
148                        name,
149                        run.url,
150                        o.total_requests,
151                        o.successful,
152                        o.failed,
153                        o.bytes_sent,
154                        if o.run_failed {
155                            "  (some operations failed to run)"
156                        } else {
157                            ""
158                        }
159                    );
160                    rows.push(serde_json::json!({
161                        "target": name,
162                        "url": run.url,
163                        "spec": run.spec.as_ref().map(|p| p.display().to_string()),
164                        // Per-target files (exports / violations) exist only in spec mode.
165                        "output_dir": run.spec.as_ref().map(|_| run.output.display().to_string()),
166                        "total_requests": o.total_requests,
167                        "successful": o.successful,
168                        "failed": o.failed,
169                        "bytes_sent": o.bytes_sent,
170                        "status_counts": o.status_counts,
171                        "run_failed": o.run_failed,
172                    }));
173                }
174                Err(e) => {
175                    all_ok = false;
176                    println!("  {:<10} {}  ERROR: {:#}", name, run.url, e);
177                    rows.push(serde_json::json!({
178                        "target": name,
179                        "url": run.url,
180                        "error": format!("{e:#}"),
181                    }));
182                }
183            }
184        }
185
186        std::fs::create_dir_all(&self.output)
187            .with_context(|| format!("failed to create output directory {:?}", self.output))?;
188        let path = self.output.join("chunked-multi-target-summary.json");
189        let payload = serde_json::json!({
190            "targets_file": file.display().to_string(),
191            "concurrency_per_target": self.concurrency,
192            "max_concurrency": max_concurrency,
193            "duration_secs": self.duration.as_secs(),
194            "chunk_size_bytes": self.chunk_size_bytes,
195            "total_size_bytes": self.total_size_bytes,
196            "chunk_interval_ms": self.chunk_interval_ms,
197            "targets": rows,
198        });
199        std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
200            .with_context(|| format!("failed to write {:?}", path))?;
201        println!("📝 Wrote {:?}", path);
202        Ok(all_ok)
203    }
204
205    fn bench_config(
206        &self,
207        url: String,
208        method: reqwest::Method,
209        headers: &HashMap<String, String>,
210    ) -> ChunkedBenchConfig {
211        ChunkedBenchConfig {
212            target_url: url,
213            method,
214            concurrency: self.concurrency,
215            duration: self.duration,
216            chunk_size_bytes: self.chunk_size_bytes,
217            total_size_bytes: self.total_size_bytes,
218            chunk_interval_ms: self.chunk_interval_ms,
219            headers: headers.clone(),
220            skip_tls_verify: self.insecure,
221        }
222    }
223
224    async fn run_target(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
225        match &t.spec {
226            Some(spec) => self.run_spec(t, spec).await,
227            None => self.run_single(t).await,
228        }
229    }
230
231    async fn run_single(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
232        let p = &t.prefix;
233        // Only warn once: in multi-target mode every target would repeat it.
234        if p.is_empty() {
235            if self.base_path.is_some() {
236                eprintln!(
237                    "Note: --base-path has no effect without --spec; \
238                     single-target mode uses --target verbatim"
239                );
240            }
241            if self.validate_requests {
242                eprintln!("Note: --validate-requests requires --spec (skipped)");
243            }
244            if self.export_requests {
245                eprintln!("Note: --export-requests requires --spec (skipped)");
246            }
247        }
248        let method = reqwest::Method::from_bytes(self.method.to_uppercase().as_bytes())
249            .map_err(|_| anyhow::anyhow!("Invalid HTTP method: {}", self.method))?;
250        if !p.is_empty() {
251            println!("{p}→ {} {}", self.method.to_uppercase(), t.url);
252        }
253        let r = run(self.bench_config(t.url.clone(), method, &t.headers))
254            .await
255            .context("chunked bench failed")?;
256        print_result(
257            p,
258            if p.is_empty() {
259                "single-target"
260            } else {
261                &t.url
262            },
263            &r,
264        );
265        let mut outcome = TargetOutcome::default();
266        outcome.absorb(&r);
267        Ok(outcome)
268    }
269
270    async fn run_spec(&self, t: &TargetRun, spec_path: &Path) -> anyhow::Result<TargetOutcome> {
271        let p = &t.prefix;
272        let parser = SpecParser::from_file(spec_path)
273            .await
274            .map_err(|e| anyhow::anyhow!("Failed to parse spec {:?}: {}", spec_path, e))?;
275        let base_url = t.url.trim_end_matches('/').to_string();
276        // CLI --base-path > spec.servers > none. Empty string from CLI
277        // explicitly disables (matches `mockforge bench` semantics).
278        let effective_base_path: Option<String> = match &self.base_path {
279            Some(bp) if bp.is_empty() => None,
280            Some(bp) => Some(bp.clone()),
281            None => parser.get_base_path(),
282        };
283        let want_methods = ["POST", "PUT", "PATCH"];
284        let ops: Vec<_> = parser
285            .get_operations()
286            .into_iter()
287            .filter(|op| want_methods.contains(&op.method.to_uppercase().as_str()))
288            .filter(|op| match &self.operation_id {
289                Some(id) => op.operation_id.as_deref() == Some(id.as_str()),
290                None => true,
291            })
292            .collect();
293        if ops.is_empty() {
294            bail!(
295                "No POST/PUT/PATCH operations matched in {:?}{}",
296                spec_path,
297                self.operation_id
298                    .as_deref()
299                    .map(|id| format!(" (operation-id={id})"))
300                    .unwrap_or_default()
301            );
302        }
303
304        // Pre-flight validation. Any failure here aborts before touching
305        // the network so the bench doesn't half-run.
306        let mut violations: Vec<serde_json::Value> = Vec::new();
307        // (label, method, url, op.path)
308        let mut planned: Vec<(String, String, String, String)> = Vec::with_capacity(ops.len());
309        for op in &ops {
310            let label = op.display_name();
311            match RequestGenerator::generate_template(op) {
312                Ok(template) => {
313                    let path_with_base = match &effective_base_path {
314                        Some(bp) if !bp.is_empty() => {
315                            format!("{}{}", bp, template.generate_path())
316                        }
317                        _ => template.generate_path().to_string(),
318                    };
319                    let url = format!("{}{}", base_url, path_with_base);
320                    if reqwest::Method::from_bytes(op.method.to_uppercase().as_bytes()).is_err() {
321                        violations.push(serde_json::json!({
322                            "operation": label,
323                            "method": op.method,
324                            "path": op.path,
325                            "kind": "invalid_method",
326                            "detail": format!("`{}` is not a valid HTTP method", op.method),
327                        }));
328                    } else {
329                        planned.push((label, op.method.to_uppercase(), url, op.path.clone()));
330                    }
331                }
332                Err(e) => {
333                    violations.push(serde_json::json!({
334                        "operation": label,
335                        "method": op.method,
336                        "path": op.path,
337                        "kind": "template_build_failure",
338                        "detail": e.to_string(),
339                    }));
340                }
341            }
342        }
343
344        if self.validate_requests {
345            if !violations.is_empty() {
346                std::fs::create_dir_all(&t.output)
347                    .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
348                let path = t.output.join("chunked-request-violations.json");
349                let payload = serde_json::json!({
350                    "spec": spec_path.display().to_string(),
351                    "base_url": base_url,
352                    "base_path": effective_base_path,
353                    "violations": violations,
354                });
355                std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
356                    .with_context(|| format!("Failed to write {:?}", path))?;
357                bail!("--validate-requests: {} violation(s); see {:?}", violations.len(), path);
358            }
359            println!("{p}✅ --validate-requests: all {} operations OK", planned.len());
360        }
361
362        println!(
363            "{p}→ {} chunked bench {} from {:?} (base {}{})",
364            planned.len(),
365            if planned.len() == 1 {
366                "operation"
367            } else {
368                "operations"
369            },
370            spec_path,
371            base_url,
372            effective_base_path
373                .as_deref()
374                .map(|bp| format!(", base-path {}", bp))
375                .unwrap_or_default()
376        );
377
378        let mut outcome = TargetOutcome::default();
379        let mut export_records: Vec<serde_json::Value> = Vec::new();
380        for (label, method_str, url, op_path) in &planned {
381            let method = match reqwest::Method::from_bytes(method_str.as_bytes()) {
382                Ok(m) => m,
383                Err(_) => {
384                    eprintln!("{p}✗ {}: invalid method `{}`", label, method_str);
385                    outcome.run_failed = true;
386                    continue;
387                }
388            };
389            println!();
390            println!("{p}→ {} {}  ({})", method_str, op_path, url);
391            match run(self.bench_config(url.clone(), method, &t.headers)).await {
392                Ok(r) => {
393                    print_result(p, label, &r);
394                    outcome.absorb(&r);
395                    if self.export_requests {
396                        export_records.push(export_record(
397                            self, label, method_str, url, op_path, &t.headers, &r,
398                        ));
399                    }
400                }
401                Err(e) => {
402                    eprintln!("{p}✗ {}: chunked bench failed: {}", label, e);
403                    outcome.run_failed = true;
404                }
405            }
406        }
407
408        if self.export_requests {
409            std::fs::create_dir_all(&t.output)
410                .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
411            let path = t.output.join("chunked-requests.json");
412            let payload = serde_json::json!({
413                "spec": spec_path.display().to_string(),
414                "base_url": base_url,
415                "base_path": effective_base_path,
416                "operations": export_records,
417            });
418            std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
419                .with_context(|| format!("Failed to write {:?}", path))?;
420            println!(
421                "{p}📝 --export-requests: wrote {} operations to {:?}",
422                export_records.len(),
423                path
424            );
425        }
426
427        Ok(outcome)
428    }
429}
430
431/// Resolve each targets-file entry into a run plan: normalized URL, CLI
432/// headers overlaid with the target's `headers` and `auth`, the target's
433/// `spec` falling back to the CLI `--spec`, and `<output>/target_<N>/`.
434fn plan_target_runs(
435    targets: Vec<TargetConfig>,
436    cli_headers: &HashMap<String, String>,
437    cli_spec: Option<&PathBuf>,
438    output: &Path,
439) -> Vec<TargetRun> {
440    targets
441        .into_iter()
442        .enumerate()
443        .map(|(i, mut t)| {
444            t.normalize_url();
445            let mut headers = cli_headers.clone();
446            if let Some(h) = &t.headers {
447                headers.extend(h.iter().map(|(k, v)| (k.clone(), v.clone())));
448            }
449            if let Some(auth) = &t.auth {
450                headers.insert("Authorization".to_string(), auth.clone());
451            }
452            let name = format!("target_{}", i + 1);
453            TargetRun {
454                prefix: format!("[{name}] "),
455                url: t.url,
456                headers,
457                spec: t.spec.or_else(|| cli_spec.cloned()),
458                output: output.join(name),
459            }
460        })
461        .collect()
462}
463
464fn export_record(
465    cmd: &ChunkedCommand,
466    label: &str,
467    method: &str,
468    url: &str,
469    op_path: &str,
470    headers: &HashMap<String, String>,
471    r: &ChunkedBenchResult,
472) -> serde_json::Value {
473    serde_json::json!({
474        "operation": label,
475        "method": method,
476        "url": url,
477        "spec_path": op_path,
478        "headers": headers,
479        "chunk_size_bytes": cmd.chunk_size_bytes,
480        "total_size_bytes": cmd.total_size_bytes,
481        "chunk_interval_ms": cmd.chunk_interval_ms,
482        "concurrency": cmd.concurrency,
483        "duration_secs": cmd.duration.as_secs(),
484        "result": {
485            "total_requests": r.total_requests,
486            "successful": r.successful,
487            "failed": r.failed,
488            "bytes_sent": r.bytes_sent,
489            "elapsed_ms": r.elapsed.as_millis(),
490            "req_per_sec": r.req_per_sec,
491            "avg_latency_ms": r.avg_latency_ms,
492            "p50_ms": r.p50_ms,
493            "p95_ms": r.p95_ms,
494            "p99_ms": r.p99_ms,
495            "status_counts": r.status_counts,
496            "error_samples": r.error_samples.iter().map(|s| serde_json::json!({
497                "status": s.status,
498                "server_header": s.server_header,
499                "body_excerpt": s.body_excerpt,
500            })).collect::<Vec<_>>(),
501        },
502    })
503}
504
505/// Print one result block. The block is built first and printed with one
506/// call so parallel targets don't interleave their lines.
507fn print_result(prefix: &str, label: &str, r: &ChunkedBenchResult) {
508    let mut out = String::new();
509    let mut line = |s: String| {
510        out.push_str(prefix);
511        out.push_str(&s);
512        out.push('\n');
513    };
514    line(format!("Chunked bench [{}] complete in {:?}", label, r.elapsed));
515    line(format!("  Total requests: {}", r.total_requests));
516    line(format!("  Successful:     {}", r.successful));
517    line(format!("  Failed:         {}", r.failed));
518    line(format!("  Bytes sent:     {}", r.bytes_sent));
519    line(format!("  Throughput:     {:.2} req/s", r.req_per_sec));
520    line(format!(
521        "  Latency:        avg={:.1}ms p50={}ms p95={}ms p99={}ms",
522        r.avg_latency_ms, r.p50_ms, r.p95_ms, r.p99_ms
523    ));
524    if !r.status_counts.is_empty() {
525        line("  Status codes:".to_string());
526        let mut codes: Vec<_> = r.status_counts.iter().collect();
527        codes.sort_by_key(|(k, _)| **k);
528        for (code, n) in codes {
529            line(format!("    {} = {}", code, n));
530        }
531    }
532    // Surface the captured error responses so the user can tell whether
533    // errors came from MockForge, an upstream proxy, a CDN, etc. Hint at the
534    // most common cause when 5xx is involved (proxy upstream timeout on long
535    // chunked uploads).
536    if !r.error_samples.is_empty() {
537        line("  Error response samples:".to_string());
538        for s in &r.error_samples {
539            let server = s.server_header.as_deref().unwrap_or("(no Server header)");
540            line(format!("    [{}] Server: {}", s.status, server));
541            if !s.body_excerpt.is_empty() {
542                line(format!("       body: {}", s.body_excerpt.replace('\n', " ")));
543            }
544        }
545        if r.status_counts.keys().any(|c| (500..600).contains(c)) {
546            line(
547                "  Hint: 5xx responses with `Server:` revealing a proxy/LB usually \
548                 mean the proxy timed out reading from upstream. Each chunked request \
549                 takes >= (total_size_bytes / chunk_size_bytes) * chunk_interval_ms; \
550                 if that exceeds the proxy's upstream timeout, errors are inevitable."
551                    .to_string(),
552            );
553        }
554    }
555    print!("{out}");
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn plan_target_runs_merges_headers_auth_spec_and_output() {
564        let mut target_headers = HashMap::new();
565        target_headers.insert("X-Env".to_string(), "b".to_string());
566        let targets = vec![
567            TargetConfig::from_url("10.0.0.1:8080".to_string()),
568            TargetConfig {
569                url: "https://b.example".to_string(),
570                auth: Some("Bearer t".to_string()),
571                headers: Some(target_headers),
572                spec: Some(PathBuf::from("b.yaml")),
573            },
574        ];
575        let mut cli_headers = HashMap::new();
576        cli_headers.insert("X-Env".to_string(), "cli".to_string());
577        let cli_spec = PathBuf::from("cli.yaml");
578
579        let runs = plan_target_runs(targets, &cli_headers, Some(&cli_spec), Path::new("out"));
580
581        assert_eq!(runs.len(), 2);
582        assert_eq!(runs[0].url, "http://10.0.0.1:8080");
583        assert_eq!(runs[0].headers.get("X-Env").map(String::as_str), Some("cli"));
584        assert_eq!(runs[0].spec.as_deref(), Some(Path::new("cli.yaml")));
585        assert_eq!(runs[0].output, Path::new("out").join("target_1"));
586        assert_eq!(runs[0].prefix, "[target_1] ");
587
588        assert_eq!(runs[1].headers.get("X-Env").map(String::as_str), Some("b"));
589        assert_eq!(runs[1].headers.get("Authorization").map(String::as_str), Some("Bearer t"));
590        assert_eq!(runs[1].spec.as_deref(), Some(Path::new("b.yaml")));
591        assert_eq!(runs[1].output, Path::new("out").join("target_2"));
592    }
593
594    fn cmd(target: Option<&str>, targets_file: Option<PathBuf>) -> ChunkedCommand {
595        ChunkedCommand {
596            target: target.map(str::to_string),
597            targets_file,
598            max_concurrency: 10,
599            spec: None,
600            base_path: None,
601            operation_id: None,
602            method: "POST".to_string(),
603            concurrency: 1,
604            duration: Duration::from_millis(10),
605            chunk_size_bytes: 1024,
606            total_size_bytes: 4096,
607            chunk_interval_ms: 0,
608            headers: HashMap::new(),
609            insecure: false,
610            validate_requests: false,
611            export_requests: false,
612            output: PathBuf::from("bench-results"),
613        }
614    }
615
616    #[tokio::test]
617    async fn rejects_target_and_targets_file_together() {
618        let c = cmd(Some("http://127.0.0.1:1"), Some(PathBuf::from("t.txt")));
619        assert!(c.execute().await.is_err());
620    }
621
622    #[tokio::test]
623    async fn rejects_neither_target_nor_targets_file() {
624        assert!(cmd(None, None).execute().await.is_err());
625    }
626}