Skip to main content

krabka_bench_driver/soak/
contract.rs

1//! The sustained-load contract: what a soak declares before it runs.
2//!
3//! A contract is a YAML file under `bench/soak/`. It fixes every number the
4//! verdict is judged against -- host resources, disk capacity, seed, client
5//! settings, throughput targets, latency budgets, recovery deadlines and the
6//! fault plan -- so that a run cannot be judged against limits chosen after
7//! its results were seen. [`Contract::validate`] refuses a contract that could
8//! not be run safely or judged honestly, and lists every reason at once.
9//!
10//! Every size, extent and rate carries its unit, as a benchmark scenario does:
11//! `64KiB`, `15s`, `2000/s`.
12
13use {
14    super::lifecycle::{Lifecycle, Side},
15    crate::scenario::{Acks, Compression, bounded},
16    anyhow::{Context as _, bail},
17    krabka_units::{fmt::Human as _, prelude::*, serde_units},
18    serde::{Deserialize, Serialize},
19    sha2::{Digest as _, Sha256},
20    std::{collections::BTreeSet, fmt, fs, path::Path},
21};
22
23// The executor uses the broker's default liveness sweep; its config test pins this cadence.
24const EXECUTOR_LIVENESS_TICK_INTERVAL: Time = secs(1);
25
26/// Whether a contract is a soak or the short self-check of the soak machinery.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum Profile {
30    /// A run whose verdict is evidence about the broker. It must cover every
31    /// load path the milestone names, and it may inject nothing into its own
32    /// evidence.
33    Soak,
34    /// A run of minutes whose verdict is evidence about the harness. It injects
35    /// one missing record and one missed deadline and passes only when both,
36    /// and nothing else, are detected.
37    SelfCheck,
38    /// A run whose verdict is evidence about upgrading, reconfiguring and
39    /// rolling back release images under load. Its plan holds lifecycle
40    /// transitions, and each one must reconcile at its checkpoint as well as at
41    /// the end. See [`super::lifecycle`].
42    Lifecycle,
43    /// A disaster-recovery drill: the load runs through a capture and into the
44    /// loss of the whole source cluster, and the verdict reconciles what a
45    /// fresh cluster restored from the capture serves. Every record
46    /// acknowledged before the capture started must come back; the records
47    /// acknowledged after it that do not are the recovery point, judged
48    /// against the declared [`RecoveryObjectives`].
49    DisasterRecovery,
50}
51
52/// A sustained-load and failure contract.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct Contract {
56    pub name: String,
57    pub profile: Profile,
58    /// Seeds the fault-schedule jitter and the choice of an `any` target, so
59    /// two runs of one contract inject the same faults at the same offsets.
60    pub seed: u64,
61    /// Length of the load, from the first record to the start of reconciliation.
62    #[serde(with = "bounded::positive_time")]
63    pub duration: Time,
64    /// The fault-free tail at the end of the load. No fault may start or still
65    /// be recovering inside it, so reconciliation reads a settled cluster.
66    #[serde(with = "bounded::nonnegative_time")]
67    pub settle: Time,
68    pub host: HostResources,
69    pub cluster: ClusterShape,
70    pub clients: ClientSettings,
71    pub paths: Vec<LoadPath>,
72    pub deadlines: RecoveryDeadlines,
73    #[serde(default)]
74    pub faults: Vec<FaultPlan>,
75    #[serde(default)]
76    pub self_check: Option<SelfCheck>,
77    /// The release a `lifecycle` contract starts on. Left out of the encoding
78    /// when absent, so the digest of a contract without one is unchanged.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub lifecycle: Option<Lifecycle>,
81    /// The recovery objectives of a disaster-recovery drill. Omitted from the
82    /// encoding when absent, so a soak's digest does not change with it.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub disaster_recovery: Option<RecoveryObjectives>,
85}
86
87/// What a disaster-recovery drill must achieve, declared before it runs.
88///
89/// The capture boundary is the instant the capture starts. A record the
90/// cluster acknowledged before it is inside the boundary and must be restored.
91/// A record acknowledged after it may be lost: the drill counts those, and the
92/// recovery point objective bounds how far back the oldest lost one may be.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct RecoveryObjectives {
96    /// How long the load keeps running after the capture starts, before the
97    /// source cluster is lost. Records acknowledged in it are the ones a
98    /// restore from that capture may lose.
99    #[serde(with = "bounded::positive_time")]
100    pub post_capture_load: Time,
101    /// The recovery point objective: the most time, back from the loss, whose
102    /// acknowledged records may be missing from the restored cluster.
103    #[serde(with = "bounded::positive_time")]
104    pub rpo: Time,
105    /// The recovery time objective: from the loss until a fresh cluster,
106    /// restored from the capture alone, serves authenticated produce and
107    /// consume of the captured data.
108    #[serde(with = "bounded::positive_time")]
109    pub rto: Time,
110}
111
112/// The machine a run is entitled to. Recorded so that a result names the
113/// resources it was measured on, and so a disk fill is bounded by them.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct HostResources {
117    pub cpus: u32,
118    #[serde(with = "bounded::positive_size")]
119    pub memory: ByteSize,
120    /// Each broker's data volume. A disk-pressure fault fills to a fraction of
121    /// this, never of whatever the host happens to have free.
122    #[serde(with = "bounded::positive_size")]
123    pub disk_capacity: ByteSize,
124    /// Free space a disk fill must leave on the host file system, whatever the
125    /// watermark asks for. The fill is refused rather than shrunk below it.
126    #[serde(with = "bounded::positive_size")]
127    pub disk_reserve: ByteSize,
128}
129
130/// The cluster under test.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct ClusterShape {
134    pub brokers: u32,
135    pub replication_factor: i16,
136    pub min_insync_replicas: i16,
137    /// The controller's check-quorum window: Kafka's
138    /// `controller.quorum.fetch.timeout.ms`. A pause shorter than this must not
139    /// move the controller, so a pause that does not intend a failover has to
140    /// stay below it.
141    #[serde(with = "bounded::positive_time")]
142    pub check_quorum_window: Time,
143    /// Kafka's `broker.session.timeout.ms`. Omitted contracts keep the
144    /// executor's default and their original evidence digest. The executor's
145    /// three-second heartbeat cadence must fit inside this session.
146    #[serde(
147        default,
148        skip_serializing_if = "Option::is_none",
149        with = "serde_units::human::option_time"
150    )]
151    pub broker_session_timeout: Option<Time>,
152    /// Kafka's `replica.lag.time.max.ms`, recorded because it decides whether a
153    /// paused or slowed follower leaves the ISR at all.
154    #[serde(with = "bounded::positive_time")]
155    pub replica_lag_time_max: Time,
156}
157
158/// Client settings, shared by every path of their kind.
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct ClientSettings {
162    pub producer: ProducerSettings,
163    pub transactions: TransactionSettings,
164    pub consumer: ConsumerSettings,
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct ProducerSettings {
170    pub acks: Acks,
171    #[serde(default)]
172    pub compression: Compression,
173    #[serde(with = "bounded::nonnegative_time")]
174    pub linger: Time,
175    #[serde(with = "bounded::positive_size")]
176    pub batch_size: ByteSize,
177    #[serde(with = "bounded::positive_time")]
178    pub request_timeout: Time,
179    #[serde(with = "bounded::positive_time")]
180    pub delivery_timeout: Time,
181    /// How many times the application sends one record before it gives up.
182    /// A record sent more than once may legitimately appear more than once.
183    pub max_send_attempts: u32,
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187#[serde(deny_unknown_fields)]
188pub struct TransactionSettings {
189    #[serde(with = "bounded::positive_time")]
190    pub timeout: Time,
191    pub records_per_transaction: u32,
192    /// Every `abort_every`-th transaction is aborted on purpose, so aborted
193    /// data exists for a `read_committed` reader to wrongly see.
194    pub abort_every: u32,
195}
196
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct ConsumerSettings {
200    #[serde(with = "bounded::positive_time")]
201    pub session_timeout: Time,
202    /// How often a committing consumer commits what it has polled.
203    #[serde(with = "bounded::positive_time")]
204    pub commit_interval: Time,
205    #[serde(with = "bounded::positive_time")]
206    pub poll_timeout: Time,
207}
208
209/// What a path's producer promises about its records.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum Semantics {
213    /// An idempotent producer: a record the application sent once appears at
214    /// most once, and an acknowledged one appears exactly once.
215    Idempotent,
216    /// A transactional producer: committed transactions appear whole under
217    /// `read_committed`, aborted ones never do.
218    Transactional,
219    /// An idempotent producer over a compacted topic: the latest acknowledged
220    /// value of every key survives compaction.
221    Compacted,
222}
223
224/// Where a path's topic keeps its data.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum Storage {
228    /// Local segments only.
229    Classic,
230    /// KIP-405 tiered storage, with a local retention short enough that the
231    /// read-back from the earliest offset is served from the remote tier.
232    Tiered,
233    /// The diskless data path: the quorum WAL and the object store.
234    Diskless,
235}
236
237/// A consumer's isolation level.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
239#[serde(rename_all = "snake_case")]
240pub enum Isolation {
241    ReadCommitted,
242    ReadUncommitted,
243}
244
245impl fmt::Display for Isolation {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        f.write_str(match self {
248            Self::ReadCommitted => "read_committed",
249            Self::ReadUncommitted => "read_uncommitted",
250        })
251    }
252}
253
254/// Latency percentiles a path must stay under outside its fault windows.
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct LatencyBudget {
258    #[serde(with = "bounded::positive_time")]
259    pub p50: Time,
260    #[serde(with = "bounded::positive_time")]
261    pub p99: Time,
262    #[serde(with = "bounded::positive_time")]
263    pub p999: Time,
264}
265
266/// One load path: a producer, its topic, and the consumers that read it.
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub struct LoadPath {
270    pub name: String,
271    pub semantics: Semantics,
272    pub storage: Storage,
273    pub partitions: i32,
274    /// The paced produce rate.
275    #[serde(with = "bounded::positive_rate")]
276    pub rate: Frequency,
277    /// The lowest achieved produce rate, outside fault windows, that passes.
278    #[serde(with = "bounded::positive_rate")]
279    pub throughput_floor: Frequency,
280    #[serde(with = "bounded::positive_size")]
281    pub payload: ByteSize,
282    /// Distinct keys a compacted path cycles through.
283    #[serde(default)]
284    pub key_space: Option<u64>,
285    /// The isolation levels this path is consumed and read back at.
286    pub consumers: Vec<Isolation>,
287    /// Whether the consumers commit offsets, so committed offsets are checked
288    /// across every fault.
289    #[serde(default)]
290    pub commit_offsets: bool,
291    /// Acknowledgement latency of the producer.
292    pub produce_latency: LatencyBudget,
293    /// Create-time to poll latency of the consumers.
294    pub consume_latency: LatencyBudget,
295}
296
297/// How long the cluster has to recover from a fault.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct RecoveryDeadlines {
301    /// From the fault until every partition the target led has a live leader
302    /// elsewhere.
303    #[serde(with = "bounded::positive_time")]
304    pub leader_failover: Time,
305    /// From the fault's end until every partition's ISR is its full replica set
306    /// again.
307    #[serde(with = "bounded::positive_time")]
308    pub isr_rejoin: Time,
309    /// From the fault until another node is the active controller.
310    #[serde(with = "bounded::positive_time")]
311    pub controller_failover: Time,
312    /// From `SIGCONT` until every partition's ISR is its full replica set again.
313    #[serde(with = "bounded::positive_time")]
314    pub catch_up_after_pause: Time,
315    /// From a certificate rotation until the rotated broker serves the new
316    /// certificate. Required when the plan rotates one.
317    #[serde(
318        default,
319        skip_serializing_if = "Option::is_none",
320        with = "serde_units::human::option_time"
321    )]
322    pub certificate_rotation: Option<Time>,
323    /// From a reassignment until it is complete: no partition of the path is
324    /// still reassigning, each has the planned replicas, and its ISR is full.
325    /// Required when the plan reassigns.
326    #[serde(
327        default,
328        skip_serializing_if = "Option::is_none",
329        with = "serde_units::human::option_time"
330    )]
331    pub reassignment: Option<Time>,
332}
333
334/// A recovery the harness measures after a fault.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
336#[serde(rename_all = "snake_case")]
337pub enum RecoveryCheck {
338    LeaderFailover,
339    IsrRejoin,
340    ControllerFailover,
341    CatchUpAfterPause,
342    CertificateServed,
343    ReassignmentComplete,
344}
345
346impl RecoveryCheck {
347    /// The deadline the contract gives this check. A lifecycle check whose
348    /// deadline the contract left out has none to meet: [`Contract::validate`]
349    /// refuses a plan that needs it.
350    #[must_use]
351    pub fn deadline(self, deadlines: &RecoveryDeadlines) -> Time {
352        match self {
353            Self::LeaderFailover => deadlines.leader_failover,
354            Self::IsrRejoin => deadlines.isr_rejoin,
355            Self::ControllerFailover => deadlines.controller_failover,
356            Self::CatchUpAfterPause => deadlines.catch_up_after_pause,
357            Self::CertificateServed => deadlines.certificate_rotation.unwrap_or(Time::ZERO),
358            Self::ReassignmentComplete => deadlines.reassignment.unwrap_or(Time::ZERO),
359        }
360    }
361}
362
363impl fmt::Display for RecoveryCheck {
364    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
365        f.write_str(match self {
366            Self::LeaderFailover => "leader_failover",
367            Self::IsrRejoin => "isr_rejoin",
368            Self::ControllerFailover => "controller_failover",
369            Self::CatchUpAfterPause => "catch_up_after_pause",
370            Self::CertificateServed => "certificate_served",
371            Self::ReassignmentComplete => "reassignment_complete",
372        })
373    }
374}
375
376/// Which broker a fault lands on. All but `broker` are resolved when the
377/// fault starts, against the cluster as it is then, and the resolved broker is
378/// recorded with the fault.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum Target {
382    /// The active controller.
383    Controller,
384    /// The leader of the first path's partition 0.
385    Leader,
386    /// A broker that is neither the controller nor that leader.
387    Follower,
388    /// A broker chosen from the seed when the schedule is expanded.
389    Any,
390    /// The broker at this index, counting from zero.
391    Broker(u32),
392}
393
394/// How a slow follower is slowed.
395#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
397pub enum Slowdown {
398    /// `tc qdisc ... netem delay` in the broker's network namespace, run from
399    /// `image`, which must carry `tc`.
400    Netem {
401        #[serde(with = "bounded::positive_time")]
402        delay: Time,
403        #[serde(with = "bounded::nonnegative_time")]
404        jitter: Time,
405        image: String,
406    },
407    /// A CPU quota on the broker's container, which slows its fetches and its
408    /// disk writes alike.
409    CpuThrottle { cpus: f64 },
410}
411
412/// What a fault does.
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
415pub enum FaultKind {
416    /// Fill the target's data volume to `watermark` of `host.disk_capacity`.
417    DiskPressure {
418        #[serde(with = "serde_units::human::ratio")]
419        watermark: Ratio,
420    },
421    /// Slow the target's replication.
422    SlowFollower { slowdown: Slowdown },
423    /// `SIGSTOP` the target for the fault's duration, then `SIGCONT` it.
424    Pause {
425        /// Whether the pause is meant to outlast the check-quorum window and
426        /// move leadership. A pause that is not must stay below the window.
427        #[serde(default)]
428        intends_failover: bool,
429    },
430    /// Stop the target and start it again after the fault's duration.
431    Restart {
432        /// `SIGTERM` and a controlled shutdown when true, `SIGKILL` when false.
433        graceful: bool,
434    },
435    /// A lifecycle transition: stop the target with a controlled shutdown,
436    /// and after the fault's duration start it again, on the same data, from
437    /// the image of the `to` release.
438    Roll { to: Side },
439    /// A lifecycle transition: give the target's TLS listener a certificate
440    /// from a new CA, with the same DN and SANs, and a truststore that holds
441    /// both CAs. The files are rewritten in place and the listener's
442    /// keystore and truststore are altered to them, which the broker reloads
443    /// without a restart.
444    CertificateRotation,
445    /// A lifecycle transition: drain the target of `path`. Every replica of
446    /// the path that the target holds moves, with
447    /// `AlterPartitionReassignments`, to a broker that holds no replica of that
448    /// partition, so the cluster needs more brokers than the replication
449    /// factor.
450    Reassignment { path: String },
451}
452
453impl FaultKind {
454    /// The snake-case name the YAML spells this kind with.
455    #[must_use]
456    pub fn name(&self) -> &'static str {
457        match self {
458            Self::DiskPressure { .. } => "disk_pressure",
459            Self::SlowFollower { .. } => "slow_follower",
460            Self::Pause { .. } => "pause",
461            Self::Restart { .. } => "restart",
462            Self::Roll { .. } => "roll",
463            Self::CertificateRotation => "certificate_rotation",
464            Self::Reassignment { .. } => "reassignment",
465        }
466    }
467
468    /// Whether the fault takes its target out of the quorum long enough to
469    /// move leadership, and so a controller failover when the target is the
470    /// active controller.
471    #[must_use]
472    pub fn moves_leadership(&self) -> bool {
473        matches!(
474            self,
475            Self::Restart { .. }
476                | Self::Roll { .. }
477                | Self::Pause {
478                    intends_failover: true
479                }
480        )
481    }
482
483    /// The recoveries the harness measures after a fault of this kind on
484    /// `target`.
485    #[must_use]
486    pub fn checks(&self, target: Target) -> Vec<RecoveryCheck> {
487        let mut checks = match self {
488            Self::Pause {
489                intends_failover: false,
490            } => vec![RecoveryCheck::CatchUpAfterPause],
491            Self::Pause {
492                intends_failover: true,
493            } => vec![
494                RecoveryCheck::LeaderFailover,
495                RecoveryCheck::CatchUpAfterPause,
496            ],
497            Self::Restart { .. } | Self::Roll { .. } => {
498                vec![RecoveryCheck::LeaderFailover, RecoveryCheck::IsrRejoin]
499            }
500            Self::SlowFollower { .. } | Self::DiskPressure { .. } => {
501                vec![RecoveryCheck::IsrRejoin]
502            }
503            Self::CertificateRotation => vec![RecoveryCheck::CertificateServed],
504            Self::Reassignment { .. } => vec![RecoveryCheck::ReassignmentComplete],
505        };
506        if self.moves_leadership() && target == Target::Controller {
507            checks.push(RecoveryCheck::ControllerFailover);
508        }
509        checks
510    }
511}
512
513/// A fault, once or on a period.
514#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
515#[serde(deny_unknown_fields)]
516pub struct FaultPlan {
517    pub name: String,
518    /// What the fault does, as `{ kind: pause }` or `{ kind: restart, graceful: false }`.
519    pub fault: FaultKind,
520    pub target: Target,
521    /// When the first occurrence starts, from the start of the load.
522    #[serde(with = "bounded::nonnegative_time")]
523    pub first_at: Time,
524    /// The period of later occurrences. Absent means once.
525    #[serde(default, with = "serde_units::human::option_time")]
526    pub every: Option<Time>,
527    /// Each occurrence starts up to this much later than its slot, by a draw
528    /// from the seed.
529    #[serde(default = "zero_time", with = "bounded::nonnegative_time")]
530    pub jitter: Time,
531    /// How long the fault holds before its cleanup runs.
532    #[serde(with = "bounded::positive_time")]
533    pub duration: Time,
534}
535
536fn zero_time() -> Time { Time::ZERO }
537
538/// What a self-check injects, so that it can prove each is detected.
539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
540#[serde(deny_unknown_fields)]
541pub struct SelfCheck {
542    /// The path whose earliest acknowledged record is deleted from the cluster
543    /// once the load stops. The reconciler must report exactly that record lost.
544    pub missing_record_path: String,
545    pub missed_deadline: MissedDeadline,
546}
547
548/// A recovery deadline the self-check sets below anything a cluster can meet,
549/// so the deadline checker must report it missed.
550#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
551#[serde(deny_unknown_fields)]
552pub struct MissedDeadline {
553    /// The fault plan whose first occurrence carries the impossible deadline.
554    pub fault: String,
555    pub check: RecoveryCheck,
556    #[serde(with = "bounded::positive_time")]
557    pub deadline: Time,
558}
559
560/// One reason a contract cannot be run or judged.
561#[derive(Debug, Clone, PartialEq, Eq)]
562pub struct ContractError(pub String);
563
564impl fmt::Display for ContractError {
565    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) }
566}
567
568/// The highest fraction of a data volume a disk-pressure fault may fill.
569pub const MAX_DISK_WATERMARK: f64 = 0.95;
570
571impl Contract {
572    /// Parse a contract from YAML. This checks the shape only; call
573    /// [`Self::validate`] before running or judging it.
574    ///
575    /// # Errors
576    ///
577    /// Returns the parser's error for a document that is not a contract.
578    pub fn from_yaml(text: &str) -> Result<Self, serde_saphyr::Error> {
579        serde_saphyr::from_str(text)
580    }
581
582    /// Read, parse and validate the contract at `path`.
583    ///
584    /// # Errors
585    ///
586    /// Returns why the file cannot be read or parsed, or every reason
587    /// [`Self::validate`] refuses it, one per line.
588    pub fn from_file(path: &Path) -> anyhow::Result<Self> {
589        let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
590        let contract =
591            Self::from_yaml(&text).with_context(|| format!("parse contract {}", path.display()))?;
592        let errors = contract.validate();
593        if !errors.is_empty() {
594            bail!(
595                "{} is not a usable contract ({} problem(s)):\n{}",
596                path.display(),
597                errors.len(),
598                errors
599                    .iter()
600                    .map(|error| format!("contract: {error}"))
601                    .collect::<Vec<_>>()
602                    .join("\n")
603            );
604        }
605        Ok(contract)
606    }
607
608    /// The SHA-256 of the contract's canonical JSON encoding, as lowercase hex.
609    ///
610    /// Evidence records this, and the judge refuses evidence whose digest
611    /// differs. A name and a seed do not change when a budget or a deadline is
612    /// edited after a run; this does.
613    #[must_use]
614    pub fn digest(&self) -> String {
615        let canonical = serde_json::to_vec(self).unwrap_or_default();
616        hex::encode(Sha256::digest(canonical))
617    }
618
619    /// The path named `name`.
620    #[must_use]
621    pub fn path(&self, name: &str) -> Option<&LoadPath> {
622        self.paths.iter().find(|path| path.name == name)
623    }
624
625    /// The load paths, storage kinds and isolation levels the milestone names
626    /// that this contract does not cover.
627    #[must_use]
628    pub fn missing_coverage(&self) -> Vec<String> {
629        let semantics: BTreeSet<Semantics> = self.paths.iter().map(|path| path.semantics).collect();
630        let storage: BTreeSet<Storage> = self.paths.iter().map(|path| path.storage).collect();
631        let isolation: BTreeSet<Isolation> = self
632            .paths
633            .iter()
634            .flat_map(|path| path.consumers.iter().copied())
635            .collect();
636        let mut missing = Vec::new();
637        for (wanted, label) in [
638            (Semantics::Idempotent, "an idempotent produce path"),
639            (Semantics::Transactional, "a transactional produce path"),
640            (Semantics::Compacted, "a compacted path"),
641        ] {
642            if !semantics.contains(&wanted) {
643                missing.push(label.to_owned());
644            }
645        }
646        for (wanted, label) in [
647            (Storage::Classic, "a classic topic"),
648            (Storage::Tiered, "a tiered topic"),
649            (Storage::Diskless, "a diskless topic"),
650        ] {
651            if !storage.contains(&wanted) {
652                missing.push(label.to_owned());
653            }
654        }
655        for wanted in [Isolation::ReadCommitted, Isolation::ReadUncommitted] {
656            if !isolation.contains(&wanted) {
657                missing.push(format!("a {wanted} consumer"));
658            }
659        }
660        if !self.paths.iter().any(|path| path.commit_offsets) {
661            missing.push("a path that commits offsets".to_owned());
662        }
663        missing
664    }
665
666    /// Every reason this contract cannot be run safely or judged honestly.
667    ///
668    /// An empty list is a contract the harness will run. The fault schedule is
669    /// expanded and checked here too, so a plan whose occurrences would overlap
670    /// or run into the settle window is refused before the first record.
671    #[must_use]
672    pub fn validate(&self) -> Vec<ContractError> {
673        let mut errors = Vec::new();
674        let mut fail = |message: String| errors.push(ContractError(message));
675
676        if self.settle >= self.duration {
677            fail(format!(
678                "settle ({}) must be shorter than the run ({})",
679                human(self.settle),
680                human(self.duration)
681            ));
682        }
683        self.validate_cluster(&mut fail);
684        self.validate_clients(&mut fail);
685        self.validate_paths(&mut fail);
686        self.validate_faults(&mut fail);
687        self.validate_transitions(&mut fail);
688        self.validate_profile(&mut fail);
689        if let Err(schedule_errors) = super::schedule::expand(self) {
690            errors.extend(schedule_errors);
691        }
692        errors
693    }
694
695    fn validate_cluster(&self, fail: &mut impl FnMut(String)) {
696        let cluster = &self.cluster;
697        if cluster
698            .broker_session_timeout
699            .is_some_and(|timeout| timeout <= secs(3))
700        {
701            fail(
702                "broker_session_timeout must exceed the executor's 3s heartbeat interval"
703                    .to_owned(),
704            );
705        }
706        if cluster.replication_factor != 3 {
707            fail(format!(
708                "replication_factor is {}; the sustained-load contract is RF=3",
709                cluster.replication_factor
710            ));
711        }
712        if u32::try_from(cluster.replication_factor).unwrap_or(u32::MAX) > cluster.brokers {
713            fail(format!(
714                "replication_factor {} needs at least that many brokers, not {}",
715                cluster.replication_factor, cluster.brokers
716            ));
717        }
718        if cluster.min_insync_replicas < 2
719            || cluster.min_insync_replicas >= cluster.replication_factor
720        {
721            fail(format!(
722                "min_insync_replicas is {}; it must be at least 2 and below the replication factor, \
723                 or one fault either loses acknowledged data or stops every write",
724                cluster.min_insync_replicas
725            ));
726        }
727        if self.host.cpus == 0 {
728            fail("host.cpus must be at least 1".to_owned());
729        }
730    }
731
732    fn validate_clients(&self, fail: &mut impl FnMut(String)) {
733        let producer = &self.clients.producer;
734        if producer.acks != Acks::All {
735            fail("clients.producer.acks must be `all`: anything less acknowledges records the contract cannot hold the cluster to".to_owned());
736        }
737        if producer.max_send_attempts == 0 {
738            fail("clients.producer.max_send_attempts must be at least 1".to_owned());
739        }
740        if producer.delivery_timeout < producer.request_timeout + producer.linger {
741            fail("clients.producer.delivery_timeout must cover request_timeout plus linger, as Kafka requires".to_owned());
742        }
743        let txn = &self.clients.transactions;
744        if txn.records_per_transaction == 0 {
745            fail("clients.transactions.records_per_transaction must be at least 1".to_owned());
746        }
747        if txn.abort_every < 2 {
748            fail(
749                "clients.transactions.abort_every must be at least 2, so some transactions commit"
750                    .to_owned(),
751            );
752        }
753    }
754
755    fn validate_paths(&self, fail: &mut impl FnMut(String)) {
756        if self.paths.is_empty() {
757            fail("a contract needs at least one load path".to_owned());
758        }
759        let mut names = BTreeSet::new();
760        for path in &self.paths {
761            let name = &path.name;
762            if !names.insert(name.as_str()) {
763                fail(format!("path `{name}` is declared twice"));
764            }
765            if path.partitions < 1 {
766                fail(format!("path `{name}` needs at least one partition"));
767            }
768            if path.throughput_floor > path.rate {
769                fail(format!(
770                    "path `{name}` sets a throughput floor above its paced rate, which no run can meet"
771                ));
772            }
773            for (label, budget) in [
774                ("produce", &path.produce_latency),
775                ("consume", &path.consume_latency),
776            ] {
777                if budget.p50 > budget.p99 || budget.p99 > budget.p999 {
778                    fail(format!(
779                        "path `{name}`: the {label} latency budget must satisfy p50 <= p99 <= p999"
780                    ));
781                }
782            }
783            if path.consumers.is_empty() {
784                fail(format!(
785                    "path `{name}` has no consumers, so nothing would reconcile what it acknowledged"
786                ));
787            }
788            match (path.semantics, path.key_space) {
789                (Semantics::Compacted, None | Some(0)) => {
790                    fail(format!(
791                        "compacted path `{name}` needs a positive key_space"
792                    ));
793                }
794                (Semantics::Idempotent | Semantics::Transactional, Some(_)) => {
795                    fail(format!("path `{name}` sets key_space but is not compacted"));
796                }
797                (Semantics::Compacted, Some(_)) | (_, None) => {}
798            }
799            if path.semantics == Semantics::Transactional
800                && !path.consumers.contains(&Isolation::ReadCommitted)
801            {
802                fail(format!(
803                    "transactional path `{name}` needs a read_committed consumer, or aborted data could \
804                     never be caught"
805                ));
806            }
807            if path.semantics == Semantics::Compacted && path.storage == Storage::Diskless {
808                fail(format!(
809                    "compacted path `{name}` cannot be diskless: compaction runs over classic segments"
810                ));
811            }
812        }
813    }
814
815    fn validate_faults(&self, fail: &mut impl FnMut(String)) {
816        let mut names = BTreeSet::new();
817        for plan in &self.faults {
818            let name = &plan.name;
819            if !names.insert(name.as_str()) {
820                fail(format!("fault `{name}` is declared twice"));
821            }
822            if let Target::Broker(index) = plan.target
823                && index >= self.cluster.brokers
824            {
825                fail(format!(
826                    "fault `{name}` targets broker {index} of a {}-broker cluster",
827                    self.cluster.brokers
828                ));
829            }
830            if let Some(every) = plan.every
831                && plan.jitter >= every
832            {
833                fail(format!(
834                    "fault `{name}`: jitter must be shorter than its period"
835                ));
836            }
837            match &plan.fault {
838                FaultKind::Pause {
839                    intends_failover: true,
840                } if self.cluster.broker_session_timeout.is_some_and(|session| {
841                    plan.duration
842                        <= session
843                            + self.cluster.check_quorum_window
844                            + EXECUTOR_LIVENESS_TICK_INTERVAL
845                }) =>
846                {
847                    fail(format!(
848                        "fault `{name}` must outlast controller election plus the declared broker session timeout and the executor's 1s liveness sweep"
849                    ));
850                }
851                FaultKind::Pause {
852                    intends_failover: false,
853                } if plan.duration >= self.cluster.check_quorum_window => fail(format!(
854                    "fault `{name}` pauses for {} but the check-quorum window is {}: a pause that does \
855                     not intend a failover must stay below it",
856                    human(plan.duration),
857                    human(self.cluster.check_quorum_window)
858                )),
859                FaultKind::Pause {
860                    intends_failover: true,
861                } if plan.duration < self.cluster.check_quorum_window => fail(format!(
862                    "fault `{name}` intends a failover but pauses for {}, inside the {} check-quorum window",
863                    human(plan.duration),
864                    human(self.cluster.check_quorum_window)
865                )),
866                FaultKind::DiskPressure { watermark } => {
867                    let fraction = watermark.as_f64();
868                    if !(fraction > 0.0 && fraction <= MAX_DISK_WATERMARK) {
869                        fail(format!(
870                            "fault `{name}`: the disk watermark must be above 0% and at most {:.0}%",
871                            MAX_DISK_WATERMARK * 100.0
872                        ));
873                    }
874                }
875                FaultKind::SlowFollower {
876                    slowdown: Slowdown::CpuThrottle { cpus },
877                } if !(*cpus > 0.0 && *cpus < f64::from(self.host.cpus)) => fail(format!(
878                    "fault `{name}`: a CPU throttle must be above zero and below host.cpus"
879                )),
880                FaultKind::SlowFollower {
881                    slowdown: Slowdown::Netem { image, .. },
882                } if !image.contains("@sha256:") => fail(format!(
883                    "fault `{name}`: the netem image must be pinned by digest"
884                )),
885                _ => {}
886            }
887        }
888    }
889
890    /// The rules for lifecycle transitions: a `roll` needs a declared source
891    /// and a fixed broker, a rotation and a reassignment need their deadlines,
892    /// and a reassignment needs a path and a broker that holds none of it.
893    fn validate_transitions(&self, fail: &mut impl FnMut(String)) {
894        for plan in &self.faults {
895            let name = &plan.name;
896            let is_transition = matches!(
897                plan.fault,
898                FaultKind::Roll { .. }
899                    | FaultKind::CertificateRotation
900                    | FaultKind::Reassignment { .. }
901            );
902            if is_transition && self.profile != Profile::Lifecycle {
903                fail(format!(
904                    "fault `{name}` is a lifecycle transition, which only a lifecycle contract runs"
905                ));
906            }
907            if is_transition && plan.every.is_some() {
908                fail(format!(
909                    "transition `{name}` repeats; a lifecycle transition happens once"
910                ));
911            }
912            if is_transition && !matches!(plan.target, Target::Broker(_)) {
913                fail(format!(
914                    "transition `{name}` must target a broker by index, so the plan says which \
915                     broker moves and in what order"
916                ));
917            }
918            match &plan.fault {
919                FaultKind::CertificateRotation if self.deadlines.certificate_rotation.is_none() => {
920                    fail(format!(
921                        "transition `{name}` rotates a certificate, but deadlines.certificate_rotation \
922                         is not set"
923                    ));
924                }
925                FaultKind::Reassignment { path } => {
926                    if self.deadlines.reassignment.is_none() {
927                        fail(format!(
928                            "transition `{name}` reassigns, but deadlines.reassignment is not set"
929                        ));
930                    }
931                    if self.path(path).is_none() {
932                        fail(format!(
933                            "transition `{name}` reassigns `{path}`, which is no path"
934                        ));
935                    }
936                    if u32::try_from(self.cluster.replication_factor).unwrap_or(u32::MAX)
937                        >= self.cluster.brokers
938                    {
939                        fail(format!(
940                            "transition `{name}` reassigns, but every one of the {} brokers already \
941                             holds a replica at replication_factor {}",
942                            self.cluster.brokers, self.cluster.replication_factor
943                        ));
944                    }
945                }
946                _ => {}
947            }
948        }
949    }
950
951    fn validate_profile(&self, fail: &mut impl FnMut(String)) {
952        if self.profile != Profile::DisasterRecovery && self.disaster_recovery.is_some() {
953            fail(
954                "only a disaster_recovery contract declares recovery objectives; drop disaster_recovery"
955                    .to_owned(),
956            );
957        }
958        if self.profile == Profile::Lifecycle {
959            self.validate_lifecycle(fail);
960            return;
961        }
962        if self.lifecycle.is_some() {
963            fail("only a lifecycle contract declares a lifecycle section".to_owned());
964        }
965        match (self.profile, &self.self_check) {
966            (Profile::DisasterRecovery, _) => self.validate_drill(fail),
967            (Profile::Soak, Some(_)) => {
968                fail(
969                    "a soak may not inject failures into its own evidence; drop self_check"
970                        .to_owned(),
971                );
972            }
973            (Profile::Soak, None) => {
974                if self.faults.is_empty() {
975                    fail(
976                        "a soak needs at least one fault plan, or it proves nothing about recovery"
977                            .to_owned(),
978                    );
979                }
980                for missing in self.missing_coverage() {
981                    fail(format!("a soak must cover {missing}"));
982                }
983            }
984            (Profile::SelfCheck, None) => {
985                fail("a self-check contract needs a self_check section".to_owned());
986            }
987            (Profile::SelfCheck, Some(check)) => {
988                match self.path(&check.missing_record_path) {
989                    None => fail(format!(
990                        "self_check.missing_record_path names no path: `{}`",
991                        check.missing_record_path
992                    )),
993                    Some(path) if path.semantics != Semantics::Idempotent => fail(format!(
994                        "self_check.missing_record_path `{}` must be an idempotent path, where one \
995                         deleted record is exactly one lost record",
996                        path.name
997                    )),
998                    Some(_) => {}
999                }
1000                let missed = &check.missed_deadline;
1001                match self.faults.iter().find(|plan| plan.name == missed.fault) {
1002                    None => fail(format!(
1003                        "self_check.missed_deadline names no fault: `{}`",
1004                        missed.fault
1005                    )),
1006                    Some(plan) if !plan.fault.checks(plan.target).contains(&missed.check) => {
1007                        fail(format!(
1008                            "self_check.missed_deadline: fault `{}` is not measured for {}",
1009                            missed.fault, missed.check
1010                        ));
1011                    }
1012                    Some(_) => {}
1013                }
1014                if missed.deadline >= missed.check.deadline(&self.deadlines) {
1015                    fail("self_check.missed_deadline must be shorter than the contract's own deadline".to_owned());
1016                }
1017            }
1018            // Validated above, by `validate_lifecycle`.
1019            (Profile::Lifecycle, _) => {}
1020        }
1021    }
1022
1023    /// A lifecycle contract declares its source release by digest, injects
1024    /// nothing into its own evidence, and upgrades at least one broker.
1025    fn validate_lifecycle(&self, fail: &mut impl FnMut(String)) {
1026        if self.self_check.is_some() {
1027            fail(
1028                "a lifecycle run may not inject failures into its own evidence; drop self_check"
1029                    .to_owned(),
1030            );
1031        }
1032        match &self.lifecycle {
1033            None => fail("a lifecycle contract needs a lifecycle section".to_owned()),
1034            Some(lifecycle) => {
1035                let source = &lifecycle.source;
1036                if !source.image.contains("@sha256:") {
1037                    fail(format!(
1038                        "lifecycle.source.image must be pinned by digest, not `{}`",
1039                        source.image
1040                    ));
1041                }
1042                if !source.supports.contains_key("metadata.version") {
1043                    fail(
1044                        "lifecycle.source.supports must name metadata.version, which every release \
1045                         supports"
1046                            .to_owned(),
1047                    );
1048                }
1049                for (feature, range) in &source.supports {
1050                    if range.min > range.max {
1051                        fail(format!(
1052                            "lifecycle.source.supports.{feature} is inverted: {range}"
1053                        ));
1054                    }
1055                }
1056            }
1057        }
1058        let upgrades = self
1059            .faults
1060            .iter()
1061            .any(|plan| plan.fault == FaultKind::Roll { to: Side::Target });
1062        if !upgrades {
1063            fail("a lifecycle contract must roll at least one broker to the target".to_owned());
1064        }
1065    }
1066
1067    /// A drill restores what a capture holds and nothing else, so each of its
1068    /// paths must be one the capture can hold, and its only fault is the loss.
1069    fn validate_drill(&self, fail: &mut impl FnMut(String)) {
1070        if self.self_check.is_some() {
1071            fail("a disaster-recovery drill may not inject failures; drop self_check".to_owned());
1072        }
1073        if !self.faults.is_empty() {
1074            fail(
1075                "a disaster-recovery drill's only fault is the loss of the source cluster; drop faults"
1076                    .to_owned(),
1077            );
1078        }
1079        let Some(objectives) = &self.disaster_recovery else {
1080            fail("a disaster-recovery contract needs a disaster_recovery section".to_owned());
1081            return;
1082        };
1083        if objectives.rpo < objectives.post_capture_load {
1084            fail(format!(
1085                "disaster_recovery.rpo ({}) is shorter than post_capture_load ({}): a restore of a \
1086                 diskless topic loses everything after the capture, so no run could meet it",
1087                human(objectives.rpo),
1088                human(objectives.post_capture_load)
1089            ));
1090        }
1091        if objectives.post_capture_load >= self.duration {
1092            fail(format!(
1093                "disaster_recovery.post_capture_load ({}) must be shorter than the run ({})",
1094                human(objectives.post_capture_load),
1095                human(self.duration)
1096            ));
1097        }
1098        for path in &self.paths {
1099            if path.storage == Storage::Classic {
1100                fail(format!(
1101                    "path `{}` is classic: a classic topic's segments are never archived, so it is \
1102                     outside the backup boundary",
1103                    path.name
1104                ));
1105            }
1106            if path.semantics != Semantics::Idempotent {
1107                fail(format!(
1108                    "path `{}` must be idempotent: transaction state is never captured, and a \
1109                     compacted topic is never tiered",
1110                    path.name
1111                ));
1112            }
1113        }
1114        let storage: BTreeSet<Storage> = self.paths.iter().map(|path| path.storage).collect();
1115        for (wanted, label) in [
1116            (Storage::Tiered, "a tiered topic"),
1117            (Storage::Diskless, "a diskless topic"),
1118        ] {
1119            if !storage.contains(&wanted) {
1120                fail(format!("a disaster-recovery drill must cover {label}"));
1121            }
1122        }
1123        if !self.paths.iter().any(|path| path.commit_offsets) {
1124            fail("a disaster-recovery drill must cover a path that commits offsets".to_owned());
1125        }
1126    }
1127}
1128
1129/// A time in its human form, for a message.
1130pub(crate) fn human(time: Time) -> String { time.human().to_string() }
1131
1132#[cfg(test)]
1133pub(crate) mod fixture {
1134    use {
1135        super::{
1136            Contract, FaultKind, FaultPlan, Isolation, LoadPath, Profile, RecoveryObjectives,
1137            Storage, Target,
1138        },
1139        crate::soak::lifecycle::{LevelRange, Lifecycle, Release, Side},
1140        krabka_units::prelude::*,
1141        std::collections::BTreeMap,
1142    };
1143
1144    /// A valid self-check contract, small enough to read in a test.
1145    pub const SELF_CHECK: &str = "
1146name: fixture-self-check
1147profile: self_check
1148seed: 7
1149duration: 4min
1150settle: 30s
1151host: { cpus: 4, memory: 8GiB, disk_capacity: 4GiB, disk_reserve: 1GiB }
1152cluster:
1153  brokers: 3
1154  replication_factor: 3
1155  min_insync_replicas: 2
1156  check_quorum_window: 2s
1157  replica_lag_time_max: 30s
1158clients:
1159  producer:
1160    acks: all
1161    linger: 5ms
1162    batch_size: 16KiB
1163    request_timeout: 10s
1164    delivery_timeout: 30s
1165    max_send_attempts: 3
1166  transactions: { timeout: 30s, records_per_transaction: 5, abort_every: 4 }
1167  consumer: { session_timeout: 10s, commit_interval: 2s, poll_timeout: 200ms }
1168paths:
1169  - name: classic-idempotent
1170    semantics: idempotent
1171    storage: classic
1172    partitions: 3
1173    rate: 50/s
1174    throughput_floor: 40/s
1175    payload: 256B
1176    consumers: [read_uncommitted]
1177    commit_offsets: true
1178    produce_latency: { p50: 50ms, p99: 500ms, p999: 2s }
1179    consume_latency: { p50: 100ms, p99: 1s, p999: 3s }
1180  - name: classic-transactional
1181    semantics: transactional
1182    storage: classic
1183    partitions: 3
1184    rate: 50/s
1185    throughput_floor: 40/s
1186    payload: 256B
1187    consumers: [read_committed, read_uncommitted]
1188    produce_latency: { p50: 50ms, p99: 500ms, p999: 2s }
1189    consume_latency: { p50: 100ms, p99: 1s, p999: 3s }
1190deadlines:
1191  leader_failover: 30s
1192  isr_rejoin: 60s
1193  controller_failover: 30s
1194  catch_up_after_pause: 30s
1195faults:
1196  - name: short-pause
1197    fault: { kind: pause }
1198    target: follower
1199    first_at: 30s
1200    duration: 1s
1201  - name: leader-restart
1202    fault: { kind: restart, graceful: false }
1203    target: leader
1204    first_at: 90s
1205    duration: 5s
1206self_check:
1207  missing_record_path: classic-idempotent
1208  missed_deadline: { fault: leader-restart, check: isr_rejoin, deadline: 1ms }
1209";
1210
1211    pub fn self_check() -> Contract {
1212        let contract = Contract::from_yaml(SELF_CHECK).expect("the fixture parses");
1213        assert2::assert!(contract.validate().is_empty(), "{:?}", contract.validate());
1214        contract
1215    }
1216
1217    /// The self-check's cluster and load as a valid lifecycle contract: four
1218    /// brokers, one upgrade, one rotation and one reassignment.
1219    pub fn lifecycle() -> Contract {
1220        let mut contract = self_check();
1221        contract.name = "fixture-lifecycle".to_owned();
1222        contract.profile = Profile::Lifecycle;
1223        contract.self_check = None;
1224        contract.cluster.brokers = 4;
1225        contract.deadlines.certificate_rotation = Some(secs(30));
1226        contract.deadlines.reassignment = Some(secs(60));
1227        contract.lifecycle = Some(Lifecycle {
1228            source: Release {
1229                version: "v1.0.5".to_owned(),
1230                image: "ghcr.io/krabka-io/krabka-broker@sha256:2a81".to_owned(),
1231                supports: BTreeMap::from([("metadata.version".to_owned(), LevelRange {
1232                    min: 7,
1233                    max: 30,
1234                })]),
1235            },
1236        });
1237        let plan =
1238            |name: &str, fault: FaultKind, broker: u32, first_at: Time, duration: Time| FaultPlan {
1239                name: name.to_owned(),
1240                fault,
1241                target: Target::Broker(broker),
1242                first_at,
1243                every: None,
1244                jitter: Time::ZERO,
1245                duration,
1246            };
1247        contract.faults = vec![
1248            plan(
1249                "upgrade",
1250                FaultKind::Roll { to: Side::Target },
1251                0,
1252                secs(30),
1253                secs(5),
1254            ),
1255            plan(
1256                "rotate",
1257                FaultKind::CertificateRotation,
1258                1,
1259                secs(100),
1260                secs(1),
1261            ),
1262            plan(
1263                "reassign",
1264                FaultKind::Reassignment {
1265                    path: "classic-idempotent".to_owned(),
1266                },
1267                3,
1268                secs(135),
1269                secs(1),
1270            ),
1271            plan(
1272                "rollback",
1273                FaultKind::Roll { to: Side::Source },
1274                0,
1275                secs(200),
1276                secs(5),
1277            ),
1278        ];
1279        contract.duration = minutes(6);
1280        assert2::assert!(contract.validate().is_empty(), "{:?}", contract.validate());
1281        contract
1282    }
1283
1284    /// A valid disaster-recovery drill: the self-check's shape, with a tiered
1285    /// and a diskless idempotent path, no fault and recovery objectives.
1286    pub fn drill() -> Contract {
1287        let mut contract = self_check();
1288        contract.name = "fixture-drill".to_owned();
1289        contract.profile = Profile::DisasterRecovery;
1290        contract.duration = minutes(20);
1291        contract.settle = Time::ZERO;
1292        contract.faults.clear();
1293        contract.self_check = None;
1294        let path = contract.paths[0].clone();
1295        contract.paths = [
1296            ("tiered-idempotent", Storage::Tiered),
1297            ("diskless-idempotent", Storage::Diskless),
1298        ]
1299        .into_iter()
1300        .map(|(name, storage)| LoadPath {
1301            name: name.to_owned(),
1302            storage,
1303            consumers: vec![Isolation::ReadUncommitted, Isolation::ReadCommitted],
1304            // Low, so the recovery tests' few acknowledgements after the
1305            // boundary meet it.
1306            throughput_floor: Frequency::from_per_sec(0.1),
1307            ..path.clone()
1308        })
1309        .collect();
1310        contract.disaster_recovery = Some(RecoveryObjectives {
1311            post_capture_load: secs(20),
1312            rpo: minutes(2),
1313            rto: minutes(10),
1314        });
1315        assert2::assert!(contract.validate().is_empty(), "{:?}", contract.validate());
1316        contract
1317    }
1318}
1319
1320#[cfg(test)]
1321mod tests {
1322    use {
1323        super::{
1324            Acks, Contract, FaultKind, FaultPlan, Isolation, Profile, RecoveryCheck, Semantics,
1325            Slowdown, Storage, Target, fixture,
1326        },
1327        crate::soak::lifecycle::Side,
1328        assert2::{assert, check},
1329        krabka_units::prelude::*,
1330    };
1331
1332    fn messages(contract: &Contract) -> Vec<String> {
1333        contract
1334            .validate()
1335            .into_iter()
1336            .map(|error| error.0)
1337            .collect()
1338    }
1339
1340    fn assert_refused(contract: &Contract, fragment: &str) {
1341        let messages = messages(contract);
1342        assert!(
1343            messages.iter().any(|message| message.contains(fragment)),
1344            "expected a refusal containing {fragment:?}, got {messages:?}"
1345        );
1346    }
1347
1348    #[test]
1349    fn the_fixture_is_valid() {
1350        check!(messages(&fixture::self_check()).is_empty());
1351    }
1352
1353    #[test]
1354    fn the_digest_changes_with_any_declared_limit() {
1355        let contract = fixture::self_check();
1356        check!(contract.digest() == fixture::self_check().digest());
1357        check!(contract.digest().len() == 64);
1358        let mut edited = fixture::self_check();
1359        edited.deadlines.isr_rejoin = secs(61);
1360        check!(edited.digest() != contract.digest());
1361    }
1362
1363    #[test]
1364    fn a_bare_number_is_refused() {
1365        let yaml = fixture::SELF_CHECK.replace("duration: 4min", "duration: 240");
1366        check!(Contract::from_yaml(&yaml).is_err());
1367    }
1368
1369    #[test]
1370    fn an_unknown_key_is_refused() {
1371        let yaml = fixture::SELF_CHECK.replace("seed: 7", "seed: 7\nsede: 8");
1372        check!(Contract::from_yaml(&yaml).is_err());
1373    }
1374
1375    #[yare::parameterized(
1376        rf_two = { |c: &mut Contract| c.cluster.replication_factor = 2, "RF=3" },
1377        min_isr_one = { |c: &mut Contract| c.cluster.min_insync_replicas = 1, "min_insync_replicas" },
1378        min_isr_rf = { |c: &mut Contract| c.cluster.min_insync_replicas = 3, "min_insync_replicas" },
1379        acks_leader = { |c: &mut Contract| c.clients.producer.acks = Acks::Leader, "acks must be `all`" },
1380        settle_too_long = { |c: &mut Contract| c.settle = minutes(5), "settle" },
1381        floor_above_rate = { |c: &mut Contract| c.paths[0].throughput_floor = per_sec(100), "throughput floor" },
1382        inverted_budget = { |c: &mut Contract| c.paths[0].produce_latency.p50 = secs(5), "p50 <= p99 <= p999" },
1383        no_consumers = { |c: &mut Contract| c.paths[0].consumers.clear(), "no consumers" },
1384        txn_without_rc = { |c: &mut Contract| c.paths[1].consumers.retain(|i| *i != Isolation::ReadCommitted), "read_committed consumer" },
1385        compacted_without_keys = { |c: &mut Contract| c.paths[0].semantics = Semantics::Compacted, "positive key_space" },
1386        duplicate_path = { |c: &mut Contract| c.paths[1].name = c.paths[0].name.clone(), "declared twice" },
1387        long_quiet_pause = { |c: &mut Contract| c.faults[0].duration = secs(3), "must stay below it" },
1388        short_failover_pause = { |c: &mut Contract| c.faults[0].fault = FaultKind::Pause { intends_failover: true }, "inside the" },
1389        broker_out_of_range = { |c: &mut Contract| c.faults[0].target = Target::Broker(3), "targets broker 3" },
1390        unpinned_netem = { |c: &mut Contract| c.faults[0].fault = FaultKind::SlowFollower { slowdown: Slowdown::Netem { delay: millis(100), jitter: Time::ZERO, image: "netshoot:latest".into() } }, "pinned by digest" },
1391        overfull_disk = { |c: &mut Contract| c.faults[0].fault = FaultKind::DiskPressure { watermark: fraction(0.99) }, "disk watermark" },
1392        overlapping_faults = { |c: &mut Contract| c.faults[1].first_at = secs(31), "overlaps" },
1393        fault_in_settle = { |c: &mut Contract| c.faults[1].first_at = secs(225), "settle" },
1394        unknown_self_check_path = { |c: &mut Contract| c.self_check.as_mut().unwrap().missing_record_path = "nope".into(), "names no path" },
1395        self_check_on_txn_path = { |c: &mut Contract| c.self_check.as_mut().unwrap().missing_record_path = "classic-transactional".into(), "must be an idempotent path" },
1396        unmeasured_missed_check = { |c: &mut Contract| c.self_check.as_mut().unwrap().missed_deadline.check = RecoveryCheck::ControllerFailover, "is not measured for" },
1397        possible_missed_deadline = { |c: &mut Contract| c.self_check.as_mut().unwrap().missed_deadline.deadline = minutes(2), "shorter than the contract's own" },
1398        self_check_without_section = { |c: &mut Contract| c.self_check = None, "needs a self_check section" },
1399        soak_with_injection = { |c: &mut Contract| c.profile = Profile::Soak, "may not inject" },
1400    )]
1401    fn an_unsafe_or_unjudgeable_contract_is_refused(edit: fn(&mut Contract), fragment: &str) {
1402        let mut contract = fixture::self_check();
1403        edit(&mut contract);
1404        assert_refused(&contract, fragment);
1405    }
1406
1407    #[test]
1408    fn the_lifecycle_fixture_is_valid() {
1409        check!(messages(&fixture::lifecycle()).is_empty());
1410    }
1411
1412    #[yare::parameterized(
1413        transition_in_a_soak = { |c: &mut Contract| { c.profile = Profile::Soak; c.lifecycle = None; }, "only a lifecycle contract runs" },
1414        lifecycle_section_in_a_soak = { |c: &mut Contract| c.profile = Profile::Soak, "only a lifecycle contract declares" },
1415        no_lifecycle_section = { |c: &mut Contract| c.lifecycle = None, "needs a lifecycle section" },
1416        unpinned_source = { |c: &mut Contract| c.lifecycle.as_mut().unwrap().source.image = "ghcr.io/krabka-io/krabka-broker:v1.0.5".into(), "pinned by digest" },
1417        source_without_metadata_version = { |c: &mut Contract| c.lifecycle.as_mut().unwrap().source.supports.clear(), "must name metadata.version" },
1418        inverted_source_range = { |c: &mut Contract| c.lifecycle.as_mut().unwrap().source.supports.get_mut("metadata.version").unwrap().min = 31, "is inverted" },
1419        no_upgrade = { |c: &mut Contract| c.faults[0].fault = FaultKind::Roll { to: Side::Source }, "at least one broker to the target" },
1420        repeating_transition = { |c: &mut Contract| c.faults[0].every = Some(minutes(10)), "happens once" },
1421        transition_by_role = { |c: &mut Contract| c.faults[0].target = Target::Leader, "target a broker by index" },
1422        rotation_without_deadline = { |c: &mut Contract| c.deadlines.certificate_rotation = None, "deadlines.certificate_rotation" },
1423        reassignment_without_deadline = { |c: &mut Contract| c.deadlines.reassignment = None, "deadlines.reassignment" },
1424        reassignment_of_no_path = { |c: &mut Contract| c.faults[2].fault = FaultKind::Reassignment { path: "nope".into() }, "which is no path" },
1425        reassignment_without_a_spare_broker = { |c: &mut Contract| { c.cluster.brokers = 3; c.faults[2].target = Target::Broker(2); }, "already holds a replica" },
1426        lifecycle_with_injection = { |c: &mut Contract| c.self_check = fixture::self_check().self_check, "may not inject" },
1427    )]
1428    fn an_unrunnable_lifecycle_contract_is_refused(edit: fn(&mut Contract), fragment: &str) {
1429        let mut contract = fixture::lifecycle();
1430        edit(&mut contract);
1431        assert_refused(&contract, fragment);
1432    }
1433
1434    #[test]
1435    fn a_contract_without_lifecycle_deadlines_keeps_its_digest() {
1436        let encoded = serde_json::to_string(&fixture::self_check()).unwrap();
1437        check!(!encoded.contains("lifecycle"));
1438        check!(!encoded.contains("certificate_rotation"));
1439        check!(!encoded.contains("reassignment"));
1440        check!(!encoded.contains("broker_session_timeout"));
1441    }
1442
1443    #[test]
1444    fn a_failover_pause_outlasts_controller_broker_and_liveness_sweep_timeouts() {
1445        let mut contract = fixture::self_check();
1446        contract.cluster.broker_session_timeout = Some(secs(5));
1447        contract.faults[0].fault = FaultKind::Pause {
1448            intends_failover: true,
1449        };
1450        contract.faults[0].target = Target::Controller;
1451        for (duration, refused) in [
1452            (millis(7_999), true),
1453            (secs(8), true),
1454            (millis(8_001), false),
1455        ] {
1456            contract.faults[0].duration = duration;
1457            check!(
1458                messages(&contract)
1459                    .iter()
1460                    .any(|message| message.contains("broker session timeout"))
1461                    == refused
1462            );
1463        }
1464        for timeout in [secs(0), secs(1), secs(3)] {
1465            contract.cluster.broker_session_timeout = Some(timeout);
1466            assert_refused(&contract, "broker_session_timeout must exceed");
1467        }
1468    }
1469
1470    #[test]
1471    fn a_soak_must_cover_every_path_the_milestone_names() {
1472        let mut contract = fixture::self_check();
1473        contract.profile = Profile::Soak;
1474        contract.self_check = None;
1475        let messages = messages(&contract);
1476        for missing in ["a compacted path", "a tiered topic", "a diskless topic"] {
1477            check!(
1478                messages.iter().any(|message| message.ends_with(missing)),
1479                "{missing} not reported in {messages:?}"
1480            );
1481        }
1482        check!(
1483            !messages
1484                .iter()
1485                .any(|message| message.contains("a classic topic"))
1486        );
1487        check!(
1488            !messages
1489                .iter()
1490                .any(|message| message.contains("read_committed consumer"))
1491        );
1492        check!(
1493            contract
1494                .paths
1495                .iter()
1496                .all(|path| path.storage == Storage::Classic)
1497        );
1498    }
1499
1500    #[test]
1501    fn a_soak_without_faults_is_refused() {
1502        let mut contract = fixture::self_check();
1503        contract.profile = Profile::Soak;
1504        contract.self_check = None;
1505        contract.faults.clear();
1506        assert_refused(&contract, "at least one fault plan");
1507    }
1508
1509    #[yare::parameterized(
1510        quiet_pause = { FaultKind::Pause { intends_failover: false }, Target::Follower, &[RecoveryCheck::CatchUpAfterPause] },
1511        failover_pause = { FaultKind::Pause { intends_failover: true }, Target::Leader, &[RecoveryCheck::LeaderFailover, RecoveryCheck::CatchUpAfterPause] },
1512        controller_pause = { FaultKind::Pause { intends_failover: true }, Target::Controller, &[RecoveryCheck::LeaderFailover, RecoveryCheck::CatchUpAfterPause, RecoveryCheck::ControllerFailover] },
1513        restart = { FaultKind::Restart { graceful: true }, Target::Any, &[RecoveryCheck::LeaderFailover, RecoveryCheck::IsrRejoin] },
1514        controller_restart = { FaultKind::Restart { graceful: false }, Target::Controller, &[RecoveryCheck::LeaderFailover, RecoveryCheck::IsrRejoin, RecoveryCheck::ControllerFailover] },
1515        quiet_controller_pause = { FaultKind::Pause { intends_failover: false }, Target::Controller, &[RecoveryCheck::CatchUpAfterPause] },
1516        disk = { FaultKind::DiskPressure { watermark: fraction(0.5) }, Target::Any, &[RecoveryCheck::IsrRejoin] },
1517        roll = { FaultKind::Roll { to: Side::Target }, Target::Broker(0), &[RecoveryCheck::LeaderFailover, RecoveryCheck::IsrRejoin] },
1518        rotation = { FaultKind::CertificateRotation, Target::Broker(0), &[RecoveryCheck::CertificateServed] },
1519        reassignment = { FaultKind::Reassignment { path: "p".into() }, Target::Broker(3), &[RecoveryCheck::ReassignmentComplete] },
1520    )]
1521    fn each_fault_kind_names_the_recoveries_it_is_measured_for(
1522        kind: FaultKind,
1523        target: Target,
1524        expected: &[RecoveryCheck],
1525    ) {
1526        check!(kind.checks(target) == expected);
1527    }
1528
1529    #[test]
1530    fn the_drill_fixture_is_valid() {
1531        check!(messages(&fixture::drill()).is_empty());
1532    }
1533
1534    #[test]
1535    fn a_contract_without_objectives_encodes_none_so_its_digest_is_unchanged() {
1536        let encoded = serde_json::to_string(&fixture::self_check()).unwrap();
1537        check!(!encoded.contains("disaster_recovery"));
1538        let encoded = serde_json::to_string(&fixture::drill()).unwrap();
1539        check!(encoded.contains("disaster_recovery"));
1540    }
1541
1542    fn classic(contract: &mut Contract) { contract.paths[0].storage = Storage::Classic; }
1543
1544    fn transactional(contract: &mut Contract) {
1545        contract.paths[0].semantics = Semantics::Transactional;
1546    }
1547
1548    fn short_rpo(contract: &mut Contract) {
1549        if let Some(objectives) = &mut contract.disaster_recovery {
1550            objectives.rpo = secs(5);
1551        }
1552    }
1553
1554    fn long_post_capture_load(contract: &mut Contract) {
1555        if let Some(objectives) = &mut contract.disaster_recovery {
1556            objectives.post_capture_load = contract.duration;
1557            objectives.rpo = contract.duration;
1558        }
1559    }
1560
1561    fn no_objectives(contract: &mut Contract) { contract.disaster_recovery = None; }
1562
1563    fn a_fault(contract: &mut Contract) {
1564        contract.faults.push(FaultPlan {
1565            name: "pause".into(),
1566            fault: FaultKind::Pause {
1567                intends_failover: false,
1568            },
1569            target: Target::Follower,
1570            first_at: secs(30),
1571            every: None,
1572            jitter: Time::ZERO,
1573            duration: secs(1),
1574        });
1575    }
1576
1577    fn no_diskless(contract: &mut Contract) { contract.paths[1].storage = Storage::Tiered; }
1578
1579    fn no_commits(contract: &mut Contract) {
1580        for path in &mut contract.paths {
1581            path.commit_offsets = false;
1582        }
1583    }
1584
1585    fn objectives_on_a_soak(contract: &mut Contract) {
1586        let objectives = contract.disaster_recovery.clone();
1587        *contract = fixture::self_check();
1588        contract.disaster_recovery = objectives;
1589    }
1590
1591    #[yare::parameterized(
1592        classic_is_outside_the_boundary = { classic, "outside the backup boundary" },
1593        transactions_are_not_captured = { transactional, "must be idempotent" },
1594        rpo_below_the_post_capture_load = { short_rpo, "no run could meet it" },
1595        post_capture_load_past_the_run = { long_post_capture_load, "must be shorter than the run" },
1596        objectives_missing = { no_objectives, "needs a disaster_recovery section" },
1597        faults_declared = { a_fault, "only fault is the loss" },
1598        diskless_missing = { no_diskless, "must cover a diskless topic" },
1599        commits_missing = { no_commits, "a path that commits offsets" },
1600        objectives_on_another_profile = { objectives_on_a_soak, "only a disaster_recovery contract" },
1601    )]
1602    fn a_drill_is_refused_when_it_cannot_be_judged(edit: fn(&mut Contract), fragment: &str) {
1603        let mut contract = fixture::drill();
1604        edit(&mut contract);
1605        assert_refused(&contract, fragment);
1606    }
1607}