1use {
4 crate::{
5 aggregate::{CellKey, stat},
6 graph::{
7 TaggedRun, push_fmt, render_html as render_graph_html,
8 render_web_fragment as render_graph_web_fragment,
9 },
10 numeric::{mebibytes_f64, millis_f64, to_f64},
11 qualification::{
12 RateRecovery, consumer_sample_rate, mean_failover_recovery, mean_rate_recovery,
13 producer_sample_rate,
14 },
15 scenario::{Disturbance, LatencyPercentiles, RunOutput, Stack},
16 },
17 anyhow::{Context, Result},
18 krabka_units::{fmt::Human as _, prelude::*},
19 std::{
20 collections::BTreeMap,
21 fs,
22 path::{Path, PathBuf},
23 },
24};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum Render {
33 Number,
36 Extent,
38 Size,
40 Throughput,
42 Rate,
44}
45
46impl Render {
47 fn mean(self, values: &[f64]) -> String {
49 let m = stat(values).mean;
50 match self {
51 Self::Number => format!("{m:.3}"),
52 Self::Extent => Time::from_secs_f64(m).human().to_string(),
53 Self::Size => ByteSize::from_bytes_f64(m).human().to_string(),
54 Self::Throughput => ByteRate::from_bytes_per_sec_f64(m).human().to_string(),
55 Self::Rate => Frequency::from_per_sec(m).human().to_string(),
56 }
57 }
58
59 fn cell(self, values: &[f64]) -> String {
62 let rendered = self.mean(values);
63 if values.len() > 1 {
64 let cv = stat(values).cv_percent();
65 format!("{rendered} (±{cv:.0}%)")
66 } else {
67 rendered
68 }
69 }
70}
71
72pub fn render_markdown(input_dir: &Path, strict: bool) -> Result<String> {
78 let mut by_group: BTreeMap<CellKey, Vec<RunOutput>> = BTreeMap::new();
85 for RunFile { run, .. } in collect_runs(input_dir, strict)? {
86 by_group.entry(CellKey::of(&run)).or_default().push(run);
87 }
88
89 let mut out = String::new();
90 out.push_str("# Krabka vs Strimzi benchmark — results\n\n");
91 if by_group.is_empty() {
92 out.push_str("_no `RunOutput` JSON files found in input dir_\n");
93 return Ok(out);
94 }
95 out.push_str("Each cell is the **mean across all runs** of a (scenario, topology); `(±N%)` is the coefficient of variation (sample stddev ÷ mean), shown when a cell has more than one run. The `ratio` column is `krabka / kafka` for throughput / efficiency (higher is better for Krabka) and `kafka / krabka` for latency / resource (lower-is-better Krabka still > 1).\n\n");
96
97 for (key, runs) in &by_group {
98 render_group(&mut out, key, runs);
99 }
100
101 Ok(out)
102}
103
104fn render_group(out: &mut String, key: &CellKey, runs: &[RunOutput]) {
105 let krabka: Vec<&RunOutput> = runs
106 .iter()
107 .filter(|r| matches!(r.stack, Stack::Krabka))
108 .collect();
109 let kafka: Vec<&RunOutput> = runs
110 .iter()
111 .filter(|r| matches!(r.stack, Stack::Kafka))
112 .collect();
113
114 let stacks = StackRuns {
115 krabka: &krabka,
116 kafka: &kafka,
117 };
118
119 push_fmt(out, format_args!("## {}\n\n", key.label()));
120
121 if let Some(r) = runs.first() {
122 push_fmt(
123 out,
124 format_args!(
125 "Topology: partitions={}, RF={}, broker_count={} (per stack). Duration={}, warmup={}. Runs averaged: krabka={}, kafka={}.\n\n",
126 r.topology.partitions,
127 r.topology.replication_factor,
128 r.topology.broker_count,
129 r.scenario.duration.human(),
130 r.scenario.warmup.human(),
131 krabka.len(),
132 kafka.len(),
133 ),
134 );
135 }
136
137 let unmeasured = runs
142 .iter()
143 .filter(|r| {
144 r.notes.iter().any(|n| {
145 n == "prometheus-url-not-set"
146 || n.starts_with("prometheus-client-failed:")
147 || n.starts_with("prometheus-capture-failed:")
148 })
149 })
150 .count();
151 if unmeasured > 0 {
152 push_fmt(
153 out,
154 format_args!(
155 "> **Resource rows are incomplete.** {unmeasured} of {} run(s) captured no \
156 broker CPU or memory. Their resource and efficiency figures are zeros and \
157 not measurements.\n\n",
158 runs.len(),
159 ),
160 );
161 }
162
163 render_topline_table(out, stacks);
164
165 render_latency_table(out, "**Producer ack latency:**", stacks, |r| {
167 &r.producer_latency
168 });
169 render_latency_table(out, "**Consumer end-to-end latency:**", stacks, |r| {
170 &r.consumer_e2e_latency
171 });
172
173 let heap: Vec<f64> = kafka
175 .iter()
176 .filter_map(|r| r.resource.jvm_heap_used)
177 .map(ByteSizeExt::bytes_f64)
178 .collect();
179 let nonheap: Vec<f64> = kafka
180 .iter()
181 .filter_map(|r| r.resource.jvm_nonheap_used)
182 .map(ByteSizeExt::bytes_f64)
183 .collect();
184 let pc: Vec<f64> = kafka
185 .iter()
186 .filter_map(|r| r.resource.kafka_page_cache_approx)
187 .map(ByteSizeExt::bytes_f64)
188 .collect();
189 if !heap.is_empty() && !nonheap.is_empty() && !pc.is_empty() {
190 let ws: Vec<f64> = kafka
191 .iter()
192 .map(|r| r.resource.mem_cgroup_working_set.bytes_f64())
193 .collect();
194 out.push_str("**Kafka memory split:**\n\n");
195 push_fmt(
196 out,
197 format_args!(
198 "- JVM heap used: {}\n- JVM non-heap used: {}\n- Page-cache (approx, working-set − heap − non-heap): {}\n- cgroup working-set (limit-relevant): {}\n\n",
199 Render::Size.mean(&heap),
200 Render::Size.mean(&nonheap),
201 Render::Size.mean(&pc),
202 Render::Size.mean(&ws),
203 ),
204 );
205 }
206
207 for (label, stack_runs) in [("krabka", &krabka), ("kafka", &kafka)] {
209 let dists: Vec<&Disturbance> = stack_runs
210 .iter()
211 .filter_map(|r| r.disturbance.as_ref())
212 .collect();
213 if dists.is_empty() {
214 continue;
215 }
216 let recovery: Vec<f64> = dists
217 .iter()
218 .map(|d| d.recovery_at_ms.since(d.kill_at_ms).secs_f64())
219 .collect();
220 let dropped: Vec<f64> = dists.iter().map(|d| to_f64(d.dropped.0)).collect();
221 let spike: Vec<f64> = dists
222 .iter()
223 .map(|d| d.latency_spike_max.secs_f64())
224 .collect();
225 push_fmt(
226 out,
227 format_args!(
228 "**Failover ({label}, n={}):** recovery {}, {:.0} drops, max latency spike {}.\n\n",
229 dists.len(),
230 Render::Extent.mean(&recovery),
231 stat(&dropped).mean,
232 Render::Extent.mean(&spike),
233 ),
234 );
235 }
236 render_failover_comparison(out, &krabka, &kafka);
237
238 render_notes_and_errors(out, &krabka, &kafka);
239 out.push('\n');
240}
241
242fn render_topline_table(out: &mut String, stacks: StackRuns<'_>) {
244 out.push_str("| metric | krabka | kafka | ratio |\n");
246 out.push_str("|---|---|---|---|\n");
247 row_metric(
248 out,
249 "producer msgs/s (higher better)",
250 stacks,
251 RunMetric {
252 value: |t| t.throughput.producer_rate.per_sec_f64(),
253 render: Render::Rate,
254 },
255 true,
256 );
257 row_metric(
258 out,
259 "consumer msgs/s (higher better)",
260 stacks,
261 RunMetric {
262 value: |t| t.throughput.consumer_rate.per_sec_f64(),
263 render: Render::Rate,
264 },
265 true,
266 );
267 row_metric(
268 out,
269 "producer byte rate (higher better)",
270 stacks,
271 RunMetric {
272 value: |t| producer_byte_rate(t).bytes_per_sec_f64(),
273 render: Render::Throughput,
274 },
275 true,
276 );
277 row_metric(
278 out,
279 "p99 producer ack (lower better)",
280 stacks,
281 RunMetric {
282 value: |t| t.producer_latency.p99.secs_f64(),
283 render: Render::Extent,
284 },
285 false,
286 );
287 row_metric(
288 out,
289 "p99 consumer e2e (lower better)",
290 stacks,
291 RunMetric {
292 value: |t| t.consumer_e2e_latency.p99.secs_f64(),
293 render: Render::Extent,
294 },
295 false,
296 );
297 row_metric(
298 out,
299 "msgs/s per CPU-core (higher better)",
300 stacks,
301 RunMetric {
302 value: |t| t.resource.msgs_per_cpu_second.per_sec_f64(),
303 render: Render::Rate,
304 },
305 true,
306 );
307 row_metric(
308 out,
309 "cgroup working set (lower better)",
310 stacks,
311 RunMetric {
312 value: |t| t.resource.mem_cgroup_working_set.bytes_f64(),
313 render: Render::Size,
314 },
315 false,
316 );
317 row_metric(
318 out,
319 "startup (CR-apply → Ready) (lower better)",
320 stacks,
321 RunMetric {
322 value: |t| t.startup.unwrap_or(Time::ZERO).secs_f64(),
323 render: Render::Extent,
324 },
325 false,
326 );
327 row_metric(
328 out,
329 "first ack (Ready → first ack) (lower better)",
330 stacks,
331 RunMetric {
332 value: |t| t.first_ack.secs_f64(),
333 render: Render::Extent,
334 },
335 false,
336 );
337 out.push('\n');
338}
339
340fn render_notes_and_errors(out: &mut String, krabka: &[&RunOutput], kafka: &[&RunOutput]) {
341 for (label, stack_runs) in [("krabka", krabka), ("kafka", kafka)] {
342 let mut notes: Vec<String> = Vec::new();
343 let mut errors: Vec<String> = Vec::new();
344 for r in stack_runs {
345 for n in &r.notes {
346 if !notes.contains(n) {
347 notes.push(n.clone());
348 }
349 }
350 for e in &r.errors {
351 if !errors.contains(e) {
352 errors.push(e.clone());
353 }
354 }
355 }
356 if !notes.is_empty() {
357 push_fmt(
358 out,
359 format_args!("_{label} notes:_ {}\n\n", notes.join(", ")),
360 );
361 }
362 if !errors.is_empty() {
363 push_fmt(
364 out,
365 format_args!("_{label} errors:_ {}\n\n", truncate_list(&errors, 3)),
366 );
367 }
368 }
369}
370
371pub fn load_runs(input_dir: &Path, strict: bool) -> Result<Vec<RunOutput>> {
375 Ok(collect_runs(input_dir, strict)?
376 .into_iter()
377 .map(|file| file.run)
378 .collect())
379}
380
381fn truncate_list(items: &[String], n: usize) -> String {
382 let head: Vec<_> = items.iter().take(n).cloned().collect();
383 let extra = items.len().saturating_sub(n);
384 if extra > 0 {
385 format!("{} (+{extra} more)", head.join("; "))
386 } else {
387 head.join("; ")
388 }
389}
390
391type LatencySelector = fn(&LatencyPercentiles) -> f64;
392
393struct LatencyRow {
395 label: &'static str,
396 value: LatencySelector,
397 render: Render,
398}
399
400#[derive(Clone, Copy)]
402struct StackRuns<'a> {
403 krabka: &'a [&'a RunOutput],
404 kafka: &'a [&'a RunOutput],
405}
406
407#[derive(Clone, Copy)]
409struct RunMetric {
410 value: fn(&RunOutput) -> f64,
411 render: Render,
412}
413
414struct RunFile {
416 path: PathBuf,
417 run: RunOutput,
418}
419
420fn latency_rows() -> [LatencyRow; 7] {
424 let extent = |label, value: LatencySelector| LatencyRow {
425 label,
426 value,
427 render: Render::Extent,
428 };
429 [
430 extent("p50", |p| p.p50.secs_f64()),
431 extent("p95", |p| p.p95.secs_f64()),
432 extent("p99", |p| p.p99.secs_f64()),
433 extent("p99.9", |p| p.p999.secs_f64()),
434 extent("max", |p| p.max.secs_f64()),
435 extent("mean", |p| p.mean.secs_f64()),
436 LatencyRow {
437 label: "count",
438 value: |p| to_f64(p.count),
439 render: Render::Number,
440 },
441 ]
442}
443
444fn render_latency_table(
446 out: &mut String,
447 heading: &str,
448 StackRuns { krabka, kafka }: StackRuns<'_>,
449 select: fn(&RunOutput) -> &LatencyPercentiles,
450) {
451 push_fmt(out, format_args!("{heading}\n\n"));
452 out.push_str("| percentile | krabka | kafka |\n|---|---|---|\n");
453 for LatencyRow {
454 label,
455 value,
456 render,
457 } in latency_rows()
458 {
459 let c: Vec<f64> = krabka.iter().map(|r| value(select(r))).collect();
460 let k: Vec<f64> = kafka.iter().map(|r| value(select(r))).collect();
461 push_fmt(
462 out,
463 format_args!("| {label} | {} | {} |\n", render.cell(&c), render.cell(&k)),
464 );
465 }
466 out.push('\n');
467}
468
469fn producer_byte_rate(r: &RunOutput) -> ByteRate {
471 let window = if r.scenario.duration > Time::ZERO {
472 r.scenario.duration
473 } else {
474 secs(1)
475 };
476 byte_rate(r.throughput.bytes_in, window)
477}
478
479fn render_failover_comparison(out: &mut String, krabka: &[&RunOutput], kafka: &[&RunOutput]) {
480 let c_recovery = mean_failover_recovery(krabka);
481 let k_recovery = mean_failover_recovery(kafka);
482 let (Some(c_recovery), Some(k_recovery)) = (c_recovery, k_recovery) else {
483 return;
484 };
485
486 let verdict = if c_recovery <= k_recovery {
487 "PASS"
488 } else {
489 "FAIL"
490 };
491 let delta = if c_recovery <= k_recovery {
492 k_recovery - c_recovery
493 } else {
494 c_recovery - k_recovery
495 };
496 let faster = if c_recovery <= k_recovery {
497 "faster than"
498 } else {
499 "slower than"
500 };
501 push_fmt(
502 out,
503 format_args!(
504 "**Failover comparison:** {verdict} — Krabka recovered {} {faster} kafka (krabka {}, kafka {}).\n\n",
505 delta.human(),
506 c_recovery.human(),
507 k_recovery.human(),
508 ),
509 );
510
511 let c_producer_rate = mean_rate_recovery(krabka, producer_sample_rate);
512 let k_producer_rate = mean_rate_recovery(kafka, producer_sample_rate);
513 let c_consumer_rate = mean_rate_recovery(krabka, consumer_sample_rate);
514 let k_consumer_rate = mean_rate_recovery(kafka, consumer_sample_rate);
515 if c_producer_rate.is_none()
516 && k_producer_rate.is_none()
517 && c_consumer_rate.is_none()
518 && k_consumer_rate.is_none()
519 {
520 return;
521 }
522
523 out.push_str("| stack | recovery ms | producer baseline msgs/s | producer min after kill msgs/s | producer rate recovery ms | consumer baseline msgs/s | consumer min after kill msgs/s | consumer rate recovery ms |\n");
524 out.push_str("|---|---:|---:|---:|---:|---:|---:|---:|\n");
525 render_rate_recovery_row(out, "krabka", c_recovery, c_producer_rate, c_consumer_rate);
526 render_rate_recovery_row(out, "kafka", k_recovery, k_producer_rate, k_consumer_rate);
527 out.push('\n');
528}
529
530fn render_rate_recovery_row(
531 out: &mut String,
532 stack: &str,
533 failover_recovery: Time,
534 producer_rate: Option<RateRecovery>,
535 consumer_rate: Option<RateRecovery>,
536) {
537 let recovery = |rate: &RateRecovery| {
538 rate.recovery
539 .map_or_else(|| "unrecovered".into(), |t| t.human().to_string())
540 };
541 match (producer_rate, consumer_rate) {
542 (Some(producer_rate), Some(consumer_rate)) => push_fmt(
543 out,
544 format_args!(
545 "| {stack} | {} | {} | {} | {} | {} | {} | {} |\n",
546 failover_recovery.human(),
547 producer_rate.baseline.human(),
548 producer_rate.min_after_kill.human(),
549 recovery(&producer_rate),
550 consumer_rate.baseline.human(),
551 consumer_rate.min_after_kill.human(),
552 recovery(&consumer_rate),
553 ),
554 ),
555 _ => push_fmt(
556 out,
557 format_args!(
558 "| {stack} | {} | n/a | n/a | n/a | n/a | n/a | n/a |\n",
559 failover_recovery.human()
560 ),
561 ),
562 }
563}
564
565fn row_metric(
571 out: &mut String,
572 label: &str,
573 StackRuns { krabka, kafka }: StackRuns<'_>,
574 RunMetric { value, render }: RunMetric,
575 higher_is_better: bool,
576) {
577 let cvals: Vec<f64> = krabka.iter().map(|&r| value(r)).collect();
578 let kvals: Vec<f64> = kafka.iter().map(|&r| value(r)).collect();
579 let c = stat(&cvals).mean;
580 let k = stat(&kvals).mean;
581 let ratio = if higher_is_better {
582 if k > 0.0 {
583 format!("{:.2}×", c / k)
584 } else {
585 "—".into()
586 }
587 } else if c > 0.0 {
588 format!("{:.2}×", k / c)
589 } else {
590 "—".into()
591 };
592 push_fmt(
593 out,
594 format_args!(
595 "| {label} | {} | {} | {ratio} |\n",
596 render.cell(&cvals),
597 render.cell(&kvals)
598 ),
599 );
600}
601
602fn collect_runs(input_dir: &Path, strict: bool) -> Result<Vec<RunFile>> {
607 let mut runs: Vec<RunFile> = Vec::new();
608 let entries =
609 fs::read_dir(input_dir).with_context(|| format!("read_dir {}", input_dir.display()))?;
610 for e in entries {
611 let e = e.context("dir entry")?;
612 let path = e.path();
613 if path.extension().and_then(|s| s.to_str()) != Some("json") {
614 continue;
615 }
616 let body = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
617 match serde_json::from_str::<RunOutput>(&body) {
618 Ok(run) => runs.push(RunFile { path, run }),
619 Err(e) => {
620 if strict {
621 return Err(anyhow::anyhow!("failed to parse {}: {e}", path.display()));
622 }
623 eprintln!("warn: skipping unparseable {}: {e}", path.display());
624 }
625 }
626 }
627 Ok(runs)
628}
629
630fn run_tag_from_path(path: &Path) -> String {
633 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
634 if let Some((_, digits)) = stem.rsplit_once("-run")
635 && !digits.is_empty()
636 && digits.chars().all(|c| c.is_ascii_digit())
637 {
638 return format!("run{digits}");
639 }
640 "single".to_owned()
641}
642
643fn csv_field(s: &str) -> String {
646 if s.contains(',') || s.contains('"') || s.contains('\n') {
647 format!("\"{}\"", s.replace('"', "\"\""))
648 } else {
649 s.to_owned()
650 }
651}
652
653fn collect_csv_runs(input_dir: &Path, strict: bool) -> Result<Vec<RunFile>> {
655 let mut runs = collect_runs(input_dir, strict)?;
656 runs.sort_by(|a, b| {
657 (
658 a.run.scenario.name.as_str(),
659 a.run.topology.broker_count,
660 a.run.stack.as_str(),
661 run_tag_from_path(&a.path),
662 )
663 .cmp(&(
664 b.run.scenario.name.as_str(),
665 b.run.topology.broker_count,
666 b.run.stack.as_str(),
667 run_tag_from_path(&b.path),
668 ))
669 });
670
671 Ok(runs)
672}
673
674pub fn render_csv(input_dir: &Path, strict: bool) -> Result<String> {
680 let runs = collect_csv_runs(input_dir, strict)?;
681 let mut out = String::new();
684 out.push_str(
685 "scenario,stack,run_tag,broker_count,partitions,replication_factor,producers,consumers,\
686msg_size_bytes,acks,mode_tag,duration_s,wallclock_start_unix_ms,\
687msgs_produced,msgs_consumed,mb_in,mb_out,producer_msgs_per_sec,consumer_msgs_per_sec,\
688producer_p50_ms,producer_p95_ms,producer_p99_ms,producer_p999_ms,producer_max_ms,\
689consumer_p50_ms,consumer_p95_ms,consumer_p99_ms,consumer_p999_ms,consumer_max_ms,\
690broker_cpu_seconds,mem_working_set_bytes,msgs_per_cpu_core,\
691jvm_heap_used_bytes,jvm_nonheap_used_bytes,kafka_page_cache_approx_bytes,\
692startup_ms,first_ack_ms,failover_recovery_ms,failover_dropped,failover_latency_spike_max_ms,\
693notes,errors_count\n",
694 );
695 for RunFile { path, run: r } in &runs {
696 let acks = format!("{:?}", r.scenario.acks).to_lowercase();
697 let mode_tag = format!("{:?}", r.scenario.mode_tag).to_lowercase();
698 let (rec, drop, spike) = r.disturbance.as_ref().map_or_else(
699 || (String::new(), String::new(), String::new()),
700 |d| {
701 (
702 d.recovery_at_ms
703 .since(d.kill_at_ms)
704 .millis_i64()
705 .to_string(),
706 d.dropped.to_string(),
707 format!("{:.3}", millis_f64(d.latency_spike_max)),
708 )
709 },
710 );
711 let opt_bytes = |o: Option<ByteSize>| o.map_or_default(|v| v.bytes_i64().to_string());
712 let opt_millis = |o: Option<Time>| o.map_or_default(|v| v.millis_i64().to_string());
713 let p = &r.producer_latency;
714 let c = &r.consumer_e2e_latency;
715 let cols = [
716 csv_field(&r.scenario.name),
717 r.stack.as_str().to_owned(),
718 run_tag_from_path(path),
719 r.topology.broker_count.to_string(),
720 r.topology.partitions.to_string(),
721 r.topology.replication_factor.to_string(),
722 r.scenario.producers.to_string(),
723 r.scenario.consumers.to_string(),
724 r.scenario.msg_size.bytes_u64().to_string(),
725 acks,
726 mode_tag,
727 r.scenario.duration.secs_i64().to_string(),
728 r.wallclock_start_unix_ms.to_string(),
729 r.throughput.msgs_produced.to_string(),
730 r.throughput.msgs_consumed.to_string(),
731 format!("{:.6}", mebibytes_f64(r.throughput.bytes_in)),
732 format!("{:.6}", mebibytes_f64(r.throughput.bytes_out)),
733 format!("{:.3}", r.throughput.producer_rate.per_sec_f64()),
734 format!("{:.3}", r.throughput.consumer_rate.per_sec_f64()),
735 format!("{:.3}", millis_f64(p.p50)),
736 format!("{:.3}", millis_f64(p.p95)),
737 format!("{:.3}", millis_f64(p.p99)),
738 format!("{:.3}", millis_f64(p.p999)),
739 format!("{:.3}", millis_f64(p.max)),
740 format!("{:.3}", millis_f64(c.p50)),
741 format!("{:.3}", millis_f64(c.p95)),
742 format!("{:.3}", millis_f64(c.p99)),
743 format!("{:.3}", millis_f64(c.p999)),
744 format!("{:.3}", millis_f64(c.max)),
745 format!("{:.3}", r.resource.broker_cpu.secs_f64()),
746 r.resource.mem_cgroup_working_set.bytes_u64().to_string(),
747 format!("{:.3}", r.resource.msgs_per_cpu_second.per_sec_f64()),
748 opt_bytes(r.resource.jvm_heap_used),
749 opt_bytes(r.resource.jvm_nonheap_used),
750 opt_bytes(r.resource.kafka_page_cache_approx),
751 opt_millis(r.startup),
752 r.first_ack.millis_i64().to_string(),
753 rec,
754 drop,
755 spike,
756 csv_field(&r.notes.join("; ")),
757 r.errors.len().to_string(),
758 ];
759 out.push_str(&cols.join(","));
760 out.push('\n');
761 }
762 Ok(out)
763}
764
765pub fn render_timeseries_csv(input_dir: &Path, strict: bool) -> Result<String> {
772 let runs = collect_csv_runs(input_dir, strict)?;
773 let mut out = String::new();
774 out.push_str("scenario,stack,broker_count,partitions,replication_factor,run_tag,t_offset_ms,metric,value\n");
775 for RunFile { path, run: r } in &runs {
776 let prefix = format!(
777 "{},{},{},{},{},{}",
778 csv_field(&r.scenario.name),
779 r.stack.as_str(),
780 r.topology.broker_count,
781 r.topology.partitions,
782 r.topology.replication_factor,
783 run_tag_from_path(path),
784 );
785 for s in &r.samples {
786 for (metric, value) in [
787 ("producer_msgs_per_sec", s.producer_rate.per_sec_f64()),
788 ("consumer_msgs_per_sec", s.consumer_rate.per_sec_f64()),
789 ("producer_p50_ms", millis_f64(s.producer_p50)),
790 ("producer_p99_ms", millis_f64(s.producer_p99)),
791 ("consumer_e2e_p99_ms", millis_f64(s.consumer_e2e_p99)),
792 ] {
793 push_fmt(
794 &mut out,
795 format_args!("{prefix},{},{metric},{value:.3}\n", s.t_offset_ms),
796 );
797 }
798 }
799 for b in &r.broker_samples {
800 push_fmt(
801 &mut out,
802 format_args!(
803 "{prefix},{},broker_cpu_cores,{:.4}\n",
804 b.t_offset_ms, b.cpu_cores
805 ),
806 );
807 push_fmt(
808 &mut out,
809 format_args!(
810 "{prefix},{},broker_mem_working_set_bytes,{}\n",
811 b.t_offset_ms,
812 b.mem_working_set.bytes_u64()
813 ),
814 );
815 }
816 }
817 Ok(out)
818}
819
820pub fn render_html(input_dir: &Path, strict: bool, title: &str) -> Result<String> {
828 let outputs: Vec<RunOutput> = collect_runs(input_dir, strict)?
829 .into_iter()
830 .map(|file| file.run)
831 .collect();
832 Ok(render_graph_html(&outputs, title))
833}
834
835pub fn render_web_fragment(input_dir: &Path, strict: bool) -> Result<String> {
843 let tagged: Vec<TaggedRun> = collect_runs(input_dir, strict)?
844 .into_iter()
845 .map(|file| TaggedRun {
846 tag: run_tag_from_path(&file.path),
847 run: file.run,
848 })
849 .collect();
850 Ok(render_graph_web_fragment(&tagged))
851}
852
853#[cfg(test)]
854mod tests {
855 use {
856 super::*,
857 crate::{
858 ids::{MessageCount, TimeOffsetMs, WallclockMs},
859 numeric::{event_rate, nonnegative_i64_to_u64},
860 qualification::failover_gate_violations,
861 scenario::{
862 Disturbance, FailoverSpec, LatencyPercentiles, ModeTag, Resource, Sample,
863 Throughput, Topology,
864 fixture::{scenario, short_scenario},
865 },
866 },
867 assert2::check,
868 tempfile::tempdir,
869 };
870
871 fn fake_run(stack: Stack, msgs: u64) -> RunOutput {
872 RunOutput {
873 scenario: scenario("small-msg-saturate"),
874 stack,
875 topology: Topology {
876 partitions: 6,
877 replication_factor: 1,
878 broker_count: 1,
879 },
880 wallclock_start_unix_ms: WallclockMs(0),
881 wallclock_end_unix_ms: WallclockMs(60_000),
882 throughput: Throughput {
883 msgs_produced: MessageCount(msgs),
884 msgs_consumed: MessageCount(msgs),
885 bytes_in: mebibytes(5),
886 bytes_out: mebibytes(5),
887 producer_rate: event_rate(msgs, secs(60)),
888 consumer_rate: event_rate(msgs, secs(60)),
889 },
890 ..RunOutput::default_placeholder()
891 }
892 }
893
894 fn fake_failover_run(stack: Stack, recovery: Time, post_kill_min: Frequency) -> RunOutput {
895 let mut r = fake_run(stack, 600_000);
896 r.scenario.name = "failover".into();
897 r.scenario.mode_tag = ModeTag::Cluster;
898 r.scenario.partitions = 12;
899 r.scenario.replication_factor = 3;
900 r.scenario.duration = secs(12);
901 r.scenario.warmup = Time::ZERO;
902 r.scenario.failover = Some(FailoverSpec {
903 kill_after: secs(4),
904 target: "partition0_leader".into(),
905 });
906 r.topology.partitions = 12;
907 r.topology.replication_factor = 3;
908 r.topology.broker_count = 3;
909 r.disturbance = Some(Disturbance {
910 kill_at_ms: TimeOffsetMs(4_000),
911 recovery_at_ms: TimeOffsetMs(4_000 + nonnegative_i64_to_u64(recovery.millis_i64())),
912 dropped: MessageCount(0),
913 latency_spike_max: millis(42),
914 });
915 r.samples = vec![
916 Sample {
917 t_offset_ms: TimeOffsetMs(0),
918 producer_rate: per_sec(10_000),
919 consumer_rate: per_sec(9_800),
920 ..Sample::default()
921 },
922 Sample {
923 t_offset_ms: TimeOffsetMs(2_000),
924 producer_rate: per_sec(10_200),
925 consumer_rate: per_sec(9_900),
926 ..Sample::default()
927 },
928 Sample {
929 t_offset_ms: TimeOffsetMs(4_000),
930 producer_rate: post_kill_min,
931 consumer_rate: post_kill_min * 0.9,
932 ..Sample::default()
933 },
934 Sample {
935 t_offset_ms: TimeOffsetMs(6_000),
936 producer_rate: per_sec(9_500),
937 consumer_rate: per_sec(9_200),
938 ..Sample::default()
939 },
940 ];
941 r
942 }
943
944 impl RunOutput {
946 fn default_placeholder() -> Self {
947 Self {
948 scenario: short_scenario(1),
949 stack: Stack::Krabka,
950 topology: Topology {
951 partitions: 1,
952 replication_factor: 1,
953 broker_count: 1,
954 },
955 wallclock_start_unix_ms: WallclockMs(0),
956 wallclock_end_unix_ms: WallclockMs(0),
957 throughput: Throughput::default(),
958 resource: Resource::default(),
959 disturbance: None,
960 startup: None,
961 first_ack: Time::ZERO,
962 producer_latency: LatencyPercentiles::default(),
963 consumer_e2e_latency: LatencyPercentiles::default(),
964 errors: Vec::new(),
965 notes: Vec::new(),
966 samples: Vec::new(),
967 broker_samples: Vec::new(),
968 }
969 }
970 }
971
972 #[test]
973 fn renders_a_simple_pair() {
974 let dir = tempdir().unwrap();
975 let krabka = fake_run(Stack::Krabka, 600_000);
976 let kafka = fake_run(Stack::Kafka, 400_000);
977 write_run(dir.path().join("krabka.json"), &krabka);
978 write_run(dir.path().join("kafka.json"), &kafka);
979 let md = render_markdown(dir.path(), true).unwrap();
980 for needle in ["small-msg-saturate", "producer msgs/s", "1.50×"] {
982 assert2::assert!(md.contains(needle));
983 }
984 }
985
986 #[test]
987 fn time_series_failure_is_reported_without_marking_scalar_resource_rows_missing() {
988 let dir = tempdir().unwrap();
989 let mut krabka = fake_run(Stack::Krabka, 600_000);
990 krabka
991 .notes
992 .push("prometheus-resource-series-failed: timestamps are not aligned".into());
993 write_run(dir.path().join("krabka.json"), &krabka);
994 write_run(
995 dir.path().join("kafka.json"),
996 &fake_run(Stack::Kafka, 400_000),
997 );
998
999 let md = render_markdown(dir.path(), true).unwrap();
1000
1001 assert2::assert!(md.contains("prometheus-resource-series-failed"));
1002 assert2::assert!(!md.contains("Resource rows are incomplete"));
1003 }
1004
1005 #[test]
1006 fn averages_multiple_runs_per_cell() {
1007 let dir = tempdir().unwrap();
1008 for (i, msgs) in [600_000u64, 700_000, 800_000].iter().enumerate() {
1012 write_run(
1013 dir.path().join(format!("krabka-run{i}.json")),
1014 &fake_run(Stack::Krabka, *msgs),
1015 );
1016 }
1017 for i in 0..3 {
1018 write_run(
1019 dir.path().join(format!("kafka-run{i}.json")),
1020 &fake_run(Stack::Kafka, 400_000),
1021 );
1022 }
1023 let md = render_markdown(dir.path(), true).unwrap();
1024 for needle in ["1.75×", "Runs averaged: krabka=3, kafka=3", "±"] {
1026 assert2::assert!(md.contains(needle));
1027 }
1028 }
1029
1030 #[test]
1031 fn handles_empty_dir() {
1032 let dir = tempdir().unwrap();
1033 let md = render_markdown(dir.path(), false).unwrap();
1034 assert2::assert!(md.contains("no `RunOutput` JSON files found"));
1035 }
1036
1037 #[test]
1038 fn markdown_cells_use_shared_sample_statistics() {
1039 for (values, expected) in [
1040 (&[][..], "0.000"),
1041 (&[2.0][..], "2.000"),
1042 (&[1.0, 3.0][..], "2.000 (±71%)"),
1043 (&[0.0, 0.0][..], "0.000 (±0%)"),
1044 ] {
1045 assert2::assert!(Render::Number.cell(values) == expected);
1046 }
1047 }
1048
1049 #[test]
1050 fn failover_summary_compares_recovery_and_rate_over_time() {
1051 let dir = tempdir().unwrap();
1052 write_default_failover_runs(dir.path());
1053
1054 let md = render_markdown(dir.path(), true).unwrap();
1055
1056 for needle in [
1057 "**Failover comparison:** PASS",
1058 "Krabka recovered 1s faster than kafka",
1059 "| stack | recovery ms | producer baseline msgs/s | producer min after kill msgs/s | producer rate recovery ms | consumer baseline msgs/s | consumer min after kill msgs/s | consumer rate recovery ms |",
1060 "| krabka | 2s | 10100/s | 8000/s | 2s | 9850/s | 7200/s | 2s |",
1061 "| kafka | 3s | 10100/s | 6000/s | 2s | 9850/s | 5400/s | 2s |",
1062 ] {
1063 assert2::assert!(md.contains(needle));
1064 }
1065 }
1066
1067 #[test]
1068 fn failover_gate_passes_when_krabka_recovers_no_slower_with_rate_samples() {
1069 let runs = [
1070 fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000)),
1071 fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000)),
1072 ];
1073 let violations = failover_gate_violations(&runs);
1074
1075 assert2::assert!(violations.is_empty());
1076 }
1077
1078 fn write_default_failover_runs(dir: &Path) {
1079 write_failover_runs(
1080 dir,
1081 &fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000)),
1082 &fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000)),
1083 );
1084 }
1085
1086 fn check_failover_violation(dir: &Path, krabka: &RunOutput, expected: &str) {
1087 check_failover_runs(
1088 dir,
1089 krabka,
1090 &fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000)),
1091 &[expected],
1092 );
1093 }
1094
1095 fn check_failover_runs(dir: &Path, krabka: &RunOutput, kafka: &RunOutput, expected: &[&str]) {
1096 write_failover_runs(dir, krabka, kafka);
1097 let violations = failover_gate_violations(&load_runs(dir, true).unwrap());
1098 for expected in expected {
1099 assert_violation_contains(&violations, expected);
1100 }
1101 }
1102
1103 fn assert_violation_contains(violations: &[String], expected: &str) {
1104 assert2::assert!(
1105 violations
1106 .iter()
1107 .any(|violation| violation.contains(expected)),
1108 "{expected}: {violations:?}"
1109 );
1110 }
1111
1112 fn write_run(path: impl AsRef<Path>, run: &RunOutput) {
1113 fs::write(path, serde_json::to_string(run).unwrap()).unwrap();
1114 }
1115
1116 fn write_failover_runs(dir: &Path, krabka: &RunOutput, kafka: &RunOutput) {
1117 for (name, run) in [("krabka", krabka), ("kafka", kafka)] {
1118 write_run(
1119 dir.join(format!("{name}-failover-3broker-rf3-run01.json")),
1120 run,
1121 );
1122 }
1123 }
1124
1125 fn write_failover_pair(dir: &Path, krabka: &RunOutput) {
1128 write_failover_runs(
1129 dir,
1130 krabka,
1131 &fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000)),
1132 );
1133 }
1134
1135 fn check_failover_pair(dir: &Path, krabka: &RunOutput, expected: impl Fn(&String) -> bool) {
1136 write_failover_pair(dir, krabka);
1137 let violations = failover_gate_violations(&load_runs(dir, true).unwrap());
1138 assert2::assert!(violations.iter().any(expected), "{violations:?}");
1139 }
1140
1141 #[test]
1145 fn failover_gate_rejects_a_run_that_reported_errors() {
1146 let dir = tempdir().unwrap();
1147 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1148 krabka.errors = vec!["producer-0-build: connection refused".into()];
1149 check_failover_pair(dir.path(), &krabka, |v| {
1150 v.contains("krabka failover run is not a measurement") && v.contains("reported errors")
1151 });
1152 }
1153
1154 #[test]
1158 fn failover_gate_rejects_a_recovery_before_the_kill() {
1159 let dir = tempdir().unwrap();
1160 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1161 if let Some(disturbance) = krabka.disturbance.as_mut() {
1162 disturbance.recovery_at_ms = TimeOffsetMs(3_000);
1163 }
1164 check_failover_pair(dir.path(), &krabka, |v| {
1165 v.contains("is before the kill stamp")
1166 });
1167 }
1168
1169 #[test]
1172 fn failover_gate_rejects_a_run_with_no_traffic() {
1173 let dir = tempdir().unwrap();
1174 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1175 krabka.throughput.msgs_produced = MessageCount(0);
1176 krabka.throughput.producer_rate = Frequency::ZERO;
1177 check_failover_pair(dir.path(), &krabka, |v| v.contains("produced no messages"));
1178 }
1179
1180 #[test]
1182 fn failover_gate_rejects_a_run_with_no_recorded_kill() {
1183 let dir = tempdir().unwrap();
1184 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1185 krabka.disturbance = None;
1186 check_failover_pair(dir.path(), &krabka, |v| {
1187 v.contains("recorded no disturbance")
1188 });
1189 }
1190
1191 #[test]
1192 fn failover_gate_fails_without_failover_results() {
1193 let dir = tempdir().unwrap();
1194 write_run(
1195 dir.path()
1196 .join("krabka-small-msg-saturate-3broker-rf3-run01.json"),
1197 &fake_run(Stack::Krabka, 600_000),
1198 );
1199 write_run(
1200 dir.path()
1201 .join("kafka-small-msg-saturate-3broker-rf3-run01.json"),
1202 &fake_run(Stack::Kafka, 400_000),
1203 );
1204
1205 let violations = failover_gate_violations(&load_runs(dir.path(), true).unwrap());
1206
1207 assert_violation_contains(&violations, "missing failover results");
1208 }
1209
1210 #[test]
1211 fn failover_gate_does_not_compare_different_topologies() {
1212 let dir = tempdir().unwrap();
1213 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1214 krabka.topology.partitions = 12;
1215 krabka.topology.replication_factor = 3;
1216 let mut kafka = fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000));
1217 kafka.topology.partitions = 24;
1218 kafka.topology.replication_factor = 3;
1219 write_run(
1220 dir.path().join("krabka-failover-3broker-rf3-run01.json"),
1221 &krabka,
1222 );
1223 write_run(
1224 dir.path().join("kafka-failover-3broker-rf3-24p-run01.json"),
1225 &kafka,
1226 );
1227
1228 let violations = failover_gate_violations(&load_runs(dir.path(), true).unwrap());
1229
1230 assert_violation_contains(
1231 &violations,
1232 "failover @ 3 broker(s), 12 partitions, RF=3: missing kafka failover disturbance result",
1233 );
1234 assert_violation_contains(
1235 &violations,
1236 "failover @ 3 broker(s), 24 partitions, RF=3: missing Krabka failover disturbance result",
1237 );
1238 }
1239
1240 #[test]
1241 fn failover_gate_fails_when_krabka_recovers_slower_or_rate_samples_missing() {
1242 let dir = tempdir().unwrap();
1243 let mut kafka = fake_failover_run(Stack::Kafka, secs(2), per_sec(6_000));
1244 kafka.samples.clear();
1245 check_failover_runs(
1246 dir.path(),
1247 &fake_failover_run(Stack::Krabka, secs(4), per_sec(8_000)),
1248 &kafka,
1249 &[
1250 "Krabka recovery 4s is slower than kafka 2s",
1251 "kafka failover run is missing message-rate samples",
1252 ],
1253 );
1254 }
1255
1256 fn rate_recovery_tail() -> Sample {
1257 Sample {
1258 t_offset_ms: TimeOffsetMs(8_000),
1259 producer_rate: per_sec(9_500),
1260 consumer_rate: per_sec(9_200),
1261 ..Sample::default()
1262 }
1263 }
1264
1265 fn check_slow_rate_recovery(mut krabka: RunOutput, expected: &str) {
1266 let dir = tempdir().unwrap();
1267 krabka.samples.push(rate_recovery_tail());
1268 check_failover_violation(dir.path(), &krabka, expected);
1269 }
1270
1271 #[test]
1272 fn failover_gate_fails_when_krabka_message_rate_recovers_slower_than_kafka() {
1273 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1274 krabka.samples[3].producer_rate = per_sec(8500);
1275 check_slow_rate_recovery(
1276 krabka,
1277 "Krabka producer rate recovery 4s is slower than kafka 2s",
1278 );
1279 }
1280
1281 #[test]
1282 fn failover_gate_fails_when_krabka_message_rate_never_recovers() {
1283 let dir = tempdir().unwrap();
1284 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1285 let mut kafka = fake_failover_run(Stack::Kafka, secs(3), per_sec(6_000));
1286 let set_slow_rate = |run: &mut RunOutput| {
1287 for sample in run
1288 .samples
1289 .iter_mut()
1290 .filter(|sample| sample.t_offset_ms >= 4_000)
1291 {
1292 sample.producer_rate = per_sec(8_500);
1293 }
1294 };
1295 set_slow_rate(&mut krabka);
1296 set_slow_rate(&mut kafka);
1297 write_failover_runs(dir.path(), &krabka, &kafka);
1298
1299 let violations = failover_gate_violations(&load_runs(dir.path(), true).unwrap());
1300
1301 assert_violation_contains(&violations, "Krabka producer rate did not recover");
1302 }
1303
1304 #[test]
1305 fn failover_gate_fails_when_krabka_consumer_rate_recovers_slower_than_kafka() {
1306 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1307 krabka.samples[3].consumer_rate = per_sec(8000);
1308 check_slow_rate_recovery(
1309 krabka,
1310 "Krabka consumer rate recovery 4s is slower than kafka 2s",
1311 );
1312 }
1313
1314 #[test]
1315 fn failover_gate_rejects_worse_drops_and_latency_than_kafka() {
1316 let rows = [
1317 (
1318 MessageCount(5),
1319 millis(42),
1320 "Krabka dropped 5 messages vs kafka 0",
1321 ),
1322 (
1323 MessageCount(0),
1324 millis(90),
1325 "Krabka latency spike 90ms is higher than kafka 42ms",
1326 ),
1327 ];
1328 for (dropped, latency, expected) in rows {
1329 let dir = tempdir().unwrap();
1330 let mut krabka = fake_failover_run(Stack::Krabka, secs(2), per_sec(8_000));
1331 let disturbance = krabka.disturbance.as_mut().unwrap();
1332 disturbance.dropped = dropped;
1333 disturbance.latency_spike_max = latency;
1334 check_failover_violation(dir.path(), &krabka, expected);
1335 }
1336 }
1337
1338 fn write_comparison_runs(dir: &Path) {
1339 write_run(
1340 dir.join("krabka-small-msg-saturate-6broker-rf3-run01.json"),
1341 &fake_run(Stack::Krabka, 600_000),
1342 );
1343 write_run(
1344 dir.join("kafka-small-msg-saturate-6broker-rf3-run01.json"),
1345 &fake_run(Stack::Kafka, 400_000),
1346 );
1347 }
1348
1349 #[test]
1350 fn html_report_loads_runs_and_embeds_plotly() {
1351 let dir = tempdir().unwrap();
1352 write_comparison_runs(dir.path());
1353 let html = render_html(dir.path(), true, "Bench").unwrap();
1354 for needle in [
1355 "<html",
1356 "Bench",
1357 "small-msg-saturate",
1358 "Producer throughput",
1359 ] {
1360 assert2::assert!(html.contains(needle));
1361 }
1362 assert2::assert!(!html.contains("<script src=\"https://cdn.plot.ly"));
1369 assert2::assert!(html.contains("Plotly.newPlot"));
1370 assert2::assert!(html.len() > 1_000_000, "page is {} bytes", html.len());
1373 }
1374
1375 #[test]
1376 fn web_fragment_loads_runs_with_tags() {
1377 let dir = tempdir().unwrap();
1378 write_comparison_runs(dir.path());
1379 let frag = render_web_fragment(dir.path(), true).unwrap();
1380 for (needle, want) in [
1384 ("<html", false),
1385 ("cdn.plot.ly/plotly-3.0.1", true),
1386 ("Per run", true),
1387 ("small-msg-saturate", true),
1388 ] {
1389 assert2::assert!(frag.contains(needle) == want);
1390 }
1391 }
1392
1393 #[test]
1394 fn summary_csv_has_header_and_one_row_per_run() {
1395 let dir = tempdir().unwrap();
1396 write_run(
1397 dir.path()
1398 .join("krabka-small-msg-saturate-6broker-rf3-run01.json"),
1399 &fake_run(Stack::Krabka, 600_000),
1400 );
1401 let csv = render_csv(dir.path(), true).unwrap();
1402 let lines: Vec<&str> = csv.lines().collect();
1403 assert2::assert!(lines.len() == 2); check!(lines[0].starts_with("scenario,stack,run_tag,"));
1405 check!(lines[1].contains(",run01,"));
1407 check!(lines[1].starts_with("small-msg-saturate,krabka,"));
1408 }
1409
1410 #[test]
1411 fn timeseries_csv_emits_long_rows_per_sample() {
1412 use crate::scenario::{BrokerSample, Sample};
1413 let dir = tempdir().unwrap();
1414 let mut r = fake_run(Stack::Krabka, 600_000);
1415 r.samples = vec![
1416 Sample {
1417 t_offset_ms: TimeOffsetMs(0),
1418 producer_rate: per_sec(1000),
1419 consumer_rate: per_sec(900),
1420 producer_p50: micros(1500),
1421 producer_p99: micros(4200),
1422 consumer_e2e_p99: millis(7),
1423 },
1424 Sample {
1425 t_offset_ms: TimeOffsetMs(2000),
1426 producer_rate: per_sec(1100),
1427 consumer_rate: per_sec(950),
1428 producer_p50: micros(1600),
1429 producer_p99: micros(4500),
1430 consumer_e2e_p99: micros(7500),
1431 },
1432 ];
1433 r.broker_samples = vec![BrokerSample {
1434 t_offset_ms: TimeOffsetMs(0),
1435 cpu_cores: 2.5,
1436 mem_working_set: mebibytes(1),
1437 }];
1438 write_run(dir.path().join("krabka-x-6broker-rf3-run03.json"), &r);
1439 let csv = render_timeseries_csv(dir.path(), true).unwrap();
1440 assert2::assert!(
1441 csv.lines()
1442 .next()
1443 .unwrap()
1444 .ends_with("t_offset_ms,metric,value")
1445 );
1446 assert2::assert!(csv.lines().count() == 1 + 12);
1448 for needle in [
1449 ",run03,0,producer_msgs_per_sec,1000.000",
1450 ",run03,0,broker_cpu_cores,2.5000",
1451 ",run03,2000,producer_p99_ms,4.500",
1452 ] {
1453 assert2::assert!(csv.contains(needle));
1454 }
1455 }
1456}