1use {
9 crate::{
10 failover::{FaultConfig, FaultTask},
11 hist,
12 ids::{MessageCount, TimeOffsetMs, WallclockMs},
13 numeric::{
14 event_rate, nonnegative_i64_to_u64, saturating_u64_to_i64, saturating_u128_to_u64,
15 to_f64,
16 },
17 payload,
18 prom::PromClient,
19 rate::Pacer,
20 scenario::{
21 Acks, BrokerSample, Disturbance, FailoverSpec, LatencyPercentiles, LoadMode, ModeTag,
22 Resource, RunOutput, Sample, Scenario, Stack, Throughput, Topology,
23 },
24 },
25 anyhow::{Context, Result},
26 chrono::Utc,
27 futures::{FutureExt, StreamExt, stream::FuturesUnordered},
28 hdrhistogram::Histogram,
29 krabka_client_consumer::{AutoOffsetReset, Consumer},
30 krabka_client_core::{
31 ClientFrameMax, ConnectionDispatchQueueCapacity,
32 security::{ClientSecurity, KeyStore, TlsConnectorConfig, TrustStore},
33 },
34 krabka_client_producer::{
35 DEFAULT_PRODUCER_MAX_BLOCK, DEFAULT_PRODUCER_MAX_IN_FLIGHT, Producer, ProducerError,
36 ProducerRecord, RecordMetadata,
37 },
38 krabka_macros::RefinedNewtype,
39 krabka_security::ListenerProtocol,
40 krabka_units::prelude::*,
41 refined_type::rule::GreaterU32,
42 std::{
43 future::Future,
44 iter, mem,
45 path::PathBuf,
46 pin::Pin,
47 sync::{
48 Arc,
49 atomic::{AtomicU8, AtomicU64, Ordering},
50 },
51 },
52 tokio::{
53 task::JoinSet,
54 time::{Instant, sleep, sleep_until, timeout, timeout_at},
55 },
56 tracing::{info, warn},
57};
58
59type AckResult = Result<RecordMetadata, ProducerError>;
65type AckFuture = Pin<Box<dyn Future<Output = SettledAck> + Send>>;
66
67struct SettledAck {
69 result: AckResult,
70 sent_at: Instant,
71}
72
73pub const MAX_CLIENT_REQUEST_TIMEOUT: Time = millis(2_147_483_647);
74pub const DEFAULT_PRODUCER_REQUEST_TIMEOUT: Time = secs(2);
75pub const DEFAULT_KRABKA_CONSUMER_REQUEST_TIMEOUT: Time = secs(5);
76pub const DEFAULT_KAFKA_CONSUMER_REQUEST_TIMEOUT: Time = secs(30);
77pub const DEFAULT_CONSUMER_BUILD_ATTEMPTS: u32 = 6;
78pub const DEFAULT_CONSUMER_BUILD_INITIAL_BACKOFF: Time = millis(100);
79pub const DEFAULT_CONSUMER_BUILD_MAX_BACKOFF: Time = secs(2);
80pub const DEFAULT_CONSUMER_POLL_TIMEOUT: Time = millis(50);
81pub const DEFAULT_CONSUMER_POLL_ERROR_BACKOFF: Time = millis(100);
82pub const DEFAULT_PRODUCER_FINAL_DRAIN_TIMEOUT: Time = secs(10);
83pub const DEFAULT_SAMPLE_INTERVAL: Time = secs(2);
84
85#[must_use]
91pub const fn default_producer_request_timeout() -> Time { DEFAULT_PRODUCER_REQUEST_TIMEOUT }
92
93#[must_use]
99pub const fn default_consumer_request_timeout(stack: Stack) -> Time {
100 match stack {
101 Stack::Krabka => DEFAULT_KRABKA_CONSUMER_REQUEST_TIMEOUT,
102 Stack::Kafka => DEFAULT_KAFKA_CONSUMER_REQUEST_TIMEOUT,
103 }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, RefinedNewtype)]
108#[refined(
109 rule(GreaterU32<0>),
110 string_error,
111 default = DEFAULT_CONSUMER_BUILD_ATTEMPTS,
112 from_str,
113 display
114)]
115pub struct ConsumerBuildAttempts(u32);
116
117#[must_use]
118pub const fn default_consumer_build_initial_backoff() -> Time {
119 DEFAULT_CONSUMER_BUILD_INITIAL_BACKOFF
120}
121
122#[must_use]
123pub const fn default_consumer_build_max_backoff() -> Time { DEFAULT_CONSUMER_BUILD_MAX_BACKOFF }
124
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct ConsumerBuildRetryPolicy {
127 attempts: ConsumerBuildAttempts,
128 initial_backoff: Time,
129 max_backoff: Time,
130}
131
132impl ConsumerBuildRetryPolicy {
133 pub fn new(
139 attempts: ConsumerBuildAttempts,
140 initial_backoff: Time,
141 max_backoff: Time,
142 ) -> Result<Self, String> {
143 if initial_backoff > max_backoff {
144 return Err("consumer-build initial backoff exceeds maximum".to_owned());
145 }
146 Ok(Self {
147 attempts,
148 initial_backoff,
149 max_backoff,
150 })
151 }
152
153 #[must_use]
154 pub const fn attempts(self) -> u32 { self.attempts.into_value() }
155
156 #[must_use]
157 pub fn initial_backoff(self) -> Time { self.initial_backoff }
158
159 #[must_use]
160 pub fn max_backoff(self) -> Time { self.max_backoff }
161}
162
163impl Default for ConsumerBuildRetryPolicy {
164 fn default() -> Self {
165 Self {
166 attempts: ConsumerBuildAttempts::default(),
167 initial_backoff: default_consumer_build_initial_backoff(),
168 max_backoff: default_consumer_build_max_backoff(),
169 }
170 }
171}
172
173#[must_use]
174pub const fn default_consumer_poll_timeout() -> Time { DEFAULT_CONSUMER_POLL_TIMEOUT }
175
176#[must_use]
177pub const fn default_consumer_poll_error_backoff() -> Time { DEFAULT_CONSUMER_POLL_ERROR_BACKOFF }
178
179#[derive(Clone, Copy)]
182struct Grid {
183 meas_start: Instant,
185 meas_start_ns: u64,
191 meas_end_ns: u64,
192 interval_ms: u64,
193 n: usize,
195}
196
197impl Grid {
198 fn new(started_at: Instant, started_ns: u64, scenario: &Scenario, interval: Time) -> Self {
206 let interval_ms = nonnegative_i64_to_u64(interval.millis_i64());
207 let duration_ms = nonnegative_i64_to_u64(scenario.duration.millis_i64());
208 let warmup = nonnegative_i64_to_u64(scenario.warmup.nanos_i64());
209 let width = nonnegative_i64_to_u64(scenario.duration.nanos_i64());
210 let meas_start_ns = started_ns.saturating_add(warmup);
211 Self {
212 meas_start: started_at + scenario.warmup.to_std(),
213 meas_start_ns,
214 meas_end_ns: meas_start_ns.saturating_add(width),
215 interval_ms,
216 n: usize::try_from(duration_ms.div_ceil(interval_ms).max(1)).unwrap_or(usize::MAX),
217 }
218 }
219
220 fn sent_in_measurement(&self, send_ns: u64) -> bool {
228 send_ns >= self.meas_start_ns && send_ns < self.meas_end_ns
229 }
230
231 fn idx_for_send_ns(&self, send_ns: u64) -> usize {
234 let elapsed_ms = send_ns.saturating_sub(self.meas_start_ns) / 1_000_000;
235 self.idx_for_elapsed_ms(elapsed_ms)
236 }
237
238 fn idx(&self, now: Instant) -> usize {
240 let elapsed_ms =
241 saturating_u128_to_u64(now.saturating_duration_since(self.meas_start).as_millis());
242 self.idx_for_elapsed_ms(elapsed_ms)
243 }
244
245 fn idx_for_elapsed_ms(&self, elapsed_ms: u64) -> usize {
246 usize::try_from(elapsed_ms / self.interval_ms)
247 .unwrap_or(usize::MAX)
248 .min(self.n.saturating_sub(1))
249 }
250
251 fn interval(&self) -> Time { Time::from_millis(saturating_u64_to_i64(self.interval_ms)) }
253}
254
255pub struct DriverConfig {
259 pub bootstrap: String,
260 pub client_dispatch_queue_capacity: ConnectionDispatchQueueCapacity,
261 pub client_frame_max: ClientFrameMax,
262 pub topic: String,
263 pub stack: Stack,
264 pub namespace: String,
265 pub prometheus_url: Option<String>,
266 pub prometheus_request_timeout: Time,
267 pub producer_request_timeout: Time,
268 pub producer_final_drain_timeout: Time,
269 pub sample_interval: Time,
270 pub consumer_request_timeout: Time,
271 pub consumer_build_retry_policy: ConsumerBuildRetryPolicy,
272 pub consumer_poll_timeout: Time,
274 pub consumer_poll_error_backoff: Time,
276 pub broker_count: u32,
277 pub scenario_id: u64,
278 pub tls: Option<TlsParams>,
280}
281
282#[derive(Debug, Clone)]
288pub struct TlsParams {
289 pub ca_path: PathBuf,
292 pub server_name: String,
300 pub client_identity: Option<ClientIdentity>,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct ClientIdentity {
308 pub cert_pem: PathBuf,
310 pub key_pem: PathBuf,
312}
313
314impl TlsParams {
315 #[must_use]
320 pub fn to_security(&self) -> ClientSecurity {
321 let mut tls = TlsConnectorConfig::default();
322 tls.trust_store = TrustStore::PemFile(self.ca_path.clone());
323 tls.server_name.clone_from(&self.server_name);
324 tls.key_store = self
325 .client_identity
326 .clone()
327 .map(|identity| KeyStore::PemFiles {
328 certificate_chain: identity.cert_pem,
329 private_key: identity.key_pem,
330 key_password: None,
331 });
332 ClientSecurity {
333 protocol: ListenerProtocol::Ssl,
334 tls: Some(tls),
335 sasl: None,
336 sasl_host: None,
337 }
338 }
339}
340
341pub async fn run(scenario: Scenario, cfg: DriverConfig) -> Result<RunOutput> {
348 let started_ns = nonnegative_i64_to_u64(Utc::now().timestamp_nanos_opt().unwrap_or_default());
351 let wallclock_start = saturating_u64_to_i64(started_ns / 1_000_000);
352 let t_start = Instant::now();
353
354 let grid = Grid::new(t_start, started_ns, &scenario, cfg.sample_interval);
358 let histogram = hist::new().context("build the latency histogram")?;
361 let n_intervals = grid.n;
362
363 let mut notes: Vec<String> = Vec::new();
364 let mut errors: Vec<String> = Vec::new();
365
366 let TopologyCheck {
367 failover_active,
368 skipped,
369 } = validate_topology(&scenario, &cfg, wallclock_start, &mut notes);
370 if let Some(output) = skipped {
371 return Ok(output);
372 }
373
374 let security = client_security(&cfg);
375
376 let mut prod_set: JoinSet<ProducerOut> = JoinSet::new();
377 let stop = Arc::new(AtomicU8::new(STATE_RUN));
378 let first_ack_unix_ms = Arc::new(AtomicU64::new(0));
379 let kill_at_ms = Arc::new(AtomicU64::new(0));
383
384 spawn_producers(
385 &mut prod_set,
386 &scenario,
387 &cfg,
388 security.as_ref(),
389 grid,
390 &SharedHandles {
391 stop: &stop,
392 first_ack: &first_ack_unix_ms,
393 kill_at: &kill_at_ms,
394 },
395 &histogram,
396 );
397
398 let mut cons_set: JoinSet<ConsumerOut> = JoinSet::new();
399 spawn_consumers(
400 &mut cons_set,
401 &scenario,
402 &cfg,
403 security.as_ref(),
404 grid,
405 &stop,
406 &histogram,
407 );
408
409 let fault = scenario
410 .failover
411 .as_ref()
412 .filter(|_| failover_active)
413 .map(|spec| {
414 start_fault(
415 spec,
416 &cfg,
417 security.clone(),
418 t_start,
419 Arc::clone(&kill_at_ms),
420 )
421 });
422
423 let warmup_end = t_start + scenario.warmup.to_std();
424 sleep_until(warmup_end).await;
425 stop.store(STATE_MEASURING, Ordering::SeqCst);
426
427 let meas_end = warmup_end + scenario.duration.to_std();
428 sleep_until(meas_end).await;
429 stop.store(STATE_STOP, Ordering::SeqCst);
430
431 let fault_at_ms = finish_fault(fault, &mut notes).await;
432
433 let mut prod_hist = Histogram::new_from(&histogram);
435 let mut prod_msgs = 0u64;
436 let mut prod_bytes = 0u64;
437 let mut prod_dropped = 0u64;
438 let mut earliest_recovery_ms = 0u64;
439 let mut max_spike = Time::ZERO;
440 let mut prod_iv_msgs = vec![0u64; n_intervals];
441 let mut prod_iv_hist: Vec<Histogram<u64>> =
442 iter::repeat_with(|| Histogram::new_from(&histogram))
443 .take(n_intervals)
444 .collect();
445 while let Some(j) = prod_set.join_next().await {
446 match j {
447 Ok(t) => {
448 let _ = prod_hist.add(&t.latency);
449 prod_msgs += t.msgs;
450 prod_bytes += t.bytes;
451 prod_dropped += t.dropped;
452 for (total, m) in prod_iv_msgs.iter_mut().zip(&t.interval_msgs) {
453 *total += *m;
454 }
455 for (total, h) in prod_iv_hist.iter_mut().zip(&t.interval_hist) {
456 let _ = total.add(h);
457 }
458 if t.latency_spike_max > max_spike {
459 max_spike = t.latency_spike_max;
460 }
461 if t.recovery_unix_ms > 0
462 && (earliest_recovery_ms == 0 || t.recovery_unix_ms < earliest_recovery_ms)
463 {
464 earliest_recovery_ms = t.recovery_unix_ms;
465 }
466 if !t.error.is_empty() {
467 errors.push(t.error);
468 }
469 }
470 Err(e) => errors.push(format!("producer-task-panic: {e}")),
471 }
472 }
473
474 let consumed = join_consumers(cons_set, n_intervals, &histogram, &mut errors).await;
475
476 let samples = build_samples(
477 grid.interval(),
478 &IntervalTallies {
479 messages: &prod_iv_msgs,
480 histograms: &prod_iv_hist,
481 },
482 &IntervalTallies {
483 messages: &consumed.interval_msgs,
484 histograms: &consumed.interval_hist,
485 },
486 );
487
488 let wallclock_end = Utc::now().timestamp_millis();
489 let ResourceCapture {
490 resource,
491 broker_samples,
492 } = capture_resources(
493 &scenario,
494 &cfg,
495 WallclockWindow {
496 start_ms: wallclock_start,
497 end_ms: wallclock_end,
498 },
499 grid.meas_end_ns,
500 prod_msgs,
501 &mut notes,
502 )
503 .await;
504
505 let RunTiming {
506 disturbance,
507 first_ack,
508 } = finalize_timing(
509 failover_active,
510 FailoverStamps {
511 kill_at_ms: fault_at_ms,
512 recovery_at_ms: earliest_recovery_ms,
513 dropped: prod_dropped,
514 latency_spike_max: max_spike,
515 },
516 first_ack_unix_ms.load(Ordering::SeqCst),
517 wallclock_start,
518 );
519 note_missing_disturbance(
520 failover_active,
521 disturbance.as_ref(),
522 fault_at_ms,
523 &mut notes,
524 );
525
526 Ok(RunOutput {
527 scenario: scenario.clone(),
528 stack: cfg.stack,
529 topology: output_topology(&scenario, &cfg),
530 wallclock_start_unix_ms: WallclockMs(wallclock_start),
531 wallclock_end_unix_ms: WallclockMs(wallclock_end),
532 throughput: Throughput {
533 msgs_produced: MessageCount(prod_msgs),
534 msgs_consumed: MessageCount(consumed.msgs),
535 bytes_in: ByteSize::from_bytes(prod_bytes),
536 bytes_out: ByteSize::from_bytes(consumed.bytes),
537 producer_rate: event_rate(prod_msgs, scenario.duration),
538 consumer_rate: event_rate(consumed.msgs, scenario.duration),
539 },
540 producer_latency: hist::percentiles(&prod_hist),
541 consumer_e2e_latency: hist::percentiles(&consumed.latency),
542 resource,
543 disturbance,
544 startup: None,
545 first_ack,
546 errors,
547 notes,
548 samples,
549 broker_samples,
550 })
551}
552
553async fn finish_fault(fault: Option<FaultTask>, notes: &mut Vec<String>) -> u64 {
554 let Some(fault) = fault else {
555 return 0;
556 };
557 let outcome = fault.stop().await;
558 if let Some(note) = outcome.note() {
559 notes.push(note);
560 }
561 outcome.killed_at_ms().unwrap_or_default()
562}
563
564fn start_fault(
565 spec: &FailoverSpec,
566 cfg: &DriverConfig,
567 security: Option<ClientSecurity>,
568 started_at: Instant,
569 kill_at: Arc<AtomicU64>,
570) -> FaultTask {
571 FaultTask::spawn(
572 spec,
573 FaultConfig {
574 stack: cfg.stack,
575 namespace: cfg.namespace.clone(),
576 bootstrap: cfg.bootstrap.clone(),
577 topic: cfg.topic.clone(),
578 security,
579 dispatch_queue_capacity: cfg.client_dispatch_queue_capacity,
580 frame_max: cfg.client_frame_max,
581 },
582 started_at,
583 kill_at,
584 )
585}
586
587fn client_security(cfg: &DriverConfig) -> Option<ClientSecurity> {
588 let security = cfg.tls.as_ref().map(TlsParams::to_security);
589 if let Some(value) = &security {
590 info!(
591 server_name = value
592 .tls
593 .as_ref()
594 .map_or("", |tls| tls.server_name.as_str()),
595 "TLS data path enabled (protocol=Ssl)"
596 );
597 }
598 security
599}
600
601struct IntervalTallies<'a> {
604 messages: &'a [u64],
605 histograms: &'a [Histogram<u64>],
606}
607
608fn build_samples(
609 interval: Time,
610 producer: &IntervalTallies<'_>,
611 consumer: &IntervalTallies<'_>,
612) -> Vec<Sample> {
613 let interval_ms = nonnegative_i64_to_u64(interval.millis_i64());
614 let percentile = |histogram: &Histogram<u64>, quantile: f64| {
616 if histogram.is_empty() {
617 Time::ZERO
618 } else {
619 Time::from_micros(saturating_u64_to_i64(histogram.value_at_quantile(quantile)))
620 }
621 };
622 producer
623 .messages
624 .iter()
625 .zip(producer.histograms)
626 .zip(consumer.messages.iter().zip(consumer.histograms))
627 .enumerate()
628 .map(
629 |(index, ((messages, producer_histogram), (consumer_messages, consumer_histogram)))| {
630 Sample {
631 t_offset_ms: TimeOffsetMs(
632 u64::try_from(index)
633 .unwrap_or(u64::MAX)
634 .saturating_mul(interval_ms),
635 ),
636 producer_rate: event_rate(*messages, interval),
637 consumer_rate: event_rate(*consumer_messages, interval),
638 producer_p50: percentile(producer_histogram, 0.50),
639 producer_p99: percentile(producer_histogram, 0.99),
640 consumer_e2e_p99: percentile(consumer_histogram, 0.99),
641 }
642 },
643 )
644 .collect()
645}
646
647#[derive(Clone, Copy)]
650struct SharedHandles<'a> {
651 stop: &'a Arc<AtomicU8>,
652 first_ack: &'a Arc<AtomicU64>,
653 kill_at: &'a Arc<AtomicU64>,
654}
655
656fn spawn_producers(
661 tasks: &mut JoinSet<ProducerOut>,
662 scenario: &Scenario,
663 cfg: &DriverConfig,
664 security: Option<&ClientSecurity>,
665 grid: Grid,
666 shared: &SharedHandles<'_>,
667 histogram: &Histogram<u64>,
668) {
669 let SharedHandles {
670 stop,
671 first_ack,
672 kill_at,
673 } = *shared;
674 for idx in 0..scenario.producers {
675 tasks.spawn(run_producer(ProducerTask {
676 setup: TaskSetup::new(
677 idx,
678 scenario,
679 cfg,
680 security,
681 grid,
682 stop,
683 cfg.producer_request_timeout,
684 ),
685 first_ack: Arc::clone(first_ack),
686 kill_at: Arc::clone(kill_at),
687 final_drain_timeout: cfg.producer_final_drain_timeout,
688 histogram: Histogram::new_from(histogram),
689 }));
690 }
691}
692
693async fn join_consumers(
696 mut tasks: JoinSet<ConsumerOut>,
697 intervals: usize,
698 histogram: &Histogram<u64>,
699 errors: &mut Vec<String>,
700) -> TaskMeasurements {
701 let mut merged = TaskMeasurements::new(intervals, histogram);
702 while let Some(joined) = tasks.join_next().await {
703 match joined {
704 Ok(task) => {
705 let _ = merged.latency.add(&task.latency);
706 merged.msgs += task.msgs;
707 merged.bytes += task.bytes;
708 for (total, m) in merged.interval_msgs.iter_mut().zip(&task.interval_msgs) {
709 *total += *m;
710 }
711 for (total, h) in merged.interval_hist.iter_mut().zip(&task.interval_hist) {
712 let _ = total.add(h);
713 }
714 if !task.error.is_empty() {
715 errors.push(task.error);
716 }
717 }
718 Err(e) => errors.push(format!("consumer-task-panic: {e}")),
719 }
720 }
721 merged
722}
723
724fn spawn_consumers(
726 tasks: &mut JoinSet<ConsumerOut>,
727 scenario: &Scenario,
728 cfg: &DriverConfig,
729 security: Option<&ClientSecurity>,
730 grid: Grid,
731 stop: &Arc<AtomicU8>,
732 histogram: &Histogram<u64>,
733) {
734 for idx in 0..scenario.consumers {
735 tasks.spawn(run_consumer(ConsumerTask {
736 setup: TaskSetup::new(
737 idx,
738 scenario,
739 cfg,
740 security,
741 grid,
742 stop,
743 cfg.consumer_request_timeout,
744 ),
745 build_retry_policy: cfg.consumer_build_retry_policy,
746 poll_timeout: cfg.consumer_poll_timeout,
747 poll_error_backoff: cfg.consumer_poll_error_backoff,
748 histogram: Histogram::new_from(histogram),
749 }));
750 }
751}
752
753fn note_missing_disturbance(
754 failover_active: bool,
755 disturbance: Option<&Disturbance>,
756 kill_at_ms: u64,
757 notes: &mut Vec<String>,
758) {
759 if !failover_active || disturbance.is_some() {
760 return;
761 }
762 notes.push(if kill_at_ms == 0 {
763 "failover:no-broker-deleted".to_owned()
764 } else {
765 "failover:no-ack-after-kill".to_owned()
766 });
767}
768
769#[derive(Debug, Clone, Copy)]
771struct FailoverStamps {
772 kill_at_ms: u64,
774 recovery_at_ms: u64,
776 dropped: u64,
778 latency_spike_max: Time,
780}
781
782struct RunTiming {
785 disturbance: Option<Disturbance>,
786 first_ack: Time,
787}
788
789fn finalize_timing(
798 failover_active: bool,
799 failover: FailoverStamps,
800 first_ack: u64,
801 wallclock_start: i64,
802) -> RunTiming {
803 let measured_kill = failover.kill_at_ms > 0 && failover.recovery_at_ms >= failover.kill_at_ms;
804 let disturbance = (failover_active && measured_kill).then_some(Disturbance {
805 kill_at_ms: TimeOffsetMs(failover.kill_at_ms),
806 recovery_at_ms: TimeOffsetMs(failover.recovery_at_ms),
807 dropped: MessageCount(failover.dropped),
808 latency_spike_max: failover.latency_spike_max,
809 });
810 let first_ack = if first_ack == 0 {
813 Time::ZERO
814 } else {
815 Time::from_millis(
816 i64::try_from(first_ack)
817 .unwrap_or(i64::MAX)
818 .saturating_sub(wallclock_start)
819 .max(0),
820 )
821 };
822 RunTiming {
823 disturbance,
824 first_ack,
825 }
826}
827
828struct TopologyCheck {
831 failover_active: bool,
832 skipped: Option<RunOutput>,
833}
834
835fn validate_topology(
836 scenario: &Scenario,
837 cfg: &DriverConfig,
838 wallclock_start: i64,
839 notes: &mut Vec<String>,
840) -> TopologyCheck {
841 let failover_active =
842 scenario.failover.is_some() && scenario.replication_factor >= 3 && cfg.broker_count >= 3;
843 if scenario.failover.is_some() && !failover_active {
844 notes.push("skipped:failover-needs-rf3".into());
845 }
846 let topology_mismatch = matches!(scenario.mode_tag, ModeTag::Cluster)
847 && scenario.replication_factor >= 3
848 && cfg.broker_count < 3;
849 if !topology_mismatch {
850 return TopologyCheck {
851 failover_active,
852 skipped: None,
853 };
854 }
855 notes.push(format!(
856 "skipped:topology-mismatch (rf={} brokers={})",
857 scenario.replication_factor, cfg.broker_count
858 ));
859 let output = empty_output(scenario, cfg, wallclock_start, mem::take(notes), Vec::new());
860 TopologyCheck {
861 failover_active,
862 skipped: Some(output),
863 }
864}
865
866#[derive(Debug, Clone, Copy)]
875struct WallclockWindow {
876 start_ms: i64,
877 end_ms: i64,
878}
879
880struct ResourceCapture {
882 resource: Resource,
883 broker_samples: Vec<BrokerSample>,
884}
885
886async fn capture_resources(
887 scenario: &Scenario,
888 cfg: &DriverConfig,
889 wallclock: WallclockWindow,
890 measured_until_ns: u64,
891 produced: u64,
892 notes: &mut Vec<String>,
893) -> ResourceCapture {
894 let Some(url) = &cfg.prometheus_url else {
895 notes.push("prometheus-url-not-set".into());
896 return ResourceCapture {
897 resource: Resource::default(),
898 broker_samples: Vec::new(),
899 };
900 };
901 let client = match PromClient::new(url, cfg.prometheus_request_timeout) {
902 Ok(client) => client,
903 Err(error) => {
904 notes.push(format!("prometheus-client-failed: {error}"));
905 return ResourceCapture {
906 resource: Resource::default(),
907 broker_samples: Vec::new(),
908 };
909 }
910 };
911 let resource = match client
912 .capture_resource(
913 cfg.stack,
914 &cfg.namespace,
915 scenario.duration,
916 to_f64(measured_until_ns) / 1.0e9,
917 MessageCount(produced),
918 )
919 .await
920 {
921 Ok(resource) => resource,
922 Err(error) => {
923 warn!(%error, "prometheus capture failed");
924 notes.push(format!("prometheus-capture-failed: {error}"));
925 Resource::default()
926 }
927 };
928 let broker_samples = match client
929 .capture_resource_series(
930 cfg.stack,
931 &cfg.namespace,
932 to_f64(wallclock.start_ms) / 1000.0,
933 to_f64(wallclock.end_ms) / 1000.0,
934 15,
935 )
936 .await
937 {
938 Ok(samples) => samples,
939 Err(error) => {
940 warn!(%error, "prometheus resource time-series capture failed");
941 notes.push(format!("prometheus-resource-series-failed: {error}"));
942 Vec::new()
943 }
944 };
945 ResourceCapture {
946 resource,
947 broker_samples,
948 }
949}
950
951const STATE_RUN: u8 = 0; const STATE_MEASURING: u8 = 1;
953const STATE_STOP: u8 = 2;
954
955struct ProducerOut {
956 latency: Histogram<u64>,
957 msgs: u64,
958 bytes: u64,
959 dropped: u64,
960 recovery_unix_ms: u64,
961 latency_spike_max: Time,
962 error: String,
963 interval_msgs: Vec<u64>,
966 interval_hist: Vec<Histogram<u64>>,
967}
968
969struct ConsumerOut {
970 latency: Histogram<u64>,
971 msgs: u64,
972 bytes: u64,
973 error: String,
974 interval_msgs: Vec<u64>,
975 interval_hist: Vec<Histogram<u64>>,
976}
977
978fn empty_output(
979 scenario: &Scenario,
980 cfg: &DriverConfig,
981 start: i64,
982 notes: Vec<String>,
983 errors: Vec<String>,
984) -> RunOutput {
985 RunOutput {
986 scenario: scenario.clone(),
987 stack: cfg.stack,
988 topology: output_topology(scenario, cfg),
989 wallclock_start_unix_ms: WallclockMs(start),
990 wallclock_end_unix_ms: WallclockMs(start),
991 throughput: Throughput::default(),
992 producer_latency: LatencyPercentiles::default(),
993 consumer_e2e_latency: LatencyPercentiles::default(),
994 resource: Resource::default(),
995 disturbance: None,
996 startup: None,
997 first_ack: Time::ZERO,
998 errors,
999 notes,
1000 samples: Vec::new(),
1001 broker_samples: Vec::new(),
1002 }
1003}
1004
1005fn output_topology(scenario: &Scenario, cfg: &DriverConfig) -> Topology {
1006 Topology {
1007 partitions: scenario.partitions,
1008 replication_factor: scenario.replication_factor,
1009 broker_count: cfg.broker_count,
1010 }
1011}
1012
1013struct TaskSetup {
1016 idx: usize,
1017 scenario: Scenario,
1018 bootstrap: String,
1019 topic: String,
1020 scenario_id: u64,
1021 stop: Arc<AtomicU8>,
1022 security: Option<ClientSecurity>,
1023 grid: Grid,
1024 request_timeout: Time,
1025 dispatch_queue_capacity: ConnectionDispatchQueueCapacity,
1026 frame_max: ClientFrameMax,
1027}
1028
1029impl TaskSetup {
1030 fn new(
1031 idx: usize,
1032 scenario: &Scenario,
1033 cfg: &DriverConfig,
1034 security: Option<&ClientSecurity>,
1035 grid: Grid,
1036 stop: &Arc<AtomicU8>,
1037 request_timeout: Time,
1038 ) -> Self {
1039 Self {
1040 idx,
1041 scenario: scenario.clone(),
1042 bootstrap: cfg.bootstrap.clone(),
1043 topic: cfg.topic.clone(),
1044 scenario_id: cfg.scenario_id,
1045 stop: Arc::clone(stop),
1046 security: security.cloned(),
1047 grid,
1048 request_timeout,
1049 dispatch_queue_capacity: cfg.client_dispatch_queue_capacity,
1050 frame_max: cfg.client_frame_max,
1051 }
1052 }
1053}
1054
1055struct ProducerTask {
1056 setup: TaskSetup,
1057 first_ack: Arc<AtomicU64>,
1058 kill_at: Arc<AtomicU64>,
1062 final_drain_timeout: Time,
1063 histogram: Histogram<u64>,
1065}
1066
1067struct TaskMeasurements {
1068 latency: Histogram<u64>,
1069 interval_msgs: Vec<u64>,
1070 interval_hist: Vec<Histogram<u64>>,
1071 msgs: u64,
1072 bytes: u64,
1073}
1074
1075impl TaskMeasurements {
1076 fn new(intervals: usize, histogram: &Histogram<u64>) -> Self {
1079 Self {
1080 latency: Histogram::new_from(histogram),
1081 interval_msgs: vec![0; intervals],
1082 interval_hist: iter::repeat_with(|| Histogram::new_from(histogram))
1083 .take(intervals)
1084 .collect(),
1085 msgs: 0,
1086 bytes: 0,
1087 }
1088 }
1089}
1090
1091struct ProducerObservations<'a> {
1093 idx: usize,
1094 msg_size: u64,
1095 grid: Grid,
1096 shared: SharedHandles<'a>,
1097 output: ProducerOut,
1098}
1099
1100impl<'a> ProducerObservations<'a> {
1101 fn new(
1102 idx: usize,
1103 msg_size: u64,
1104 grid: Grid,
1105 shared: SharedHandles<'a>,
1106 histogram: &Histogram<u64>,
1107 ) -> Self {
1108 let measurements = TaskMeasurements::new(grid.n, histogram);
1109 Self {
1110 idx,
1111 msg_size,
1112 grid,
1113 shared,
1114 output: ProducerOut {
1115 latency: measurements.latency,
1116 msgs: measurements.msgs,
1117 bytes: measurements.bytes,
1118 dropped: 0,
1119 recovery_unix_ms: 0,
1120 latency_spike_max: Time::ZERO,
1121 error: String::new(),
1122 interval_msgs: measurements.interval_msgs,
1123 interval_hist: measurements.interval_hist,
1124 },
1125 }
1126 }
1127
1128 fn record_enqueue_timeout(&mut self) {
1131 self.output.dropped += 1;
1132 if self.output.error.is_empty() {
1133 self.output.error = format!("producer-{}-enqueue-timeout", self.idx);
1134 }
1135 }
1136
1137 fn record_teardown_timeout(&mut self, step: &str) {
1140 if self.output.error.is_empty() {
1141 self.output.error = format!("producer-{}-{step}-timeout", self.idx);
1142 }
1143 }
1144
1145 fn record_drain_timeout(&mut self, unresolved: usize) {
1147 self.output.dropped += unresolved as u64;
1148 if self.output.error.is_empty() {
1149 self.output.error = format!("producer-{}-final-drain-timeout:{unresolved}", self.idx);
1150 }
1151 }
1152
1153 fn record_ack(&mut self, result: AckResult, sent_at: Instant) {
1155 match result {
1156 Ok(_meta) => {
1157 let latency = sent_at.elapsed().as_time();
1158 if self.shared.stop.load(Ordering::Relaxed) == STATE_MEASURING {
1159 hist::record(&mut self.output.latency, latency);
1160 self.output.msgs += 1;
1161 self.output.bytes += self.msg_size;
1162 let iv = self.grid.idx(Instant::now());
1163 if let Some(count) = self.output.interval_msgs.get_mut(iv) {
1164 *count += 1;
1165 }
1166 if let Some(histogram) = self.output.interval_hist.get_mut(iv) {
1167 hist::record(histogram, latency);
1168 }
1169 let killed_at = self.shared.kill_at.load(Ordering::Relaxed);
1175 if killed_at > 0 {
1176 let now_ms = nonnegative_i64_to_u64(Utc::now().timestamp_millis());
1177 if self.output.recovery_unix_ms == 0 && now_ms >= killed_at {
1178 self.output.recovery_unix_ms = now_ms;
1179 }
1180 if latency > self.output.latency_spike_max {
1181 self.output.latency_spike_max = latency;
1182 }
1183 }
1184 }
1185 if self.shared.first_ack.load(Ordering::Relaxed) == 0 {
1186 let now_ms = nonnegative_i64_to_u64(Utc::now().timestamp_millis());
1187 let _ = self.shared.first_ack.compare_exchange(
1188 0,
1189 now_ms,
1190 Ordering::SeqCst,
1191 Ordering::Relaxed,
1192 );
1193 }
1194 }
1195 Err(e) => {
1196 if self.shared.stop.load(Ordering::Relaxed) == STATE_MEASURING {
1197 self.output.dropped += 1;
1198 }
1199 if self.output.dropped == 1 && self.output.error.is_empty() {
1200 self.output.error = format!("producer-{}-err: {e}", self.idx);
1201 }
1202 }
1203 }
1204 }
1205}
1206
1207async fn run_producer(task: ProducerTask) -> ProducerOut {
1208 let ProducerTask {
1209 setup:
1210 TaskSetup {
1211 idx,
1212 scenario,
1213 bootstrap,
1214 topic,
1215 scenario_id,
1216 stop,
1217 security,
1218 grid,
1219 request_timeout,
1220 dispatch_queue_capacity,
1221 frame_max,
1222 },
1223 first_ack,
1224 kill_at,
1225 final_drain_timeout,
1226 histogram,
1227 } = task;
1228 let enable_idempotence = matches!(scenario.acks, Acks::All);
1231 match Producer::builder()
1232 .bootstrap(bootstrap.clone())
1233 .dispatch_queue_capacity(dispatch_queue_capacity.get())
1234 .frame_max(frame_max.size())
1235 .client_id(format!("bench-producer-{idx}"))
1236 .acks(scenario.acks.into_producer())
1237 .compression(scenario.compression.into_producer())
1238 .enable_idempotence(enable_idempotence)
1239 .linger(scenario.linger.to_std())
1240 .batch_size(scenario.batch_size.bytes_usize())
1241 .max_in_flight_per_connection(producer_max_in_flight(enable_idempotence))
1242 .request_timeout(request_timeout.to_std())
1248 .maybe_security(security)
1251 .build()
1252 .await
1253 .context("build producer")
1254 {
1255 Err(e) => ProducerOut {
1256 latency: histogram,
1257 msgs: 0,
1258 bytes: 0,
1259 dropped: 0,
1260 recovery_unix_ms: 0,
1261 latency_spike_max: Time::ZERO,
1262 error: format!("producer-{idx}-build: {e:#}"),
1263 interval_msgs: Vec::new(),
1264 interval_hist: Vec::new(),
1265 },
1266 Ok(producer) => {
1267 let mut tmpl = payload::template(scenario.msg_size);
1268 let mut sent_records = (idx as u64) << 40;
1271 let mut observations = ProducerObservations::new(
1272 idx,
1273 scenario.msg_size.bytes_u64(),
1274 grid,
1275 SharedHandles {
1276 stop: &stop,
1277 first_ack: &first_ack,
1278 kill_at: &kill_at,
1279 },
1280 &histogram,
1281 );
1282
1283 let mut pacer = match scenario.mode {
1284 LoadMode::Saturate => None,
1285 LoadMode::FixedRate { rate } => {
1288 Some(Pacer::new(rate / to_f64(scenario.producers.max(1))))
1289 }
1290 };
1291
1292 let max_inflight: usize = 512;
1299 let mut inflight: FuturesUnordered<AckFuture> = FuturesUnordered::new();
1300 let enqueue_limit = enqueue_limit(request_timeout);
1301
1302 loop {
1303 let state = stop.load(Ordering::Relaxed);
1304 if state == STATE_STOP {
1305 break;
1306 }
1307 while let Some(SettledAck {
1311 result: res,
1312 sent_at: t0,
1313 }) = inflight.next().now_or_never().flatten()
1314 {
1315 observations.record_ack(res, t0);
1316 }
1317 while inflight.len() >= max_inflight {
1320 if let Some(SettledAck {
1321 result: res,
1322 sent_at: t0,
1323 }) = inflight.next().await
1324 {
1325 observations.record_ack(res, t0);
1326 }
1327 }
1328 if let Some(p) = pacer.as_mut() {
1329 p.await_token().await;
1330 }
1331 let value = payload::stamp_into(&mut tmpl, scenario_id);
1332 let key = payload::key(scenario.key_size, sent_records);
1337 sent_records += 1;
1338 let rec = ProducerRecord {
1339 topic: topic.clone(),
1340 key,
1341 value: Some(value),
1342 ..Default::default()
1343 };
1344 let t0 = Instant::now();
1345 match within(enqueue_limit, producer.enqueue(rec)).await {
1346 Some(Ok(delivery)) => inflight.push(Box::pin(async move {
1347 SettledAck {
1348 result: delivery.await,
1349 sent_at: t0,
1350 }
1351 })),
1352 Some(Err(error)) => observations.record_ack(Err(error), t0),
1353 None => {
1357 observations.record_enqueue_timeout();
1358 break;
1359 }
1360 }
1361 }
1362
1363 let drain_until = Instant::now() + final_drain_timeout.to_std();
1368 while !inflight.is_empty() {
1369 let now = Instant::now();
1370 if now >= drain_until {
1371 observations.record_drain_timeout(inflight.len());
1372 break;
1373 }
1374 match timeout_at(drain_until, inflight.next()).await {
1375 Ok(Some(SettledAck {
1376 result: res,
1377 sent_at: t0,
1378 })) => observations.record_ack(res, t0),
1379 Ok(None) => break,
1380 Err(_) => {
1381 observations.record_drain_timeout(inflight.len());
1382 break;
1383 }
1384 }
1385 }
1386
1387 if !finished_within(final_drain_timeout, producer.flush()).await {
1391 observations.record_teardown_timeout("flush");
1392 }
1393 if !finished_within(final_drain_timeout, Box::pin(producer.close())).await {
1394 observations.record_teardown_timeout("close");
1395 }
1396
1397 observations.output
1398 }
1399 }
1400}
1401
1402struct ConsumerTask {
1405 setup: TaskSetup,
1406 build_retry_policy: ConsumerBuildRetryPolicy,
1407 poll_timeout: Time,
1408 poll_error_backoff: Time,
1409 histogram: Histogram<u64>,
1411}
1412
1413async fn run_consumer(task: ConsumerTask) -> ConsumerOut {
1414 let ConsumerTask {
1415 setup:
1416 TaskSetup {
1417 idx,
1418 scenario,
1419 bootstrap,
1420 topic,
1421 scenario_id,
1422 stop,
1423 security,
1424 grid,
1425 request_timeout,
1426 dispatch_queue_capacity,
1427 frame_max,
1428 },
1429 build_retry_policy,
1430 poll_timeout,
1431 poll_error_backoff,
1432 histogram,
1433 } = task;
1434 let group_id = format!("krabka-bench-{}", scenario.name);
1435 let mut consumer = match build_consumer_with_retry(
1436 idx,
1437 &bootstrap,
1438 group_id,
1439 &topic,
1440 security,
1441 ConsumerBuildPolicy {
1442 request_timeout,
1443 retry: build_retry_policy,
1444 dispatch_queue_capacity,
1445 frame_max,
1446 },
1447 )
1448 .await
1449 {
1450 Ok(c) => c,
1451 Err(e) => {
1452 return ConsumerOut {
1453 latency: histogram,
1454 msgs: 0,
1455 bytes: 0,
1456 error: format!("consumer-{idx}-build: {e:#}"),
1457 interval_msgs: Vec::new(),
1458 interval_hist: Vec::new(),
1459 };
1460 }
1461 };
1462
1463 let mut measurements = TaskMeasurements::new(grid.n, &histogram);
1464 let mut error = String::new();
1465
1466 loop {
1467 if stop.load(Ordering::Relaxed) == STATE_STOP {
1468 break;
1469 }
1470 match consumer.poll(poll_timeout).await {
1471 Ok(records) => {
1472 let now_ns =
1473 nonnegative_i64_to_u64(Utc::now().timestamp_nanos_opt().unwrap_or_default());
1474 let phase = stop.load(Ordering::Relaxed);
1475 for r in records {
1476 if let Some(val) = &r.value {
1477 let bytes = val.len() as u64;
1478 if let Some(send_nanos) = payload::read_send_nanos(val, scenario_id) {
1479 let latency = Time::from_nanos(saturating_u64_to_i64(
1482 now_ns.saturating_sub(send_nanos),
1483 ));
1484 if grid.sent_in_measurement(send_nanos) {
1490 let slice = grid.idx_for_send_ns(send_nanos);
1491 hist::record(&mut measurements.latency, latency);
1492 measurements.msgs += 1;
1493 measurements.bytes += bytes;
1494 if let Some(count) = measurements.interval_msgs.get_mut(slice) {
1495 *count += 1;
1496 }
1497 if let Some(histogram) = measurements.interval_hist.get_mut(slice) {
1498 hist::record(histogram, latency);
1499 }
1500 }
1501 } else if phase == STATE_MEASURING {
1502 measurements.bytes += bytes;
1507 }
1508 }
1509 }
1510 }
1511 Err(e) => {
1512 if error.is_empty() {
1513 error = format!("consumer-{idx}-poll: {e}");
1514 }
1515 sleep(poll_error_backoff.to_std()).await;
1516 }
1517 }
1518 }
1519 if !finished_within(request_timeout, Box::pin(consumer.close())).await && error.is_empty() {
1521 error = format!("consumer-{idx}-close-timeout");
1522 }
1523 ConsumerOut {
1524 latency: measurements.latency,
1525 msgs: measurements.msgs,
1526 bytes: measurements.bytes,
1527 error,
1528 interval_msgs: measurements.interval_msgs,
1529 interval_hist: measurements.interval_hist,
1530 }
1531}
1532
1533#[derive(Clone, Copy)]
1534struct ConsumerBuildPolicy {
1535 request_timeout: Time,
1536 retry: ConsumerBuildRetryPolicy,
1537 dispatch_queue_capacity: ConnectionDispatchQueueCapacity,
1538 frame_max: ClientFrameMax,
1539}
1540
1541async fn build_consumer_with_retry(
1542 idx: usize,
1543 bootstrap: &str,
1544 group_id: String,
1545 topic: &str,
1546 security: Option<ClientSecurity>,
1547 policy: ConsumerBuildPolicy,
1548) -> Result<Consumer> {
1549 let backoff = exponential_backoff::Backoff::new(
1550 policy.retry.attempts(),
1551 policy.retry.initial_backoff().to_std(),
1552 Some(policy.retry.max_backoff().to_std()),
1553 );
1554 for (attempt_idx, delay) in backoff.into_iter().enumerate() {
1555 let attempt = attempt_idx + 1;
1556 let result = Consumer::builder()
1557 .bootstrap(bootstrap.to_owned())
1558 .dispatch_queue_capacity(policy.dispatch_queue_capacity.get())
1559 .frame_max(policy.frame_max.size())
1560 .client_id(format!("bench-consumer-{idx}"))
1561 .group_id(group_id.clone())
1562 .subscribe(vec![topic.to_owned()])
1563 .auto_offset_reset(AutoOffsetReset::Earliest)
1564 .request_timeout(policy.request_timeout)
1565 .maybe_security(security.clone())
1568 .build()
1569 .await
1570 .context("build consumer");
1571
1572 match result {
1573 Ok(consumer) => return Ok(consumer),
1574 Err(e) => match delay {
1575 Some(delay) => {
1576 warn!(
1577 attempt,
1578 retry_after_ms = delay.as_millis(),
1579 error = %e,
1580 "consumer build failed; retrying"
1581 );
1582 sleep(delay).await;
1583 }
1584 None => {
1585 return Err(
1586 e.context(format!("build consumer failed after {attempt} attempts"))
1587 );
1588 }
1589 },
1590 }
1591 }
1592 unreachable!("exponential_backoff::Backoff yields a terminal attempt");
1593}
1594
1595async fn within<T>(limit: Time, step: impl Future<Output = T>) -> Option<T> {
1597 timeout(limit.to_std(), step).await.ok()
1598}
1599
1600async fn finished_within(limit: Time, step: impl Future) -> bool {
1602 within(limit, step).await.is_some()
1603}
1604
1605fn enqueue_limit(request_timeout: Time) -> Time {
1609 Time::from_std(DEFAULT_PRODUCER_MAX_BLOCK) + request_timeout
1610}
1611
1612fn producer_max_in_flight(enable_idempotence: bool) -> usize {
1624 if enable_idempotence {
1625 DEFAULT_PRODUCER_MAX_IN_FLIGHT
1626 } else {
1627 128
1628 }
1629}
1630
1631#[cfg(test)]
1632mod tests {
1633 use {
1634 super::*,
1635 crate::{
1636 prom::DEFAULT_PROMETHEUS_REQUEST_TIMEOUT,
1637 scenario::{FailoverSpec, ModeTag, fixture::short_scenario},
1638 },
1639 assert2::{assert, check},
1640 std::{future, sync::atomic::AtomicU16},
1641 tokio::{
1642 sync::{oneshot, oneshot::Receiver},
1643 time,
1644 },
1645 };
1646
1647 #[test]
1648 fn an_idempotent_producer_stays_within_kafka_s_in_flight_limit() {
1649 let cases = [(true, 5), (false, 128)];
1651 for (enable_idempotence, expected) in cases {
1652 check!(
1653 producer_max_in_flight(enable_idempotence) == expected,
1654 "{enable_idempotence}"
1655 );
1656 }
1657 }
1658
1659 fn cfg(broker_count: u32) -> DriverConfig {
1660 DriverConfig {
1661 bootstrap: "broker:9092".into(),
1662 client_dispatch_queue_capacity: ConnectionDispatchQueueCapacity::default(),
1663 client_frame_max: ClientFrameMax::default(),
1664 topic: "t".into(),
1665 stack: Stack::Krabka,
1666 namespace: "default".into(),
1667 prometheus_url: None,
1668 prometheus_request_timeout: DEFAULT_PROMETHEUS_REQUEST_TIMEOUT,
1669 producer_request_timeout: default_producer_request_timeout(),
1670 producer_final_drain_timeout: DEFAULT_PRODUCER_FINAL_DRAIN_TIMEOUT,
1671 sample_interval: DEFAULT_SAMPLE_INTERVAL,
1672 consumer_request_timeout: default_consumer_request_timeout(Stack::Krabka),
1673 consumer_build_retry_policy: ConsumerBuildRetryPolicy::default(),
1674 consumer_poll_timeout: default_consumer_poll_timeout(),
1675 consumer_poll_error_backoff: default_consumer_poll_error_backoff(),
1676 broker_count,
1677 scenario_id: 0,
1678 tls: None,
1679 }
1680 }
1681
1682 fn scenario(rf: i16) -> Scenario { short_scenario(rf) }
1683
1684 #[derive(Debug, PartialEq, Eq)]
1685 struct ProducerCounters {
1686 messages: u64,
1687 bytes: u64,
1688 dropped: u64,
1689 }
1690
1691 fn producer_counters(output: &ProducerOut) -> ProducerCounters {
1692 ProducerCounters {
1693 messages: output.msgs,
1694 bytes: output.bytes,
1695 dropped: output.dropped,
1696 }
1697 }
1698
1699 #[test]
1700 fn an_enqueue_may_block_for_max_block_and_one_request_timeout() {
1701 assert!(enqueue_limit(secs(2)) == secs(62));
1702 }
1703
1704 #[tokio::test(start_paused = true)]
1705 async fn a_shutdown_step_is_bounded_by_its_limit() {
1706 let outcomes = (
1707 finished_within(secs(1), future::ready(())).await,
1708 finished_within(secs(1), future::pending::<()>()).await,
1709 );
1710 assert!(outcomes == (true, false));
1711 }
1712
1713 struct ObservationHandles {
1716 grid: Grid,
1717 stop: Arc<AtomicU8>,
1718 first_ack: Arc<AtomicU64>,
1719 kill_at: Arc<AtomicU64>,
1720 }
1721
1722 impl ObservationHandles {
1723 fn new(phase: u8) -> Self {
1724 Self {
1725 grid: Grid::new(Instant::now(), 0, &scenario(1), millis(10)),
1726 stop: Arc::new(AtomicU8::new(phase)),
1727 first_ack: Arc::new(AtomicU64::new(0)),
1728 kill_at: Arc::new(AtomicU64::new(0)),
1729 }
1730 }
1731
1732 fn shared(&self) -> SharedHandles<'_> {
1733 SharedHandles {
1734 stop: &self.stop,
1735 first_ack: &self.first_ack,
1736 kill_at: &self.kill_at,
1737 }
1738 }
1739 }
1740
1741 #[tokio::test(start_paused = true)]
1742 async fn a_teardown_timeout_names_its_step_and_keeps_an_earlier_error() {
1743 let handles = ObservationHandles::new(STATE_STOP);
1744 let (grid, shared) = (handles.grid, handles.shared());
1745
1746 let mut stalled = ProducerObservations::new(4, 100, grid, shared, &hist::new().unwrap());
1747 stalled.record_teardown_timeout("flush");
1748 stalled.record_teardown_timeout("close");
1749
1750 let mut drained = ProducerObservations::new(4, 100, grid, shared, &hist::new().unwrap());
1751 drained.record_drain_timeout(3);
1752 drained.record_teardown_timeout("flush");
1753
1754 let mut blocked = ProducerObservations::new(4, 100, grid, shared, &hist::new().unwrap());
1755 blocked.record_enqueue_timeout();
1756 blocked.record_teardown_timeout("flush");
1757
1758 assert!(
1759 [
1760 (stalled.output.error.as_str(), stalled.output.dropped),
1761 (drained.output.error.as_str(), drained.output.dropped),
1762 (blocked.output.error.as_str(), blocked.output.dropped),
1763 ] == [
1764 ("producer-4-flush-timeout", 0),
1765 ("producer-4-final-drain-timeout:3", 3),
1766 ("producer-4-enqueue-timeout", 1),
1767 ]
1768 );
1769 }
1770
1771 #[tokio::test(start_paused = true)]
1772 async fn settled_acknowledgements_observe_phase_and_shared_failover_markers() {
1773 let handles = ObservationHandles::new(STATE_RUN);
1774 let ObservationHandles {
1775 grid,
1776 stop,
1777 first_ack,
1778 kill_at,
1779 } = &handles;
1780 let (grid, shared) = (*grid, handles.shared());
1781 let metadata = RecordMetadata {
1782 topic: "t".into(),
1783 partition: 0,
1784 offset: 7,
1785 timestamp_ms: 0,
1786 serialized_key_size: -1,
1787 serialized_value_size: 600,
1788 };
1789
1790 for phase in [STATE_RUN, STATE_MEASURING, STATE_STOP] {
1793 stop.store(phase, Ordering::SeqCst);
1794 first_ack.store(0, Ordering::SeqCst);
1795 let mut observations =
1796 ProducerObservations::new(4, 100, grid, shared, &hist::new().unwrap());
1797 let sent_at = Instant::now();
1798 observations.record_ack(Err(ProducerError::Server(29)), sent_at);
1799 assert!(first_ack.load(Ordering::SeqCst) == 0);
1800 time::advance(millis(5).to_std()).await;
1801 observations.record_ack(Ok(metadata.clone()), sent_at);
1802 let first_receipt = first_ack.load(Ordering::SeqCst);
1803 assert!(first_receipt > 0);
1804 let measured = u64::from(phase == STATE_MEASURING);
1805 assert!(
1806 producer_counters(&observations.output)
1807 == ProducerCounters {
1808 messages: measured,
1809 bytes: measured * 100,
1810 dropped: measured,
1811 }
1812 );
1813 assert!(observations.output.latency.len() == measured);
1814 assert!(observations.output.interval_msgs.iter().sum::<u64>() == measured);
1815 assert!(
1816 observations
1817 .output
1818 .interval_hist
1819 .iter()
1820 .map(Histogram::len)
1821 .sum::<u64>()
1822 == measured
1823 );
1824 observations.record_ack(Err(ProducerError::Server(31)), sent_at);
1825 assert!(observations.output.dropped == 2 * measured);
1826 assert!(
1827 observations.output.error
1828 == if measured == 1 {
1829 "producer-4-err: broker error_code 29"
1830 } else {
1831 ""
1832 }
1833 );
1834 assert!(
1835 observations.output.recovery_unix_ms == 0
1836 && observations.output.latency_spike_max == Time::ZERO
1837 );
1838
1839 stop.store(STATE_STOP, Ordering::SeqCst);
1840 observations.record_ack(Ok(metadata.clone()), sent_at);
1841 observations.record_ack(Err(ProducerError::Server(32)), sent_at);
1842 assert!(
1843 observations.output.msgs == measured && observations.output.dropped == 2 * measured
1844 );
1845 assert!(first_ack.load(Ordering::SeqCst) == first_receipt);
1846 let first_error = observations.output.error.clone();
1847 observations.record_drain_timeout(3);
1848 assert!(observations.output.dropped == 2 * measured + 3);
1849 assert!(
1850 observations.output.error
1851 == if first_error.is_empty() {
1852 "producer-4-final-drain-timeout:3"
1853 } else {
1854 first_error.as_str()
1855 }
1856 );
1857 }
1858
1859 stop.store(STATE_MEASURING, Ordering::SeqCst);
1860 first_ack.store(777, Ordering::SeqCst);
1861 let mut observations =
1862 ProducerObservations::new(4, 100, grid, shared, &hist::new().unwrap());
1863 let sent_at = Instant::now();
1864 time::advance(millis(20).to_std()).await;
1865 observations.record_ack(Ok(metadata.clone()), sent_at);
1866 assert!(observations.output.latency_spike_max == Time::ZERO);
1867
1868 kill_at.store(u64::MAX, Ordering::SeqCst);
1871 let sent_at = Instant::now();
1872 time::advance(millis(50).to_std()).await;
1873 observations.record_ack(Ok(metadata.clone()), sent_at);
1874 assert!(observations.output.recovery_unix_ms == 0);
1875 assert!(observations.output.latency_spike_max == millis(50));
1876
1877 kill_at.store(1, Ordering::SeqCst);
1878 let sent_at = Instant::now();
1879 time::advance(millis(20).to_std()).await;
1880 observations.record_ack(Ok(metadata.clone()), sent_at);
1881 let recovered = observations.output.recovery_unix_ms;
1882 assert!(recovered >= 1);
1883 assert!(observations.output.latency_spike_max == millis(50));
1884 let sent_at = Instant::now();
1885 time::advance(millis(70).to_std()).await;
1886 observations.record_ack(Ok(metadata), sent_at);
1887 assert!(observations.output.recovery_unix_ms == recovered);
1888 assert!(observations.output.latency_spike_max == millis(70));
1889 assert!(
1890 producer_counters(&observations.output)
1891 == ProducerCounters {
1892 messages: 4,
1893 bytes: 400,
1894 dropped: 0,
1895 }
1896 );
1897 assert!(first_ack.load(Ordering::SeqCst) == 777);
1898 }
1899
1900 struct ProducerBrokerFixture {
1903 broker: krabka_client_core::MockBroker,
1904 started: Receiver<()>,
1905 largest_batch: Arc<AtomicU64>,
1906 }
1907
1908 async fn producer_broker(metadata_error: i16, delivery_error: i16) -> ProducerBrokerFixture {
1909 use {
1910 bytes::BytesMut,
1911 krabka_protocol::{
1912 Decode as _, Encode as _,
1913 owned::{
1914 api_versions_request,
1915 api_versions_response::{ApiVersion, ApiVersionsResponse},
1916 metadata_request::{self, MetadataRequest},
1917 metadata_response::{
1918 MetadataResponse, MetadataResponseBroker, MetadataResponsePartition,
1919 MetadataResponseTopic,
1920 },
1921 produce_request::{self, ProduceRequest},
1922 produce_response::{
1923 PartitionProduceResponse, ProduceResponse, TopicProduceResponse,
1924 },
1925 },
1926 records::RecordsPayload,
1927 },
1928 };
1929
1930 let port = Arc::new(AtomicU16::new(0));
1931 let handler_port = Arc::clone(&port);
1932 let largest_batch = Arc::new(AtomicU64::new(0));
1933 let observed_batch = Arc::clone(&largest_batch);
1934 let (started, receiving) = oneshot::channel();
1935 let mut started = Some(started);
1936 let broker = krabka_client_core::MockBroker::start(move |api, version, _, body| {
1937 let mut encoded = BytesMut::new();
1938 match api {
1939 api_versions_request::API_KEY => ApiVersionsResponse {
1940 api_keys: [
1941 (metadata_request::API_KEY, 0),
1942 (produce_request::API_KEY, 3),
1943 ]
1944 .into_iter()
1945 .map(|(api_key, max_version)| ApiVersion {
1946 api_key,
1947 min_version: max_version,
1948 max_version,
1949 ..Default::default()
1950 })
1951 .collect(),
1952 ..Default::default()
1953 }
1954 .encode(&mut encoded, 0)
1955 .unwrap(),
1956 metadata_request::API_KEY => {
1957 let mut body = &body[2 + "bench-producer-0".len()..];
1958 let request = MetadataRequest::decode(&mut body, version).unwrap();
1959 let named = request.topics.is_some_and(|topics| !topics.is_empty());
1960 if named && let Some(started) = started.take() {
1961 let _ = started.send(());
1962 }
1963 MetadataResponse {
1964 brokers: vec![MetadataResponseBroker {
1965 node_id: 1,
1966 host: "127.0.0.1".into(),
1967 port: i32::from(handler_port.load(Ordering::SeqCst)),
1968 ..Default::default()
1969 }],
1970 topics: named
1971 .then(|| MetadataResponseTopic {
1972 name: Some("t".into()),
1973 error_code: metadata_error,
1974 partitions: vec![MetadataResponsePartition {
1975 partition_index: 0,
1976 leader_id: 1,
1977 replica_nodes: vec![1],
1978 isr_nodes: vec![1],
1979 ..Default::default()
1980 }],
1981 ..Default::default()
1982 })
1983 .into_iter()
1984 .collect(),
1985 ..Default::default()
1986 }
1987 .encode(&mut encoded, version)
1988 .unwrap();
1989 }
1990 produce_request::API_KEY => {
1991 let mut body = &body[2 + "bench-producer-0".len()..];
1992 let request = ProduceRequest::decode(&mut body, version).unwrap();
1993 ProduceResponse {
1994 responses: request
1995 .topic_data
1996 .into_iter()
1997 .map(|topic| TopicProduceResponse {
1998 name: topic.name,
1999 partition_responses: topic
2000 .partition_data
2001 .into_iter()
2002 .map(|partition| {
2003 let Some(RecordsPayload::V2(batches)) = partition.records
2004 else {
2005 panic!("benchmark sends v2 record batches");
2006 };
2007 for batch in batches {
2008 observed_batch.fetch_max(
2009 batch.records.len() as u64,
2010 Ordering::SeqCst,
2011 );
2012 }
2013 PartitionProduceResponse {
2014 index: partition.index,
2015 error_code: delivery_error,
2016 base_offset: 0,
2017 ..Default::default()
2018 }
2019 })
2020 .collect(),
2021 ..Default::default()
2022 })
2023 .collect(),
2024 ..Default::default()
2025 }
2026 .encode(&mut encoded, version)
2027 .unwrap();
2028 }
2029 _ => panic!("unexpected benchmark API {api}"),
2030 }
2031 Some(encoded.to_vec())
2032 })
2033 .await;
2034 port.store(broker.addr.port(), Ordering::SeqCst);
2035 ProducerBrokerFixture {
2036 broker,
2037 started: receiving,
2038 largest_batch,
2039 }
2040 }
2041
2042 #[yare::parameterized(
2043 success = { 0, 0 },
2044 delivery_rejected = { 0, 29 },
2045 metadata_rejected = { 29, 0 },
2046 )]
2047 #[test_macro(tokio::test(flavor = "multi_thread", worker_threads = 2))]
2048 async fn producer_pipeline_counts_delivery_and_admission_outcomes(
2049 metadata_error: i16,
2050 delivery_error: i16,
2051 ) {
2052 let ProducerBrokerFixture {
2053 broker,
2054 started,
2055 largest_batch,
2056 } = producer_broker(metadata_error, delivery_error).await;
2057 let mut scenario = scenario(1);
2058 scenario.linger = millis(20);
2059 let stop = Arc::new(AtomicU8::new(STATE_MEASURING));
2060 let first_ack = Arc::new(AtomicU64::new(0));
2061 let kill_at = Arc::new(AtomicU64::new(0));
2062 let cfg = DriverConfig {
2063 bootstrap: broker.addr.to_string(),
2064 producer_request_timeout: secs(1),
2065 producer_final_drain_timeout: secs(1),
2066 ..cfg(1)
2067 };
2068 let grid = Grid::new(Instant::now(), 0, &scenario, cfg.sample_interval);
2069 let mut producers = JoinSet::new();
2070 spawn_producers(
2071 &mut producers,
2072 &scenario,
2073 &cfg,
2074 None,
2075 grid,
2076 &SharedHandles {
2077 stop: &stop,
2078 first_ack: &first_ack,
2079 kill_at: &kill_at,
2080 },
2081 &hist::new().unwrap(),
2082 );
2083 assert2::assert!(producers.len() == scenario.producers);
2084 time::timeout(secs(10).to_std(), started)
2085 .await
2086 .unwrap()
2087 .expect("producer reaches topic admission");
2088 if metadata_error == 0 && delivery_error == 0 {
2093 time::timeout(secs(10).to_std(), async {
2094 while first_ack.load(Ordering::SeqCst) == 0 {
2095 time::sleep(millis(10).to_std()).await;
2096 }
2097 })
2098 .await
2099 .expect("the first record is acknowledged");
2100 }
2101 time::sleep(millis(200).to_std()).await;
2102 stop.store(STATE_STOP, Ordering::SeqCst);
2103 let output = time::timeout(secs(10).to_std(), producers.join_next())
2104 .await
2105 .unwrap()
2106 .expect("one producer task")
2107 .unwrap();
2108 assert2::assert!(producers.is_empty());
2109 broker.stop();
2110
2111 assert2::assert!(output.interval_msgs.iter().sum::<u64>() == output.msgs);
2112 assert2::assert!(output.latency.len() == output.msgs);
2113 assert2::assert!(output.bytes == output.msgs * 100);
2114 if metadata_error == 0 && delivery_error == 0 {
2115 assert2::assert!(output.msgs > 0);
2116 assert2::assert!((output.dropped, output.error.as_str()) == (0, ""));
2117 assert2::assert!(first_ack.load(Ordering::SeqCst) > 0);
2118 } else {
2119 assert2::assert!(
2120 (output.msgs, output.bytes, first_ack.load(Ordering::SeqCst)) == (0, 0, 0)
2121 );
2122 assert2::assert!(output.dropped > 0);
2123 assert2::assert!(output.error == "producer-0-err: broker error_code 29");
2124 }
2125 if metadata_error == 0 {
2126 assert2::assert!(
2127 largest_batch.load(Ordering::SeqCst) > 1,
2128 "records are queued before awaiting delivery"
2129 );
2130 } else {
2131 assert2::assert!(
2132 largest_batch.load(Ordering::SeqCst) == 0,
2133 "admission failures never reach Produce"
2134 );
2135 }
2136 }
2137
2138 #[tokio::test(start_paused = true)]
2139 async fn spawned_consumers_report_startup_failure_without_measurements() {
2140 let mut scenario = scenario(1);
2141 scenario.consumers = 2;
2142 let cfg = DriverConfig {
2143 bootstrap: String::new(),
2144 consumer_build_retry_policy: ConsumerBuildRetryPolicy::new(
2145 ConsumerBuildAttempts::new(1).unwrap(),
2146 millis(1),
2147 millis(1),
2148 )
2149 .unwrap(),
2150 ..cfg(1)
2151 };
2152 let stop = Arc::new(AtomicU8::new(STATE_MEASURING));
2153 let grid = Grid::new(Instant::now(), 0, &scenario, cfg.sample_interval);
2154 let mut consumers = JoinSet::new();
2155 spawn_consumers(
2156 &mut consumers,
2157 &scenario,
2158 &cfg,
2159 None,
2160 grid,
2161 &stop,
2162 &hist::new().unwrap(),
2163 );
2164 assert!(consumers.len() == scenario.consumers);
2165 let mut errors = Vec::new();
2166 while let Some(joined) = time::timeout(secs(600).to_std(), consumers.join_next())
2169 .await
2170 .expect("consumer startup retries end")
2171 {
2172 let output = joined.expect("consumer reports its build error");
2173 assert!((output.msgs, output.bytes, output.latency.len()) == (0, 0, 0));
2174 assert!(output.interval_msgs.is_empty() && output.interval_hist.is_empty());
2175 errors.push(output.error);
2176 }
2177 errors.sort();
2178 for (idx, error) in errors.iter().enumerate() {
2179 assert!(error.starts_with(&format!(
2180 "consumer-{idx}-build: build consumer failed after 1 attempts:"
2181 )));
2182 }
2183 assert!(errors.len() == scenario.consumers);
2184 }
2185
2186 #[test]
2187 fn client_request_timeout_defaults_preserve_policy() {
2188 check!(default_producer_request_timeout() == secs(2));
2189 check!(default_consumer_request_timeout(Stack::Krabka) == secs(5));
2190 check!(default_consumer_request_timeout(Stack::Kafka) == secs(30));
2191 }
2192
2193 #[test]
2194 fn consumer_build_retry_defaults_preserve_policy() {
2195 let policy = ConsumerBuildRetryPolicy::default();
2196
2197 check!(
2198 (
2199 policy.attempts(),
2200 policy.initial_backoff(),
2201 policy.max_backoff()
2202 ) == (6, millis(100), secs(2))
2203 );
2204 }
2205
2206 #[test]
2207 fn consumer_build_retry_accepts_positive_minimum_and_equal_backoffs() {
2208 let attempts = ConsumerBuildAttempts::new(1).expect("one attempt is valid");
2209 let policy = ConsumerBuildRetryPolicy::new(attempts, millis(1), millis(1))
2210 .expect("equal bounds are valid");
2211
2212 check!(policy.attempts() == 1);
2213 check!(policy.initial_backoff() == millis(1));
2214 check!(policy.max_backoff() == millis(1));
2215 }
2216
2217 #[yare::parameterized(
2218 zero = { "0" },
2219 text = { "not-a-number" },
2220 negative = { "-1" },
2221 overflow = { "4294967296" },
2222 )]
2223 fn consumer_build_retry_rejects_invalid_primitive_values(invalid: &str) {
2224 check!(
2225 invalid.parse::<ConsumerBuildAttempts>().is_err(),
2226 "attempts {invalid:?} must be rejected"
2227 );
2228 }
2229
2230 #[test]
2231 fn consumer_build_retry_rejects_inverted_backoff_range() {
2232 let attempts = ConsumerBuildAttempts::new(1).expect("valid attempts");
2233 assert!(ConsumerBuildRetryPolicy::new(attempts, millis(2), millis(1)).is_err());
2234 }
2235
2236 #[test]
2237 fn consumer_poll_timing_defaults_preserve_behavior() {
2238 check!(default_consumer_poll_timeout() == millis(50));
2239 check!(default_consumer_poll_error_backoff() == millis(100));
2240 }
2241
2242 #[test]
2243 fn producer_final_drain_timeout_default_preserves_behavior() {
2244 check!(DEFAULT_PRODUCER_FINAL_DRAIN_TIMEOUT == secs(10));
2245 }
2246
2247 #[test]
2248 fn sample_interval_default_preserves_behavior() {
2249 assert!(DEFAULT_SAMPLE_INTERVAL == secs(2));
2250 }
2251
2252 #[test]
2257 fn tls_params_build_ssl_security_with_server_name() {
2258 let params = TlsParams {
2259 ca_path: "/etc/bench-ca/ca.crt".into(),
2260 server_name: "demo-broker-headless.default.svc.cluster.local".into(),
2261 client_identity: None,
2262 };
2263 let sec = params.to_security();
2264 assert2::assert!(sec.protocol == ListenerProtocol::Ssl);
2265 assert2::assert!(sec.protocol.requires_tls());
2266 assert2::assert!(sec.sasl.is_none());
2267 let tls = sec.tls.expect("Ssl security carries a TLS config");
2268 assert2::assert!(
2269 tls.server_name.as_str() == "demo-broker-headless.default.svc.cluster.local"
2270 );
2271 assert2::assert!(tls.trust_store == TrustStore::PemFile("/etc/bench-ca/ca.crt".into()));
2272 assert2::assert!(tls.key_store == None);
2273 }
2274
2275 #[test]
2277 fn tls_params_carry_client_identity_for_mtls() {
2278 let params = TlsParams {
2279 ca_path: "/etc/bench-ca/ca.crt".into(),
2280 server_name: "demo-kafka-bootstrap".into(),
2281 client_identity: Some(ClientIdentity {
2282 cert_pem: "/c/tls.crt".into(),
2283 key_pem: "/c/tls.key".into(),
2284 }),
2285 };
2286 let sec = params.to_security();
2287 let tls = sec.tls.expect("TLS config present");
2288 assert2::assert!(
2289 tls.key_store
2290 == Some(KeyStore::PemFiles {
2291 certificate_chain: "/c/tls.crt".into(),
2292 private_key: "/c/tls.key".into(),
2293 key_password: None,
2294 })
2295 );
2296 }
2297
2298 #[test]
2301 fn no_tls_params_means_plaintext_security_is_none() {
2302 let c = cfg(1);
2303 assert2::assert!(c.tls.is_none());
2304 let security: Option<ClientSecurity> = c.tls.as_ref().map(TlsParams::to_security);
2305 assert2::assert!(security.is_none());
2306 }
2307
2308 #[test]
2312 fn grid_places_a_record_by_its_send_stamp() {
2313 let mut s = scenario(1);
2314 s.warmup = secs(10);
2315 s.duration = secs(60);
2316 let started_ns = 1_000_000_000_000u64;
2317 let grid = Grid::new(Instant::now(), started_ns, &s, secs(2));
2318
2319 let warmup_send = started_ns + nonnegative_i64_to_u64(secs(5).nanos_i64());
2320 let first_slice = started_ns + nonnegative_i64_to_u64(secs(11).nanos_i64());
2321 let last_slice = started_ns + nonnegative_i64_to_u64(secs(69).nanos_i64());
2322 let after_window = started_ns + nonnegative_i64_to_u64(secs(71).nanos_i64());
2323
2324 check!(!grid.sent_in_measurement(warmup_send));
2325 check!(grid.sent_in_measurement(first_slice));
2326 check!(grid.sent_in_measurement(last_slice));
2327 check!(!grid.sent_in_measurement(after_window));
2328
2329 check!(grid.idx_for_send_ns(first_slice) == 0);
2331 check!(grid.idx_for_send_ns(last_slice) == grid.n - 1);
2332 }
2333
2334 #[yare::parameterized(
2335 no_kill_marker = {
2336 FailoverStamps {
2337 kill_at_ms: 0,
2338 recovery_at_ms: 5_000,
2339 dropped: 3,
2340 latency_spike_max: millis(9),
2341 },
2342 },
2343 no_ack_after_kill = {
2344 FailoverStamps {
2345 kill_at_ms: 5_000,
2346 recovery_at_ms: 0,
2347 dropped: 3,
2348 latency_spike_max: millis(9),
2349 },
2350 },
2351 )]
2352 fn finalize_timing_records_no_disturbance(failover: FailoverStamps) {
2353 let RunTiming { disturbance, .. } = finalize_timing(true, failover, 1_000, 0);
2354 check!(disturbance.is_none());
2355 }
2356
2357 #[test]
2358 fn finalize_timing_records_a_measured_kill() {
2359 let RunTiming { disturbance, .. } = finalize_timing(
2360 true,
2361 FailoverStamps {
2362 kill_at_ms: 5_000,
2363 recovery_at_ms: 7_500,
2364 dropped: 3,
2365 latency_spike_max: millis(9),
2366 },
2367 1_000,
2368 0,
2369 );
2370 let disturbance = disturbance.expect("a measured kill gives a disturbance");
2371 check!(disturbance.kill_at_ms == TimeOffsetMs(5_000));
2372 check!(disturbance.recovery_at_ms == TimeOffsetMs(7_500));
2373 check!(disturbance.dropped == MessageCount(3));
2374 }
2375
2376 #[test]
2377 fn empty_output_preserves_inputs() {
2378 let s = scenario(1);
2379 let c = cfg(1);
2380 let out = empty_output(&s, &c, 42, vec!["a-note".into()], vec!["an-error".into()]);
2381 check!(out.wallclock_start_unix_ms == WallclockMs(42));
2382 check!(out.wallclock_end_unix_ms == WallclockMs(42));
2383 check!(out.topology.broker_count == 1);
2384 check!(out.notes == vec!["a-note".to_owned()]);
2385 check!(out.errors == vec!["an-error".to_owned()]);
2386 check!(out.first_ack == Time::ZERO);
2387 check!(out.disturbance.is_none());
2388 }
2389
2390 #[test]
2394 fn state_constants_are_distinct() {
2395 check!(STATE_RUN != STATE_MEASURING);
2396 check!(STATE_MEASURING != STATE_STOP);
2397 check!(STATE_RUN != STATE_STOP);
2398 }
2399
2400 #[tokio::test(start_paused = true)]
2401 async fn cluster_mode_rf3_with_one_broker_is_skipped() {
2402 let mut s = scenario(3);
2403 s.mode_tag = ModeTag::Cluster;
2404 let out = run(s, cfg(1)).await.expect("run returned");
2405 assert2::assert!(out.throughput.msgs_produced == 0);
2406 assert2::assert!(out.notes.iter().any(|n| n.contains("topology-mismatch")));
2407 }
2408
2409 #[tokio::test(start_paused = true)]
2410 async fn failover_request_without_rf3_records_skip_note() {
2411 let mut s = scenario(1);
2419 s.failover = Some(FailoverSpec {
2420 kill_after: secs(1),
2421 target: "partition0_leader".into(),
2422 });
2423 s.producers = 0;
2424 s.consumers = 0;
2425 s.warmup = Time::ZERO;
2426 s.duration = Time::ZERO;
2427 let out = run(s, cfg(1)).await.expect("run returned");
2428 check!(
2429 out.notes
2430 .iter()
2431 .any(|n| n.contains("skipped:failover-needs-rf3"))
2432 );
2433 }
2434}