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, and the revision chain grew by one
9//!   per committed put.
10//! - **Conflicts surface** — racing writers observed `Conflict` (never a silent
11//!   overwrite). Zero observed conflicts means the invariant was never actually
12//!   exercised, which is itself a failure.
13//! - **Durability under churn** — every acked unique-key write is still readable.
14//! - **Liveness** — the run made progress and every writer finished.
15//!
16//! This is a targeted invariant oracle, not a linearizability checker: it asserts
17//! on set membership / chain length / completion, never on exact interleavings,
18//! so normal scheduling jitter cannot flake it.
19
20/// The result of a single conditional-write op, as observed by the driver.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum OpResult {
23    /// The conditional put committed.
24    Committed,
25    /// The put was rejected as a conflict — a concurrent writer won the race.
26    Conflict,
27    /// The op ultimately failed (transport error, exhausted retries).
28    Failed,
29}
30
31/// One recorded write against a contended key: the unique op-id and its result.
32#[derive(Debug, Clone)]
33pub struct OpRecord {
34    pub key: String,
35    pub op_id: u64,
36    pub result: OpResult,
37}
38
39/// The final observed state of one contended key.
40#[derive(Debug, Clone)]
41pub struct FinalContended {
42    pub key: String,
43    /// The op-ids present in the final record's accumulated set (read from storage).
44    pub members: Vec<u64>,
45}
46
47/// The final observed state of one unique (uncontended) key.
48#[derive(Debug, Clone)]
49pub struct FinalUnique {
50    pub key: String,
51    /// The write was acked (`Committed`) by the driver.
52    pub acked: bool,
53    /// The key is readable with the exact value that was written.
54    pub readable_with_value: bool,
55}
56
57/// Everything the oracle needs: op outcomes, final reads, and writer completion.
58#[derive(Debug, Clone, Default)]
59pub struct SoakStats {
60    pub ops: Vec<OpRecord>,
61    pub contended: Vec<FinalContended>,
62    pub unique: Vec<FinalUnique>,
63    /// Total transient `Conflict` outcomes observed across all RMW retries — the
64    /// evidence the CAS actually arbitrated racing writers. Zero means the race
65    /// invariant was never exercised.
66    pub conflicts_observed: u64,
67    pub writers_completed: usize,
68    pub writers_total: usize,
69}
70
71/// A violated invariant. An empty [`check`] result means the soak passed.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Violation {
74    /// Committed op-ids missing from the contended key's final set — a lost update.
75    LostUpdate { key: String, missing: Vec<u64> },
76    /// An op-id appears more than once in a contended key's final set.
77    DuplicateUpdate { key: String, duplicated: Vec<u64> },
78    /// No conflicts were observed anywhere — the race invariant was not exercised.
79    NoConflictsObserved,
80    /// An acked unique-key write is missing or has the wrong value after chaos.
81    UniqueWriteLost { key: String },
82    /// Not every writer task completed within the deadline (liveness).
83    WritersDidNotComplete { completed: usize, total: usize },
84    /// The run committed nothing at all (liveness).
85    NoProgress,
86}
87
88/// Check every soak invariant. Returns the (possibly empty) set of violations.
89pub fn check(stats: &SoakStats) -> Vec<Violation> {
90    let mut out = Vec::new();
91
92    // Per contended key: committed op-ids must all survive, exactly once, and the
93    // revision chain must have grown by one per committed put.
94    for fc in &stats.contended {
95        let committed: Vec<u64> = stats
96            .ops
97            .iter()
98            .filter(|o| o.key == fc.key && o.result == OpResult::Committed)
99            .map(|o| o.op_id)
100            .collect();
101
102        let missing: Vec<u64> = committed
103            .iter()
104            .copied()
105            .filter(|id| !fc.members.contains(id))
106            .collect();
107        if !missing.is_empty() {
108            out.push(Violation::LostUpdate {
109                key: fc.key.clone(),
110                missing,
111            });
112        }
113
114        let duplicated: Vec<u64> = fc
115            .members
116            .iter()
117            .copied()
118            .filter(|id| fc.members.iter().filter(|m| *m == id).count() > 1)
119            .collect::<std::collections::BTreeSet<_>>()
120            .into_iter()
121            .collect();
122        if !duplicated.is_empty() {
123            out.push(Violation::DuplicateUpdate {
124                key: fc.key.clone(),
125                duplicated,
126            });
127        }
128    }
129
130    // The race invariant must actually have been exercised: with real contention
131    // across replicas, some RMW attempts must lose the CAS and observe `Conflict`.
132    if stats.conflicts_observed == 0 {
133        out.push(Violation::NoConflictsObserved);
134    }
135
136    // Durability under churn: every acked unique write must still be readable.
137    for fu in &stats.unique {
138        if fu.acked && !fu.readable_with_value {
139            out.push(Violation::UniqueWriteLost {
140                key: fu.key.clone(),
141            });
142        }
143    }
144
145    // Liveness: the run made progress and every writer finished.
146    let committed_total = stats
147        .ops
148        .iter()
149        .filter(|o| o.result == OpResult::Committed)
150        .count();
151    if committed_total == 0 {
152        out.push(Violation::NoProgress);
153    }
154    if stats.writers_completed < stats.writers_total {
155        out.push(Violation::WritersDidNotComplete {
156            completed: stats.writers_completed,
157            total: stats.writers_total,
158        });
159    }
160
161    out
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    /// A clean run: two contended keys whose final sets hold exactly their
169    /// committed op-ids, chains match, conflicts were seen, unique writes stuck,
170    /// and all writers finished. Must report zero violations.
171    fn clean_stats() -> SoakStats {
172        SoakStats {
173            ops: vec![
174                OpRecord {
175                    key: "k1".into(),
176                    op_id: 1,
177                    result: OpResult::Committed,
178                },
179                OpRecord {
180                    key: "k1".into(),
181                    op_id: 2,
182                    result: OpResult::Committed,
183                },
184                OpRecord {
185                    key: "k1".into(),
186                    op_id: 3,
187                    result: OpResult::Conflict,
188                },
189                OpRecord {
190                    key: "k2".into(),
191                    op_id: 4,
192                    result: OpResult::Committed,
193                },
194            ],
195            contended: vec![
196                FinalContended {
197                    key: "k1".into(),
198                    members: vec![1, 2],
199                },
200                FinalContended {
201                    key: "k2".into(),
202                    members: vec![4],
203                },
204            ],
205            unique: vec![FinalUnique {
206                key: "u1".into(),
207                acked: true,
208                readable_with_value: true,
209            }],
210            conflicts_observed: 3,
211            writers_completed: 4,
212            writers_total: 4,
213        }
214    }
215
216    #[test]
217    fn clean_run_has_no_violations() {
218        assert_eq!(check(&clean_stats()), vec![]);
219    }
220
221    #[test]
222    fn detects_lost_update() {
223        let mut s = clean_stats();
224        // op-id 2 committed but is missing from k1's final set — a lost update.
225        s.contended[0].members = vec![1];
226        let v = check(&s);
227        assert!(
228            v.contains(&Violation::LostUpdate {
229                key: "k1".into(),
230                missing: vec![2]
231            }),
232            "expected LostUpdate, got {v:?}"
233        );
234    }
235
236    #[test]
237    fn detects_no_conflicts_observed() {
238        let mut s = clean_stats();
239        s.conflicts_observed = 0; // the race was never actually exercised
240        assert!(
241            check(&s).contains(&Violation::NoConflictsObserved),
242            "zero observed conflicts must be flagged"
243        );
244    }
245
246    #[test]
247    fn detects_unique_write_lost() {
248        let mut s = clean_stats();
249        s.unique[0].readable_with_value = false;
250        assert!(
251            check(&s).contains(&Violation::UniqueWriteLost { key: "u1".into() }),
252            "an acked unique write that isn't readable is a lost write"
253        );
254    }
255
256    #[test]
257    fn detects_writers_did_not_complete() {
258        let mut s = clean_stats();
259        s.writers_completed = 3;
260        assert!(check(&s).contains(&Violation::WritersDidNotComplete {
261            completed: 3,
262            total: 4
263        }));
264    }
265
266    #[test]
267    fn detects_no_progress() {
268        let s = SoakStats {
269            ops: vec![OpRecord {
270                key: "k1".into(),
271                op_id: 1,
272                result: OpResult::Failed,
273            }],
274            contended: vec![FinalContended {
275                key: "k1".into(),
276                members: vec![],
277            }],
278            unique: vec![],
279            conflicts_observed: 0,
280            writers_completed: 0,
281            writers_total: 4,
282        };
283        assert!(check(&s).contains(&Violation::NoProgress));
284    }
285}