1use {
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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum Side {
38 Source,
40 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct Release {
71 pub version: String,
73 pub image: String,
76 pub supports: BTreeMap<String, LevelRange>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct Lifecycle {
87 pub source: Release,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(tag = "rollback", content = "reasons", rename_all = "snake_case")]
94pub enum Rollback {
95 Supported,
97 Unsupported(Vec<String>),
100}
101
102#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Checkpoint {
136 pub fault: String,
138 pub unix_ms: i64,
139 pub images: BTreeMap<i32, String>,
141 pub finalized: BTreeMap<String, i16>,
143 pub rollback: Rollback,
145 pub records: Vec<CheckpointRead>,
149 #[serde(default)]
151 pub config_mismatches: Vec<String>,
152}
153
154#[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 pub acknowledged: SeqSet,
163 pub reader: ReaderLedger,
164}
165
166#[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
199fn 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#[must_use]
217pub fn is_transition(kind: &FaultKind) -> bool {
218 matches!(
219 kind,
220 FaultKind::Roll { .. } | FaultKind::CertificateRotation | FaultKind::Reassignment { .. }
221 )
222}
223
224#[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 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
287fn 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
335fn 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 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}