Skip to main content

krabka_bench_driver/soak/
verdict.rs

1//! The verdict: a contract and a run's evidence, judged.
2//!
3//! [`judge`] is what a soak is graded by. [`judge_self_check`] is what proves
4//! the grading works: it passes only when the injected missing record and the
5//! injected missed deadline are each reported exactly, and nothing else is.
6
7use {
8    super::{
9        contract::{Contract, Profile},
10        deadline::{
11            BudgetVerdict, DeadlineOutcome, DeadlineVerdict, FaultProblem, check_budgets,
12            check_recoveries,
13        },
14        evidence::Evidence,
15        ledger::SeqSet,
16        lifecycle::judge_lifecycle,
17        reconcile::{FindingKind, Reconciliation, reconcile},
18        recovery::{RecoveryVerdict, check_recovery},
19        schedule::{ScheduledFault, expand},
20    },
21    serde::{Deserialize, Serialize},
22    std::collections::BTreeMap,
23};
24
25/// A run, judged against its contract.
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct Verdict {
28    pub contract: String,
29    pub profile: Profile,
30    /// Reasons the contract itself, or the evidence's claim to belong to it,
31    /// is unusable. Any entry fails the verdict.
32    pub contract_errors: Vec<String>,
33    pub schedule: Vec<ScheduledFault>,
34    pub reconciliation: Reconciliation,
35    pub deadlines: Vec<DeadlineVerdict>,
36    pub fault_problems: Vec<FaultProblem>,
37    pub budgets: Vec<BudgetVerdict>,
38    /// What a lifecycle contract adds: see [`judge_lifecycle`]. Empty for any
39    /// other profile.
40    #[serde(default)]
41    pub lifecycle: Vec<String>,
42    /// A disaster-recovery drill's recovery point and recovery time against
43    /// its objectives. Absent for a soak.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub recovery: Option<RecoveryVerdict>,
46}
47
48impl Verdict {
49    /// One line per failure, in the order a reader should fix them.
50    #[must_use]
51    pub fn failures(&self) -> Vec<String> {
52        let mut lines: Vec<String> = self
53            .contract_errors
54            .iter()
55            .map(|error| format!("contract: {error}"))
56            .collect();
57        lines.extend(
58            self.reconciliation
59                .findings
60                .iter()
61                .map(|finding| format!("reconcile: {finding}")),
62        );
63        lines.extend(
64            self.fault_problems
65                .iter()
66                .map(|problem| format!("fault: {} {}", problem.fault, problem.problem)),
67        );
68        lines.extend(
69            self.deadlines
70                .iter()
71                .filter(|verdict| verdict.outcome != DeadlineOutcome::Met)
72                .map(|verdict| format!("deadline: {verdict}")),
73        );
74        lines.extend(
75            self.budgets
76                .iter()
77                .filter(|verdict| !verdict.within)
78                .map(|verdict| {
79                    format!(
80                        "budget: {} {} measured {}, budget {}",
81                        verdict.path, verdict.measure, verdict.measured, verdict.budget
82                    )
83                }),
84        );
85        lines.extend(
86            self.lifecycle
87                .iter()
88                .map(|failure| format!("lifecycle: {failure}")),
89        );
90        if let Some(recovery) = &self.recovery {
91            lines.extend(
92                recovery
93                    .problems
94                    .iter()
95                    .map(|problem| format!("recovery: {problem}")),
96            );
97        }
98        lines
99    }
100
101    /// Whether the run met its contract.
102    #[must_use]
103    pub fn passed(&self) -> bool { self.failures().is_empty() }
104}
105
106/// Judge `evidence` against `contract`.
107#[must_use]
108pub fn judge(contract: &Contract, evidence: &Evidence) -> Verdict {
109    let mut contract_errors: Vec<String> = contract
110        .validate()
111        .into_iter()
112        .map(|error| error.0)
113        .collect();
114    if evidence.contract != contract.name {
115        contract_errors.push(format!(
116            "the evidence was recorded under contract `{}`, not `{}`",
117            evidence.contract, contract.name
118        ));
119    }
120    if evidence.contract_digest != contract.digest() {
121        contract_errors.push(format!(
122            "the evidence was recorded under contract digest {}, but this contract's is {}: \
123             it was edited after the run",
124            evidence.contract_digest,
125            contract.digest()
126        ));
127    }
128    if evidence.seed != contract.seed {
129        contract_errors.push(format!(
130            "the evidence was recorded with seed {}, not the contract's {}",
131            evidence.seed, contract.seed
132        ));
133    }
134    if contract.profile != Profile::SelfCheck && !evidence.injected.is_empty() {
135        contract_errors.push(format!(
136            "a {:?} run's evidence records injected losses",
137            contract.profile
138        ));
139    }
140    let schedule = expand(contract).unwrap_or_default();
141    let (deadlines, fault_problems) = check_recoveries(contract, &schedule, evidence);
142    Verdict {
143        contract: contract.name.clone(),
144        profile: contract.profile,
145        contract_errors,
146        reconciliation: reconcile(contract, &schedule, evidence),
147        budgets: check_budgets(contract, evidence),
148        lifecycle: judge_lifecycle(contract, &schedule, evidence),
149        recovery: check_recovery(contract, evidence),
150        schedule,
151        deadlines,
152        fault_problems,
153    }
154}
155
156/// What a self-check proved, or why it did not.
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct SelfCheckReport {
159    pub passed: bool,
160    /// What was injected and found, one line each.
161    pub detected: Vec<String>,
162    /// What was injected and not found, or found and not injected.
163    pub problems: Vec<String>,
164    pub verdict: Verdict,
165}
166
167/// Judge a self-check run.
168///
169/// The verdict must fail, and fail for exactly two reasons: the records the
170/// run deleted are reported lost (by every reader of their path, and nothing
171/// else is), and the one deadline the contract made impossible is reported
172/// missed (and no other recovery is). Anything else the verdict reports is a
173/// problem with the harness or the cluster, and fails the self-check too.
174#[must_use]
175pub fn judge_self_check(contract: &Contract, evidence: &Evidence) -> SelfCheckReport {
176    let verdict = judge(contract, evidence);
177    let mut detected = Vec::new();
178    let mut problems = Vec::new();
179    let Some(self_check) = &contract.self_check else {
180        problems.push("the contract has no self_check section".to_owned());
181        return SelfCheckReport {
182            passed: false,
183            detected,
184            problems,
185            verdict,
186        };
187    };
188
189    // Injected losses, per producer.
190    let mut injected: BTreeMap<&str, SeqSet> = BTreeMap::new();
191    for loss in &evidence.injected {
192        if loss.path == self_check.missing_record_path {
193            injected
194                .entry(loss.producer.as_str())
195                .or_default()
196                .insert(loss.seq);
197        } else {
198            problems.push(format!(
199                "a loss was injected on `{}`, not on `{}`",
200                loss.path, self_check.missing_record_path
201            ));
202        }
203    }
204    if injected.is_empty() {
205        problems.push("the run injected no missing record, so detection proves nothing".to_owned());
206    }
207    let readers = contract
208        .path(&self_check.missing_record_path)
209        .map_or(0, |path| path.consumers.len());
210    for (producer, seqs) in &injected {
211        let found: Vec<_> = verdict
212            .reconciliation
213            .findings
214            .iter()
215            .filter(|finding| {
216                finding.kind == FindingKind::Lost
217                    && finding.path == self_check.missing_record_path
218                    && finding.subject == *producer
219            })
220            .collect();
221        let exact = found.len() == readers && found.iter().all(|finding| finding.seqs == *seqs);
222        if exact {
223            detected.push(format!(
224                "reconciler: {} injected missing record(s) of {producer} ({:?}) reported lost by all {readers} reader(s)",
225                seqs.len(),
226                seqs.sample(5)
227            ));
228        } else {
229            problems.push(format!(
230                "reconciler: injected {:?} of {producer} on {} reader(s) but found {:?}",
231                seqs.sample(5),
232                readers,
233                found
234                    .iter()
235                    .map(|finding| finding.seqs.sample(5))
236                    .collect::<Vec<_>>()
237            ));
238        }
239    }
240    for finding in &verdict.reconciliation.findings {
241        let expected = finding.kind == FindingKind::Lost
242            && finding.path == self_check.missing_record_path
243            && injected.contains_key(finding.subject.as_str());
244        if !expected {
245            problems.push(format!("unexpected reconciliation finding: {finding}"));
246        }
247    }
248
249    // The impossible deadline.
250    let wanted_fault = format!("{}#0", self_check.missed_deadline.fault);
251    for deadline in &verdict.deadlines {
252        let is_target =
253            deadline.fault == wanted_fault && deadline.check == self_check.missed_deadline.check;
254        match (is_target, deadline.outcome) {
255            (true, DeadlineOutcome::Missed) => {
256                detected.push(format!("deadline checker: {deadline}"));
257            }
258            (true, _) => problems.push(format!(
259                "deadline checker did not report a miss: {deadline}"
260            )),
261            (false, DeadlineOutcome::Met) => {}
262            (false, _) => problems.push(format!("unexpected deadline failure: {deadline}")),
263        }
264    }
265    if !verdict.deadlines.iter().any(|deadline| {
266        deadline.fault == wanted_fault && deadline.check == self_check.missed_deadline.check
267    }) {
268        problems.push(format!(
269            "no {} measurement for {wanted_fault} exists to miss",
270            self_check.missed_deadline.check
271        ));
272    }
273
274    // Everything else must be clean.
275    problems.extend(
276        verdict
277            .contract_errors
278            .iter()
279            .map(|error| format!("contract: {error}")),
280    );
281    problems.extend(
282        verdict
283            .fault_problems
284            .iter()
285            .map(|problem| format!("fault: {} {}", problem.fault, problem.problem)),
286    );
287    problems.extend(
288        verdict
289            .budgets
290            .iter()
291            .filter(|budget| !budget.within)
292            .map(|budget| {
293                format!(
294                    "budget: {} {} measured {}, budget {}",
295                    budget.path, budget.measure, budget.measured, budget.budget
296                )
297            }),
298    );
299
300    SelfCheckReport {
301        passed: problems.is_empty() && detected.len() == injected.len() + 1,
302        detected,
303        problems,
304        verdict,
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use {
311        super::{judge, judge_self_check},
312        crate::soak::{
313            contract::{Profile, fixture},
314            synthetic::{clean_run, inject_missing_record},
315        },
316        assert2::{assert, check},
317        krabka_units::prelude::*,
318    };
319
320    #[test]
321    fn a_clean_synthetic_run_of_a_soak_passes() {
322        let mut contract = fixture::self_check();
323        contract.self_check = None;
324        contract.profile = Profile::SelfCheck;
325        let verdict = judge(&contract, &clean_run(&contract));
326        // Only the missing self_check section is wrong with this contract.
327        check!(verdict.reconciliation.findings.is_empty());
328        check!(verdict.fault_problems.is_empty());
329        check!(verdict.budgets.iter().all(|budget| budget.within));
330        check!(
331            verdict.failures() == ["contract: a self-check contract needs a self_check section"]
332        );
333    }
334
335    #[test]
336    fn the_self_check_passes_when_both_injections_are_detected() {
337        let contract = fixture::self_check();
338        let mut evidence = clean_run(&contract);
339        inject_missing_record(&contract, &mut evidence);
340        let report = judge_self_check(&contract, &evidence);
341        assert!(report.passed, "{:#?}", report.problems);
342        check!(report.detected.len() == 2);
343        check!(report.detected[0].starts_with("reconciler: 1 injected missing record(s)"));
344        check!(report.detected[1].starts_with("deadline checker: leader-restart#0 isr_rejoin"));
345    }
346
347    #[test]
348    fn the_self_check_fails_when_nothing_was_injected() {
349        let contract = fixture::self_check();
350        let report = judge_self_check(&contract, &clean_run(&contract));
351        check!(!report.passed);
352        check!(
353            report
354                .problems
355                .iter()
356                .any(|problem| problem.contains("injected no missing record"))
357        );
358    }
359
360    #[test]
361    fn the_self_check_fails_when_the_reconciler_misses_the_loss() {
362        let contract = fixture::self_check();
363        let mut evidence = clean_run(&contract);
364        inject_missing_record(&contract, &mut evidence);
365        // The record is claimed deleted but every reader still saw it: a
366        // reconciler that reports nothing here would pass a lossy cluster.
367        let seq = evidence.injected[0].seq;
368        for reader in &mut evidence.readers {
369            reader.seen.insert(seq);
370        }
371        let report = judge_self_check(&contract, &evidence);
372        check!(!report.passed);
373        check!(
374            report
375                .problems
376                .iter()
377                .any(|problem| problem.starts_with("reconciler: injected"))
378        );
379    }
380
381    #[test]
382    fn the_self_check_fails_on_any_other_failure() {
383        let contract = fixture::self_check();
384        let mut evidence = clean_run(&contract);
385        inject_missing_record(&contract, &mut evidence);
386        evidence.faults[0].cleared_unix_ms = None;
387        let report = judge_self_check(&contract, &evidence);
388        check!(!report.passed);
389        check!(report.problems == ["fault: short-pause#0 injected but never cleared"]);
390    }
391
392    #[test]
393    fn evidence_judged_against_an_edited_contract_is_refused() {
394        let contract = fixture::self_check();
395        let evidence = clean_run(&contract);
396        let mut edited = fixture::self_check();
397        edited.paths[0].produce_latency.p99 = secs(9);
398        let verdict = judge(&edited, &evidence);
399        check!(
400            verdict
401                .contract_errors
402                .iter()
403                .any(|error| error.contains("edited after the run"))
404        );
405    }
406
407    #[test]
408    fn evidence_from_another_contract_is_refused() {
409        let contract = fixture::self_check();
410        let mut evidence = clean_run(&contract);
411        evidence.contract = "something-else".into();
412        let verdict = judge(&contract, &evidence);
413        check!(
414            verdict
415                .contract_errors
416                .iter()
417                .any(|error| error.contains("something-else"))
418        );
419    }
420}