Skip to main content

krabka_bench_driver/
scenario.rs

1//! `Scenario` is the on-disk YAML schema the driver reads. `RunOutput` is
2//! the on-disk JSON schema the driver writes. The report aggregator reads
3//! `RunOutput` documents and writes Markdown.
4//!
5//! # Encoding of dimensioned fields
6//!
7//! Every size, extent, and rate here is a [`krabka_units`] quantity, and each
8//! carries an explicit `#[serde(with = ...)]` so the file never holds a bare
9//! base-unit float. The scenario an operator writes uses the human form
10//! (`msg_size: 1KiB`, `linger: 5ms`, `rate: 20000/s`), which refuses a bare
11//! number. A guess about whether `5` is seconds or milliseconds is the mistake
12//! the types prevent. The measured output uses the exact integer form,
13//! nanoseconds for latencies and bytes for sizes, so the numbers a report
14//! compares or plots survive the round trip unrounded.
15//!
16//! Epoch timestamps (`wallclock_*_unix_ms`, `Disturbance::kill_at_ms`) stay raw
17//! integers, because they are coordinates and not magnitudes.
18//!
19//! The private `bounded` module below also range-checks every input magnitude
20//! as the driver reads it, so a scenario file that asks for something
21//! unrunnable fails at load and not at the far end of the driver.
22
23use {
24    crate::ids::{MessageCount, TimeOffsetMs, WallclockMs},
25    krabka_units::{prelude::*, serde_units},
26    serde::{Deserialize, Serialize},
27};
28
29/// `#[serde(with = ...)]` adapters that bound an operator-written magnitude.
30///
31/// The human forms accept a signed magnitude, so `-1B` reads as minus one byte.
32/// The `usize`/`u64` fields they replaced could not be negative at all. Without a
33/// check, a negative size deserializes cleanly and then saturates to zero deep
34/// inside the driver. `payload::template` would emit header-sized records for a
35/// scenario that asks for `-1B`, and the report would name the run after a
36/// benchmark it did not perform. A bound on the read path turns that into a load
37/// failure that names the field.
38///
39/// Whether zero is admissible is a per-field question, so each field picks the
40/// adapter that matches it. A keyless record (`key_size: 0`) and an unbuffered
41/// producer (`linger: 0`) are runnable. A zero-length measurement window or a
42/// zero-byte record is not.
43pub(crate) mod bounded {
44
45    krabka_macros::bounded_unit_modules! {
46    /// A size that must be positive: a record or a producer batch.
47     pub mod positive_size: ByteSize [codec = krabka_units::serde_units::human::byte_size, bound = "positive", requirement = "a positive size"];
48
49    /// A size that may be zero: a key length, where zero means keyless.
50     pub mod nonnegative_size: ByteSize [codec = krabka_units::serde_units::human::byte_size, bound = "nonnegative", requirement = "a size of zero or more"];
51
52    /// An extent that must be positive: a measurement window.
53     pub mod positive_time: Time [codec = krabka_units::serde_units::human::time, bound = "positive", requirement = "a positive extent"];
54
55    /// An extent that may be zero: a warmup that is skipped, a producer that
56            /// does not linger, a kill scheduled at the very start of the run.
57     pub mod nonnegative_time: Time [codec = krabka_units::serde_units::human::time, bound = "nonnegative", requirement = "an extent of zero or more"];
58
59    /// An event rate that must be positive. A paced producer that asks for
60            /// no messages at all never sends one, and no scenario means that.
61     pub mod positive_rate: Frequency [codec = krabka_units::serde_units::human::frequency, bound = "positive", requirement = "a positive rate"];
62    }
63}
64
65/// Which Kafka stack the scenario runs against. This is metadata only. The
66/// driver's client behaviour is the same for both, because Krabka's
67/// wire-compatible client speaks to either broker.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
69#[serde(rename_all = "lowercase")]
70pub enum Stack {
71    Krabka,
72    Kafka,
73}
74
75impl Stack {
76    /// The lowercase name the stack goes by in reports, matching its serde
77    /// and command-line spelling.
78    #[must_use]
79    pub fn as_str(self) -> &'static str {
80        match self {
81            Self::Krabka => "krabka",
82            Self::Kafka => "kafka",
83        }
84    }
85
86    /// Pod-name regex that `prom.rs` uses to pick out the right brokers.
87    /// Both regexes match the `StatefulSet` names that each operator
88    /// creates with cluster name `demo`.
89    #[must_use]
90    pub fn broker_pod_regex(self) -> &'static str {
91        match self {
92            // Krabka StatefulSets are `<kafka>-<nodepool>` per the operator.
93            // `^demo-broker` is a literal prefix common to both the e2e
94            // single-pool naming (`demo-brokers-0`, pool `brokers`) and the
95            // multi-pool bench topology (`demo-broker-0-0`, pools `broker-0/1/2`)
96            // — `failover.rs` uses it via `starts_with`, `prom.rs` as a regex.
97            Self::Krabka => "^demo-broker",
98            // Strimzi StatefulSets are `<kafka>-<pool>` with pool typically
99            // `kafka` for the broker pool.
100            Self::Kafka => "^demo-kafka-",
101        }
102    }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum ModeTag {
108    /// Lightweight scenarios suitable for `KinD` in CI.
109    Ci,
110    /// Heavier scenarios reserved for real Kubernetes clusters.
111    Cluster,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum Acks {
117    None,
118    Leader,
119    All,
120}
121
122impl Acks {
123    pub(crate) fn into_producer(self) -> krabka_client_producer::Acks {
124        match self {
125            Self::None => krabka_client_producer::Acks::Zero,
126            Self::Leader => krabka_client_producer::Acks::One,
127            Self::All => krabka_client_producer::Acks::All,
128        }
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "lowercase")]
134#[derive(Default)]
135pub enum Compression {
136    #[default]
137    None,
138    Gzip,
139    Snappy,
140    Lz4,
141    Zstd,
142}
143
144impl Compression {
145    pub(crate) fn into_producer(self) -> krabka_client_producer::Compression {
146        match self {
147            Self::None => krabka_client_producer::Compression::None,
148            Self::Gzip => krabka_client_producer::Compression::Gzip,
149            Self::Snappy => krabka_client_producer::Compression::Snappy,
150            Self::Lz4 => krabka_client_producer::Compression::Lz4,
151            Self::Zstd => krabka_client_producer::Compression::Zstd,
152        }
153    }
154}
155
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157#[serde(tag = "kind", rename_all = "snake_case")]
158pub enum LoadMode {
159    /// Producers run flat-out and backpressure naturally.
160    Saturate,
161    /// Producers are paced by a token bucket at exactly this rate, written as
162    /// an event rate (`20000/s`).
163    FixedRate {
164        #[serde(with = "bounded::positive_rate")]
165        rate: Frequency,
166    },
167}
168
169/// Inject a broker kill mid-scenario to measure failover behaviour.
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct FailoverSpec {
173    /// How far into the scenario the driver deletes the target broker pod
174    /// (`4s`). Zero kills at the start of warmup, which is extreme but runnable.
175    #[serde(with = "bounded::nonnegative_time")]
176    pub kill_after: Time,
177    /// Which broker to kill. `partition0_leader` picks the broker that hosts
178    /// partition 0's leader. `any_broker` picks the first matching pod. Only
179    /// `partition0_leader` is wired today.
180    #[serde(default = "default_failover_target")]
181    pub target: String,
182}
183
184fn default_failover_target() -> String { "partition0_leader".to_owned() }
185
186/// The scenario configuration. The driver loads it from YAML and
187/// echoes it back into `RunOutput.scenario` for the report.
188///
189/// This struct rejects unknown keys. Almost every field has a default, so
190/// without that rejection a stale or misspelled key is not an error. Serde would
191/// ignore the key and substitute the default, and the driver would run a
192/// *different* benchmark from the one the file describes and label the results
193/// with the file's name. A scenario written as `msg_size_bytes: 102400` before
194/// sizes carried units would have run at the 1 KiB default and been reported as
195/// a 100 KiB benchmark.
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct Scenario {
199    pub name: String,
200    #[serde(default = "default_mode_tag")]
201    pub mode_tag: ModeTag,
202    /// Record value size on the wire (`1KiB`). A zero-byte record measures
203    /// nothing, so this field rejects it and does not floor it at the payload
204    /// header.
205    #[serde(default = "default_msg_size", with = "bounded::positive_size")]
206    pub msg_size: ByteSize,
207    /// Record key size (`0` for keyless records).
208    #[serde(default, with = "bounded::nonnegative_size")]
209    pub key_size: ByteSize,
210    #[serde(default = "default_partitions")]
211    pub partitions: i32,
212    #[serde(default = "default_replicas")]
213    pub replication_factor: i16,
214    #[serde(default = "default_producers")]
215    pub producers: usize,
216    #[serde(default = "default_consumers")]
217    pub consumers: usize,
218    pub mode: LoadMode,
219    #[serde(default = "default_acks")]
220    pub acks: Acks,
221    #[serde(default)]
222    pub compression: Compression,
223    /// How long the producer holds a partial batch before sending (`5ms`). Zero
224    /// means send as soon as a record arrives.
225    #[serde(default = "default_linger", with = "bounded::nonnegative_time")]
226    pub linger: Time,
227    /// Producer batch size (`16KiB`).
228    #[serde(default = "default_batch_size", with = "bounded::positive_size")]
229    pub batch_size: ByteSize,
230    /// Length of the measurement window (`60s`). A zero-length window measures
231    /// nothing, so this field rejects it.
232    #[serde(default = "default_duration", with = "bounded::positive_time")]
233    pub duration: Time,
234    /// Length of the discarded warmup window before it (`10s`). Zero skips
235    /// warmup and measures from the first record.
236    #[serde(default = "default_warmup", with = "bounded::nonnegative_time")]
237    pub warmup: Time,
238    #[serde(default)]
239    pub failover: Option<FailoverSpec>,
240}
241
242fn default_mode_tag() -> ModeTag { ModeTag::Ci }
243fn default_msg_size() -> ByteSize { kibibytes(1) }
244fn default_partitions() -> i32 { 6 }
245fn default_replicas() -> i16 { 1 }
246fn default_producers() -> usize { 1 }
247fn default_consumers() -> usize { 1 }
248fn default_acks() -> Acks { Acks::Leader }
249fn default_linger() -> Time { millis(5) }
250fn default_batch_size() -> ByteSize { kibibytes(16) }
251fn default_duration() -> Time { secs(60) }
252fn default_warmup() -> Time { secs(10) }
253
254// ── Output schema ───────────────────────────────────────────────────────────
255
256/// Latency percentiles as measured extents, encoded as whole nanoseconds.
257///
258/// The driver records latencies at microsecond resolution, and the report
259/// renders them in milliseconds to three decimal places. A millisecond integer
260/// would round both away.
261#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
262pub struct LatencyPercentiles {
263    #[serde(with = "serde_units::numeric::nanos_i64")]
264    pub p50: Time,
265    #[serde(with = "serde_units::numeric::nanos_i64")]
266    pub p95: Time,
267    #[serde(with = "serde_units::numeric::nanos_i64")]
268    pub p99: Time,
269    #[serde(with = "serde_units::numeric::nanos_i64")]
270    pub p999: Time,
271    #[serde(with = "serde_units::numeric::nanos_i64")]
272    pub max: Time,
273    #[serde(with = "serde_units::numeric::nanos_i64")]
274    pub mean: Time,
275    pub count: u64,
276}
277
278#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
279pub struct Throughput {
280    pub msgs_produced: MessageCount,
281    pub msgs_consumed: MessageCount,
282    /// Total record bytes produced over the measurement window.
283    #[serde(with = "serde_units::numeric::bytes_u64")]
284    pub bytes_in: ByteSize,
285    /// Total record bytes consumed over the measurement window.
286    #[serde(with = "serde_units::numeric::bytes_u64")]
287    pub bytes_out: ByteSize,
288    #[serde(with = "serde_units::human::frequency")]
289    pub producer_rate: Frequency,
290    #[serde(with = "serde_units::human::frequency")]
291    pub consumer_rate: Frequency,
292}
293
294#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
295pub struct Resource {
296    /// CPU time burned by the broker pods over the measurement window.
297    #[serde(with = "serde_units::numeric::nanos_i64")]
298    pub broker_cpu: Time,
299    #[serde(with = "serde_units::numeric::bytes_u64")]
300    pub mem_cgroup_working_set: ByteSize,
301    /// JVM heap used, summed across broker pods. Strimzi only.
302    #[serde(
303        default,
304        skip_serializing_if = "Option::is_none",
305        with = "serde_units::numeric::option_bytes_u64"
306    )]
307    pub jvm_heap_used: Option<ByteSize>,
308    /// JVM non-heap used. Strimzi only.
309    #[serde(
310        default,
311        skip_serializing_if = "Option::is_none",
312        with = "serde_units::numeric::option_bytes_u64"
313    )]
314    pub jvm_nonheap_used: Option<ByteSize>,
315    /// The cgroup working set minus JVM heap and non-heap. This is an
316    /// approximation of the page-cache footprint on the broker pod. Strimzi
317    /// only.
318    #[serde(
319        default,
320        skip_serializing_if = "Option::is_none",
321        with = "serde_units::numeric::option_bytes_i64"
322    )]
323    pub kafka_page_cache_approx: Option<ByteSize>,
324    /// Messages produced per second of broker CPU time.
325    #[serde(with = "serde_units::human::frequency")]
326    pub msgs_per_cpu_second: Frequency,
327}
328
329#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
330pub struct Disturbance {
331    /// Unix-epoch millisecond at which the driver deleted the broker pod. This
332    /// is an instant, so it stays a raw stamp.
333    pub kill_at_ms: TimeOffsetMs,
334    /// Unix-epoch millisecond of the first ack after the kill.
335    pub recovery_at_ms: TimeOffsetMs,
336    pub dropped: MessageCount,
337    #[serde(with = "serde_units::numeric::nanos_i64")]
338    pub latency_spike_max: Time,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct Topology {
343    pub partitions: i32,
344    pub replication_factor: i16,
345    pub broker_count: u32,
346}
347
348/// One time-series sample of client-side throughput and latency over a fixed
349/// interval of the measurement window.
350///
351/// The default interval is 2s. This lets the report graph values *over the
352/// test* and not only end-of-run aggregates. The latency percentiles cover this
353/// window only and are not cumulative, so a latency-vs-time curve shows real
354/// movement.
355#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
356pub struct Sample {
357    /// Milliseconds since the measurement window started.
358    pub t_offset_ms: TimeOffsetMs,
359    #[serde(with = "serde_units::human::frequency")]
360    pub producer_rate: Frequency,
361    #[serde(with = "serde_units::human::frequency")]
362    pub consumer_rate: Frequency,
363    /// Interval producer-ack latency (this window only).
364    #[serde(with = "serde_units::numeric::nanos_i64")]
365    pub producer_p50: Time,
366    #[serde(with = "serde_units::numeric::nanos_i64")]
367    pub producer_p99: Time,
368    /// Interval consumer end-to-end p99 latency (this window only).
369    #[serde(with = "serde_units::numeric::nanos_i64")]
370    pub consumer_e2e_p99: Time,
371}
372
373/// One time-series sample of broker resource usage, scraped from Prometheus
374/// as a range query over the run window.
375///
376/// The default step is 15s, which is the scrape interval. This covers the full
377/// wallclock window, warmup and measurement.
378#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
379pub struct BrokerSample {
380    /// Milliseconds since `wallclock_start_unix_ms`.
381    pub t_offset_ms: TimeOffsetMs,
382    /// Summed CPU usage across broker pods, in cores. A core count is
383    /// dimensionless, so it stays a plain number.
384    pub cpu_cores: f64,
385    /// Summed working-set memory across broker pods.
386    #[serde(with = "serde_units::numeric::bytes_u64")]
387    pub mem_working_set: ByteSize,
388}
389
390/// One run = one scenario × one stack. The driver writes it and the report
391/// aggregator reads it.
392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393pub struct RunOutput {
394    pub scenario: Scenario,
395    pub stack: Stack,
396    pub topology: Topology,
397    pub wallclock_start_unix_ms: WallclockMs,
398    pub wallclock_end_unix_ms: WallclockMs,
399    pub throughput: Throughput,
400    pub producer_latency: LatencyPercentiles,
401    pub consumer_e2e_latency: LatencyPercentiles,
402    pub resource: Resource,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub disturbance: Option<Disturbance>,
405    /// Operator and broker startup wall-clock from CR apply to broker Ready, in
406    /// whole milliseconds so a shell script can write the field.
407    /// `run-scenario.sh` fills it in after the driver finishes.
408    #[serde(
409        default,
410        skip_serializing_if = "Option::is_none",
411        with = "serde_units::numeric::option_millis_i64"
412    )]
413    pub startup: Option<Time>,
414    /// Driver-observed wall-clock from start to first successful
415    /// `send().await?`.
416    #[serde(with = "serde_units::numeric::millis_i64")]
417    pub first_ack: Time,
418    #[serde(default)]
419    pub errors: Vec<String>,
420    #[serde(default)]
421    pub notes: Vec<String>,
422    /// Per-interval client throughput and latency over the measurement window.
423    /// This is empty for runs made before time-series sampling existed.
424    #[serde(default)]
425    pub samples: Vec<Sample>,
426    /// Per-interval broker CPU and memory over the run window, from a Prometheus
427    /// range query.
428    #[serde(default)]
429    pub broker_samples: Vec<BrokerSample>,
430}
431
432#[cfg(test)]
433mod tests {
434    use {super::*, assert2::check};
435
436    /// A scenario with every dimensioned field set to a distinctive value, so a
437    /// round trip that drops or rescales one is visible.
438    fn scenario() -> Scenario {
439        Scenario {
440            name: "round-trip".into(),
441            mode_tag: ModeTag::Cluster,
442            msg_size: kibibytes(4),
443            key_size: bytes(16),
444            partitions: 12,
445            replication_factor: 3,
446            producers: 2,
447            consumers: 4,
448            mode: LoadMode::FixedRate {
449                rate: per_sec(20_000),
450            },
451            acks: Acks::All,
452            compression: Compression::Zstd,
453            linger: millis(5),
454            batch_size: kibibytes(16),
455            duration: secs(60),
456            warmup: secs(10),
457            failover: Some(FailoverSpec {
458                kill_after: secs(4),
459                target: "partition0_leader".into(),
460            }),
461        }
462    }
463
464    krabka_macros::benchmark_latency_fixture!(sample_latency);
465
466    fn run_output() -> RunOutput {
467        RunOutput {
468            scenario: scenario(),
469            stack: Stack::Krabka,
470            topology: Topology {
471                partitions: 12,
472                replication_factor: 3,
473                broker_count: 3,
474            },
475            wallclock_start_unix_ms: WallclockMs(1_700_000_000_000),
476            wallclock_end_unix_ms: WallclockMs(1_700_000_060_000),
477            throughput: Throughput {
478                msgs_produced: MessageCount(600_000),
479                msgs_consumed: MessageCount(599_000),
480                bytes_in: mebibytes(2400),
481                bytes_out: mebibytes(2396),
482                producer_rate: per_sec(10_000),
483                consumer_rate: per_sec(9_983),
484            },
485            producer_latency: sample_latency(),
486            consumer_e2e_latency: LatencyPercentiles::default(),
487            resource: Resource {
488                broker_cpu: millis(123_456),
489                mem_cgroup_working_set: mebibytes(300),
490                jvm_heap_used: Some(mebibytes(1024)),
491                jvm_nonheap_used: Some(mebibytes(96)),
492                kafka_page_cache_approx: Some(mebibytes(200)),
493                msgs_per_cpu_second: per_sec(4_860),
494            },
495            disturbance: Some(Disturbance {
496                kill_at_ms: TimeOffsetMs(1_700_000_004_000),
497                recovery_at_ms: TimeOffsetMs(1_700_000_006_000),
498                dropped: MessageCount(7),
499                latency_spike_max: micros(42_500),
500            }),
501            startup: Some(millis(1234)),
502            first_ack: millis(42),
503            errors: vec!["an-error".into()],
504            notes: vec!["a-note".into()],
505            samples: vec![Sample {
506                t_offset_ms: TimeOffsetMs(2000),
507                producer_rate: per_sec(10_100),
508                consumer_rate: per_sec(9_900),
509                producer_p50: micros(1500),
510                producer_p99: micros(4200),
511                consumer_e2e_p99: millis(7),
512            }],
513            broker_samples: vec![BrokerSample {
514                t_offset_ms: TimeOffsetMs(0),
515                cpu_cores: 2.5,
516                mem_working_set: mebibytes(300),
517            }],
518        }
519    }
520
521    #[test]
522    fn stack_broker_pod_regex_distinguishes_stacks() {
523        assert2::assert!(Stack::Krabka.broker_pod_regex() == "^demo-broker");
524        assert2::assert!(Stack::Kafka.broker_pod_regex() == "^demo-kafka-");
525        // The krabka prefix must match BOTH the single-pool e2e naming and the
526        // multi-pool bench naming (used by failover.rs `starts_with`).
527        let p = Stack::Krabka.broker_pod_regex().trim_start_matches('^');
528        assert2::assert!("demo-brokers-0".starts_with(p));
529        assert2::assert!("demo-broker-0-0".starts_with(p));
530    }
531
532    #[yare::parameterized(
533        none = { Acks::None, krabka_client_producer::Acks::Zero },
534        leader = { Acks::Leader, krabka_client_producer::Acks::One },
535        all = { Acks::All, krabka_client_producer::Acks::All },
536    )]
537    fn acks_map_to_producer_enum(acks: Acks, want: krabka_client_producer::Acks) {
538        assert2::assert!(acks.into_producer() == want);
539    }
540
541    #[yare::parameterized(
542        none = { Compression::None, krabka_client_producer::Compression::None },
543        gzip = { Compression::Gzip, krabka_client_producer::Compression::Gzip },
544        snappy = { Compression::Snappy, krabka_client_producer::Compression::Snappy },
545        lz4 = { Compression::Lz4, krabka_client_producer::Compression::Lz4 },
546        zstd = { Compression::Zstd, krabka_client_producer::Compression::Zstd },
547    )]
548    fn compression_maps_to_producer_enum(
549        compression: Compression,
550        want: krabka_client_producer::Compression,
551    ) {
552        assert2::assert!(compression.into_producer() == want);
553    }
554
555    #[test]
556    fn compression_default_is_none() {
557        assert2::assert!(Compression::default() == Compression::None);
558    }
559
560    #[test]
561    fn scenario_yaml_reads_the_operator_form() {
562        let y = "
563name: small-msg-saturate
564mode_tag: ci
565msg_size: 100B
566partitions: 6
567replication_factor: 1
568producers: 1
569consumers: 1
570mode:
571  kind: saturate
572acks: leader
573compression: none
574linger: 5ms
575batch_size: 16KiB
576duration: 60s
577warmup: 10s
578";
579        let s: Scenario = serde_saphyr::from_str(y).expect("parse");
580        check!(s.name.as_str() == "small-msg-saturate");
581        check!(s.partitions == 6);
582        check!(s.msg_size == bytes(100));
583        check_default_batch_timing(&s);
584        check!(matches!(s.mode, LoadMode::Saturate));
585    }
586
587    fn check_default_batch_timing(s: &Scenario) {
588        check!(s.key_size == ByteSize::ZERO);
589        check!(s.linger == millis(5));
590        check!(s.batch_size == kibibytes(16));
591        check!(s.duration == secs(60));
592        check!(s.warmup == secs(10));
593    }
594
595    #[test]
596    fn scenario_yaml_defaults_every_omitted_field() {
597        let y = "
598name: bare
599mode:
600  kind: saturate
601";
602        let s: Scenario = serde_saphyr::from_str(y).expect("parse");
603        check!(s.msg_size == kibibytes(1));
604        check_default_batch_timing(&s);
605        check!(s.failover == None);
606    }
607
608    #[test]
609    fn scenario_yaml_rejects_a_size_without_a_unit() {
610        let y = "
611name: unitless
612msg_size: 100
613mode:
614  kind: saturate
615";
616        let error = serde_saphyr::from_str::<Scenario>(y).expect_err("a bare number is not a size");
617        check!(error.to_string().contains("missing unit"));
618    }
619
620    /// A minimal `saturate` scenario with `extra` appended, so one field's bound
621    /// can be exercised against an otherwise-runnable file.
622    fn parse_saturate_with(extra: &str) -> Result<Scenario, serde_saphyr::Error> {
623        serde_saphyr::from_str(&format!("name: bounds\nmode:\n  kind: saturate\n{extra}\n"))
624    }
625
626    #[yare::parameterized(
627        negative_message_size = { "msg_size: -1B", "a positive size" },
628        zero_message_size = { "msg_size: 0", "a positive size" },
629        negative_key_size = { "key_size: -1B", "a size of zero or more" },
630        negative_batch_size = { "batch_size: -16KiB", "a positive size" },
631        zero_batch_size = { "batch_size: 0", "a positive size" },
632        negative_linger = { "linger: -5ms", "an extent of zero or more" },
633        negative_duration = { "duration: -60s", "a positive extent" },
634        zero_duration = { "duration: 0", "a positive extent" },
635        negative_warmup = { "warmup: -10s", "an extent of zero or more" },
636        negative_failover_kill_after = {
637            "failover:\n  kill_after: -4s",
638            "an extent of zero or more",
639        },
640    )]
641    fn scenario_yaml_rejects_unrunnable_magnitudes(field: &str, requirement: &str) {
642        // The human forms accept a sign, so every field that used to be an
643        // unsigned primitive has to say so itself.
644        let error = parse_saturate_with(field)
645            .expect_err("an unrunnable magnitude must fail at load, not at run");
646        check!(error.to_string().contains(requirement), "{field:?}");
647    }
648
649    #[yare::parameterized(
650        negative = { "\"-1/s\"" },
651        zero = { "\"0\"" },
652    )]
653    fn scenario_yaml_rejects_an_unrunnable_fixed_rate(rate: &str) {
654        // Quoted because `LoadMode` is internally tagged: serde buffers the
655        // variant's fields before handing them on, and a buffered YAML `0` is an
656        // integer rather than the scalar text the human form reads.
657        let yaml = format!("name: bounds\nmode:\n  kind: fixed_rate\n  rate: {rate}\n");
658        let error = serde_saphyr::from_str::<Scenario>(&yaml)
659            .expect_err("a paced producer needs a positive rate");
660        check!(error.to_string().contains("a positive rate"), "{rate}");
661    }
662
663    #[test]
664    fn scenario_yaml_admits_zero_where_zero_is_runnable() {
665        let s =
666            parse_saturate_with("key_size: 0\nlinger: 0\nwarmup: 0\nfailover:\n  kill_after: 0")
667                .expect("keyless records, no linger, no warmup, and an immediate kill all run");
668        check!(s.key_size == ByteSize::ZERO);
669        check!(s.linger == Time::ZERO);
670        check!(s.warmup == Time::ZERO);
671        check!(s.failover.map(|failover| failover.kill_after) == Some(Time::ZERO));
672    }
673
674    #[test]
675    fn fixed_rate_yaml_parses_an_event_rate() {
676        let y = "
677name: fixed-rate
678mode:
679  kind: fixed_rate
680  rate: 20000/s
681";
682        let s: Scenario = serde_saphyr::from_str(y).unwrap();
683        check!(
684            s.mode
685                == LoadMode::FixedRate {
686                    rate: per_sec(20_000)
687                }
688        );
689    }
690
691    #[test]
692    fn failover_yaml_parses_a_kill_offset() {
693        let y = "
694name: failover
695mode:
696  kind: saturate
697failover:
698  kill_after: 4s
699";
700        let s: Scenario = serde_saphyr::from_str(y).unwrap();
701        check!(
702            s.failover
703                == Some(FailoverSpec {
704                    kill_after: secs(4),
705                    target: "partition0_leader".into(),
706                })
707        );
708    }
709
710    #[test]
711    fn scenario_yaml_round_trips_through_the_human_form() {
712        let yaml = serde_saphyr::to_string(&scenario()).expect("encode");
713        // Operator-facing fields carry their unit rather than a bare float.
714        for needle in ["msg_size: 4KiB", "linger: 5ms", "rate: 20000/s"] {
715            check!(yaml.contains(needle));
716        }
717        let back: Scenario = serde_saphyr::from_str(&yaml).expect("decode");
718        check!(back == scenario());
719    }
720
721    #[test]
722    fn run_output_json_round_trips() {
723        let out = run_output();
724        let json = serde_json::to_string(&out).expect("encode");
725        let back: RunOutput = serde_json::from_str(&json).expect("decode");
726        check!(back == out);
727    }
728
729    #[test]
730    fn run_output_json_encodes_measurements_as_exact_integers() {
731        let json = serde_json::to_value(run_output()).expect("encode");
732        check!(json["producer_latency"]["p99"] == serde_json::json!(4_250_000));
733        check!(json["throughput"]["bytes_in"] == serde_json::json!(2_516_582_400_u64));
734        check!(json["throughput"]["producer_rate"] == serde_json::json!("10000/s"));
735        check!(json["resource"]["mem_cgroup_working_set"] == serde_json::json!(314_572_800));
736        check!(json["first_ack"] == serde_json::json!(42));
737        check!(json["startup"] == serde_json::json!(1234));
738    }
739
740    #[test]
741    fn absent_optional_resource_fields_decode_as_none() {
742        let mut out = run_output();
743        out.resource.jvm_heap_used = None;
744        out.resource.jvm_nonheap_used = None;
745        out.resource.kafka_page_cache_approx = None;
746        out.startup = None;
747        let json = serde_json::to_string(&out).expect("encode");
748        check!(!json.contains("jvm_heap_used"));
749        check!(!json.contains("startup"));
750        let back: RunOutput = serde_json::from_str(&json).expect("decode");
751        check!(back == out);
752    }
753}
754
755#[cfg(test)]
756pub(crate) mod fixture {
757    use super::*;
758    krabka_macros::benchmark_scenario_fixture!(default_scenario);
759
760    pub fn scenario(name: &str) -> Scenario { default_scenario(name, krabka_units::bytes(100)) }
761
762    pub struct RunSetup<'a> {
763        pub scenario: &'a str,
764        pub stack: Stack,
765        pub topology: Topology,
766        pub producer_rate: Frequency,
767        pub samples: Vec<Sample>,
768        pub broker_samples: Vec<BrokerSample>,
769        pub p99: Time,
770    }
771
772    impl Default for RunSetup<'_> {
773        fn default() -> Self {
774            Self {
775                scenario: "small-msg",
776                stack: Stack::Krabka,
777                topology: Topology {
778                    partitions: 100,
779                    replication_factor: 3,
780                    broker_count: 6,
781                },
782                producer_rate: krabka_units::per_sec(1000),
783                samples: vec![],
784                broker_samples: vec![],
785                p99: Time::ZERO,
786            }
787        }
788    }
789
790    #[bon::builder]
791    pub fn empty_run(
792        #[builder(default = RunSetup::default().scenario)] scenario: &'_ str,
793        #[builder(default = RunSetup::default().stack)] stack: Stack,
794        #[builder(default = RunSetup::default().topology)] topology: Topology,
795        #[builder(default = RunSetup::default().producer_rate)] producer_rate: Frequency,
796        #[builder(default = RunSetup::default().samples)] samples: Vec<Sample>,
797        #[builder(default = RunSetup::default().broker_samples)] broker_samples: Vec<BrokerSample>,
798        #[builder(default = RunSetup::default().p99)] p99: Time,
799    ) -> RunOutput {
800        let setup = RunSetup {
801            scenario,
802            stack,
803            topology,
804            producer_rate,
805            samples,
806            broker_samples,
807            p99,
808        };
809        let RunSetup {
810            scenario: name,
811            stack,
812            topology,
813            producer_rate,
814            samples,
815            broker_samples,
816            p99,
817        } = setup;
818        let scenario = Scenario {
819            mode_tag: ModeTag::Cluster,
820            partitions: topology.partitions,
821            replication_factor: topology.replication_factor,
822            ..self::scenario(name)
823        };
824        RunOutput {
825            scenario,
826            stack,
827            topology,
828            wallclock_start_unix_ms: WallclockMs(0),
829            wallclock_end_unix_ms: WallclockMs(60_000),
830            throughput: Throughput {
831                producer_rate,
832                ..Throughput::default()
833            },
834            producer_latency: LatencyPercentiles {
835                p99,
836                ..LatencyPercentiles::default()
837            },
838            consumer_e2e_latency: LatencyPercentiles::default(),
839            resource: Resource::default(),
840            disturbance: None,
841            startup: None,
842            first_ack: Time::ZERO,
843            errors: vec![],
844            notes: vec![],
845            samples,
846            broker_samples,
847        }
848    }
849
850    pub fn short_scenario(replication_factor: i16) -> Scenario {
851        Scenario {
852            partitions: 1,
853            replication_factor,
854            linger: krabka_units::Time::ZERO,
855            duration: krabka_units::secs(1),
856            warmup: krabka_units::Time::ZERO,
857            ..scenario("x")
858        }
859    }
860}