1use {
10 super::{
11 contract::{Contract, RecoveryCheck, Target, human},
12 evidence::{Evidence, FaultRecord, LatencySummary, RecoveryRecord},
13 schedule::ScheduledFault,
14 },
15 krabka_units::{prelude::*, serde_units},
16 serde::{Deserialize, Serialize},
17 std::fmt,
18};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum DeadlineOutcome {
24 Met,
25 Missed,
26 NotObserved,
28}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct DeadlineVerdict {
33 pub fault: String,
34 pub check: RecoveryCheck,
35 #[serde(with = "serde_units::human::time")]
36 pub deadline: Time,
37 #[serde(with = "serde_units::human::option_time")]
38 pub took: Option<Time>,
39 pub outcome: DeadlineOutcome,
40}
41
42impl fmt::Display for DeadlineVerdict {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 let took = self.took.map_or_else(|| "never".to_owned(), human);
45 write!(
46 f,
47 "{} {}: took {took} against {} ({:?})",
48 self.fault,
49 self.check,
50 human(self.deadline),
51 self.outcome
52 )
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct FaultProblem {
59 pub fault: String,
60 pub problem: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct BudgetVerdict {
66 pub path: String,
67 pub measure: String,
69 pub budget: String,
70 pub measured: String,
71 pub within: bool,
72}
73
74pub const START_SLACK: Time = secs(30);
78
79fn expected_role(target: Target) -> Option<&'static str> {
81 match target {
82 Target::Controller => Some("controller"),
83 Target::Leader => Some("leader"),
84 Target::Follower => Some("follower"),
85 Target::Any | Target::Broker(_) => None,
86 }
87}
88
89fn execution_problems(
91 fault: &ScheduledFault,
92 record: &FaultRecord,
93 started_unix_ms: i64,
94) -> Vec<String> {
95 let mut problems = Vec::new();
96 if record.kind != fault.kind.name() {
97 problems.push(format!(
98 "recorded as a `{}` fault, but the schedule names `{}`",
99 record.kind,
100 fault.kind.name()
101 ));
102 }
103 if let Some(role) = expected_role(fault.target)
106 && record.role != role
107 && !(role == "leader" && record.role == "controller")
108 {
109 problems.push(format!(
110 "landed on a {} ({}), but the plan targets the {role}",
111 record.role, record.target
112 ));
113 }
114 let offset = Time::from_millis(record.injected_unix_ms.saturating_sub(started_unix_ms));
115 if offset + millis(1) < fault.start || offset > fault.start + START_SLACK {
118 problems.push(format!(
119 "injected at {} into the load, but scheduled at {} (at most {} late)",
120 human(offset),
121 human(fault.start),
122 human(START_SLACK)
123 ));
124 }
125 if let Some(error) = &record.cleanup_error {
126 problems.push(format!("cleanup failed: {error}"));
127 } else if record.cleared_unix_ms.is_none() {
128 problems.push("injected but never cleared".to_owned());
129 }
130 problems
131}
132
133#[must_use]
141pub fn checks_for(fault: &ScheduledFault, role: &str) -> Vec<RecoveryCheck> {
142 let mut checks = fault.checks.clone();
143 if role == "controller"
144 && fault.kind.moves_leadership()
145 && !checks.contains(&RecoveryCheck::ControllerFailover)
146 {
147 checks.push(RecoveryCheck::ControllerFailover);
148 }
149 checks
150}
151
152fn required_checks(fault: &ScheduledFault, record: Option<&FaultRecord>) -> Vec<RecoveryCheck> {
155 checks_for(fault, record.map_or("", |record| record.role.as_str()))
156}
157
158#[must_use]
166pub fn check_recoveries(
167 contract: &Contract,
168 schedule: &[ScheduledFault],
169 evidence: &Evidence,
170) -> (Vec<DeadlineVerdict>, Vec<FaultProblem>) {
171 let mut verdicts = Vec::new();
172 let mut problems = Vec::new();
173 for fault in schedule {
174 let record = evidence.faults.iter().find(|record| record.id == fault.id);
175 match record {
176 None => problems.push(FaultProblem {
177 fault: fault.id.clone(),
178 problem: "scheduled but never injected".to_owned(),
179 }),
180 Some(record) => problems.extend(
181 execution_problems(fault, record, evidence.started_unix_ms)
182 .into_iter()
183 .map(|problem| FaultProblem {
184 fault: fault.id.clone(),
185 problem,
186 }),
187 ),
188 }
189 for check in required_checks(fault, record) {
190 let deadline = deadline_for(contract, fault, check);
191 let took = evidence
192 .recoveries
193 .iter()
194 .find(|record| record.fault == fault.id && record.check == check)
195 .and_then(RecoveryRecord::took);
196 let outcome = match took {
197 None => DeadlineOutcome::NotObserved,
198 Some(took) if took > deadline => DeadlineOutcome::Missed,
199 Some(_) => DeadlineOutcome::Met,
200 };
201 verdicts.push(DeadlineVerdict {
202 fault: fault.id.clone(),
203 check,
204 deadline,
205 took,
206 outcome,
207 });
208 }
209 }
210 (verdicts, problems)
211}
212
213#[must_use]
215pub fn deadline_for(contract: &Contract, fault: &ScheduledFault, check: RecoveryCheck) -> Time {
216 match &contract.self_check {
217 Some(self_check)
218 if self_check.missed_deadline.fault == fault.plan
219 && self_check.missed_deadline.check == check
220 && fault.id == format!("{}#0", fault.plan) =>
221 {
222 self_check.missed_deadline.deadline
223 }
224 _ => check.deadline(&contract.deadlines),
225 }
226}
227
228#[must_use]
230pub fn check_budgets(contract: &Contract, evidence: &Evidence) -> Vec<BudgetVerdict> {
231 let mut verdicts = Vec::new();
232 for path in &contract.paths {
233 let Some(measured) = evidence
234 .paths
235 .iter()
236 .find(|measured| measured.path == path.name)
237 else {
238 verdicts.push(BudgetVerdict {
239 path: path.name.clone(),
240 measure: "measurement".to_owned(),
241 budget: "present".to_owned(),
242 measured: "absent".to_owned(),
243 within: false,
244 });
245 continue;
246 };
247 for (direction, budget, summary) in [
248 ("produce", &path.produce_latency, &measured.produce_steady),
249 ("consume", &path.consume_latency, &measured.consume_steady),
250 ] {
251 push_latency(&mut verdicts, &path.name, direction, summary, [
252 ("p50", budget.p50, summary.p50),
253 ("p99", budget.p99, summary.p99),
254 ("p999", budget.p999, summary.p999),
255 ]);
256 }
257 verdicts.push(BudgetVerdict {
258 path: path.name.clone(),
259 measure: "throughput".to_owned(),
260 budget: format!(">= {}", rate(path.throughput_floor)),
261 measured: rate(measured.steady_rate),
262 within: measured.steady_rate >= path.throughput_floor,
263 });
264 }
265 verdicts
266}
267
268fn push_latency(
269 verdicts: &mut Vec<BudgetVerdict>,
270 path: &str,
271 direction: &str,
272 summary: &LatencySummary,
273 percentiles: [(&str, Time, Time); 3],
274) {
275 if summary.count == 0 {
276 verdicts.push(BudgetVerdict {
277 path: path.to_owned(),
278 measure: format!("{direction} latency"),
279 budget: "samples".to_owned(),
280 measured: "none".to_owned(),
281 within: false,
282 });
283 return;
284 }
285 for (name, budget, measured) in percentiles {
286 verdicts.push(BudgetVerdict {
287 path: path.to_owned(),
288 measure: format!("{direction} {name}"),
289 budget: format!("<= {}", human(budget)),
290 measured: human(measured),
291 within: measured <= budget,
292 });
293 }
294}
295
296fn rate(frequency: Frequency) -> String {
297 use krabka_units::fmt::Human as _;
298 frequency.human().to_string()
299}
300
301#[cfg(test)]
302mod tests {
303 use {
304 super::{DeadlineOutcome, check_budgets, check_recoveries, checks_for},
305 crate::soak::{
306 contract::{
307 FaultKind,
308 RecoveryCheck::{
309 self, CatchUpAfterPause, ControllerFailover, IsrRejoin, LeaderFailover,
310 },
311 Target, fixture,
312 },
313 evidence::{Evidence, FaultRecord, LatencySummary, PathMeasurement, RecoveryRecord},
314 schedule::{ScheduledFault, expand},
315 },
316 assert2::check,
317 krabka_units::prelude::*,
318 };
319
320 const STARTED_MS: i64 = 1_000_000;
323
324 fn record(id: &str, kind: &str, role: &str, offset_ms: i64) -> FaultRecord {
325 FaultRecord {
326 id: id.to_owned(),
327 kind: kind.to_owned(),
328 target: "broker-1".to_owned(),
329 role: role.to_owned(),
330 node: None,
331 injected_unix_ms: STARTED_MS + offset_ms,
332 cleared_unix_ms: Some(STARTED_MS + offset_ms + 1_000),
333 cleanup_error: None,
334 detail: String::new(),
335 }
336 }
337
338 fn recovery(fault: &str, check: RecoveryCheck, took_ms: Option<i64>) -> RecoveryRecord {
339 RecoveryRecord {
340 fault: fault.to_owned(),
341 check,
342 from_unix_ms: 10_000,
343 recovered_unix_ms: took_ms.map(|took| 10_000 + took),
344 }
345 }
346
347 fn recovered() -> Evidence {
349 Evidence {
350 started_unix_ms: STARTED_MS,
351 faults: vec![
352 record("short-pause#0", "pause", "follower", 30_200),
353 record("leader-restart#0", "restart", "leader", 90_500),
354 ],
355 recoveries: vec![
356 recovery(
357 "short-pause#0",
358 RecoveryCheck::CatchUpAfterPause,
359 Some(2_000),
360 ),
361 recovery(
362 "leader-restart#0",
363 RecoveryCheck::LeaderFailover,
364 Some(2_000),
365 ),
366 recovery("leader-restart#0", RecoveryCheck::IsrRejoin, Some(2_000)),
367 ],
368 ..Evidence::default()
369 }
370 }
371
372 #[test]
373 fn the_self_check_override_makes_its_one_deadline_impossible() {
374 let contract = fixture::self_check();
375 let schedule = expand(&contract).unwrap();
376 let (verdicts, problems) = check_recoveries(&contract, &schedule, &recovered());
377 check!(problems.is_empty());
378 let missed: Vec<String> = verdicts
379 .iter()
380 .filter(|verdict| verdict.outcome != DeadlineOutcome::Met)
381 .map(|verdict| format!("{} {}", verdict.fault, verdict.check))
382 .collect();
383 check!(missed == ["leader-restart#0 isr_rejoin"]);
384 }
385
386 #[test]
387 fn a_fault_that_lands_on_the_controller_is_judged_on_controller_failover() {
388 let mut contract = fixture::self_check();
389 contract.self_check = None;
390 let schedule = expand(&contract).unwrap();
391 let mut evidence = recovered();
392 evidence.faults[1].role = "controller".into();
395 let (verdicts, _) = check_recoveries(&contract, &schedule, &evidence);
396 let unmeasured: Vec<String> = verdicts
397 .iter()
398 .filter(|verdict| verdict.outcome == DeadlineOutcome::NotObserved)
399 .map(|verdict| format!("{} {}", verdict.fault, verdict.check))
400 .collect();
401 check!(unmeasured == ["leader-restart#0 controller_failover"]);
402 }
403
404 type Edit = fn(&mut Evidence);
405
406 #[yare::parameterized(
407 late = { |e: &mut Evidence| e.recoveries[0].recovered_unix_ms = Some(10_000 + 31_000), "short-pause#0 catch_up_after_pause Missed" },
408 never = { |e: &mut Evidence| e.recoveries[1].recovered_unix_ms = None, "leader-restart#0 leader_failover NotObserved" },
409 unmeasured = { |e: &mut Evidence| { e.recoveries.remove(0); }, "short-pause#0 catch_up_after_pause NotObserved" },
410 )]
411 fn a_late_or_unmeasured_recovery_fails(edit: Edit, expected: &str) {
412 let mut contract = fixture::self_check();
413 contract.self_check = None;
414 let schedule = expand(&contract).unwrap();
415 let mut evidence = recovered();
416 edit(&mut evidence);
417 let (verdicts, _) = check_recoveries(&contract, &schedule, &evidence);
418 let failed: Vec<String> = verdicts
419 .iter()
420 .filter(|verdict| verdict.outcome != DeadlineOutcome::Met)
421 .map(|verdict| format!("{} {} {:?}", verdict.fault, verdict.check, verdict.outcome))
422 .collect();
423 check!(failed == [expected]);
424 }
425
426 #[yare::parameterized(
427 never_injected = { |e: &mut Evidence| { e.faults.remove(0); }, "scheduled but never injected" },
428 never_cleared = { |e: &mut Evidence| e.faults[0].cleared_unix_ms = None, "injected but never cleared" },
429 cleanup_failed = { |e: &mut Evidence| e.faults[0].cleanup_error = Some("SIGCONT refused".into()), "cleanup failed: SIGCONT refused" },
430 wrong_kind = { |e: &mut Evidence| e.faults[0].kind = "restart".into(), "recorded as a `restart` fault, but the schedule names `pause`" },
431 wrong_role = { |e: &mut Evidence| e.faults[0].role = "leader".into(), "landed on a leader (broker-1), but the plan targets the follower" },
432 early = { |e: &mut Evidence| e.faults[0].injected_unix_ms = STARTED_MS + 10_000, "injected at 10s into the load, but scheduled at 30s (at most 30s late)" },
433 late = { |e: &mut Evidence| e.faults[0].injected_unix_ms = STARTED_MS + 61_000, "injected at 61s into the load, but scheduled at 30s (at most 30s late)" },
434 )]
435 fn a_fault_that_did_not_run_cleanly_is_a_problem(edit: Edit, expected: &str) {
436 let contract = fixture::self_check();
437 let schedule = expand(&contract).unwrap();
438 let mut evidence = recovered();
439 edit(&mut evidence);
440 let (_, problems) = check_recoveries(&contract, &schedule, &evidence);
441 check!(problems.len() == 1);
442 check!(problems[0].fault == "short-pause#0");
443 check!(problems[0].problem == expected);
444 }
445
446 fn summary(p50: Time, p99: Time, p999: Time) -> LatencySummary {
447 LatencySummary {
448 p50,
449 p99,
450 p999,
451 count: 1_000,
452 }
453 }
454
455 #[yare::parameterized(
456 inside = { millis(400), per_sec(45), &[] },
457 slow_p99 = { millis(600), per_sec(45), &["classic-idempotent produce p99"] },
458 too_few = { millis(400), per_sec(30), &["classic-idempotent throughput"] },
459 )]
460 fn budgets_are_judged_on_the_steady_windows(p99: Time, steady: Frequency, outside: &[&str]) {
461 let contract = fixture::self_check();
462 let measured = |path: &str, p99, steady_rate| PathMeasurement {
463 path: path.to_owned(),
464 produce_steady: summary(millis(10), p99, secs(1)),
465 produce_disturbed: summary(secs(9), secs(9), secs(9)),
466 consume_steady: summary(millis(10), millis(100), secs(1)),
467 consume_disturbed: LatencySummary::default(),
468 steady_rate,
469 };
470 let evidence = Evidence {
471 paths: vec![
472 measured("classic-idempotent", p99, steady),
473 measured("classic-transactional", millis(400), per_sec(45)),
474 ],
475 ..Evidence::default()
476 };
477 let failed: Vec<String> = check_budgets(&contract, &evidence)
478 .iter()
479 .filter(|verdict| !verdict.within)
480 .map(|verdict| format!("{} {}", verdict.path, verdict.measure))
481 .collect();
482 check!(failed == outside);
483 }
484
485 #[test]
486 fn a_path_with_no_measurement_fails_its_budget() {
487 let contract = fixture::self_check();
488 let verdicts = check_budgets(&contract, &Evidence::default());
489 check!(verdicts.iter().all(|verdict| !verdict.within));
490 check!(verdicts.len() == contract.paths.len());
491 }
492
493 #[yare::parameterized(
494 restart_on_the_controller = { FaultKind::Restart { graceful: false }, Target::Leader, "controller", &[LeaderFailover, IsrRejoin, ControllerFailover] },
495 restart_on_a_leader = { FaultKind::Restart { graceful: false }, Target::Leader, "leader", &[LeaderFailover, IsrRejoin] },
496 failover_pause_on_the_controller = { FaultKind::Pause { intends_failover: true }, Target::Any, "controller", &[LeaderFailover, CatchUpAfterPause, ControllerFailover] },
497 short_pause_on_the_controller = { FaultKind::Pause { intends_failover: false }, Target::Any, "controller", &[CatchUpAfterPause] },
498 controller_plan_on_the_controller = { FaultKind::Restart { graceful: true }, Target::Controller, "controller", &[LeaderFailover, IsrRejoin, ControllerFailover] },
499 no_record = { FaultKind::Restart { graceful: true }, Target::Any, "", &[LeaderFailover, IsrRejoin] },
500 )]
501 fn checks_for_adds_controller_failover_only_when_leadership_left_the_controller(
502 kind: FaultKind,
503 target: Target,
504 role: &str,
505 expected: &[RecoveryCheck],
506 ) {
507 let fault = ScheduledFault {
508 id: "f#0".to_owned(),
509 plan: "f".to_owned(),
510 checks: kind.checks(target),
511 kind,
512 target,
513 start: secs(10),
514 duration: secs(5),
515 };
516 check!(checks_for(&fault, role) == expected);
517 }
518}