Skip to main content

gonzalo_soak/
oracle.rs

1//! The soak safety/liveness oracle.
2//!
3//! Given the recorded outcome of every write op plus a final read of every key,
4//! [`check`] asserts the invariants a correct conditional-write store must hold
5//! under concurrent multi-replica load and replica-kill chaos:
6//!
7//! - **No lost update** — every *committed* op-id on a contended key survives in
8//!   that key's final record, exactly once.
9//! - **Conflicts surface** — racing writers observed `Conflict` (never a silent
10//!   overwrite). Zero observed conflicts means the invariant was never actually
11//!   exercised, which is itself a failure. The same holds for deletes racing
12//!   edits: zero delete conflicts is a failure.
13//! - **One commit per base** — on a lifecycle key, two edits never both commit
14//!   on the same base revision, and an edit never commits on the same base as a
15//!   delete that actually wrote a tombstone there. A delete acknowledged without
16//!   evidence that it wrote (a no-op over a later tombstone, or a raced evidence
17//!   read) is ambiguous and skipped, never flagged.
18//! - **Durability under churn** — every acked unique-key write is still readable,
19//!   and every acked delete still reads as a tombstone (no resurrection).
20//! - **Replicas agree on deletion** (#203) — once the writers have stopped and every
21//!   replica is live again, each lifecycle key is live on every replica or a
22//!   tombstone on every replica (never a mix), every replica reports the same raw
23//!   revision, each replica's consumer read agrees with its own raw read on
24//!   whether the key is live, and a key that had committed writes is never
25//!   physically absent.
26//! - **Liveness** — the run made progress and every writer finished.
27//!
28//! **What "replicas agree" means here.** The HA soak's replicas are `gonzalod`
29//! processes over *one shared bucket*, so no replica holds its own copy. In that
30//! topology the agreement check catches daemon read-path bugs (caching, raw reads
31//! routed to consumer reads, tombstone filtering), not storage replication, which
32//! the sync and git-pull tests cover. The check itself is topology-agnostic: it
33//! compares per-replica views and would apply unchanged to independent stores
34//! that had been synced.
35//!
36//! Per-replica agreement is checked for the `life-*` keys; deleted unique keys
37//! are checked through one replica, because they use the same read path.
38//!
39//! This is a targeted invariant oracle, not a linearizability checker: it asserts
40//! on set membership / agreement / completion, never on exact interleavings,
41//! so normal scheduling jitter cannot flake it.
42
43use std::collections::{BTreeMap, BTreeSet};
44
45/// The result of a single conditional-write op, as observed by the driver.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum OpResult {
48    /// The conditional put committed.
49    Committed,
50    /// The put was rejected as a conflict — a concurrent writer won the race.
51    Conflict,
52    /// The op ultimately failed (transport error, exhausted retries).
53    Failed,
54}
55
56/// One recorded write against a contended key: the unique op-id and its result.
57#[derive(Debug, Clone)]
58pub struct OpRecord {
59    pub key: String,
60    pub op_id: u64,
61    pub result: OpResult,
62}
63
64/// The final observed state of one contended key.
65#[derive(Debug, Clone)]
66pub struct FinalContended {
67    pub key: String,
68    /// The op-ids present in the final record's accumulated set (read from storage).
69    pub members: Vec<u64>,
70}
71
72/// The final observed state of one unique (uncontended) key.
73#[derive(Debug, Clone)]
74pub struct FinalUnique {
75    pub key: String,
76    /// The write was acked (`Committed`) by the driver.
77    pub acked: bool,
78    /// The key is readable (consumer `get`) with the exact value that was written.
79    pub readable_with_value: bool,
80    /// The driver later deleted this key and the delete was acked (`Deleted`).
81    pub deleted: bool,
82    /// A raw read returned a tombstone. Only meaningful when `deleted`. `false`
83    /// also covers a failed raw read (`get_raw` returning `Err`) — the collector
84    /// cannot distinguish "not a tombstone" from "couldn't tell". As of this
85    /// writing, `workload::collect_unique` folds `Err` into `false` via
86    /// `matches!(store.get_raw(&key).await, Ok(Some(rec)) if rec.is_tombstone())`,
87    /// so a raw read that errors on every replica during collection reads here
88    /// as a lost delete rather than a read failure.
89    pub raw_tombstone: bool,
90}
91
92/// A lifecycle op against a `life-*` key: the delete/recreate stream (#203).
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum LifecycleOp {
95    /// Conditionally update a live record. Skipped when the key reads as absent.
96    Edit,
97    /// Conditionally tombstone a live record. Skipped when the key reads as absent.
98    Delete,
99    /// Create the key when it reads as absent. Over a tombstone this is a
100    /// recreation. Skipped when the key is live.
101    Recreate,
102}
103
104/// The outcome of one lifecycle op. Lifecycle ops are not retried: a lost race
105/// is recorded as `Conflict` and the writer moves on.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum LifecycleResult {
108    /// The conditional put or delete committed.
109    Committed,
110    /// A concurrent writer moved the key between the read and the write. A
111    /// failover retry after a lost acknowledgement is also recorded here, which
112    /// errs safe.
113    Conflict,
114    /// The op did not apply to the key's state (e.g. delete of an absent key).
115    Skipped,
116    /// A transport or backend error.
117    Failed,
118}
119
120/// One recorded lifecycle op.
121#[derive(Debug, Clone)]
122pub struct LifecycleRecord {
123    pub key: String,
124    pub op_id: u64,
125    pub op: LifecycleOp,
126    pub result: LifecycleResult,
127    /// The `"<counter>:<hash>"` rendering of the revision this op read and
128    /// conditioned its write on. `None` for `Recreate`, and for any op that
129    /// never read a live record (its `get` returned absent, so it wrote
130    /// unconditionally or was skipped).
131    pub base: Option<String>,
132    /// For an acknowledged conditional `Delete`, whether it actually wrote the
133    /// tombstone on its base: `Some(true)` when a raw read straight after the
134    /// delete found a tombstone whose parent is `base`, `Some(false)` when it
135    /// found a tombstone with a different parent (a no-op over an existing
136    /// tombstone), and `None` when that read failed or found the key live or
137    /// absent (a later op raced it). `None` for every other op and result.
138    pub delete_wrote: Option<bool>,
139}
140
141/// What one replica's raw read (`get_raw`) returned for a key after settling.
142/// Revisions are rendered as `"<counter>:<hash>"` so the oracle stays pure.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum RawState {
145    Absent,
146    Live { revision: String },
147    Tombstone { revision: String },
148    ReadFailed { error: String },
149}
150
151/// One replica's view of a lifecycle key after settling.
152#[derive(Debug, Clone)]
153pub struct ReplicaView {
154    /// The replica's raw read.
155    pub raw: RawState,
156    /// Whether the replica's consumer read (`get`) returned a record; `None` if
157    /// that read failed.
158    pub consumer_live: Option<bool>,
159}
160
161/// The final per-replica state of one lifecycle key, in replica order.
162#[derive(Debug, Clone)]
163pub struct FinalLifecycle {
164    pub key: String,
165    pub replicas: Vec<ReplicaView>,
166}
167
168/// Everything the oracle needs: op outcomes, final reads, and writer completion.
169#[derive(Debug, Clone, Default)]
170pub struct SoakStats {
171    pub ops: Vec<OpRecord>,
172    pub contended: Vec<FinalContended>,
173    pub unique: Vec<FinalUnique>,
174    /// Total transient `Conflict` outcomes observed across all RMW retries — the
175    /// evidence the CAS actually arbitrated racing writers. Zero means the race
176    /// invariant was never exercised.
177    pub conflicts_observed: u64,
178    pub writers_completed: usize,
179    pub writers_total: usize,
180    /// Every lifecycle op and its outcome.
181    pub lifecycle_ops: Vec<LifecycleRecord>,
182    /// Per-replica final state of every lifecycle key. Filled by
183    /// `workload::collect_lifecycle` once every replica is live again.
184    pub lifecycle: Vec<FinalLifecycle>,
185}
186
187/// A violated invariant. An empty [`check`] result means the soak passed.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum Violation {
190    /// Committed op-ids missing from the contended key's final set — a lost update.
191    LostUpdate { key: String, missing: Vec<u64> },
192    /// An op-id appears more than once in a contended key's final set.
193    DuplicateUpdate { key: String, duplicated: Vec<u64> },
194    /// No conflicts were observed anywhere — the race invariant was not exercised.
195    NoConflictsObserved,
196    /// An acked unique-key write is missing or has the wrong value after chaos.
197    UniqueWriteLost { key: String },
198    /// Not every writer task completed within the deadline (liveness).
199    WritersDidNotComplete { completed: usize, total: usize },
200    /// The run committed nothing at all (liveness).
201    NoProgress,
202    /// A lifecycle key is live on some replicas and a tombstone on others.
203    MixedLiveAndTombstone { key: String, states: Vec<RawState> },
204    /// Replicas that read successfully disagree on a lifecycle key's raw state
205    /// (different revisions, or absent on some).
206    ReplicasDisagree { key: String, states: Vec<RawState> },
207    /// A replica's consumer read disagrees with its own raw read on whether the
208    /// key is live: a tombstone served to consumers, or a live record hidden
209    /// from them. Checked on liveness only, not on revision — see the module
210    /// doc.
211    ConsumerRawMismatch { key: String, replica: usize },
212    /// A replica could not be read after settling.
213    FinalReadFailed { key: String, replica: usize },
214    /// A lifecycle key with committed writes reads as absent — delete must write
215    /// a tombstone, never physically remove.
216    LifecycleKeyVanished { key: String },
217    /// Lifecycle ops ran but no delete ever lost a race — the delete-conflict
218    /// invariant was not exercised.
219    NoDeleteConflictsObserved,
220    /// Lifecycle ops ran but no delete was ever acknowledged (a no-op delete
221    /// over an existing tombstone counts).
222    NoDeletesCommitted,
223    /// Lifecycle ops ran but no per-replica final state was collected.
224    LifecycleNotChecked,
225    /// An acked delete of a unique key reads as live again, or not as a tombstone.
226    AckedDeleteLost { key: String },
227    /// Two conditional writes committed on the same base revision of one key:
228    /// (a) two edits, or (b) an edit plus a delete that actually wrote a
229    /// tombstone on that base (`delete_wrote == Some(true)`). Under OCC at most
230    /// one can win, so this is a lost update or a resurrection over a tombstone
231    /// (spec §3.2, §5.4). An edit sharing a base only with deletes whose write
232    /// evidence is `Some(false)` or `None` is ambiguous (a stale delete can land
233    /// as a no-op over a later tombstone and still report `Deleted`) and is
234    /// skipped, never flagged.
235    StaleBaseCommitted { key: String, base: String },
236}
237
238/// Delete ops that lost a race (`Conflict`). Edit and recreate conflicts don't count.
239pub fn delete_conflicts(stats: &SoakStats) -> usize {
240    stats
241        .lifecycle_ops
242        .iter()
243        .filter(|o| o.op == LifecycleOp::Delete && o.result == LifecycleResult::Conflict)
244        .count()
245}
246
247/// Check every soak invariant. Returns the (possibly empty) set of violations.
248pub fn check(stats: &SoakStats) -> Vec<Violation> {
249    let mut out = Vec::new();
250
251    // Per contended key: committed op-ids must all survive, exactly once.
252    for fc in &stats.contended {
253        let committed: Vec<u64> = stats
254            .ops
255            .iter()
256            .filter(|o| o.key == fc.key && o.result == OpResult::Committed)
257            .map(|o| o.op_id)
258            .collect();
259
260        let missing: Vec<u64> = committed
261            .iter()
262            .copied()
263            .filter(|id| !fc.members.contains(id))
264            .collect();
265        if !missing.is_empty() {
266            out.push(Violation::LostUpdate {
267                key: fc.key.clone(),
268                missing,
269            });
270        }
271
272        let duplicated: Vec<u64> = fc
273            .members
274            .iter()
275            .copied()
276            .filter(|id| fc.members.iter().filter(|m| *m == id).count() > 1)
277            .collect::<BTreeSet<_>>()
278            .into_iter()
279            .collect();
280        if !duplicated.is_empty() {
281            out.push(Violation::DuplicateUpdate {
282                key: fc.key.clone(),
283                duplicated,
284            });
285        }
286    }
287
288    // The race invariant must actually have been exercised: with real contention
289    // across replicas, some RMW attempts must lose the CAS and observe `Conflict`.
290    if stats.conflicts_observed == 0 {
291        out.push(Violation::NoConflictsObserved);
292    }
293
294    // Durability under churn: every acked unique write must still be readable,
295    // and every acked delete must still be a tombstone.
296    for fu in &stats.unique {
297        if fu.acked && !fu.deleted && !fu.readable_with_value {
298            out.push(Violation::UniqueWriteLost {
299                key: fu.key.clone(),
300            });
301        }
302        if fu.deleted && (fu.readable_with_value || !fu.raw_tombstone) {
303            out.push(Violation::AckedDeleteLost {
304                key: fu.key.clone(),
305            });
306        }
307    }
308
309    check_lifecycle(stats, &mut out);
310
311    // Liveness: the run made progress and every writer finished.
312    let committed_total = stats
313        .ops
314        .iter()
315        .filter(|o| o.result == OpResult::Committed)
316        .count();
317    if committed_total == 0 {
318        out.push(Violation::NoProgress);
319    }
320    if stats.writers_completed < stats.writers_total {
321        out.push(Violation::WritersDidNotComplete {
322            completed: stats.writers_completed,
323            total: stats.writers_total,
324        });
325    }
326
327    out
328}
329
330/// The #203 deletion invariants over the lifecycle stream. A run with no
331/// lifecycle ops (e.g. `--lifecycle-ops-per-writer 0`) skips them entirely.
332fn check_lifecycle(stats: &SoakStats, out: &mut Vec<Violation>) {
333    if stats.lifecycle_ops.is_empty() {
334        return;
335    }
336    if stats.lifecycle.is_empty() {
337        out.push(Violation::LifecycleNotChecked);
338    }
339    if delete_conflicts(stats) == 0 {
340        out.push(Violation::NoDeleteConflictsObserved);
341    }
342    if !stats
343        .lifecycle_ops
344        .iter()
345        .any(|o| o.op == LifecycleOp::Delete && o.result == LifecycleResult::Committed)
346    {
347        out.push(Violation::NoDeletesCommitted);
348    }
349
350    // Under OCC at most one conditional write may commit on a given base
351    // revision. Group committed ops by (key, base), skipping ops that never
352    // read a live record, and flag a group that holds (a) two or more Edits —
353    // `put` reports Committed only when it wrote — or (b) an Edit plus a Delete
354    // with evidence that it wrote its tombstone on that base. Either is a lost
355    // update or a resurrection over a tombstone. Deletes alone are legal: a
356    // second delete on the same base is a no-op that still reports `Deleted`.
357    // An Edit alongside only Deletes without write evidence is ambiguous: a
358    // stale delete that reaches a later tombstone returns `Deleted` without
359    // writing (spec §3.2), so it is skipped rather than flagged. A BTreeMap
360    // keeps the emitted order deterministic.
361    #[derive(Default)]
362    struct BaseGroup {
363        edits: usize,
364        deletes_that_wrote: usize,
365    }
366    let mut committed_by_base: BTreeMap<(&str, &str), BaseGroup> = BTreeMap::new();
367    for o in &stats.lifecycle_ops {
368        if o.result != LifecycleResult::Committed {
369            continue;
370        }
371        let Some(base) = o.base.as_deref() else {
372            continue;
373        };
374        let group = committed_by_base.entry((o.key.as_str(), base)).or_default();
375        match o.op {
376            LifecycleOp::Edit => group.edits += 1,
377            LifecycleOp::Delete if o.delete_wrote == Some(true) => group.deletes_that_wrote += 1,
378            LifecycleOp::Delete | LifecycleOp::Recreate => {}
379        }
380    }
381    for ((key, base), group) in &committed_by_base {
382        if group.edits >= 2 || (group.edits >= 1 && group.deletes_that_wrote >= 1) {
383            out.push(Violation::StaleBaseCommitted {
384                key: (*key).to_string(),
385                base: (*base).to_string(),
386            });
387        }
388    }
389
390    let written: BTreeSet<&str> = stats
391        .lifecycle_ops
392        .iter()
393        .filter(|o| o.result == LifecycleResult::Committed)
394        .map(|o| o.key.as_str())
395        .collect();
396
397    for fl in &stats.lifecycle {
398        for (replica, view) in fl.replicas.iter().enumerate() {
399            match (&view.raw, view.consumer_live) {
400                (RawState::ReadFailed { .. }, _) | (_, None) => {
401                    out.push(Violation::FinalReadFailed {
402                        key: fl.key.clone(),
403                        replica,
404                    });
405                }
406                (raw, Some(consumer_live)) => {
407                    let raw_live = matches!(raw, RawState::Live { .. });
408                    if consumer_live != raw_live {
409                        out.push(Violation::ConsumerRawMismatch {
410                            key: fl.key.clone(),
411                            replica,
412                        });
413                    }
414                }
415            }
416        }
417
418        let states: Vec<RawState> = fl.replicas.iter().map(|v| v.raw.clone()).collect();
419        let readable: Vec<&RawState> = states
420            .iter()
421            .filter(|s| !matches!(s, RawState::ReadFailed { .. }))
422            .collect();
423        let any_live = readable.iter().any(|s| matches!(s, RawState::Live { .. }));
424        let any_tomb = readable
425            .iter()
426            .any(|s| matches!(s, RawState::Tombstone { .. }));
427        let any_absent = readable.iter().any(|s| matches!(s, RawState::Absent));
428
429        if any_live && any_tomb {
430            out.push(Violation::MixedLiveAndTombstone {
431                key: fl.key.clone(),
432                states: states.clone(),
433            });
434        } else if readable.windows(2).any(|w| w[0] != w[1]) {
435            out.push(Violation::ReplicasDisagree {
436                key: fl.key.clone(),
437                states: states.clone(),
438            });
439        }
440
441        if any_absent && written.contains(fl.key.as_str()) {
442            out.push(Violation::LifecycleKeyVanished {
443                key: fl.key.clone(),
444            });
445        }
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    fn live(rev: &str) -> ReplicaView {
454        ReplicaView {
455            raw: RawState::Live {
456                revision: rev.into(),
457            },
458            consumer_live: Some(true),
459        }
460    }
461
462    fn tomb(rev: &str) -> ReplicaView {
463        ReplicaView {
464            raw: RawState::Tombstone {
465                revision: rev.into(),
466            },
467            consumer_live: Some(false),
468        }
469    }
470
471    fn lop(
472        key: &str,
473        op_id: u64,
474        op: LifecycleOp,
475        result: LifecycleResult,
476        base: Option<&str>,
477    ) -> LifecycleRecord {
478        LifecycleRecord {
479            key: key.into(),
480            op_id,
481            op,
482            result,
483            base: base.map(String::from),
484            delete_wrote: None,
485        }
486    }
487
488    /// `rec` with its delete write evidence set.
489    fn wrote(mut rec: LifecycleRecord, delete_wrote: Option<bool>) -> LifecycleRecord {
490        rec.delete_wrote = delete_wrote;
491        rec
492    }
493
494    /// A clean run: two contended keys whose final sets hold exactly their
495    /// committed op-ids, conflicts were seen, unique writes stuck, the acked
496    /// unique delete is still a tombstone, both lifecycle keys agree across all
497    /// three replicas, a delete conflict was observed, and all writers finished.
498    /// Must report zero violations.
499    fn clean_stats() -> SoakStats {
500        SoakStats {
501            ops: vec![
502                OpRecord {
503                    key: "k1".into(),
504                    op_id: 1,
505                    result: OpResult::Committed,
506                },
507                OpRecord {
508                    key: "k1".into(),
509                    op_id: 2,
510                    result: OpResult::Committed,
511                },
512                OpRecord {
513                    key: "k1".into(),
514                    op_id: 3,
515                    result: OpResult::Conflict,
516                },
517                OpRecord {
518                    key: "k2".into(),
519                    op_id: 4,
520                    result: OpResult::Committed,
521                },
522            ],
523            contended: vec![
524                FinalContended {
525                    key: "k1".into(),
526                    members: vec![1, 2],
527                },
528                FinalContended {
529                    key: "k2".into(),
530                    members: vec![4],
531                },
532            ],
533            unique: vec![
534                FinalUnique {
535                    key: "u1".into(),
536                    acked: true,
537                    readable_with_value: true,
538                    deleted: false,
539                    raw_tombstone: false,
540                },
541                FinalUnique {
542                    key: "u2".into(),
543                    acked: true,
544                    readable_with_value: false,
545                    deleted: true,
546                    raw_tombstone: true,
547                },
548            ],
549            conflicts_observed: 3,
550            writers_completed: 4,
551            writers_total: 4,
552            lifecycle_ops: vec![
553                lop(
554                    "life-0",
555                    0,
556                    LifecycleOp::Recreate,
557                    LifecycleResult::Committed,
558                    None,
559                ),
560                wrote(
561                    lop(
562                        "life-0",
563                        1,
564                        LifecycleOp::Delete,
565                        LifecycleResult::Committed,
566                        Some("0:aaa"),
567                    ),
568                    Some(true),
569                ),
570                lop(
571                    "life-0",
572                    2,
573                    LifecycleOp::Delete,
574                    LifecycleResult::Conflict,
575                    Some("0:bbb"),
576                ),
577                lop(
578                    "life-1",
579                    3,
580                    LifecycleOp::Recreate,
581                    LifecycleResult::Committed,
582                    None,
583                ),
584                lop(
585                    "life-1",
586                    4,
587                    LifecycleOp::Edit,
588                    LifecycleResult::Skipped,
589                    None,
590                ),
591            ],
592            lifecycle: vec![
593                FinalLifecycle {
594                    key: "life-0".into(),
595                    replicas: vec![tomb("1:t"), tomb("1:t"), tomb("1:t")],
596                },
597                FinalLifecycle {
598                    key: "life-1".into(),
599                    replicas: vec![live("0:h"), live("0:h"), live("0:h")],
600                },
601            ],
602        }
603    }
604
605    #[test]
606    fn clean_run_has_no_violations() {
607        assert_eq!(check(&clean_stats()), vec![]);
608    }
609
610    #[test]
611    fn detects_lost_update() {
612        let mut s = clean_stats();
613        // op-id 2 committed but is missing from k1's final set — a lost update.
614        s.contended[0].members = vec![1];
615        let v = check(&s);
616        assert!(
617            v.contains(&Violation::LostUpdate {
618                key: "k1".into(),
619                missing: vec![2]
620            }),
621            "expected LostUpdate, got {v:?}"
622        );
623    }
624
625    #[test]
626    fn detects_no_conflicts_observed() {
627        let mut s = clean_stats();
628        s.conflicts_observed = 0; // the race was never actually exercised
629        assert!(
630            check(&s).contains(&Violation::NoConflictsObserved),
631            "zero observed conflicts must be flagged"
632        );
633    }
634
635    #[test]
636    fn detects_unique_write_lost() {
637        let mut s = clean_stats();
638        s.unique[0].readable_with_value = false;
639        assert!(
640            check(&s).contains(&Violation::UniqueWriteLost { key: "u1".into() }),
641            "an acked unique write that isn't readable is a lost write"
642        );
643    }
644
645    #[test]
646    fn deleted_unique_key_is_not_a_lost_write() {
647        let mut s = clean_stats();
648        s.unique[1].raw_tombstone = false; // a lost delete…
649        let v = check(&s);
650        assert!(v.contains(&Violation::AckedDeleteLost { key: "u2".into() }));
651        assert!(
652            !v.contains(&Violation::UniqueWriteLost { key: "u2".into() }),
653            "…is reported once, not also as a lost write: {v:?}"
654        );
655    }
656
657    #[test]
658    fn detects_acked_delete_lost_when_readable_again() {
659        let mut s = clean_stats();
660        s.unique[1].readable_with_value = true; // resurrected
661        assert_eq!(
662            check(&s),
663            vec![Violation::AckedDeleteLost { key: "u2".into() }]
664        );
665    }
666
667    #[test]
668    fn detects_mixed_live_and_tombstone() {
669        let mut s = clean_stats();
670        s.lifecycle[0].replicas[2] = live("2:h");
671        let v = check(&s);
672        assert!(
673            v.iter().any(|x| matches!(
674                x,
675                Violation::MixedLiveAndTombstone { key, .. } if key == "life-0"
676            )),
677            "live on one replica and tombstone on others must be flagged: {v:?}"
678        );
679        assert!(
680            !v.iter()
681                .any(|x| matches!(x, Violation::ReplicasDisagree { .. })),
682            "a mixed key is reported once, as MixedLiveAndTombstone: {v:?}"
683        );
684    }
685
686    #[test]
687    fn detects_replicas_disagree_on_revision() {
688        let mut s = clean_stats();
689        s.lifecycle[0].replicas[1] = tomb("2:t");
690        assert_eq!(
691            check(&s),
692            vec![Violation::ReplicasDisagree {
693                key: "life-0".into(),
694                states: vec![
695                    RawState::Tombstone {
696                        revision: "1:t".into()
697                    },
698                    RawState::Tombstone {
699                        revision: "2:t".into()
700                    },
701                    RawState::Tombstone {
702                        revision: "1:t".into()
703                    },
704                ],
705            }],
706            "two tombstones at different revisions must be flagged, exactly once"
707        );
708    }
709
710    #[test]
711    fn detects_absent_on_one_replica_as_disagreement_and_vanished() {
712        let mut s = clean_stats();
713        s.lifecycle[1].replicas[0] = ReplicaView {
714            raw: RawState::Absent,
715            consumer_live: Some(false),
716        };
717        assert_eq!(
718            check(&s),
719            vec![
720                Violation::ReplicasDisagree {
721                    key: "life-1".into(),
722                    states: vec![
723                        RawState::Absent,
724                        RawState::Live {
725                            revision: "0:h".into()
726                        },
727                        RawState::Live {
728                            revision: "0:h".into()
729                        },
730                    ],
731                },
732                Violation::LifecycleKeyVanished {
733                    key: "life-1".into()
734                },
735            ]
736        );
737    }
738
739    /// The #203-shaped read-path bug — a deleted key is a tombstone on some
740    /// replicas but physically absent on another. Must be pinned as exactly
741    /// `ReplicasDisagree` plus `LifecycleKeyVanished`, not `MixedLiveAndTombstone`
742    /// (absent is not live).
743    #[test]
744    fn detects_tombstone_on_some_replicas_absent_on_another() {
745        let mut s = clean_stats();
746        s.lifecycle[0].replicas[2] = ReplicaView {
747            raw: RawState::Absent,
748            consumer_live: Some(false),
749        };
750        assert_eq!(
751            check(&s),
752            vec![
753                Violation::ReplicasDisagree {
754                    key: "life-0".into(),
755                    states: vec![
756                        RawState::Tombstone {
757                            revision: "1:t".into()
758                        },
759                        RawState::Tombstone {
760                            revision: "1:t".into()
761                        },
762                        RawState::Absent,
763                    ],
764                },
765                Violation::LifecycleKeyVanished {
766                    key: "life-0".into()
767                },
768            ]
769        );
770    }
771
772    #[test]
773    fn detects_lifecycle_key_vanished_everywhere() {
774        let mut s = clean_stats();
775        s.lifecycle[0].replicas.fill(ReplicaView {
776            raw: RawState::Absent,
777            consumer_live: Some(false),
778        });
779        assert_eq!(
780            check(&s),
781            vec![Violation::LifecycleKeyVanished {
782                key: "life-0".into()
783            }],
784            "a committed-then-deleted key must be a tombstone, never physically gone"
785        );
786    }
787
788    #[test]
789    fn untouched_absent_key_is_not_vanished() {
790        let mut s = clean_stats();
791        s.lifecycle.push(FinalLifecycle {
792            key: "life-2".into(),
793            replicas: vec![
794                ReplicaView {
795                    raw: RawState::Absent,
796                    consumer_live: Some(false),
797                };
798                3
799            ],
800        });
801        assert_eq!(
802            check(&s),
803            vec![],
804            "no committed op on life-2, so absent is fine"
805        );
806    }
807
808    #[test]
809    fn detects_consumer_raw_mismatch() {
810        let mut s = clean_stats();
811        // Replica 1 serves a tombstoned key to consumers.
812        s.lifecycle[0].replicas[1].consumer_live = Some(true);
813        assert_eq!(
814            check(&s),
815            vec![Violation::ConsumerRawMismatch {
816                key: "life-0".into(),
817                replica: 1
818            }]
819        );
820    }
821
822    #[test]
823    fn detects_final_read_failed() {
824        let mut s = clean_stats();
825        s.lifecycle[1].replicas[2] = ReplicaView {
826            raw: RawState::ReadFailed {
827                error: "connection refused".into(),
828            },
829            consumer_live: Some(true),
830        };
831        s.lifecycle[0].replicas[0].consumer_live = None;
832        let v = check(&s);
833        assert!(v.contains(&Violation::FinalReadFailed {
834            key: "life-1".into(),
835            replica: 2
836        }));
837        assert!(v.contains(&Violation::FinalReadFailed {
838            key: "life-0".into(),
839            replica: 0
840        }));
841        assert!(
842            !v.iter()
843                .any(|x| matches!(x, Violation::ReplicasDisagree { .. })),
844            "an unreadable replica is FinalReadFailed, not a disagreement: {v:?}"
845        );
846    }
847
848    #[test]
849    fn detects_no_delete_conflicts_observed() {
850        let mut s = clean_stats();
851        s.lifecycle_ops
852            .retain(|o| !(o.op == LifecycleOp::Delete && o.result == LifecycleResult::Conflict));
853        assert_eq!(delete_conflicts(&s), 0);
854        assert!(check(&s).contains(&Violation::NoDeleteConflictsObserved));
855    }
856
857    #[test]
858    fn edit_conflicts_do_not_count_as_delete_conflicts() {
859        let mut s = clean_stats();
860        s.lifecycle_ops
861            .retain(|o| !(o.op == LifecycleOp::Delete && o.result == LifecycleResult::Conflict));
862        s.lifecycle_ops.push(lop(
863            "life-1",
864            9,
865            LifecycleOp::Edit,
866            LifecycleResult::Conflict,
867            Some("9:ccc"),
868        ));
869        assert!(check(&s).contains(&Violation::NoDeleteConflictsObserved));
870    }
871
872    #[test]
873    fn detects_no_deletes_committed() {
874        let mut s = clean_stats();
875        s.lifecycle_ops
876            .retain(|o| !(o.op == LifecycleOp::Delete && o.result == LifecycleResult::Committed));
877        assert!(check(&s).contains(&Violation::NoDeletesCommitted));
878    }
879
880    /// A committed Edit and a committed Delete that wrote its tombstone share
881    /// one base: the edit was wrongly accepted over the tombstone (a
882    /// resurrection), or the delete wrongly overwrote the edit (a lost update).
883    /// `clean_stats`'s life-0 already has a committed Delete on base "0:aaa"
884    /// with `delete_wrote: Some(true)`; adding one committed Edit on the same
885    /// base is the whole mutation.
886    #[test]
887    fn delete_that_wrote_on_an_edited_base_is_flagged() {
888        let mut s = clean_stats();
889        s.lifecycle_ops.push(lop(
890            "life-0",
891            100,
892            LifecycleOp::Edit,
893            LifecycleResult::Committed,
894            Some("0:aaa"),
895        ));
896        assert_eq!(
897            check(&s),
898            vec![Violation::StaleBaseCommitted {
899                key: "life-0".into(),
900                base: "0:aaa".into(),
901            }]
902        );
903    }
904
905    /// The legal interleaving on one key at live revision r: W1 reads r to
906    /// delete it; W2's edit r → r1 commits; W3 deletes r1 and writes tombstone
907    /// t; W1's stale delete on r then reaches t, writes nothing and still
908    /// reports `Deleted`. W1 and W2 both commit on base r, but the write
909    /// evidence shows W1 was a no-op, so nothing is flagged.
910    #[test]
911    fn stale_delete_noop_over_later_tombstone_is_not_flagged() {
912        let mut s = clean_stats();
913        s.lifecycle_ops.push(lop(
914            "life-1",
915            110,
916            LifecycleOp::Edit,
917            LifecycleResult::Committed,
918            Some("1:r"),
919        ));
920        s.lifecycle_ops.push(wrote(
921            lop(
922                "life-1",
923                111,
924                LifecycleOp::Delete,
925                LifecycleResult::Committed,
926                Some("2:r1"),
927            ),
928            Some(true),
929        ));
930        s.lifecycle_ops.push(wrote(
931            lop(
932                "life-1",
933                112,
934                LifecycleOp::Delete,
935                LifecycleResult::Committed,
936                Some("1:r"),
937            ),
938            Some(false),
939        ));
940        assert_eq!(check(&s), vec![]);
941    }
942
943    /// An Edit and a Delete committed on one base, where the delete's write
944    /// evidence couldn't be taken (the raw read failed or found the key live
945    /// or absent): ambiguous, so skipped rather than flagged.
946    #[test]
947    fn delete_with_unknown_write_evidence_is_not_flagged() {
948        let mut s = clean_stats();
949        s.lifecycle_ops.push(lop(
950            "life-1",
951            120,
952            LifecycleOp::Edit,
953            LifecycleResult::Committed,
954            Some("1:r"),
955        ));
956        s.lifecycle_ops.push(lop(
957            "life-1",
958            121,
959            LifecycleOp::Delete,
960            LifecycleResult::Committed,
961            Some("1:r"),
962        ));
963        assert_eq!(check(&s), vec![]);
964    }
965
966    /// Two committed Edits share one base: a lost update, with no delete
967    /// involved at all.
968    #[test]
969    fn two_edits_on_one_base_are_flagged() {
970        let mut s = clean_stats();
971        s.lifecycle_ops.push(lop(
972            "life-1",
973            101,
974            LifecycleOp::Edit,
975            LifecycleResult::Committed,
976            Some("9:zzz"),
977        ));
978        s.lifecycle_ops.push(lop(
979            "life-1",
980            102,
981            LifecycleOp::Edit,
982            LifecycleResult::Committed,
983            Some("9:zzz"),
984        ));
985        assert_eq!(
986            check(&s),
987            vec![Violation::StaleBaseCommitted {
988                key: "life-1".into(),
989                base: "9:zzz".into(),
990            }]
991        );
992    }
993
994    /// Two committed Deletes sharing a base are legitimate — the second is a
995    /// no-op delete over the tombstone the first just wrote, and still reports
996    /// `Deleted`. Its evidence read finds the first delete's tombstone, whose
997    /// parent is the shared base, so both can carry `Some(true)`. Must not be
998    /// flagged.
999    #[test]
1000    fn concurrent_noop_deletes_share_a_base_without_violation() {
1001        let mut s = clean_stats();
1002        s.lifecycle_ops.push(wrote(
1003            lop(
1004                "life-0",
1005                103,
1006                LifecycleOp::Delete,
1007                LifecycleResult::Committed,
1008                Some("0:aaa"), // same base as the existing committed Delete op-id 1
1009            ),
1010            Some(true),
1011        ));
1012        assert_eq!(check(&s), vec![]);
1013    }
1014
1015    /// A committed Delete and an Edit that lost the race (`Conflict`) on
1016    /// the same base is the expected, correct outcome of OCC — only one
1017    /// conditional write may commit. Must not be flagged.
1018    #[test]
1019    fn conflicted_edit_on_a_deleted_base_is_fine() {
1020        let mut s = clean_stats();
1021        s.lifecycle_ops.push(lop(
1022            "life-0",
1023            104,
1024            LifecycleOp::Edit,
1025            LifecycleResult::Conflict,
1026            Some("0:aaa"), // same base as the existing committed Delete op-id 1
1027        ));
1028        assert_eq!(check(&s), vec![]);
1029    }
1030
1031    #[test]
1032    fn detects_lifecycle_not_checked() {
1033        let mut s = clean_stats();
1034        s.lifecycle.clear(); // the harness forgot to read every replica
1035        assert!(check(&s).contains(&Violation::LifecycleNotChecked));
1036    }
1037
1038    #[test]
1039    fn run_without_lifecycle_ops_skips_lifecycle_checks() {
1040        let mut s = clean_stats();
1041        s.lifecycle_ops.clear();
1042        s.lifecycle.clear();
1043        assert_eq!(check(&s), vec![]);
1044    }
1045
1046    #[test]
1047    fn detects_writers_did_not_complete() {
1048        let mut s = clean_stats();
1049        s.writers_completed = 3;
1050        assert!(check(&s).contains(&Violation::WritersDidNotComplete {
1051            completed: 3,
1052            total: 4
1053        }));
1054    }
1055
1056    #[test]
1057    fn detects_no_progress() {
1058        let s = SoakStats {
1059            ops: vec![OpRecord {
1060                key: "k1".into(),
1061                op_id: 1,
1062                result: OpResult::Failed,
1063            }],
1064            contended: vec![FinalContended {
1065                key: "k1".into(),
1066                members: vec![],
1067            }],
1068            writers_total: 4,
1069            ..Default::default()
1070        };
1071        assert!(check(&s).contains(&Violation::NoProgress));
1072    }
1073}