krabka_bench_driver/soak/
verdict.rs1use {
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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27pub struct Verdict {
28 pub contract: String,
29 pub profile: Profile,
30 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 #[serde(default)]
41 pub lifecycle: Vec<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub recovery: Option<RecoveryVerdict>,
46}
47
48impl Verdict {
49 #[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 #[must_use]
103 pub fn passed(&self) -> bool { self.failures().is_empty() }
104}
105
106#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct SelfCheckReport {
159 pub passed: bool,
160 pub detected: Vec<String>,
162 pub problems: Vec<String>,
164 pub verdict: Verdict,
165}
166
167#[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 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 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 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 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 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}