Skip to main content

krabka_bench_driver/
workload.rs

1//! Producer and consumer workload runners.
2//!
3//! This module builds N producers and M consumers as members of one group, runs
4//! the warmup phase and then the measurement phase, triggers an optional
5//! failover mid-measurement, and merges the per-task histograms into the public
6//! `LatencyPercentiles` shape.
7
8use {
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
59/// Width of one time-series sample bucket. The driver splits the measurement
60/// window into slices of the configured interval. Each producer task and
61/// consumer task tallies its per-slice counts and its per-slice latency
62/// histogram locally, so the hot path holds no shared locks. `run()` then merges
63/// them into the `samples` series.
64type AckResult = Result<RecordMetadata, ProducerError>;
65type AckFuture = Pin<Box<dyn Future<Output = SettledAck> + Send>>;
66
67/// A send that settled, with the instant it was enqueued.
68struct 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/// Return the validated producer request-timeout default.
86///
87/// # Panics
88///
89/// Panics if the named default is not protocol-safe.
90#[must_use]
91pub const fn default_producer_request_timeout() -> Time { DEFAULT_PRODUCER_REQUEST_TIMEOUT }
92
93/// Return the validated consumer request-timeout default for `stack`.
94///
95/// # Panics
96///
97/// Panics if the selected named default is not protocol-safe.
98#[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/// How many times the driver tries to build a consumer before it gives up.
107#[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    /// Validate a complete consumer-build retry policy.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error when the initial backoff exceeds the maximum.
138    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/// The fixed sampling grid. Every task gets a copy, so all tasks bucket into
180/// the same slices.
181#[derive(Clone, Copy)]
182struct Grid {
183    /// When the measurement window starts, which is the end of warmup.
184    meas_start: Instant,
185    /// The same instant as an epoch nanosecond, and the close of the window.
186    ///
187    /// A consumer classifies a record by the epoch stamp the producer wrote
188    /// into it, not by the monotonic clock at poll time, so it needs the window
189    /// on the same scale the payload carries.
190    meas_start_ns: u64,
191    meas_end_ns: u64,
192    interval_ms: u64,
193    /// Number of slices covering the measurement window.
194    n: usize,
195}
196
197impl Grid {
198    /// The grid that covers `scenario`'s measurement window. That window opens
199    /// after its warmup has elapsed from `started_at`. The grid is always at
200    /// least one slice wide, so a run shorter than a slice still has somewhere
201    /// to tally into.
202    ///
203    /// `started_ns` is the epoch nanosecond that `started_at` was read at. The
204    /// two are one instant on two clocks.
205    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    /// Whether a record sent at epoch nanosecond `send_ns` belongs to the
221    /// measurement window.
222    ///
223    /// A saturating consumer can still be draining warmup backlog when the
224    /// window opens. Counting those records would report warmup throughput and
225    /// warmup-aged latency as measurement, which is what the scenario
226    /// discarded the warmup to avoid.
227    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    /// Slice index for a record sent at epoch nanosecond `send_ns`, clamped
232    /// into `[0, n-1]`. Callers check [`Self::sent_in_measurement`] first.
233    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    /// Slice index for an event observed at `now`, clamped into `[0, n-1]`.
239    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    /// The width of one slice, as the extent the per-slice rates divide by.
252    fn interval(&self) -> Time { Time::from_millis(saturating_u64_to_i64(self.interval_ms)) }
253}
254
255/// Parameters wired in from `main` and the CLI. This struct is distinct from
256/// `Scenario` because the scenario YAML describes *what* to run, and this struct
257/// describes *where* it runs.
258pub 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    /// How long one consumer `poll` waits for records.
273    pub consumer_poll_timeout: Time,
274    /// How long the consumer loop sleeps after a poll error before retrying.
275    pub consumer_poll_error_backoff: Time,
276    pub broker_count: u32,
277    pub scenario_id: u64,
278    /// TLS data-path config. `None` means plaintext, the default benchmark path.
279    pub tls: Option<TlsParams>,
280}
281
282/// TLS knobs for the producer and consumer data path.
283///
284/// When this is present, both clients dial the broker's TLS listener and the
285/// produce and fetch byte stream is encrypted. Krabka can serve that path with
286/// kTLS sendfile, and Strimzi serves it with JVM SSL.
287#[derive(Debug, Clone)]
288pub struct TlsParams {
289    /// Mounted CA bundle (`ca.crt`) that the client trusts to verify the broker
290    /// serving cert. It comes from the per-stack cluster-CA Secret.
291    pub ca_path: PathBuf,
292    /// SNI server name that the client presents in the TLS `ClientHello`. The
293    /// broker matches it against a SAN on its serving cert. LOAD-BEARING: the
294    /// client DNS-resolves the bootstrap to a pod IP and dials by IP, so the SNI
295    /// is NOT derived from the bootstrap host. You must set it to a cert-SAN
296    /// name. For krabka that name is
297    /// `demo-broker-headless.<ns>.svc.cluster.local`, and for Strimzi it is
298    /// `demo-kafka-bootstrap`.
299    pub server_name: String,
300    /// Optional mTLS client identity. One-way TLS with `None` is enough for the
301    /// benchmark. Set this only if the listener requires client auth.
302    pub client_identity: Option<ClientIdentity>,
303}
304
305/// The PEM files of an mTLS client identity.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct ClientIdentity {
308    /// Path of the PEM certificate chain.
309    pub cert_pem: PathBuf,
310    /// Path of the PEM private key.
311    pub key_pem: PathBuf,
312}
313
314impl TlsParams {
315    /// Builds the [`ClientSecurity`] for an `Ssl` listener. The default is
316    /// one-way TLS, which authenticates the server against the trust roots from
317    /// the mounted CA. It becomes mutual TLS when [`Self::client_identity`] is
318    /// set.
319    #[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
341/// Top-level entry point that `main` calls. Returns the populated
342/// `RunOutput`. The caller must serialise it to disk.
343/// # Errors
344/// Returns an error when input data is invalid, required I/O fails, or the destination rejects the generated report or audit event.
345/// # Panics
346/// Panics if synchronized state is poisoned or validated input is missing a field required to produce the output.
347pub async fn run(scenario: Scenario, cfg: DriverConfig) -> Result<RunOutput> {
348    // One reading of the wall clock, kept at both scales. The nanosecond form
349    // is what a consumer compares a record's embedded send stamp against.
350    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    // Time-series sampling grid: split the measurement window into fixed
355    // slices. Computed up front (meas_start is t_start + warmup) so it can be
356    // handed to each task at spawn time.
357    let grid = Grid::new(t_start, started_ns, &scenario, cfg.sample_interval);
358    // Every histogram of the run is built empty from this one, so they share
359    // its bounds and merge without error.
360    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    // The one marker every producer reads to learn that the broker is gone.
380    // It stays zero until the delete succeeds, so it is created before the
381    // producers start and not beside the fault task.
382    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    // ── Join all tasks ──────────────────────────────────────────────────────
434    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
601/// The per-interval message counts and latency histograms that one side of the
602/// run (producers or consumers) tallied.
603struct 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    // The histogram counts microseconds; an empty slice measured nothing.
615    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/// The run-phase signal, the first-ack stamp, and the kill marker that every
648/// producer task shares.
649#[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
656/// Spawn one producer task per `scenario.producers`.
657///
658/// The three shared handles travel together because they are one thing: the
659/// run-phase signal, the first-ack stamp, and the kill marker every task reads.
660fn 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
693/// Wait for every consumer task, merging what each measured. A task that
694/// failed or panicked leaves its error in `errors`.
695async 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
724/// Spawn one consumer task per `scenario.consumers`.
725fn 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/// What the producers measured around a failover kill.
770#[derive(Debug, Clone, Copy)]
771struct FailoverStamps {
772    /// Epoch milliseconds of the pod delete, or zero when it never landed.
773    kill_at_ms: u64,
774    /// Epoch milliseconds of the first ack at or after the kill, or zero.
775    recovery_at_ms: u64,
776    /// Sends the producers counted as dropped.
777    dropped: u64,
778    /// The largest ack latency seen after the kill.
779    latency_spike_max: Time,
780}
781
782/// The disturbance a run measured and the extent from run start to the first
783/// ack.
784struct RunTiming {
785    disturbance: Option<Disturbance>,
786    first_ack: Time,
787}
788
789/// The disturbance a failover run measured, and the run's first-ack extent.
790///
791/// A disturbance is written only when the delete actually landed and a record
792/// was acknowledged after it. `failover.kill_at_ms` is the shared kill marker,
793/// which stays zero when the pod delete failed; `failover.recovery_at_ms` is
794/// the first ack at or after that marker. Either one at zero means the run has
795/// no failover evidence, and an empty disturbance is the honest answer. The
796/// report's gate reads that as a violation rather than as an instant recovery.
797fn 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    // Both stamps are epoch milliseconds — coordinates — so their difference is
811    // the extent from run start to the first ack. No ack at all means no extent.
812    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
828/// Whether failover is active for the run, and the early output of a run that
829/// the topology cannot host.
830struct 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/// Capture broker CPU and memory for the window the run measured.
867///
868/// `measured_until_ns` is the epoch nanosecond at which the measurement window
869/// closed, which is not the same instant as this call: joining the tasks and
870/// draining the producers happens in between, and a failover run can wait out
871/// its final-drain timeout there. The queries are evaluated at the close of the
872/// window, so the lookback covers what was measured.
873/// The wall-clock window of a run, in epoch milliseconds.
874#[derive(Debug, Clone, Copy)]
875struct WallclockWindow {
876    start_ms: i64,
877    end_ms: i64,
878}
879
880/// The resource summary of a run and its broker resource time series.
881struct 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; // warmup phase, record-but-discard
952const 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    /// Measurement-window tallies, one per slice of the [`Grid`]. This is empty
964    /// on a build failure. `run()` merges by index with bounds checks.
965    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
1013// ── Producer task ───────────────────────────────────────────────────────────
1014
1015struct 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    /// Epoch millisecond at which the failover task deleted the broker pod, or
1059    /// zero while no delete has succeeded. This is the only thing that says a
1060    /// kill happened. A producer error says the send failed and nothing more.
1061    kill_at: Arc<AtomicU64>,
1062    final_drain_timeout: Time,
1063    /// The empty histogram every latency histogram of the task is built from.
1064    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    /// Empty measurements over `intervals` slices, each histogram built empty
1077    /// from `histogram`.
1078    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
1091/// One producer's acknowledgement tallies and the run controls they observe.
1092struct 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    /// Record a send that `enqueue` held past the producer's own blocking
1129    /// limit. The record counts as dropped, and the first error stays.
1130    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    /// Record a shutdown step (`flush` or `close`) that outlived its limit.
1138    /// The first error a producer hit stays the one it reports.
1139    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    /// Account for every send still outstanding at the final drain deadline.
1146    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    /// Record one settled send in the phase observed at receipt.
1154    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                    // Recovery and the spike are measured against the
1170                    // shared kill marker, not against this task's own
1171                    // errors. The marker is published only after the pod
1172                    // delete succeeds, so an ack before it belongs to the
1173                    // healthy cluster and is not a recovery.
1174                    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    // Idempotence forces acks=All; turn it off whenever the scenario
1229    // requested something weaker.
1230    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        // A produce to a healthy broker completes in low single-digit ms, so a
1243        // 2s ceiling should not trip under normal load — but it bounds how long a
1244        // send to a *killed* leader blocks before the client re-routes, so the
1245        // measured failover recovery reflects the cluster's leader re-election
1246        // rather than the 30s default request timeout.
1247        .request_timeout(request_timeout.to_std())
1248        // `None` → plaintext (default). `Some` → all produce traffic for this
1249        // task goes over the broker's TLS listener.
1250        .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            // Counter behind each record key, so keys vary within this task. The task
1269            // index is folded in, so two tasks never mint the same key.
1270            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                // The scenario rate is for the whole run, so each task paces at its
1286                // share of it. `Pacer` floors the result at one message a second.
1287                LoadMode::FixedRate { rate } => {
1288                    Some(Pacer::new(rate / to_f64(scenario.producers.max(1))))
1289                }
1290            };
1291
1292            // Pipeline depth: how many records may be in flight (awaiting their ack)
1293            // before the loop applies back-pressure. Without pipelining the loop sent
1294            // one record and awaited its ack before the next, capping a task's
1295            // throughput at 1/RTT regardless of cluster capacity — so the numbers
1296            // measured the driver, not the cluster. A bounded window lets throughput
1297            // track the cluster while keeping memory in hand.
1298            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                // Drain every ack that has already settled — promptly, so the recorded
1308                // latency is the real send→ack time, not how long a record waited in
1309                // the in-flight set behind an older stuck send.
1310                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                // Back-pressure: at capacity, block until any send completes. Waiting
1318                // for the oldest can make one dead-leader send hide live-partition acks.
1319                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                // A scenario that sets `key_size` gets keyed records. The key changes
1333                // per record, so the partitioner spreads them the way a real keyed
1334                // workload spreads, and the key's bytes are on the wire the way the
1335                // scenario asked for. `key_size: 0` stays keyless.
1336                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                    // `enqueue` outlived the producer's own `max_block`: the producer
1354                    // is stuck, and another send would only wait again. Stop sending;
1355                    // the bounded drain and teardown below still let the run report.
1356                    None => {
1357                        observations.record_enqueue_timeout();
1358                        break;
1359                    }
1360                }
1361            }
1362
1363            // Drain anything still outstanding when the measurement window closed, but
1364            // do not let a stuck producer retry slot keep the whole benchmark job alive
1365            // forever. Unsettled sends are counted as drops so failover reports capture
1366            // the stall instead of timing out without a JSON result.
1367            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            // `flush` and `close` wait on the same sends the drain gave up on, so
1388            // they get the same bound: a producer that stops issuing requests must
1389            // not hold the run open past its report.
1390            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
1402// ── Consumer task ───────────────────────────────────────────────────────────
1403
1404struct ConsumerTask {
1405    setup: TaskSetup,
1406    build_retry_policy: ConsumerBuildRetryPolicy,
1407    poll_timeout: Time,
1408    poll_error_backoff: Time,
1409    /// The empty histogram every latency histogram of the task is built from.
1410    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                            // Both stamps are epoch nanoseconds; their
1480                            // difference is the end-to-end extent.
1481                            let latency = Time::from_nanos(saturating_u64_to_i64(
1482                                now_ns.saturating_sub(send_nanos),
1483                            ));
1484                            // Classify by when the producer SENT the record,
1485                            // not by the phase this poll returned in. A lagging
1486                            // consumer drains warmup backlog after the window
1487                            // opens, and counting it reports warmup throughput
1488                            // and warmup-aged latency as measurement.
1489                            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                            // Non-bench record, left over from an earlier run.
1503                            // It carries no send stamp, so the poll phase is
1504                            // the only thing that can place it. Count bytes and
1505                            // not end-to-end latency.
1506                            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    // A close that outlives one request timeout would only hold the run open.
1520    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            // `None` → plaintext (default). `Some` → all fetch traffic for this
1566            // task goes over the broker's TLS listener (kTLS sendfile on krabka).
1567            .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
1595/// Await `step` for at most `limit`. `None` when the limit ran out first.
1596async fn within<T>(limit: Time, step: impl Future<Output = T>) -> Option<T> {
1597    timeout(limit.to_std(), step).await.ok()
1598}
1599
1600/// Await `step` for at most `limit`. `false` when the limit ran out first.
1601async fn finished_within(limit: Time, step: impl Future) -> bool {
1602    within(limit, step).await.is_some()
1603}
1604
1605/// How long the driver lets one `enqueue` take. The producer itself fails a
1606/// send that waits past `max_block`, which the driver leaves at its default,
1607/// so a send still pending one request timeout after that is stuck.
1608fn enqueue_limit(request_timeout: Time) -> Time {
1609    Time::from_std(DEFAULT_PRODUCER_MAX_BLOCK) + request_timeout
1610}
1611
1612/// The producer's `max_in_flight_per_connection` for a scenario.
1613///
1614/// The producer pipelines one in-flight request per partition and uses this
1615/// value as the cross-partition fan-out cap: how many distinct partitions it
1616/// services concurrently per drain cycle. The default (5) throttles topics with
1617/// more partitions than that, and the scenarios run 6 to 100 partitions, so a
1618/// non-idempotent producer gets a cap well past the largest partition count.
1619///
1620/// An idempotent producer refuses more than 5, as Kafka's
1621/// `ProducerConfig.postProcessAndValidateIdempotenceConfigs` does, so an
1622/// `acks: all` scenario, which turns idempotence on, takes Kafka's limit.
1623fn 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        // (enable_idempotence, expected cap)
1650        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    /// The phase signal and failover markers a producer's observations
1714    /// read, for a one-producer scenario sampled every 10 ms.
1715    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        // Warmup and stopped receipts publish the first acknowledgement, but
1791        // only measuring receipts contribute throughput, latency, or drops.
1792        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        // A marker beyond this receipt does not establish recovery. The
1869        // original spike ledger still records latency once a marker exists.
1870        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    /// A mock broker for the producer pipeline test, with the signal that a
1901    /// metadata request named a topic and the largest batch it saw.
1902    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        // A short measurement window exercises the bounded pipeline and its
2089        // final drain, using the same phase signal as a benchmark run. When
2090        // delivery succeeds the window opens at the first ack, so a slow
2091        // runner still measures something.
2092        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        // Empty bootstrap does no DNS or TCP work; paused time advances the
2167        // consumer client's internal startup retries to their deadline.
2168        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    // TLS enabled: a CA path + server_name must build a `ClientSecurity` whose
2253    // protocol is `Ssl`, whose TLS config carries the mounted CA as trust roots
2254    // and the explicit SNI, and (one-way TLS) no client identity. This is the
2255    // exact shape passed to `.maybe_security(...)` on both clients.
2256    #[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    // mTLS variant: a client identity threads through to the TLS config.
2276    #[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    // Disabled: when `DriverConfig.tls` is `None`, the derived security is
2299    // `None` (plaintext) — the unchanged default benchmark path.
2300    #[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    /// A record produced during warmup but polled after the window opens
2309    /// belongs to the warmup. The grid says so from the record's own send
2310    /// stamp, whatever the consumer's lag was.
2311    #[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        // 1s into a 2s-slice grid is slice 0; 59s in is the last slice.
2330        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    // The state byte is shared across producer/consumer tasks; verify the
2391    // three values are pairwise distinct so a flat AtomicU8 can encode them
2392    // without ambiguity.
2393    #[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        // Scenario asks for failover, but RF=1 + 1 broker → driver must
2412        // record a skip note. The scenario has no producers or consumers, so
2413        // the run builds no client and needs no broker. A consumer pointed at
2414        // the unresolvable "broker:9092" retries its startup for five minutes
2415        // and looks the name up again on every attempt: the paused clock makes
2416        // the backoff free, but each lookup takes real time, a few
2417        // milliseconds on one machine and far longer on another.
2418        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}