1use crate::chunked_bench::{
23 build_json_body, is_json_content_type, per_second, run, ChunkedBenchConfig, ChunkedBenchResult,
24};
25use crate::parallel_executor::ParallelExecutor;
26use crate::request_gen::RequestGenerator;
27use crate::spec_parser::SpecParser;
28use crate::target_parser::{parse_targets_file, TargetConfig};
29use anyhow::{bail, Context};
30use futures::StreamExt;
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36#[derive(Debug, Clone)]
38pub struct ChunkedCommand {
39 pub target: Option<String>,
41 pub targets_file: Option<PathBuf>,
43 pub max_concurrency: u32,
45 pub spec: Option<PathBuf>,
46 pub base_path: Option<String>,
47 pub operation_id: Option<String>,
48 pub method: String,
49 pub concurrency: u32,
50 pub duration: Duration,
51 pub chunk_size_bytes: usize,
52 pub total_size_bytes: usize,
53 pub raw_body: bool,
55 pub chunk_interval_ms: u64,
56 pub headers: HashMap<String, String>,
57 pub insecure: bool,
58 pub validate_requests: bool,
59 pub export_requests: bool,
60 pub output: PathBuf,
61 pub rps: Option<u32>,
63 pub cps: bool,
65 pub rounds: Option<u32>,
67 pub repeat_until: Option<Duration>,
69 pub keep_rounds: Option<u32>,
71 pub request_timeout: Option<Duration>,
74 pub overlap_operations: bool,
78}
79
80#[derive(Debug, Clone)]
82struct TargetRun {
83 prefix: String,
85 url: String,
86 headers: HashMap<String, String>,
87 spec: Option<PathBuf>,
88 output: PathBuf,
89}
90
91#[derive(Debug, Clone, Default)]
93struct TargetOutcome {
94 total_requests: u64,
95 successful: u64,
96 failed: u64,
97 bytes_sent: u64,
98 elapsed: Duration,
99 load_window: Duration,
101 timed_out: u64,
103 status_counts: HashMap<u16, u64>,
104 run_failed: bool,
106}
107
108impl TargetOutcome {
109 fn rate_fields(&self) -> serde_json::Value {
112 serde_json::json!({
113 "elapsed_seconds": self.elapsed.as_secs_f64(),
114 "measured_rps": per_second(self.total_requests as f64, self.elapsed),
115 "load_window_seconds": self.load_window.as_secs_f64(),
116 "start_window_rps": per_second(self.total_requests as f64, self.load_window),
117 "upload_mbps": per_second(self.bytes_sent as f64 * 8.0, self.elapsed) / 1_000_000.0,
118 "timed_out": self.timed_out,
119 })
120 }
121
122 fn absorb(&mut self, r: &ChunkedBenchResult) {
123 self.total_requests += r.total_requests;
124 self.successful += r.successful;
125 self.failed += r.failed;
126 self.bytes_sent += r.bytes_sent;
127 self.elapsed += r.elapsed;
128 self.load_window += r.load_window;
129 self.timed_out += r.timed_out;
130 for (code, n) in &r.status_counts {
131 *self.status_counts.entry(*code).or_insert(0) += n;
132 }
133 }
134}
135
136impl ChunkedCommand {
137 pub async fn execute(&self) -> anyhow::Result<bool> {
140 match (&self.target, &self.targets_file) {
141 (Some(_), Some(_)) => bail!("--target and --targets-file are mutually exclusive"),
142 (None, None) => bail!("either --target or --targets-file is required"),
143 _ => {}
144 }
145 if self.rounds == Some(0) {
146 bail!("--rounds must be >= 1 (or omit it for a single pass / --repeat-until)");
147 }
148 println!(
149 "Request-start limit per target: {}",
150 self.rps
151 .map(|n| format!("{n} req/s"))
152 .unwrap_or_else(|| "none (--rps not set)".into())
153 );
154 println!(
155 "Connections: {}",
156 if self.cps {
157 "new TCP/TLS connection per request (--cps)"
158 } else {
159 "pooled (--cps not set)"
160 }
161 );
162 if self.raw_body {
163 println!("Request bodies: raw X filler; declared Content-Type preserved (--raw-body)");
164 }
165 let max_rounds = self.rounds.unwrap_or(if self.repeat_until.is_some() {
166 u32::MAX
167 } else {
168 1
169 });
170 let looping = max_rounds > 1 || self.repeat_until.is_some();
171 if !looping {
172 if self.keep_rounds.is_some() {
173 eprintln!(
174 "Note: --keep-rounds only applies to campaign runs (--repeat-until / --rounds > 1); ignoring"
175 );
176 }
177 return Ok(self.execute_pass(&self.output).await?.0);
178 }
179
180 match self.repeat_until {
181 Some(until) => println!(
182 "Campaign: re-running every pass until {}s of wall clock (or --rounds {})",
183 until.as_secs(),
184 self.rounds.map(|n| n.to_string()).unwrap_or_else(|| "unlimited".into())
185 ),
186 None => println!("Campaign: {max_rounds} round(s)"),
187 }
188 if let Some(keep) = self.keep_rounds {
189 println!(
190 "Round pruning: keeping newest {keep} round_* dir(s); per-round stats persist in campaign.jsonl + round-summaries/"
191 );
192 }
193
194 let campaign_start = std::time::Instant::now();
195 let mut all_ok = true;
196 let mut round: u32 = 0;
197 while round < max_rounds {
198 if let Some(until) = self.repeat_until {
199 if round > 0 && campaign_start.elapsed() >= until {
200 println!("Reached --repeat-until; stopping after {round} round(s)");
201 break;
202 }
203 }
204 round += 1;
205 let round_output = self.output.join(format!("round_{round}"));
206 println!(
207 "\n=== Round {round} (campaign elapsed {}s) → {} ===",
208 campaign_start.elapsed().as_secs(),
209 round_output.display()
210 );
211 let round_start = std::time::Instant::now();
212 let (ok, summary) = self.execute_pass(&round_output).await?;
213 all_ok &= ok;
214 write_round_record(
215 &self.output,
216 round,
217 campaign_start.elapsed(),
218 round_start.elapsed(),
219 ok,
220 summary,
221 )?;
222 if let Some(keep) = self.keep_rounds {
223 ParallelExecutor::prune_old_rounds(&self.output, keep);
224 }
225 }
226 Ok(all_ok)
227 }
228
229 async fn execute_pass(&self, output: &Path) -> anyhow::Result<(bool, serde_json::Value)> {
232 match (&self.target, &self.targets_file) {
233 (Some(target), _) => {
234 let run = TargetRun {
235 prefix: String::new(),
236 url: target.clone(),
237 headers: self.headers.clone(),
238 spec: self.spec.clone(),
239 output: output.to_path_buf(),
240 };
241 let o = self.run_target(&run).await?;
242 let summary = serde_json::json!({
243 "url": target,
244 "total_requests": o.total_requests,
245 "successful": o.successful,
246 "failed": o.failed,
247 "bytes_sent": o.bytes_sent,
248 "status_counts": o.status_counts,
249 "run_failed": o.run_failed,
250 "rps_per_target": self.rps,
251 "rate_limit_enabled": self.rps.is_some(),
252 "new_connection_per_request": self.cps,
253 "raw_body": self.raw_body,
254 "overlap_operations": self.overlap_operations,
255 });
256 let summary = merge_json(summary, self.request_timeout_fields());
257 Ok((!o.run_failed, merge_json(summary, o.rate_fields())))
258 }
259 (None, Some(file)) => self.execute_multi_target(file, output).await,
260 (None, None) => bail!("either --target or --targets-file is required"),
261 }
262 }
263
264 async fn execute_multi_target(
265 &self,
266 file: &Path,
267 output: &Path,
268 ) -> anyhow::Result<(bool, serde_json::Value)> {
269 let targets = parse_targets_file(file)
270 .map_err(|e| anyhow::anyhow!("{e}"))
271 .with_context(|| format!("failed to read targets file {}", file.display()))?;
272 if targets.is_empty() {
273 bail!("no targets found in {}", file.display());
274 }
275 let runs = plan_target_runs(targets, &self.headers, self.spec.as_ref(), output);
276 let max_concurrency = self.max_concurrency.max(1) as usize;
277 println!(
278 "→ Chunked bench across {} targets from {:?} (max {} in parallel)",
279 runs.len(),
280 file,
281 max_concurrency
282 );
283
284 let mut outcomes: Vec<(usize, TargetRun, anyhow::Result<TargetOutcome>)> =
285 futures::stream::iter(runs.into_iter().enumerate())
286 .map(|(i, run)| async move {
287 let res = self.run_target(&run).await;
288 (i, run, res)
289 })
290 .buffer_unordered(max_concurrency)
291 .collect()
292 .await;
293 outcomes.sort_by_key(|(i, _, _)| *i);
294
295 println!();
296 println!("Multi-target chunked summary:");
297 let mut all_ok = true;
298 let mut rows = Vec::with_capacity(outcomes.len());
299 for (i, run, res) in &outcomes {
300 let name = format!("target_{}", i + 1);
301 match res {
302 Ok(o) => {
303 if o.run_failed {
304 all_ok = false;
305 }
306 println!(
307 " {:<10} {} requests={} ok={} failed={} bytes={} start_rps={:.2} rps={:.2}{}",
308 name,
309 run.url,
310 o.total_requests,
311 o.successful,
312 o.failed,
313 o.bytes_sent,
314 per_second(o.total_requests as f64, o.load_window),
315 per_second(o.total_requests as f64, o.elapsed),
316 if o.run_failed {
317 " (some operations failed to run)"
318 } else {
319 ""
320 }
321 );
322 rows.push(merge_json(
323 serde_json::json!({
324 "target": name,
325 "url": run.url,
326 "spec": run.spec.as_ref().map(|p| p.display().to_string()),
327 "output_dir": run.spec.as_ref().map(|_| run.output.display().to_string()),
329 "total_requests": o.total_requests,
330 "successful": o.successful,
331 "failed": o.failed,
332 "bytes_sent": o.bytes_sent,
333 "status_counts": o.status_counts,
334 "run_failed": o.run_failed,
335 }),
336 o.rate_fields(),
337 ));
338 }
339 Err(e) => {
340 all_ok = false;
341 println!(" {:<10} {} ERROR: {:#}", name, run.url, e);
342 rows.push(serde_json::json!({
343 "target": name,
344 "url": run.url,
345 "error": format!("{e:#}"),
346 }));
347 }
348 }
349 }
350
351 std::fs::create_dir_all(output)
352 .with_context(|| format!("failed to create output directory {:?}", output))?;
353 let path = output.join("chunked-multi-target-summary.json");
354 let payload = serde_json::json!({
355 "targets_file": file.display().to_string(),
356 "concurrency_per_target": self.concurrency,
357 "max_concurrency": max_concurrency,
358 "duration_secs": self.duration.as_secs(),
359 "chunk_size_bytes": self.chunk_size_bytes,
360 "total_size_bytes": self.total_size_bytes,
361 "chunk_interval_ms": self.chunk_interval_ms,
362 "rps_per_target": self.rps,
363 "rate_limit_enabled": self.rps.is_some(),
364 "new_connection_per_request": self.cps,
365 "raw_body": self.raw_body,
366 "overlap_operations": self.overlap_operations,
367 "targets": rows,
368 });
369 let payload = merge_json(payload, self.request_timeout_fields());
370 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
371 .with_context(|| format!("failed to write {:?}", path))?;
372 println!("📝 Wrote {:?}", path);
373 Ok((all_ok, payload))
374 }
375
376 fn bench_config(
377 &self,
378 url: String,
379 method: reqwest::Method,
380 headers: &HashMap<String, String>,
381 json_seed: Option<&serde_json::Value>,
382 ) -> ChunkedBenchConfig {
383 let body = (!self.raw_body && is_json_content_type(headers))
386 .then(|| Arc::new(build_json_body(json_seed, self.total_size_bytes)));
387 if let Some(b) = &body {
388 if b.len() > self.total_size_bytes {
389 eprintln!(
390 "Note: --total-size-bytes {} is below the smallest valid JSON body; sending {} bytes",
391 self.total_size_bytes,
392 b.len()
393 );
394 }
395 }
396 let mut cfg = ChunkedBenchConfig {
397 target_url: url,
398 method,
399 concurrency: self.concurrency,
400 duration: self.duration,
401 chunk_size_bytes: self.chunk_size_bytes,
402 total_size_bytes: self.total_size_bytes,
403 chunk_interval_ms: self.chunk_interval_ms,
404 headers: headers.clone(),
405 skip_tls_verify: self.insecure,
406 rps: self.rps,
407 no_keep_alive: self.cps,
408 body,
409 request_timeout: None,
410 };
411 cfg.request_timeout = match self.request_timeout {
412 None => Some(cfg.auto_request_timeout()),
413 Some(t) if t.is_zero() => None,
414 Some(t) => Some(t),
415 };
416 cfg
417 }
418
419 fn request_timeout_fields(&self) -> serde_json::Value {
421 let (mode, secs) = match self.request_timeout {
422 None => ("auto", None),
423 Some(t) if t.is_zero() => ("off", None),
424 Some(t) => ("fixed", Some(t.as_secs_f64())),
425 };
426 serde_json::json!({ "request_timeout_mode": mode, "request_timeout_secs": secs })
427 }
428
429 async fn run_target(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
430 match &t.spec {
431 Some(spec) => self.run_spec(t, spec).await,
432 None => self.run_single(t).await,
433 }
434 }
435
436 async fn run_single(&self, t: &TargetRun) -> anyhow::Result<TargetOutcome> {
437 let p = &t.prefix;
438 if p.is_empty() {
440 if self.base_path.is_some() {
441 eprintln!(
442 "Note: --base-path has no effect without --spec; \
443 single-target mode uses --target verbatim"
444 );
445 }
446 if self.validate_requests {
447 eprintln!("Note: --validate-requests requires --spec (skipped)");
448 }
449 if self.export_requests {
450 eprintln!("Note: --export-requests requires --spec (skipped)");
451 }
452 if self.overlap_operations {
453 eprintln!("Note: --overlap-operations requires --spec (single operation)");
454 }
455 }
456 let method = reqwest::Method::from_bytes(self.method.to_uppercase().as_bytes())
457 .map_err(|_| anyhow::anyhow!("Invalid HTTP method: {}", self.method))?;
458 if !p.is_empty() {
459 println!("{p}→ {} {}", self.method.to_uppercase(), t.url);
460 }
461 let r = run(self.bench_config(t.url.clone(), method, &t.headers, None))
462 .await
463 .context("chunked bench failed")?;
464 print_result(
465 p,
466 if p.is_empty() {
467 "single-target"
468 } else {
469 &t.url
470 },
471 &r,
472 );
473 let mut outcome = TargetOutcome::default();
474 outcome.absorb(&r);
475 Ok(outcome)
476 }
477
478 async fn run_spec(&self, t: &TargetRun, spec_path: &Path) -> anyhow::Result<TargetOutcome> {
479 let p = &t.prefix;
480 let parser = SpecParser::from_file(spec_path)
481 .await
482 .map_err(|e| anyhow::anyhow!("Failed to parse spec {:?}: {}", spec_path, e))?;
483 let base_url = t.url.trim_end_matches('/').to_string();
484 let effective_base_path: Option<String> = match &self.base_path {
487 Some(bp) if bp.is_empty() => None,
488 Some(bp) => Some(bp.clone()),
489 None => parser.get_base_path(),
490 };
491 let want_methods = ["POST", "PUT", "PATCH"];
492 let ops: Vec<_> = parser
493 .get_operations()
494 .into_iter()
495 .filter(|op| want_methods.contains(&op.method.to_uppercase().as_str()))
496 .filter(|op| match &self.operation_id {
497 Some(id) => op.operation_id.as_deref() == Some(id.as_str()),
498 None => true,
499 })
500 .collect();
501 if ops.is_empty() {
502 bail!(
503 "No POST/PUT/PATCH operations matched in {:?}{}",
504 spec_path,
505 self.operation_id
506 .as_deref()
507 .map(|id| format!(" (operation-id={id})"))
508 .unwrap_or_default()
509 );
510 }
511
512 let mut violations: Vec<serde_json::Value> = Vec::new();
515 let mut planned: Vec<(String, String, String, String, Option<serde_json::Value>)> =
517 Vec::with_capacity(ops.len());
518 for op in &ops {
519 let label = op.display_name();
520 match RequestGenerator::generate_template(op) {
521 Ok(template) => {
522 let path_with_base = match &effective_base_path {
523 Some(bp) if !bp.is_empty() => {
524 format!("{}{}", bp, template.generate_path())
525 }
526 _ => template.generate_path().to_string(),
527 };
528 let url = format!("{}{}", base_url, path_with_base);
529 if reqwest::Method::from_bytes(op.method.to_uppercase().as_bytes()).is_err() {
530 violations.push(serde_json::json!({
531 "operation": label,
532 "method": op.method,
533 "path": op.path,
534 "kind": "invalid_method",
535 "detail": format!("`{}` is not a valid HTTP method", op.method),
536 }));
537 } else {
538 planned.push((
539 label,
540 op.method.to_uppercase(),
541 url,
542 op.path.clone(),
543 template.body.clone(),
544 ));
545 }
546 }
547 Err(e) => {
548 violations.push(serde_json::json!({
549 "operation": label,
550 "method": op.method,
551 "path": op.path,
552 "kind": "template_build_failure",
553 "detail": e.to_string(),
554 }));
555 }
556 }
557 }
558
559 if self.validate_requests {
560 if !violations.is_empty() {
561 std::fs::create_dir_all(&t.output)
562 .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
563 let path = t.output.join("chunked-request-violations.json");
564 let payload = serde_json::json!({
565 "spec": spec_path.display().to_string(),
566 "base_url": base_url,
567 "base_path": effective_base_path,
568 "violations": violations,
569 });
570 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
571 .with_context(|| format!("Failed to write {:?}", path))?;
572 bail!("--validate-requests: {} violation(s); see {:?}", violations.len(), path);
573 }
574 println!("{p}✅ --validate-requests: all {} operations OK", planned.len());
575 }
576
577 println!(
578 "{p}→ {} chunked bench {} from {:?} (base {}{})",
579 planned.len(),
580 if planned.len() == 1 {
581 "operation"
582 } else {
583 "operations"
584 },
585 spec_path,
586 base_url,
587 effective_base_path
588 .as_deref()
589 .map(|bp| format!(", base-path {}", bp))
590 .unwrap_or_default()
591 );
592
593 let mut outcome = TargetOutcome::default();
594 let mut export_records: Vec<serde_json::Value> = Vec::new();
595 let pass_started = tokio::time::Instant::now();
599 let in_parallel = if self.overlap_operations {
600 planned.len().max(1)
601 } else {
602 1
603 };
604 let mut runs = futures::stream::iter(planned.iter().enumerate())
605 .map(|(i, (label, method_str, url, op_path, json_seed))| async move {
606 if self.overlap_operations {
607 tokio::time::sleep_until(pass_started + self.duration * i as u32).await;
608 }
609 let result = match reqwest::Method::from_bytes(method_str.as_bytes()) {
610 Ok(method) => {
611 println!();
612 println!("{p}→ {} {} ({})", method_str, op_path, url);
613 let cfg =
614 self.bench_config(url.clone(), method, &t.headers, json_seed.as_ref());
615 Some(run(cfg).await)
616 }
617 Err(_) => None,
618 };
619 (label, method_str, url, op_path, result)
620 })
621 .buffered(in_parallel);
622 while let Some((label, method_str, url, op_path, result)) = runs.next().await {
623 let Some(result) = result else {
624 eprintln!("{p}✗ {}: invalid method `{}`", label, method_str);
625 outcome.run_failed = true;
626 continue;
627 };
628 match result {
629 Ok(r) => {
630 print_result(p, label, &r);
631 outcome.absorb(&r);
632 if self.export_requests {
633 export_records.push(export_record(
634 self, label, method_str, url, op_path, &t.headers, &r,
635 ));
636 }
637 }
638 Err(e) => {
639 eprintln!("{p}✗ {}: chunked bench failed: {}", label, e);
640 outcome.run_failed = true;
641 }
642 }
643 }
644 drop(runs);
645 if self.overlap_operations {
646 outcome.elapsed = pass_started.elapsed();
649 }
650
651 if self.export_requests {
652 std::fs::create_dir_all(&t.output)
653 .with_context(|| format!("Failed to create output directory {:?}", t.output))?;
654 let path = t.output.join("chunked-requests.json");
655 let payload = serde_json::json!({
656 "spec": spec_path.display().to_string(),
657 "base_url": base_url,
658 "base_path": effective_base_path,
659 "operations": export_records,
660 });
661 std::fs::write(&path, serde_json::to_string_pretty(&payload)?)
662 .with_context(|| format!("Failed to write {:?}", path))?;
663 println!(
664 "{p}📝 --export-requests: wrote {} operations to {:?}",
665 export_records.len(),
666 path
667 );
668 }
669
670 Ok(outcome)
671 }
672}
673
674fn plan_target_runs(
678 targets: Vec<TargetConfig>,
679 cli_headers: &HashMap<String, String>,
680 cli_spec: Option<&PathBuf>,
681 output: &Path,
682) -> Vec<TargetRun> {
683 targets
684 .into_iter()
685 .enumerate()
686 .map(|(i, mut t)| {
687 t.normalize_url();
688 let mut headers = cli_headers.clone();
689 if let Some(h) = &t.headers {
690 headers.extend(h.iter().map(|(k, v)| (k.clone(), v.clone())));
691 }
692 if let Some(auth) = &t.auth {
693 headers.insert("Authorization".to_string(), auth.clone());
694 }
695 let name = format!("target_{}", i + 1);
696 TargetRun {
697 prefix: format!("[{name}] "),
698 url: t.url,
699 headers,
700 spec: t.spec.or_else(|| cli_spec.cloned()),
701 output: output.join(name),
702 }
703 })
704 .collect()
705}
706
707fn write_round_record(
711 output: &Path,
712 round: u32,
713 campaign_elapsed: Duration,
714 round_elapsed: Duration,
715 ok: bool,
716 pass: serde_json::Value,
717) -> anyhow::Result<()> {
718 use std::io::Write;
719 let record = serde_json::json!({
720 "round": round,
721 "campaign_elapsed_seconds": campaign_elapsed.as_secs(),
722 "round_elapsed_seconds": round_elapsed.as_secs_f64(),
723 "all_runs_completed": ok,
724 "pass": pass,
725 });
726 let summaries = output.join("round-summaries");
727 std::fs::create_dir_all(&summaries)
728 .with_context(|| format!("failed to create {:?}", summaries))?;
729 std::fs::write(
730 summaries.join(format!("round_{round}.json")),
731 serde_json::to_string_pretty(&record)?,
732 )?;
733 let mut line = serde_json::to_string(&record)?;
734 line.push('\n');
735 std::fs::OpenOptions::new()
736 .create(true)
737 .append(true)
738 .open(output.join("campaign.jsonl"))?
739 .write_all(line.as_bytes())
740 .context("failed to append campaign.jsonl")?;
741 Ok(())
742}
743
744fn export_record(
745 cmd: &ChunkedCommand,
746 label: &str,
747 method: &str,
748 url: &str,
749 op_path: &str,
750 headers: &HashMap<String, String>,
751 r: &ChunkedBenchResult,
752) -> serde_json::Value {
753 serde_json::json!({
754 "operation": label,
755 "method": method,
756 "url": url,
757 "spec_path": op_path,
758 "headers": headers,
759 "chunk_size_bytes": cmd.chunk_size_bytes,
760 "total_size_bytes": cmd.total_size_bytes,
761 "chunk_interval_ms": cmd.chunk_interval_ms,
762 "concurrency": cmd.concurrency,
763 "rps": cmd.rps,
764 "rate_limit_enabled": cmd.rps.is_some(),
765 "new_connection_per_request": cmd.cps,
766 "raw_body": cmd.raw_body,
767 "duration_secs": cmd.duration.as_secs(),
768 "result": {
769 "total_requests": r.total_requests,
770 "successful": r.successful,
771 "failed": r.failed,
772 "bytes_sent": r.bytes_sent,
773 "elapsed_ms": r.elapsed.as_millis(),
774 "req_per_sec": r.req_per_sec,
775 "load_window_ms": r.load_window.as_millis(),
776 "start_rate_per_sec": r.start_rate_per_sec,
777 "upload_mbps": r.upload_mbps,
778 "timed_out": r.timed_out,
779 "request_timeout_ms": r.request_timeout.map(|t| t.as_millis()),
780 "avg_latency_ms": r.avg_latency_ms,
781 "p50_ms": r.p50_ms,
782 "p95_ms": r.p95_ms,
783 "p99_ms": r.p99_ms,
784 "status_counts": r.status_counts,
785 "error_samples": r.error_samples.iter().map(|s| serde_json::json!({
786 "status": s.status,
787 "server_header": s.server_header,
788 "body_excerpt": s.body_excerpt,
789 })).collect::<Vec<_>>(),
790 },
791 })
792}
793
794fn merge_json(mut base: serde_json::Value, extra: serde_json::Value) -> serde_json::Value {
795 if let (Some(base), serde_json::Value::Object(extra)) = (base.as_object_mut(), extra) {
796 base.extend(extra);
797 }
798 base
799}
800
801fn print_result(prefix: &str, label: &str, r: &ChunkedBenchResult) {
804 let mut out = String::new();
805 let mut line = |s: String| {
806 out.push_str(prefix);
807 out.push_str(&s);
808 out.push('\n');
809 };
810 line(format!("Chunked bench [{}] complete in {:?}", label, r.elapsed));
811 line(format!(" Total requests: {}", r.total_requests));
812 line(format!(" Successful: {}", r.successful));
813 match r.request_timeout {
814 Some(t) if r.timed_out > 0 => line(format!(
815 " Failed: {} ({} timed out after {:.1}s)",
816 r.failed,
817 r.timed_out,
818 t.as_secs_f64()
819 )),
820 _ => line(format!(" Failed: {}", r.failed)),
821 }
822 line(format!(" Bytes sent: {}", r.bytes_sent));
823 line(format!(
824 " RPS (started): {:.2} req/s over the {:.1}s load window",
825 r.start_rate_per_sec,
826 r.load_window.as_secs_f64()
827 ));
828 line(format!(
829 " RPS (overall): {:.2} req/s over {:.1}s, incl. in-flight drain",
830 r.req_per_sec,
831 r.elapsed.as_secs_f64()
832 ));
833 line(format!(" Upload: {:.2} Mbps", r.upload_mbps));
834 line(format!(
835 " Latency: avg={:.1}ms p50={}ms p95={}ms p99={}ms",
836 r.avg_latency_ms, r.p50_ms, r.p95_ms, r.p99_ms
837 ));
838 if !r.status_counts.is_empty() {
839 line(" Status codes:".to_string());
840 let mut codes: Vec<_> = r.status_counts.iter().collect();
841 codes.sort_by_key(|(k, _)| **k);
842 for (code, n) in codes {
843 line(format!(" {} = {}", code, n));
844 }
845 }
846 if !r.error_samples.is_empty() {
851 line(" Error response samples:".to_string());
852 for s in &r.error_samples {
853 let server = s.server_header.as_deref().unwrap_or("(no Server header)");
854 line(format!(" [{}] Server: {}", s.status, server));
855 if !s.body_excerpt.is_empty() {
856 line(format!(" body: {}", s.body_excerpt.replace('\n', " ")));
857 }
858 }
859 if r.status_counts.keys().any(|c| (500..600).contains(c)) {
860 line(
861 " Hint: 5xx responses with `Server:` revealing a proxy/LB usually \
862 mean the proxy timed out reading from upstream. Each chunked request \
863 takes >= (total_size_bytes / chunk_size_bytes) * chunk_interval_ms; \
864 if that exceeds the proxy's upstream timeout, errors are inevitable."
865 .to_string(),
866 );
867 }
868 }
869 print!("{out}");
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875
876 #[test]
877 fn plan_target_runs_merges_headers_auth_spec_and_output() {
878 let mut target_headers = HashMap::new();
879 target_headers.insert("X-Env".to_string(), "b".to_string());
880 let targets = vec![
881 TargetConfig::from_url("10.0.0.1:8080".to_string()),
882 TargetConfig {
883 url: "https://b.example".to_string(),
884 auth: Some("Bearer t".to_string()),
885 headers: Some(target_headers),
886 spec: Some(PathBuf::from("b.yaml")),
887 },
888 ];
889 let mut cli_headers = HashMap::new();
890 cli_headers.insert("X-Env".to_string(), "cli".to_string());
891 let cli_spec = PathBuf::from("cli.yaml");
892
893 let runs = plan_target_runs(targets, &cli_headers, Some(&cli_spec), Path::new("out"));
894
895 assert_eq!(runs.len(), 2);
896 assert_eq!(runs[0].url, "http://10.0.0.1:8080");
897 assert_eq!(runs[0].headers.get("X-Env").map(String::as_str), Some("cli"));
898 assert_eq!(runs[0].spec.as_deref(), Some(Path::new("cli.yaml")));
899 assert_eq!(runs[0].output, Path::new("out").join("target_1"));
900 assert_eq!(runs[0].prefix, "[target_1] ");
901
902 assert_eq!(runs[1].headers.get("X-Env").map(String::as_str), Some("b"));
903 assert_eq!(runs[1].headers.get("Authorization").map(String::as_str), Some("Bearer t"));
904 assert_eq!(runs[1].spec.as_deref(), Some(Path::new("b.yaml")));
905 assert_eq!(runs[1].output, Path::new("out").join("target_2"));
906 }
907
908 fn cmd(target: Option<&str>, targets_file: Option<PathBuf>) -> ChunkedCommand {
909 ChunkedCommand {
910 target: target.map(str::to_string),
911 targets_file,
912 max_concurrency: 10,
913 spec: None,
914 base_path: None,
915 operation_id: None,
916 method: "POST".to_string(),
917 concurrency: 1,
918 duration: Duration::from_millis(10),
919 chunk_size_bytes: 1024,
920 total_size_bytes: 4096,
921 raw_body: false,
922 chunk_interval_ms: 0,
923 headers: HashMap::new(),
924 insecure: false,
925 validate_requests: false,
926 export_requests: false,
927 output: PathBuf::from("bench-results"),
928 rps: None,
929 cps: false,
930 rounds: None,
931 repeat_until: None,
932 keep_rounds: None,
933 request_timeout: None,
934 overlap_operations: false,
935 }
936 }
937
938 #[tokio::test]
939 async fn rejects_target_and_targets_file_together() {
940 let c = cmd(Some("http://127.0.0.1:1"), Some(PathBuf::from("t.txt")));
941 assert!(c.execute().await.is_err());
942 }
943
944 #[tokio::test]
945 async fn rejects_neither_target_nor_targets_file() {
946 assert!(cmd(None, None).execute().await.is_err());
947 }
948
949 fn scratch_dir(name: &str) -> PathBuf {
950 let dir = std::env::temp_dir().join(format!(
951 "mf-chunked-{name}-{}-{}",
952 std::process::id(),
953 line!()
954 ));
955 let _ = std::fs::remove_dir_all(&dir);
956 std::fs::create_dir_all(&dir).unwrap();
957 dir
958 }
959
960 #[tokio::test]
961 async fn campaign_rounds_write_records_and_prune_round_dirs() {
962 let mut server = mockito::Server::new_async().await;
963 let _m = server.mock("POST", "/upload").with_status(200).create_async().await;
964 let dir = scratch_dir("campaign");
965 let targets = dir.join("targets.txt");
966 std::fs::write(&targets, format!("{}/upload\n{}/upload\n", server.url(), server.url()))
967 .unwrap();
968 let mut c = cmd(None, Some(targets));
969 c.output = dir.join("out");
970 c.duration = Duration::from_millis(50);
971 c.rounds = Some(3);
972 c.keep_rounds = Some(1);
973
974 assert!(c.execute().await.unwrap());
975
976 let jsonl = std::fs::read_to_string(c.output.join("campaign.jsonl")).unwrap();
977 let rounds: Vec<serde_json::Value> =
978 jsonl.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
979 assert_eq!(rounds.len(), 3);
980 assert_eq!(rounds[2]["round"], 3);
981 assert_eq!(rounds[2]["pass"]["targets"].as_array().unwrap().len(), 2);
982 for n in 1..=3 {
983 assert!(c.output.join(format!("round-summaries/round_{n}.json")).exists());
984 }
985 assert!(!c.output.join("round_1").exists());
986 assert!(!c.output.join("round_2").exists());
987 assert!(c.output.join("round_3/chunked-multi-target-summary.json").exists());
988 let _ = std::fs::remove_dir_all(&dir);
989 }
990
991 #[tokio::test]
992 async fn campaign_repeat_until_stops_on_wall_clock() {
993 let mut server = mockito::Server::new_async().await;
994 let _m = server.mock("POST", "/upload").with_status(200).create_async().await;
995 let dir = scratch_dir("until");
996 let mut c = cmd(Some(&format!("{}/upload", server.url())), None);
997 c.output = dir.clone();
998 c.duration = Duration::from_millis(50);
1002 c.repeat_until = Some(Duration::from_millis(500));
1003
1004 assert!(c.execute().await.unwrap());
1005
1006 let n = std::fs::read_to_string(dir.join("campaign.jsonl")).unwrap().lines().count();
1007 assert!((2..=11).contains(&n), "expected 2-11 rounds of 50ms in 500ms, got {n}");
1008 let _ = std::fs::remove_dir_all(&dir);
1009 }
1010
1011 #[tokio::test]
1012 async fn raw_body_preserves_json_headers_in_spec_campaign_and_exports() {
1013 let mut server = mockito::Server::new_async().await;
1014 let m = server
1015 .mock("POST", "/upload")
1016 .match_header("content-type", "application/json")
1017 .match_header("transfer-encoding", "chunked")
1018 .match_body(mockito::Matcher::Exact("X".repeat(4096)))
1019 .with_status(400)
1020 .expect_at_least(2)
1021 .create_async()
1022 .await;
1023 let dir = tempfile::tempdir().unwrap();
1024 let spec = dir.path().join("api.json");
1025 std::fs::write(
1026 &spec,
1027 serde_json::json!({
1028 "openapi": "3.0.3", "info": {"title": "test", "version": "1"},
1029 "paths": {"/upload": {"post": {
1030 "operationId": "upload",
1031 "requestBody": {"content": {"application/json": {"schema": {
1032 "type": "object", "properties": {"name": {"type": "string"}}
1033 }}}},
1034 "responses": {"200": {"description": "ok"}}
1035 }}}
1036 })
1037 .to_string(),
1038 )
1039 .unwrap();
1040 let targets = dir.path().join("targets.json");
1041 std::fs::write(
1042 &targets,
1043 serde_json::json!([
1044 {"url": server.url(), "headers": {"Content-Type": "application/json"}}
1045 ])
1046 .to_string(),
1047 )
1048 .unwrap();
1049 let mut c = cmd(None, Some(targets));
1050 c.spec = Some(spec);
1051 c.raw_body = true;
1052 c.output = dir.path().join("out");
1053 c.rounds = Some(2);
1054 c.rps = Some(10);
1055 c.cps = true;
1056 c.export_requests = true;
1057 assert!(c.execute().await.unwrap());
1058 m.assert_async().await;
1059 let record: serde_json::Value = serde_json::from_slice(
1060 &std::fs::read(c.output.join("round-summaries/round_2.json")).unwrap(),
1061 )
1062 .unwrap();
1063 assert!(record["round_elapsed_seconds"].as_f64().unwrap() > 0.0);
1064 let pass = &record["pass"];
1065 assert_eq!(pass["raw_body"], true);
1066 assert_eq!(pass["rps_per_target"], 10);
1067 assert_eq!(pass["rate_limit_enabled"], true);
1068 assert_eq!(pass["new_connection_per_request"], true);
1069 let row = &pass["targets"][0];
1070 let requests = row["total_requests"].as_u64().unwrap();
1071 let elapsed = row["elapsed_seconds"].as_f64().unwrap();
1072 assert!(requests > 0);
1073 let measured = row["measured_rps"].as_f64().unwrap();
1075 let expected = requests as f64 / elapsed;
1076 assert!((measured - expected).abs() <= expected.abs() * 1e-12);
1077 let window = row["load_window_seconds"].as_f64().unwrap();
1078 assert!(window > 0.0);
1079 let started = row["start_window_rps"].as_f64().unwrap();
1080 assert!((started - requests as f64 / window).abs() <= started * 1e-12);
1081 assert!(row["upload_mbps"].as_f64().unwrap() > 0.0);
1082 assert_eq!(row["status_counts"]["400"], requests);
1083 let exported: serde_json::Value = serde_json::from_slice(
1084 &std::fs::read(c.output.join("round_2/target_1/chunked-requests.json")).unwrap(),
1085 )
1086 .unwrap();
1087 assert_eq!(exported["operations"][0]["raw_body"], true);
1088 assert_eq!(exported["operations"][0]["headers"]["Content-Type"], "application/json");
1089 }
1090
1091 #[tokio::test]
1092 async fn automatic_json_and_raw_body_work_without_spec() {
1093 let mut server = mockito::Server::new_async().await;
1094 let json = server
1095 .mock("POST", "/json")
1096 .match_header("content-type", "application/vnd.test+json")
1097 .match_header("transfer-encoding", "chunked")
1098 .match_body(mockito::Matcher::PartialJson(
1099 serde_json::json!({"_padding": "X".repeat(4081)}),
1100 ))
1101 .with_status(200)
1102 .expect_at_least(1)
1103 .create_async()
1104 .await;
1105 let raw = server
1106 .mock("POST", "/raw")
1107 .match_header("content-type", "application/vnd.test+json")
1108 .match_header("transfer-encoding", "chunked")
1109 .match_body(mockito::Matcher::Exact("X".repeat(4096)))
1110 .with_status(400)
1111 .expect_at_least(1)
1112 .create_async()
1113 .await;
1114 let mut c = cmd(Some(&format!("{}/json", server.url())), None);
1115 c.headers.insert("Content-Type".into(), "application/vnd.test+json".into());
1116 let (_, summary) = c.execute_pass(Path::new("unused")).await.unwrap();
1117 assert!(summary["total_requests"].as_u64().unwrap() > 0);
1118 assert_eq!(summary["rps_per_target"], serde_json::Value::Null);
1119 assert_eq!(summary["rate_limit_enabled"], false);
1120 assert_eq!(summary["new_connection_per_request"], false);
1121 assert!(summary["measured_rps"].as_f64().unwrap() > 0.0);
1122 json.assert_async().await;
1123 c.target = Some(format!("{}/raw", server.url()));
1124 c.raw_body = true;
1125 let (_, summary) = c.execute_pass(Path::new("unused")).await.unwrap();
1126 assert!(summary["status_counts"]["400"].as_u64().unwrap() > 0);
1127 raw.assert_async().await;
1128 }
1129
1130 #[test]
1131 fn start_window_rps_excludes_in_flight_drain() {
1132 let o = TargetOutcome {
1134 total_requests: 30,
1135 bytes_sent: 30 * 1_048_576,
1136 elapsed: Duration::from_secs(77),
1137 load_window: Duration::from_secs(30),
1138 ..Default::default()
1139 };
1140 let f = o.rate_fields();
1141 assert_eq!(f["start_window_rps"], 1.0);
1142 assert_eq!(f["load_window_seconds"], 30.0);
1143 assert!((f["measured_rps"].as_f64().unwrap() - 30.0 / 77.0).abs() < 1e-12);
1144 let mbps = 30.0 * 1_048_576.0 * 8.0 / 77.0 / 1e6;
1145 assert!((f["upload_mbps"].as_f64().unwrap() - mbps).abs() < 1e-12);
1146 }
1147
1148 async fn slow_receiver(
1151 hold: Duration,
1152 ) -> (String, Arc<std::sync::Mutex<Vec<(String, std::time::Instant)>>>) {
1153 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1154 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1155 let url = format!("http://{}", listener.local_addr().unwrap());
1156 let arrivals = Arc::new(std::sync::Mutex::new(Vec::new()));
1157 let log = arrivals.clone();
1158 tokio::spawn(async move {
1159 loop {
1160 let (mut sock, _) = listener.accept().await.unwrap();
1161 let log = log.clone();
1162 tokio::spawn(async move {
1163 let mut buf = Vec::new();
1164 let mut chunk = [0u8; 4096];
1165 loop {
1166 let header_end = loop {
1168 if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
1169 break i + 4;
1170 }
1171 match sock.read(&mut chunk).await {
1172 Ok(0) | Err(_) => return,
1173 Ok(n) => buf.extend_from_slice(&chunk[..n]),
1174 }
1175 };
1176 let head = String::from_utf8_lossy(&buf[..header_end]).to_string();
1177 let path = head.split_whitespace().nth(1).unwrap_or("").to_string();
1178 log.lock().unwrap().push((path, std::time::Instant::now()));
1179 let body_end = loop {
1180 if let Some(i) =
1181 buf[header_end..].windows(5).position(|w| w == b"0\r\n\r\n")
1182 {
1183 break header_end + i + 5;
1184 }
1185 match sock.read(&mut chunk).await {
1186 Ok(0) | Err(_) => return,
1187 Ok(n) => buf.extend_from_slice(&chunk[..n]),
1188 }
1189 };
1190 buf.drain(..body_end);
1191 tokio::time::sleep(hold).await;
1192 let ok = b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n";
1193 if sock.write_all(ok).await.is_err() {
1194 return;
1195 }
1196 }
1197 });
1198 }
1199 });
1200 (url, arrivals)
1201 }
1202
1203 async fn second_operation_start(overlap: bool) -> (Duration, serde_json::Value) {
1204 let (url, arrivals) = slow_receiver(Duration::from_millis(1200)).await;
1205 let dir = tempfile::tempdir().unwrap();
1206 let spec = dir.path().join("api.json");
1207 let op = |id: &str| {
1208 serde_json::json!({"post": {
1209 "operationId": id,
1210 "requestBody": {"content": {"application/octet-stream": {"schema": {"type": "string"}}}},
1211 "responses": {"200": {"description": "ok"}}
1212 }})
1213 };
1214 std::fs::write(
1215 &spec,
1216 serde_json::json!({
1217 "openapi": "3.0.3", "info": {"title": "t", "version": "1"},
1218 "paths": {"/a": op("a"), "/b": op("b")}
1219 })
1220 .to_string(),
1221 )
1222 .unwrap();
1223 let mut c = cmd(Some(&url), None);
1224 c.spec = Some(spec);
1225 c.duration = Duration::from_millis(400);
1226 c.overlap_operations = overlap;
1227 let started = std::time::Instant::now();
1228 let (ok, summary) = c.execute_pass(dir.path()).await.unwrap();
1229 assert!(ok);
1230 let first_b = arrivals
1231 .lock()
1232 .unwrap()
1233 .iter()
1234 .find(|(path, _)| path == "/b")
1235 .map(|(_, at)| at.duration_since(started))
1236 .expect("operation /b ran");
1237 (first_b, summary)
1238 }
1239
1240 #[tokio::test]
1241 async fn overlap_operations_starts_the_next_operation_when_the_window_closes() {
1242 let (sequential, seq_summary) = second_operation_start(false).await;
1244 let (overlapped, overlap_summary) = second_operation_start(true).await;
1245 assert!(sequential >= Duration::from_millis(1200), "sequential /b at {sequential:?}");
1246 assert!(overlapped < Duration::from_millis(900), "overlapped /b at {overlapped:?}");
1247
1248 assert_eq!(overlap_summary["overlap_operations"], true);
1249 assert_eq!(seq_summary["overlap_operations"], false);
1250 let wall = overlap_summary["elapsed_seconds"].as_f64().unwrap();
1252 let summed = seq_summary["elapsed_seconds"].as_f64().unwrap();
1253 assert!(wall < summed - 0.5, "overlap {wall}s vs sequential {summed}s");
1254 assert_eq!(overlap_summary["request_timeout_mode"], "auto");
1255 }
1256
1257 #[test]
1258 fn round_records_distinguish_round_time_from_campaign_time() {
1259 let dir = tempfile::tempdir().unwrap();
1260 write_round_record(
1261 dir.path(),
1262 2,
1263 Duration::from_secs(30),
1264 Duration::from_millis(12500),
1265 true,
1266 serde_json::json!({}),
1267 )
1268 .unwrap();
1269 let record: serde_json::Value = serde_json::from_slice(
1270 &std::fs::read(dir.path().join("round-summaries/round_2.json")).unwrap(),
1271 )
1272 .unwrap();
1273 assert_eq!(record["campaign_elapsed_seconds"], 30);
1274 assert_eq!(record["round_elapsed_seconds"], 12.5);
1275 let jsonl: serde_json::Value = serde_json::from_str(
1276 &std::fs::read_to_string(dir.path().join("campaign.jsonl")).unwrap(),
1277 )
1278 .unwrap();
1279 assert_eq!(jsonl, record);
1280 }
1281
1282 #[tokio::test]
1283 async fn rejects_zero_rounds() {
1284 let mut c = cmd(Some("http://127.0.0.1:1"), None);
1285 c.rounds = Some(0);
1286 assert!(c.execute().await.is_err());
1287 }
1288}