Skip to main content

krabka_bench_driver/soak/
lifecycle.rs

1//! The lifecycle contract: the release a cluster starts on, the transitions it
2//! goes through under load, and whether it may be rolled back to that release.
3//!
4//! A `lifecycle` contract is a soak contract whose fault plan holds lifecycle
5//! transitions as well as faults: `roll` restarts one broker from another
6//! release's image, `certificate_rotation` swaps one broker's TLS keystore and
7//! truststore, and `reassignment` moves one replica of every partition of a
8//! path onto another broker. The executor measures each one's disruption as it
9//! measures a fault's, and after each one writes a [`Checkpoint`]: the image
10//! every broker runs, the finalized feature levels, whether a rollback to the
11//! source release is still supported, and a reconciliation of the records,
12//! the committed offsets and the topic configuration as they stand.
13//!
14//! The source release is declared by digest, with the feature ranges it
15//! supports. The target is the candidate the run is given, so it is not
16//! declared. [`rollback`] derives whether a cluster may roll back to the
17//! source from the levels it has finalized, by the rule of
18//! `docs/persisted_formats.md`: a 1.x broker writes a new format only once its
19//! feature level is finalized, so a cluster that finalized nothing above what
20//! the source supports holds nothing the source cannot read.
21
22use {
23    super::{
24        contract::{Contract, FaultKind, Isolation, Semantics, Target},
25        evidence::{Evidence, ReaderLedger},
26        ledger::SeqSet,
27        reconcile::{Finding, FindingKind},
28        schedule::ScheduledFault,
29    },
30    serde::{Deserialize, Serialize},
31    std::{collections::BTreeMap, fmt},
32};
33
34/// Which release a `roll` restarts a broker from.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Side {
38    /// The declared source release: a rollback.
39    Source,
40    /// The candidate under test: an upgrade.
41    Target,
42}
43
44impl fmt::Display for Side {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        f.write_str(match self {
47            Self::Source => "source",
48            Self::Target => "target",
49        })
50    }
51}
52
53/// The levels of one feature that a release supports, inclusive.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct LevelRange {
57    pub min: i16,
58    pub max: i16,
59}
60
61impl fmt::Display for LevelRange {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(f, "{}-{}", self.min, self.max)
64    }
65}
66
67/// A published release, by image digest.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct Release {
71    /// The release tag, for example `v1.0.5`.
72    pub version: String,
73    /// The image, `<reference>@sha256:<digest>`. A tag alone is refused: it
74    /// can move after the contract is written.
75    pub image: String,
76    /// The feature ranges the release advertises in `ApiVersions`. A feature
77    /// it does not name supports level 0 only, as Kafka's
78    /// `QuorumFeatures.DISABLED` does. The executor reads the running source's
79    /// ranges back and the judge compares them with these.
80    pub supports: BTreeMap<String, LevelRange>,
81}
82
83/// The `lifecycle` section of a contract.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Lifecycle {
87    /// The release the cluster is formatted and started on.
88    pub source: Release,
89}
90
91/// Whether a cluster may roll back to the source release.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(tag = "rollback", content = "reasons", rename_all = "snake_case")]
94pub enum Rollback {
95    /// Every finalized level is one the source supports.
96    Supported,
97    /// The finalized levels the source does not support. A source broker
98    /// refuses to start over them.
99    Unsupported(Vec<String>),
100}
101
102/// Whether a cluster that finalized `finalized` may roll back to `source`.
103///
104/// It may when the source supports every finalized level. A feature the
105/// source does not name supports level 0 only, so finalizing such a feature
106/// above 0 also rules a rollback out. A feature that is not finalized is at
107/// level 0, which every release supports.
108#[must_use]
109pub fn rollback(source: &Release, finalized: &BTreeMap<String, i16>) -> Rollback {
110    let reasons: Vec<String> = finalized
111        .iter()
112        .filter_map(|(feature, &level)| {
113            let range = source
114                .supports
115                .get(feature)
116                .copied()
117                .unwrap_or(LevelRange { min: 0, max: 0 });
118            (!(range.min..=range.max).contains(&level)).then(|| {
119                format!(
120                    "{feature} is finalized at {level}, but {} supports {range}",
121                    source.version
122                )
123            })
124        })
125        .collect();
126    if reasons.is_empty() {
127        Rollback::Supported
128    } else {
129        Rollback::Unsupported(reasons)
130    }
131}
132
133/// The cluster as it stood after one lifecycle transition.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Checkpoint {
136    /// The transition, `<plan>#<n>`.
137    pub fault: String,
138    pub unix_ms: i64,
139    /// The image every broker runs, by node id, as `docker inspect` reports it.
140    pub images: BTreeMap<i32, String>,
141    /// The finalized feature levels, as `ApiVersions` reports them.
142    pub finalized: BTreeMap<String, i16>,
143    /// [`rollback`] over `finalized`.
144    pub rollback: Rollback,
145    /// For each path, what every record acknowledged before the read-back
146    /// was checked against: the producer's acknowledged records then, and what
147    /// a `read_uncommitted` reader found from the earliest offset.
148    pub records: Vec<CheckpointRead>,
149    /// Topic and broker configuration that differs from what the run set.
150    #[serde(default)]
151    pub config_mismatches: Vec<String>,
152}
153
154/// One path's records at a checkpoint.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct CheckpointRead {
157    pub path: String,
158    pub semantics: Semantics,
159    #[serde(default)]
160    pub key_space: Option<u64>,
161    /// The producer's acknowledged records when the read-back started.
162    pub acknowledged: SeqSet,
163    pub reader: ReaderLedger,
164}
165
166/// The acknowledged records of `read` that its reader did not find.
167///
168/// A compacted path is held to the latest acknowledged value of each key, which
169/// compaction must keep; an older value of a key may already be gone.
170#[must_use]
171pub fn checkpoint_findings(read: &CheckpointRead) -> Vec<Finding> {
172    let (kind, missing, detail) =
173        if let (Semantics::Compacted, Some(keys)) = (read.semantics, read.key_space) {
174            (
175                FindingKind::CompactionRegression,
176                regressed_keys(&read.acknowledged, keys, &read.reader.latest_by_key),
177                "the latest acknowledged value of a key was not read back",
178            )
179        } else {
180            (
181                FindingKind::Lost,
182                read.acknowledged.difference(&read.reader.seen),
183                "acknowledged before the checkpoint and not read back",
184            )
185        };
186    if missing.is_empty() {
187        return Vec::new();
188    }
189    vec![Finding {
190        kind,
191        path: read.path.clone(),
192        subject: read.reader.producer.clone(),
193        isolation: Some(Isolation::ReadUncommitted),
194        seqs: missing,
195        detail: detail.to_owned(),
196    }]
197}
198
199/// The latest acknowledged record of each of `keys` keys whose value the
200/// reader's `latest_by_key` does not reach.
201fn regressed_keys(acknowledged: &SeqSet, keys: u64, latest_by_key: &BTreeMap<u64, u64>) -> SeqSet {
202    let mut latest: BTreeMap<u64, u64> = BTreeMap::new();
203    for seq in acknowledged.iter() {
204        latest.insert(seq % keys.max(1), seq);
205    }
206    let mut regressed = SeqSet::new();
207    for (key, seq) in latest {
208        if latest_by_key.get(&key).is_none_or(|read| *read < seq) {
209            regressed.insert(seq);
210        }
211    }
212    regressed
213}
214
215/// Whether `kind` is a lifecycle transition, which leaves a checkpoint.
216#[must_use]
217pub fn is_transition(kind: &FaultKind) -> bool {
218    matches!(
219        kind,
220        FaultKind::Roll { .. } | FaultKind::CertificateRotation | FaultKind::Reassignment { .. }
221    )
222}
223
224/// Every lifecycle failure of `evidence`: the checks a lifecycle contract adds
225/// to the soak verdict, one line each.
226///
227/// - The source must advertise the feature ranges the contract declares.
228/// - Every transition must leave a checkpoint, and every checkpoint must
229///   reconcile its records and its configuration.
230/// - After a `roll`, the rolled broker must run that side's image.
231/// - A `roll` to the source must not follow a checkpoint that ruled a
232///   rollback out.
233#[must_use]
234pub fn judge_lifecycle(
235    contract: &Contract,
236    schedule: &[ScheduledFault],
237    evidence: &Evidence,
238) -> Vec<String> {
239    let Some(lifecycle) = &contract.lifecycle else {
240        return Vec::new();
241    };
242    let mut failures = Vec::new();
243    if evidence.source_supports != lifecycle.source.supports {
244        failures.push(format!(
245            "{} advertised {:?}, but the contract declares {:?}",
246            lifecycle.source.version, evidence.source_supports, lifecycle.source.supports
247        ));
248    }
249    let mut rollback_ruled_out: Option<String> = None;
250    for fault in schedule.iter().filter(|fault| is_transition(&fault.kind)) {
251        if fault.kind == (FaultKind::Roll { to: Side::Source })
252            && let Some(reason) = &rollback_ruled_out
253        {
254            failures.push(format!(
255                "{} rolled back to {} after a checkpoint ruled it out: {reason}",
256                fault.id, lifecycle.source.version
257            ));
258        }
259        let Some(checkpoint) = evidence
260            .checkpoints
261            .iter()
262            .find(|checkpoint| checkpoint.fault == fault.id)
263        else {
264            failures.push(format!("{}: no checkpoint after the transition", fault.id));
265            continue;
266        };
267        failures.extend(
268            checkpoint_failures(contract, lifecycle, checkpoint)
269                .into_iter()
270                .map(|failure| format!("{}: {failure}", fault.id)),
271        );
272        failures.extend(
273            placement_failures(fault, checkpoint, evidence)
274                .into_iter()
275                .map(|failure| format!("{}: {failure}", fault.id)),
276        );
277        // The judge's own reading of the finalized levels, not the one the
278        // executor recorded, decides whether a later rollback was allowed.
279        rollback_ruled_out = match rollback(&lifecycle.source, &checkpoint.finalized) {
280            Rollback::Supported => None,
281            Rollback::Unsupported(reasons) => Some(reasons.join("; ")),
282        };
283    }
284    failures
285}
286
287/// What one checkpoint fails on by itself: a path read back other than once,
288/// a record or configuration that did not reconcile, and a recorded rollback
289/// answer that its own finalized levels contradict.
290fn checkpoint_failures(
291    contract: &Contract,
292    lifecycle: &Lifecycle,
293    checkpoint: &Checkpoint,
294) -> Vec<String> {
295    let mut failures = Vec::new();
296    for path in &contract.paths {
297        let reads = checkpoint
298            .records
299            .iter()
300            .filter(|read| read.path == path.name)
301            .count();
302        if reads != 1 {
303            failures.push(format!(
304                "path {} was read back {reads} times, not once",
305                path.name
306            ));
307        }
308    }
309    for read in &checkpoint.records {
310        if contract.path(&read.path).is_none() {
311            failures.push(format!("{} is not a path of the contract", read.path));
312        }
313        failures.extend(
314            checkpoint_findings(read)
315                .into_iter()
316                .map(|finding| finding.to_string()),
317        );
318    }
319    failures.extend(
320        checkpoint
321            .config_mismatches
322            .iter()
323            .map(|mismatch| format!("configuration {mismatch}")),
324    );
325    let derived = rollback(&lifecycle.source, &checkpoint.finalized);
326    if checkpoint.rollback != derived {
327        failures.push(format!(
328            "recorded the rollback as {:?}, but the finalized levels {:?} make it {derived:?}",
329            checkpoint.rollback, checkpoint.finalized
330        ));
331    }
332    failures
333}
334
335/// Whether `fault` landed on the broker its plan names, the node at that index
336/// of the cluster's nodes in id order, and, for a `roll`, left that node on
337/// its side's image.
338fn placement_failures(
339    fault: &ScheduledFault,
340    checkpoint: &Checkpoint,
341    evidence: &Evidence,
342) -> Vec<String> {
343    let mut failures = Vec::new();
344    let node = evidence
345        .faults
346        .iter()
347        .find(|record| record.id == fault.id)
348        .and_then(|record| record.node);
349    if let Target::Broker(index) = fault.target {
350        let planned = usize::try_from(index)
351            .ok()
352            .and_then(|index| checkpoint.images.keys().nth(index).copied());
353        if node.is_none() || node != planned {
354            failures.push(format!(
355                "landed on node {node:?}, but broker {index} of the plan is node {planned:?}"
356            ));
357        }
358    }
359    if let FaultKind::Roll { to } = fault.kind {
360        let expected = evidence.releases.get(&to.to_string());
361        let running = node.and_then(|node| checkpoint.images.get(&node));
362        if expected.is_none() || running != expected {
363            failures.push(format!(
364                "node {node:?} runs {running:?} after a roll to the {to}, whose image is {expected:?}"
365            ));
366        }
367    }
368    failures
369}
370
371#[cfg(test)]
372mod tests {
373    use {
374        super::{
375            CheckpointRead, LevelRange, Release, Rollback, checkpoint_findings, judge_lifecycle,
376            rollback,
377        },
378        crate::soak::{
379            contract::{Isolation, Semantics, fixture},
380            evidence::{Evidence, ReaderLedger},
381            ledger::SeqSet,
382            reconcile::FindingKind,
383            schedule::expand,
384            synthetic::clean_run,
385        },
386        assert2::check,
387        std::collections::BTreeMap,
388    };
389
390    fn source() -> Release {
391        Release {
392            version: "v1.0.5".to_owned(),
393            image: "ghcr.io/krabka-io/krabka-broker@sha256:00".to_owned(),
394            supports: BTreeMap::from([
395                ("metadata.version".to_owned(), LevelRange {
396                    min: 7,
397                    max: 30,
398                }),
399                ("krabka.version".to_owned(), LevelRange { min: 0, max: 1 }),
400            ]),
401        }
402    }
403
404    fn levels(levels: &[(&str, i16)]) -> BTreeMap<String, i16> {
405        levels
406            .iter()
407            .map(|(feature, level)| ((*feature).to_owned(), *level))
408            .collect()
409    }
410
411    #[yare::parameterized(
412        nothing_finalized = { &[], None },
413        the_source_levels = { &[("metadata.version", 30), ("krabka.version", 1)], None },
414        level_zero_of_an_unknown_feature = { &[("group.version", 0)], None },
415        a_newer_metadata_version = {
416            &[("metadata.version", 31)],
417            Some("metadata.version is finalized at 31, but v1.0.5 supports 7-30")
418        },
419        the_first_new_krabka_format = {
420            &[("krabka.version", 2)],
421            Some("krabka.version is finalized at 2, but v1.0.5 supports 0-1")
422        },
423        a_feature_the_source_does_not_know = {
424            &[("share.version", 1)],
425            Some("share.version is finalized at 1, but v1.0.5 supports 0-0")
426        },
427    )]
428    fn a_rollback_is_supported_only_below_every_level_the_source_supports(
429        finalized: &[(&str, i16)],
430        refused: Option<&str>,
431    ) {
432        let expected = refused.map_or(Rollback::Supported, |reason| {
433            Rollback::Unsupported(vec![reason.to_owned()])
434        });
435        check!(rollback(&source(), &levels(finalized)) == expected);
436    }
437
438    fn read(semantics: Semantics, acknowledged: SeqSet, seen: SeqSet) -> CheckpointRead {
439        let key_space = (semantics == Semantics::Compacted).then_some(4);
440        let latest_by_key = key_space.map_or_else(BTreeMap::new, |keys| {
441            let mut latest = BTreeMap::new();
442            for seq in seen.iter() {
443                latest.insert(seq % keys, seq);
444            }
445            latest
446        });
447        CheckpointRead {
448            path: "p".to_owned(),
449            semantics,
450            key_space,
451            acknowledged,
452            reader: ReaderLedger {
453                path: "p".to_owned(),
454                isolation: Isolation::ReadUncommitted,
455                producer: "p-0".to_owned(),
456                seen,
457                duplicates: Vec::new(),
458                latest_by_key,
459            },
460        }
461    }
462
463    /// A clean synthetic run of the lifecycle fixture, judged, after `edit`.
464    fn lifecycle_failures(edit: fn(&mut Evidence)) -> Vec<String> {
465        let contract = fixture::lifecycle();
466        let mut evidence = clean_run(&contract);
467        edit(&mut evidence);
468        judge_lifecycle(&contract, &expand(&contract).unwrap(), &evidence)
469    }
470
471    #[yare::parameterized(
472        clean = { |_| {}, None },
473        source_ranges_differ = {
474            |e: &mut Evidence| e.source_supports.clear(),
475            Some("v1.0.5 advertised {}")
476        },
477        no_checkpoint = {
478            |e: &mut Evidence| { e.checkpoints.remove(1); },
479            Some("rotate#0: no checkpoint")
480        },
481        wrong_image_after_a_roll = {
482            |e: &mut Evidence| { e.checkpoints[0].images.insert(1, "synthetic-source".into()); },
483            Some("upgrade#0: node Some(1) runs Some(\"synthetic-source\") after a roll to the target")
484        },
485        wrong_broker = {
486            |e: &mut Evidence| e.faults[1].node = Some(1),
487            Some("rotate#0: landed on node Some(1), but broker 1 of the plan is node Some(2)")
488        },
489        rollback_after_a_newer_level = {
490            |e: &mut Evidence| { e.checkpoints[2].finalized.insert("krabka.version".into(), 2); },
491            Some("reassign#0: recorded the rollback as Supported")
492        },
493        rollback_past_a_newer_level = {
494            |e: &mut Evidence| { e.checkpoints[2].finalized.insert("krabka.version".into(), 2); },
495            Some("rollback#0 rolled back to v1.0.5 after a checkpoint ruled it out: krabka.version is finalized at 2, but v1.0.5 supports 0-0")
496        },
497        rollback_answer_contradicts_the_levels = {
498            |e: &mut Evidence| e.checkpoints[0].rollback = Rollback::Unsupported(vec!["made up".into()]),
499            Some("upgrade#0: recorded the rollback as Unsupported")
500        },
501        a_path_left_unread = {
502            |e: &mut Evidence| { e.checkpoints[1].records.pop(); },
503            Some("rotate#0: path classic-transactional was read back 0 times, not once")
504        },
505        no_path_read = {
506            |e: &mut Evidence| e.checkpoints[1].records.clear(),
507            Some("rotate#0: path classic-idempotent was read back 0 times, not once")
508        },
509        config_drift = {
510            |e: &mut Evidence| e.checkpoints[1].config_mismatches.push("classic-idempotent min.insync.replicas is 1, not 2".into()),
511            Some("rotate#0: configuration classic-idempotent min.insync.replicas is 1, not 2")
512        },
513    )]
514    fn a_lifecycle_run_fails_on_each_broken_promise(
515        edit: fn(&mut Evidence),
516        failure: Option<&str>,
517    ) {
518        let failures = lifecycle_failures(edit);
519        check!(failures.is_empty() == failure.is_none(), "{failures:#?}");
520        check!(
521            failure.is_none_or(|failure| failures.iter().any(|found| found.starts_with(failure))),
522            "{failures:#?}"
523        );
524    }
525
526    #[yare::parameterized(
527        all_read = { Semantics::Idempotent, SeqSet::range(0, 10), SeqSet::range(0, 12), None },
528        one_lost = {
529            Semantics::Transactional,
530            SeqSet::range(0, 10),
531            SeqSet::range(0, 9),
532            Some((FindingKind::Lost, vec![9]))
533        },
534        compacted_older_values_gone = {
535            Semantics::Compacted, SeqSet::range(0, 8), SeqSet::range(4, 8), None
536        },
537        compacted_latest_value_gone = {
538            Semantics::Compacted,
539            SeqSet::range(0, 8),
540            SeqSet::range(3, 7),
541            Some((FindingKind::CompactionRegression, vec![7]))
542        },
543    )]
544    fn a_checkpoint_holds_every_acknowledged_record_its_semantics_keep(
545        semantics: Semantics,
546        acknowledged: SeqSet,
547        seen: SeqSet,
548        expected: Option<(FindingKind, Vec<u64>)>,
549    ) {
550        let found: Vec<(FindingKind, Vec<u64>)> =
551            checkpoint_findings(&read(semantics, acknowledged, seen))
552                .into_iter()
553                .map(|finding| (finding.kind, finding.seqs.iter().collect()))
554                .collect();
555        check!(found == expected.into_iter().collect::<Vec<_>>());
556    }
557}