1use {
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
23const EXECUTOR_LIVENESS_TICK_INTERVAL: Time = secs(1);
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum Profile {
30 Soak,
34 SelfCheck,
38 Lifecycle,
43 DisasterRecovery,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct Contract {
56 pub name: String,
57 pub profile: Profile,
58 pub seed: u64,
61 #[serde(with = "bounded::positive_time")]
63 pub duration: Time,
64 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub lifecycle: Option<Lifecycle>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub disaster_recovery: Option<RecoveryObjectives>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct RecoveryObjectives {
96 #[serde(with = "bounded::positive_time")]
100 pub post_capture_load: Time,
101 #[serde(with = "bounded::positive_time")]
104 pub rpo: Time,
105 #[serde(with = "bounded::positive_time")]
109 pub rto: Time,
110}
111
112#[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 #[serde(with = "bounded::positive_size")]
123 pub disk_capacity: ByteSize,
124 #[serde(with = "bounded::positive_size")]
127 pub disk_reserve: ByteSize,
128}
129
130#[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 #[serde(with = "bounded::positive_time")]
142 pub check_quorum_window: Time,
143 #[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 #[serde(with = "bounded::positive_time")]
155 pub replica_lag_time_max: Time,
156}
157
158#[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 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 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 #[serde(with = "bounded::positive_time")]
204 pub commit_interval: Time,
205 #[serde(with = "bounded::positive_time")]
206 pub poll_timeout: Time,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum Semantics {
213 Idempotent,
216 Transactional,
219 Compacted,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum Storage {
228 Classic,
230 Tiered,
233 Diskless,
235}
236
237#[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#[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#[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 #[serde(with = "bounded::positive_rate")]
276 pub rate: Frequency,
277 #[serde(with = "bounded::positive_rate")]
279 pub throughput_floor: Frequency,
280 #[serde(with = "bounded::positive_size")]
281 pub payload: ByteSize,
282 #[serde(default)]
284 pub key_space: Option<u64>,
285 pub consumers: Vec<Isolation>,
287 #[serde(default)]
290 pub commit_offsets: bool,
291 pub produce_latency: LatencyBudget,
293 pub consume_latency: LatencyBudget,
295}
296
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub struct RecoveryDeadlines {
301 #[serde(with = "bounded::positive_time")]
304 pub leader_failover: Time,
305 #[serde(with = "bounded::positive_time")]
308 pub isr_rejoin: Time,
309 #[serde(with = "bounded::positive_time")]
311 pub controller_failover: Time,
312 #[serde(with = "bounded::positive_time")]
314 pub catch_up_after_pause: Time,
315 #[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 #[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#[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "snake_case")]
381pub enum Target {
382 Controller,
384 Leader,
386 Follower,
388 Any,
390 Broker(u32),
392}
393
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396#[serde(tag = "method", rename_all = "snake_case", deny_unknown_fields)]
397pub enum Slowdown {
398 Netem {
401 #[serde(with = "bounded::positive_time")]
402 delay: Time,
403 #[serde(with = "bounded::nonnegative_time")]
404 jitter: Time,
405 image: String,
406 },
407 CpuThrottle { cpus: f64 },
410}
411
412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
415pub enum FaultKind {
416 DiskPressure {
418 #[serde(with = "serde_units::human::ratio")]
419 watermark: Ratio,
420 },
421 SlowFollower { slowdown: Slowdown },
423 Pause {
425 #[serde(default)]
428 intends_failover: bool,
429 },
430 Restart {
432 graceful: bool,
434 },
435 Roll { to: Side },
439 CertificateRotation,
445 Reassignment { path: String },
451}
452
453impl FaultKind {
454 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
515#[serde(deny_unknown_fields)]
516pub struct FaultPlan {
517 pub name: String,
518 pub fault: FaultKind,
520 pub target: Target,
521 #[serde(with = "bounded::nonnegative_time")]
523 pub first_at: Time,
524 #[serde(default, with = "serde_units::human::option_time")]
526 pub every: Option<Time>,
527 #[serde(default = "zero_time", with = "bounded::nonnegative_time")]
530 pub jitter: Time,
531 #[serde(with = "bounded::positive_time")]
533 pub duration: Time,
534}
535
536fn zero_time() -> Time { Time::ZERO }
537
538#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
540#[serde(deny_unknown_fields)]
541pub struct SelfCheck {
542 pub missing_record_path: String,
545 pub missed_deadline: MissedDeadline,
546}
547
548#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
551#[serde(deny_unknown_fields)]
552pub struct MissedDeadline {
553 pub fault: String,
555 pub check: RecoveryCheck,
556 #[serde(with = "bounded::positive_time")]
557 pub deadline: Time,
558}
559
560#[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
568pub const MAX_DISK_WATERMARK: f64 = 0.95;
570
571impl Contract {
572 pub fn from_yaml(text: &str) -> Result<Self, serde_saphyr::Error> {
579 serde_saphyr::from_str(text)
580 }
581
582 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 #[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 #[must_use]
621 pub fn path(&self, name: &str) -> Option<&LoadPath> {
622 self.paths.iter().find(|path| path.name == name)
623 }
624
625 #[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 #[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 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 (Profile::Lifecycle, _) => {}
1020 }
1021 }
1022
1023 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 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
1129pub(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 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 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 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 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}