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}