Skip to main content

krabka_bench_driver/soak/
reconcile.rs

1//! The reconciler: what the producers were told against what the readers saw.
2//!
3//! The question it answers is narrower than "did every record arrive". A
4//! producer that was told a send failed may have written it anyway, and an
5//! application that resends after a failure may legitimately write a record
6//! twice. Neither is data loss, and a reconciler that called them failures
7//! would teach its readers to ignore it. So every observation is classified
8//! against the semantics the path declared:
9//!
10//! | observation | idempotent | transactional | compacted |
11//! | :--- | :--- | :--- | :--- |
12//! | acknowledged, not read | **lost** | **lost** if its transaction committed (`read_committed`) or at all (`read_uncommitted`) | -- |
13//! | read twice, sent once | **unexpected duplicate** | **unexpected duplicate** | -- |
14//! | read twice, sent again after a failure | permitted | -- | -- |
15//! | read, send failed | permitted (maybe delivered) | permitted | permitted |
16//! | read, never sent | **phantom** | **phantom** | **phantom** |
17//! | aborted, read under `read_committed` | -- | **aborted visible** | -- |
18//! | outcome unknown, partly read under `read_committed` | -- | **partial transaction** | -- |
19//! | key's latest acknowledged value not read | -- | -- | **compaction regression** |
20//!
21//! Committed offsets are checked separately: after each fault, the offset a
22//! group fetches back must be no lower than the last commit the coordinator
23//! acknowledged before it, and no higher than the log end.
24
25use {
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/// A class of reconciliation failure.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum FindingKind {
40    /// An acknowledged record no reader can find.
41    Lost,
42    /// A record from an aborted transaction, read under `read_committed`.
43    AbortedVisible,
44    /// Part, but not all, of a transaction whose outcome is unknown, read under
45    /// `read_committed`.
46    PartialTransaction,
47    /// A record sent once and read more than once.
48    UnexpectedDuplicate,
49    /// A record no producer ever sent.
50    Phantom,
51    /// A compacted key whose latest acknowledged value is gone.
52    CompactionRegression,
53    /// A committed offset that went backwards across a fault.
54    CommittedOffsetRegressed,
55    /// A committed offset past the log end.
56    CommittedOffsetBeyondLog,
57    /// A committed offset that could not be fetched back at all.
58    CommittedOffsetMissing,
59    /// A declared consumer whose read-back is absent from the evidence.
60    NoReadback,
61    /// A path whose producer acknowledged nothing.
62    NothingAcknowledged,
63    /// A producer ledger that cannot be judged as its path declares: labelled
64    /// with other semantics, or a transactional ledger with acknowledged
65    /// records outside every transaction.
66    IncompleteLedger,
67    /// A committed-offset check the contract implies but the evidence lacks.
68    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/// One failure of the reconciliation.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct Finding {
84    pub kind: FindingKind,
85    pub path: String,
86    /// The producer or consumer group concerned.
87    pub subject: String,
88    pub isolation: Option<Isolation>,
89    /// The sequence numbers concerned, for the kinds that are about records.
90    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/// What reconciliation counted for one producer and reader, pass or fail.
116#[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    /// Read, although the send reported a failure.
124    pub maybe_delivered: u64,
125    /// Read more than once after the application resent them.
126    pub permitted_duplicates: u64,
127    /// Records of aborted transactions, correctly hidden or correctly shown.
128    pub aborted: u64,
129}
130
131/// The reconciler's whole answer.
132#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133pub struct Reconciliation {
134    pub findings: Vec<Finding>,
135    pub tallies: Vec<Tally>,
136}
137
138/// Reconcile `evidence` against the semantics `contract` declares.
139///
140/// `schedule` is the contract's expanded fault schedule. Every fault in it
141/// needs a committed-offset check on each path that commits offsets.
142#[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            // The executor writes a negative log end when its end-offset
215            // lookup failed. That is a check the evidence lacks, not a
216            // committed offset past the log.
217            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
236/// Refuse a ledger that cannot be judged as its path declares.
237///
238/// The path's semantics, not the ledger's label, decide every rule below. A
239/// mislabelled ledger is a finding of its own, and a transactional ledger must
240/// place every acknowledged record in a transaction: with no transactions, the
241/// committed and aborted sets would both be empty and `read_committed` would
242/// be checked against nothing.
243fn 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
275/// Every committed-offset check the contract implies is in the evidence: for
276/// each path that commits offsets, at least one check across every scheduled
277/// fault and one `final` check.
278fn 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    // Only `read_committed` can see part of a transaction whose outcome is
347    // unknown; `read_uncommitted` may see any part of it.
348    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
434/// The largest member of `acked` for each key, where the record numbered `seq`
435/// carries key `seq % space`.
436///
437/// It walks down from the largest member and stops once every one of the
438/// `space` keys has its latest value, so a ledger of many records per key
439/// costs about `space` steps, not one step per record.
440fn 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    /// Ten idempotent records and two transactions of five, the second
496    /// aborted, every one read where its semantics say it should be.
497    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            // The fixture schedules two faults, and the idempotent path
525            // commits offsets: one check across each, and the final one.
526            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        // Keys 0..4: the latest acknowledged sequences are 8, 9, 6 and 7.
638        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        // Key 2's latest value was compacted away in favour of an older one.
652        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    /// The latest acknowledged record of each key, one record at a time: the
660    /// walk over every member that [`latest_by_key`] replaces.
661    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}