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}