1use {
26 super::{
27 contract::{Contract, Isolation, Semantics},
28 evidence::{Evidence, FINAL_COMMIT_CHECK, ProducerLedger, ReaderLedger, TxnOutcome},
29 ledger::SeqSet,
30 schedule::ScheduledFault,
31 },
32 serde::{Deserialize, Serialize},
33 std::{collections::BTreeMap, fmt},
34};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum FindingKind {
40 Lost,
42 AbortedVisible,
44 PartialTransaction,
47 UnexpectedDuplicate,
49 Phantom,
51 CompactionRegression,
53 CommittedOffsetRegressed,
55 CommittedOffsetBeyondLog,
57 CommittedOffsetMissing,
59 NoReadback,
61 NothingAcknowledged,
63 IncompleteLedger,
67 CommitCheckMissing,
69}
70
71impl fmt::Display for FindingKind {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 let text = serde_json::to_value(self)
74 .ok()
75 .and_then(|value| value.as_str().map(str::to_owned))
76 .unwrap_or_default();
77 f.write_str(&text)
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct Finding {
84 pub kind: FindingKind,
85 pub path: String,
86 pub subject: String,
88 pub isolation: Option<Isolation>,
89 pub seqs: SeqSet,
91 pub detail: String,
92}
93
94impl fmt::Display for Finding {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 write!(f, "{} on {} ({})", self.kind, self.path, self.subject)?;
97 if let Some(isolation) = self.isolation {
98 write!(f, " under {isolation}")?;
99 }
100 if !self.seqs.is_empty() {
101 write!(
102 f,
103 ": {} records, first {:?}",
104 self.seqs.len(),
105 self.seqs.sample(5)
106 )?;
107 }
108 if !self.detail.is_empty() {
109 write!(f, ": {}", self.detail)?;
110 }
111 Ok(())
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Tally {
118 pub path: String,
119 pub producer: String,
120 pub isolation: Isolation,
121 pub acknowledged: u64,
122 pub read: u64,
123 pub maybe_delivered: u64,
125 pub permitted_duplicates: u64,
127 pub aborted: u64,
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133pub struct Reconciliation {
134 pub findings: Vec<Finding>,
135 pub tallies: Vec<Tally>,
136}
137
138#[must_use]
143pub fn reconcile(
144 contract: &Contract,
145 schedule: &[ScheduledFault],
146 evidence: &Evidence,
147) -> Reconciliation {
148 let mut out = Reconciliation::default();
149 let mut readers: BTreeMap<(&str, Isolation), &ReaderLedger> = BTreeMap::new();
150 for reader in &evidence.readers {
151 readers.insert((reader.producer.as_str(), reader.isolation), reader);
152 }
153
154 for path in &contract.paths {
155 let producers: Vec<&ProducerLedger> = evidence
156 .producers
157 .iter()
158 .filter(|producer| producer.path == path.name)
159 .collect();
160 if producers.iter().all(|producer| producer.acked.is_empty()) {
161 out.findings.push(Finding {
162 kind: FindingKind::NothingAcknowledged,
163 path: path.name.clone(),
164 subject: "-".to_owned(),
165 isolation: None,
166 seqs: SeqSet::new(),
167 detail: "a path that acknowledged nothing proves nothing".to_owned(),
168 });
169 }
170 for producer in producers {
171 check_ledger(path.semantics, producer, &mut out);
172 for &isolation in &path.consumers {
173 let Some(reader) = readers.get(&(producer.producer.as_str(), isolation)) else {
174 out.findings.push(Finding {
175 kind: FindingKind::NoReadback,
176 path: path.name.clone(),
177 subject: producer.producer.clone(),
178 isolation: Some(isolation),
179 seqs: SeqSet::new(),
180 detail: "the declared consumer left no read-back".to_owned(),
181 });
182 continue;
183 };
184 reconcile_reader(path.semantics, path.key_space, producer, reader, &mut out);
185 }
186 }
187 }
188
189 check_commit_coverage(contract, schedule, evidence, &mut out);
190 for check in &evidence.commits {
191 let mut fail = |kind, detail: String| {
192 out.findings.push(Finding {
193 kind,
194 path: check.topic.clone(),
195 subject: check.group.clone(),
196 isolation: None,
197 seqs: SeqSet::new(),
198 detail,
199 });
200 };
201 let place = format!("{}-{} across {}", check.topic, check.partition, check.fault);
202 match check.fetched {
203 None => fail(
204 FindingKind::CommittedOffsetMissing,
205 format!("{place}: no committed offset came back"),
206 ),
207 Some(fetched) if fetched < check.acknowledged => fail(
208 FindingKind::CommittedOffsetRegressed,
209 format!(
210 "{place}: fetched {fetched}, below the acknowledged commit {}",
211 check.acknowledged
212 ),
213 ),
214 Some(_) if check.log_end < 0 => fail(
218 FindingKind::CommitCheckMissing,
219 format!("{place}: the log end could not be read, so the commit was not checked"),
220 ),
221 Some(fetched) if fetched > check.log_end => fail(
222 FindingKind::CommittedOffsetBeyondLog,
223 format!(
224 "{place}: fetched {fetched}, past the log end {}",
225 check.log_end
226 ),
227 ),
228 Some(_) => {}
229 }
230 }
231 out.findings
232 .sort_by(|a, b| (a.kind, &a.path, &a.subject).cmp(&(b.kind, &b.path, &b.subject)));
233 out
234}
235
236fn check_ledger(declared: Semantics, producer: &ProducerLedger, out: &mut Reconciliation) {
244 let mut fail = |seqs: SeqSet, detail: String| {
245 out.findings.push(Finding {
246 kind: FindingKind::IncompleteLedger,
247 path: producer.path.clone(),
248 subject: producer.producer.clone(),
249 isolation: None,
250 seqs,
251 detail,
252 });
253 };
254 if producer.semantics != declared {
255 fail(
256 SeqSet::new(),
257 format!(
258 "the ledger says {:?}, the contract declares {declared:?}",
259 producer.semantics
260 ),
261 );
262 }
263 if declared == Semantics::Transactional {
264 let in_transactions = SeqSet::union_all(producer.transactions.iter().map(|txn| &txn.seqs));
265 let outside = producer.acked.difference(&in_transactions);
266 if !outside.is_empty() {
267 fail(
268 outside,
269 "acknowledged records outside every recorded transaction".to_owned(),
270 );
271 }
272 }
273}
274
275fn check_commit_coverage(
279 contract: &Contract,
280 schedule: &[ScheduledFault],
281 evidence: &Evidence,
282 out: &mut Reconciliation,
283) {
284 let wanted: Vec<&str> = schedule
285 .iter()
286 .map(|fault| fault.id.as_str())
287 .chain([FINAL_COMMIT_CHECK])
288 .collect();
289 for path in contract.paths.iter().filter(|path| path.commit_offsets) {
290 for fault in &wanted {
291 let present = evidence
292 .commits
293 .iter()
294 .any(|check| check.topic == path.name && check.fault == **fault);
295 if !present {
296 out.findings.push(Finding {
297 kind: FindingKind::CommitCheckMissing,
298 path: path.name.clone(),
299 subject: "-".to_owned(),
300 isolation: None,
301 seqs: SeqSet::new(),
302 detail: format!("no committed offset was checked across {fault}"),
303 });
304 }
305 }
306 }
307}
308
309fn reconcile_reader(
310 semantics: Semantics,
311 key_space: Option<u64>,
312 producer: &ProducerLedger,
313 reader: &ReaderLedger,
314 out: &mut Reconciliation,
315) {
316 let mut emit = |kind, seqs: SeqSet, detail: String| {
317 if !seqs.is_empty() || !detail.is_empty() {
318 out.findings.push(Finding {
319 kind,
320 path: producer.path.clone(),
321 subject: producer.producer.clone(),
322 isolation: Some(reader.isolation),
323 seqs,
324 detail,
325 });
326 }
327 };
328 let sent = SeqSet::range(0, producer.attempted);
329 emit(
330 FindingKind::Phantom,
331 reader.seen.difference(&sent),
332 String::new(),
333 );
334
335 let with_outcome = |outcome| {
336 SeqSet::union_all(
337 producer
338 .transactions
339 .iter()
340 .filter(move |txn| txn.outcome == outcome)
341 .map(|txn| &txn.seqs),
342 )
343 };
344 let committed = with_outcome(TxnOutcome::Committed);
345 let aborted = with_outcome(TxnOutcome::Aborted);
346 if reader.isolation == Isolation::ReadCommitted {
349 let unknown = producer
350 .transactions
351 .iter()
352 .filter(|txn| txn.outcome == TxnOutcome::Unknown);
353 for txn in unknown {
354 let visible = reader.seen.intersection(&txn.seqs);
355 if !visible.is_empty() && visible.len() < txn.seqs.len() {
356 emit(
357 FindingKind::PartialTransaction,
358 txn.seqs.difference(&visible),
359 format!(
360 "transaction {} shows {} of its {} records",
361 txn.id,
362 visible.len(),
363 txn.seqs.len()
364 ),
365 );
366 }
367 }
368 }
369
370 let must_read = match (semantics, reader.isolation) {
371 (Semantics::Transactional, Isolation::ReadCommitted) => {
372 producer.acked.intersection(&committed)
373 }
374 (Semantics::Compacted, _) => SeqSet::new(),
375 _ => producer.acked.clone(),
376 };
377 emit(
378 FindingKind::Lost,
379 must_read.difference(&reader.seen),
380 String::new(),
381 );
382 if semantics == Semantics::Transactional && reader.isolation == Isolation::ReadCommitted {
383 emit(
384 FindingKind::AbortedVisible,
385 aborted.intersection(&reader.seen),
386 String::new(),
387 );
388 }
389
390 let resent = producer.retried.union(&producer.failed);
391 let mut permitted = 0;
392 let mut unexpected = SeqSet::new();
393 for duplicate in &reader.duplicates {
394 if resent.contains(duplicate.seq) {
395 permitted += 1;
396 } else {
397 unexpected.insert(duplicate.seq);
398 }
399 }
400 if semantics != Semantics::Compacted {
401 emit(FindingKind::UnexpectedDuplicate, unexpected, String::new());
402 }
403
404 if let (Semantics::Compacted, Some(space)) = (semantics, key_space) {
405 let regressed: SeqSet = latest_by_key(&producer.acked, space)
406 .iter()
407 .filter(|(key, seq)| {
408 reader
409 .latest_by_key
410 .get(*key)
411 .is_none_or(|read| read < *seq)
412 })
413 .map(|(_, seq)| *seq)
414 .collect();
415 emit(FindingKind::CompactionRegression, regressed, String::new());
416 }
417
418 out.tallies.push(Tally {
419 path: producer.path.clone(),
420 producer: producer.producer.clone(),
421 isolation: reader.isolation,
422 acknowledged: producer.acked.len(),
423 read: reader.seen.len(),
424 maybe_delivered: reader
425 .seen
426 .intersection(&producer.failed)
427 .difference(&producer.acked)
428 .len(),
429 permitted_duplicates: permitted,
430 aborted: aborted.len(),
431 });
432}
433
434fn latest_by_key(acked: &SeqSet, space: u64) -> BTreeMap<u64, u64> {
441 let mut latest = BTreeMap::new();
442 for seq in acked.iter().rev() {
443 if u64::try_from(latest.len()).is_ok_and(|found| found >= space) {
444 break;
445 }
446 latest.entry(seq % space).or_insert(seq);
447 }
448 latest
449}
450
451#[cfg(test)]
452mod tests {
453 use {
454 super::{FindingKind, latest_by_key, reconcile},
455 crate::soak::{
456 contract::{Contract, Isolation, Semantics, fixture},
457 evidence::{
458 CommitCheck, DuplicateRead, Evidence, ProducerLedger, ReaderLedger, TxnLedger,
459 TxnOutcome,
460 },
461 ledger::SeqSet,
462 schedule::expand,
463 },
464 assert2::check,
465 std::{collections::BTreeMap, iter},
466 };
467
468 const IDEMPOTENT: &str = "classic-idempotent";
469 const TRANSACTIONAL: &str = "classic-transactional";
470
471 fn producer(path: &str, semantics: Semantics, attempted: u64) -> ProducerLedger {
472 ProducerLedger {
473 path: path.to_owned(),
474 producer: format!("{path}-0"),
475 semantics,
476 attempted,
477 acked: SeqSet::range(0, attempted),
478 failed: SeqSet::new(),
479 retried: SeqSet::new(),
480 transactions: Vec::new(),
481 }
482 }
483
484 fn reader(path: &str, isolation: Isolation, seen: SeqSet) -> ReaderLedger {
485 ReaderLedger {
486 path: path.to_owned(),
487 isolation,
488 producer: format!("{path}-0"),
489 seen,
490 duplicates: Vec::new(),
491 latest_by_key: BTreeMap::new(),
492 }
493 }
494
495 fn clean() -> (Contract, Evidence) {
498 let contract = fixture::self_check();
499 let mut txn = producer(TRANSACTIONAL, Semantics::Transactional, 10);
500 txn.transactions = vec![
501 TxnLedger {
502 id: 0,
503 seqs: SeqSet::range(0, 5),
504 outcome: TxnOutcome::Committed,
505 },
506 TxnLedger {
507 id: 1,
508 seqs: SeqSet::range(5, 10),
509 outcome: TxnOutcome::Aborted,
510 },
511 ];
512 let evidence = Evidence {
513 contract: contract.name.clone(),
514 producers: vec![producer(IDEMPOTENT, Semantics::Idempotent, 10), txn],
515 readers: vec![
516 reader(IDEMPOTENT, Isolation::ReadUncommitted, SeqSet::range(0, 10)),
517 reader(TRANSACTIONAL, Isolation::ReadCommitted, SeqSet::range(0, 5)),
518 reader(
519 TRANSACTIONAL,
520 Isolation::ReadUncommitted,
521 SeqSet::range(0, 10),
522 ),
523 ],
524 commits: ["leader-restart#0", "short-pause#0", "final"]
527 .into_iter()
528 .map(|fault| CommitCheck {
529 group: "g".into(),
530 topic: IDEMPOTENT.into(),
531 partition: 0,
532 fault: fault.into(),
533 acknowledged: 4,
534 fetched: Some(4),
535 log_end: 6,
536 })
537 .collect(),
538 ..Evidence::default()
539 };
540 (contract, evidence)
541 }
542
543 fn kinds(contract: &Contract, evidence: &Evidence) -> Vec<FindingKind> {
544 reconcile(contract, &expand(contract).unwrap(), evidence)
545 .findings
546 .iter()
547 .map(|finding| finding.kind)
548 .collect()
549 }
550
551 #[test]
552 fn a_clean_run_has_no_findings() {
553 let (contract, evidence) = clean();
554 let outcome = reconcile(&contract, &expand(&contract).unwrap(), &evidence);
555 check!(outcome.findings.is_empty(), "{:?}", outcome.findings);
556 check!(outcome.tallies.len() == 3);
557 }
558
559 #[test]
560 fn a_missing_acknowledged_record_is_lost_and_named() {
561 let (contract, mut evidence) = clean();
562 evidence.readers[0].seen.remove(3);
563 let outcome = reconcile(&contract, &expand(&contract).unwrap(), &evidence);
564 check!(outcome.findings.len() == 1);
565 check!(outcome.findings[0].kind == FindingKind::Lost);
566 check!(outcome.findings[0].seqs == iter::once(3).collect());
567 }
568
569 type Edit = fn(&mut Evidence);
570
571 #[yare::parameterized(
572 committed_txn_record_missing = { |e: &mut Evidence| { e.readers[1].seen.remove(2); }, &[FindingKind::Lost] },
573 aborted_record_under_read_uncommitted_missing = { |e: &mut Evidence| { e.readers[2].seen.remove(7); }, &[FindingKind::Lost] },
574 aborted_record_visible = { |e: &mut Evidence| { e.readers[1].seen.insert(7); }, &[FindingKind::AbortedVisible] },
575 phantom = { |e: &mut Evidence| { e.readers[0].seen.insert(99); }, &[FindingKind::Phantom] },
576 duplicate_of_a_single_send = { |e: &mut Evidence| e.readers[0].duplicates.push(DuplicateRead { seq: 4, positions: vec![(0, 4), (0, 11)] }), &[FindingKind::UnexpectedDuplicate] },
577 commit_regressed = { |e: &mut Evidence| e.commits[0].fetched = Some(2), &[FindingKind::CommittedOffsetRegressed] },
578 commit_beyond_log = { |e: &mut Evidence| e.commits[0].fetched = Some(7), &[FindingKind::CommittedOffsetBeyondLog] },
579 commit_gone = { |e: &mut Evidence| e.commits[0].fetched = None, &[FindingKind::CommittedOffsetMissing] },
580 log_end_unknown = { |e: &mut Evidence| { e.commits[0].fetched = Some(7); e.commits[0].log_end = -1; }, &[FindingKind::CommitCheckMissing] },
581 reader_absent = { |e: &mut Evidence| { e.readers.remove(2); }, &[FindingKind::NoReadback] },
582 nothing_acked = { |e: &mut Evidence| e.producers[0].acked = SeqSet::new(), &[FindingKind::NothingAcknowledged] },
583 mislabelled_ledger = { |e: &mut Evidence| e.producers[1].semantics = Semantics::Idempotent, &[FindingKind::IncompleteLedger] },
584 no_transactions_recorded = { |e: &mut Evidence| { e.producers[1].transactions.clear(); e.readers[1].seen = SeqSet::range(0, 10); }, &[FindingKind::IncompleteLedger] },
585 commit_check_absent = { |e: &mut Evidence| { e.commits.pop(); }, &[FindingKind::CommitCheckMissing] },
586 all_commit_checks_absent = { |e: &mut Evidence| e.commits.clear(), &[FindingKind::CommitCheckMissing, FindingKind::CommitCheckMissing, FindingKind::CommitCheckMissing] },
587 )]
588 fn each_violation_is_classified(edit: Edit, expected: &[FindingKind]) {
589 let (contract, mut evidence) = clean();
590 edit(&mut evidence);
591 check!(kinds(&contract, &evidence) == expected);
592 }
593
594 #[yare::parameterized(
595 failed_send_read = { |e: &mut Evidence| { e.producers[0].attempted = 11; e.producers[0].failed.insert(10); e.readers[0].seen.insert(10); } },
596 failed_send_not_read = { |e: &mut Evidence| { e.producers[0].attempted = 11; e.producers[0].failed.insert(10); } },
597 resent_record_read_twice = { |e: &mut Evidence| { e.producers[0].retried.insert(4); e.readers[0].duplicates.push(DuplicateRead { seq: 4, positions: vec![(0, 4), (0, 11)] }); } },
598 commit_moved_forward = { |e: &mut Evidence| e.commits[0].fetched = Some(6) },
599 )]
600 fn permitted_retries_are_not_failures(edit: Edit) {
601 let (contract, mut evidence) = clean();
602 edit(&mut evidence);
603 check!(kinds(&contract, &evidence).is_empty());
604 }
605
606 #[yare::parameterized(
607 none_visible = { SeqSet::range(0, 5), &[] },
608 all_visible = { SeqSet::range(0, 10), &[] },
609 half_visible = { SeqSet::range(0, 7), &[FindingKind::PartialTransaction] },
610 )]
611 fn an_unknown_transaction_must_be_all_or_nothing(seen: SeqSet, expected: &[FindingKind]) {
612 let (contract, mut evidence) = clean();
613 evidence.producers[1].transactions[1].outcome = TxnOutcome::Unknown;
614 evidence.readers[1].seen = seen;
615 check!(kinds(&contract, &evidence) == expected);
616 }
617
618 #[test]
619 fn a_failed_send_that_was_read_counts_as_maybe_delivered() {
620 let (contract, mut evidence) = clean();
621 evidence.producers[0].attempted = 12;
622 evidence.producers[0].failed = SeqSet::range(10, 12);
623 evidence.readers[0].seen.insert(11);
624 let outcome = reconcile(&contract, &expand(&contract).unwrap(), &evidence);
625 check!(outcome.tallies[0].maybe_delivered == 1);
626 }
627
628 #[test]
629 fn compaction_must_keep_each_keys_latest_acknowledged_value() {
630 let mut contract = fixture::self_check();
631 contract.paths[0].semantics = Semantics::Compacted;
632 contract.paths[0].key_space = Some(4);
633 contract.paths[0].commit_offsets = false;
634 contract.paths.truncate(1);
635 let mut compacted = producer(IDEMPOTENT, Semantics::Compacted, 10);
636 compacted.semantics = Semantics::Compacted;
637 let mut read = reader(
639 IDEMPOTENT,
640 Isolation::ReadUncommitted,
641 [6, 7, 8, 9].into_iter().collect(),
642 );
643 read.latest_by_key = BTreeMap::from([(0, 8), (1, 9), (2, 6), (3, 7)]);
644 let mut evidence = Evidence {
645 producers: vec![compacted],
646 readers: vec![read],
647 ..Evidence::default()
648 };
649 check!(kinds(&contract, &evidence).is_empty());
650
651 evidence.readers[0].latest_by_key.insert(2, 2);
653 let outcome = reconcile(&contract, &expand(&contract).unwrap(), &evidence);
654 check!(outcome.findings.len() == 1);
655 check!(outcome.findings[0].kind == FindingKind::CompactionRegression);
656 check!(outcome.findings[0].seqs == iter::once(6).collect());
657 }
658
659 fn latest_by_key_model(acked: &SeqSet, space: u64) -> BTreeMap<u64, u64> {
662 acked.iter().map(|seq| (seq % space, seq)).collect()
663 }
664
665 #[yare::parameterized(
666 empty = { SeqSet::new(), 4 },
667 fewer_records_than_keys = { SeqSet::range(0, 3), 4 },
668 one_run = { SeqSet::range(0, 1_000), 7 },
669 gaps = { SeqSet::from(vec![[0, 10], [13, 14], [20, 23]]), 4 },
670 a_key_never_written = { (0..100).filter(|seq| seq % 5 != 2).collect(), 5 },
671 one_key = { SeqSet::range(3, 9), 1 },
672 )]
673 fn latest_by_key_matches_a_walk_over_every_record(acked: SeqSet, space: u64) {
674 check!(latest_by_key(&acked, space) == latest_by_key_model(&acked, space));
675 }
676}