1use crate::dispatch::Dispatcher;
29use crate::oracle::{
30 FinalContended, FinalLifecycle, FinalUnique, LifecycleOp, LifecycleRecord, LifecycleResult,
31 OpRecord, OpResult, RawState, ReplicaView, SoakStats,
32};
33use gonzalo_core::{
34 Body, CoreError, DeleteResult, Identity, Meta, PutResult, Record, RecordKey, RecordKind,
35 Revision, Store,
36};
37use std::collections::{BTreeMap, BTreeSet};
38use std::sync::Arc;
39use std::sync::atomic::{AtomicU64, Ordering};
40
41#[derive(Debug, Clone)]
44pub struct WorkloadConfig {
45 pub namespace: String,
46 pub collection: String,
47 pub writers: usize,
48 pub shared_keys: usize,
49 pub ops_per_writer: usize,
50 pub unique_per_writer: usize,
51 pub max_conflict_retries: usize,
52 pub lifecycle_keys: usize,
55 pub lifecycle_ops_per_writer: usize,
58 pub unique_deletes_per_writer: usize,
60 pub seed_delete_conflict: bool,
64}
65
66impl Default for WorkloadConfig {
67 fn default() -> Self {
68 Self {
69 namespace: "soak".into(),
70 collection: "ha".into(),
71 writers: 8,
72 shared_keys: 4,
73 ops_per_writer: 25,
74 unique_per_writer: 4,
75 max_conflict_retries: 50,
76 lifecycle_keys: 2,
77 lifecycle_ops_per_writer: 20,
78 unique_deletes_per_writer: 1,
79 seed_delete_conflict: true,
80 }
81 }
82}
83
84pub fn lifecycle_op_for(life_id: u64) -> LifecycleOp {
87 match life_id % 4 {
88 0 => LifecycleOp::Delete,
89 1 => LifecycleOp::Recreate,
90 _ => LifecycleOp::Edit,
91 }
92}
93
94pub fn lifecycle_key_for(life_id: u64, lifecycle_keys: usize) -> String {
98 format!("life-{}", ((life_id / 4) as usize) % lifecycle_keys.max(1))
99}
100
101pub fn raw_state(read: gonzalo_core::Result<Option<Record>>) -> RawState {
103 match read {
104 Ok(None) => RawState::Absent,
105 Ok(Some(rec)) if rec.is_tombstone() => RawState::Tombstone {
106 revision: rev_string(&rec.revision),
107 },
108 Ok(Some(rec)) => RawState::Live {
109 revision: rev_string(&rec.revision),
110 },
111 Err(e) => RawState::ReadFailed {
112 error: e.to_string(),
113 },
114 }
115}
116
117fn rev_string(r: &Revision) -> String {
118 format!("{}:{}", r.counter, r.hash.0)
119}
120
121pub async fn run(dispatcher: Arc<Dispatcher>, cfg: WorkloadConfig) -> SoakStats {
126 let op_ids = Arc::new(AtomicU64::new(1));
127 let life_ids = Arc::new(AtomicU64::new(0));
128
129 let mut lifecycle_ops = if cfg.seed_delete_conflict && cfg.lifecycle_keys > 0 {
135 seed_delete_conflict(&dispatcher, &cfg, &life_ids).await
136 } else {
137 Vec::new()
138 };
139
140 let mut handles = Vec::new();
141 for w in 0..cfg.writers {
142 let d = dispatcher.clone();
143 let c = cfg.clone();
144 let ids = op_ids.clone();
145 let lids = life_ids.clone();
146 handles.push(tokio::spawn(
147 async move { writer(w, d, c, ids, lids).await },
148 ));
149 }
150
151 let mut ops = Vec::new();
152 let mut unique_acked: Vec<(String, Vec<u8>)> = Vec::new();
153 let mut unique_deleted: BTreeSet<String> = BTreeSet::new();
154 let mut conflicts_observed = 0u64;
155 let mut writers_completed = 0;
156 for h in handles {
157 if let Ok(res) = h.await {
158 ops.extend(res.ops);
159 lifecycle_ops.extend(res.lifecycle);
160 unique_acked.extend(res.unique_acked);
161 unique_deleted.extend(res.unique_deleted);
162 conflicts_observed += res.conflicts;
163 writers_completed += 1;
164 }
165 }
166
167 let contended = collect_contended(&dispatcher, &cfg).await;
168 let unique = collect_unique(&dispatcher, &cfg, &unique_acked, &unique_deleted).await;
169
170 SoakStats {
171 ops,
172 contended,
173 unique,
174 conflicts_observed,
175 writers_completed,
176 writers_total: cfg.writers,
177 lifecycle_ops,
178 lifecycle: Vec::new(),
179 }
180}
181
182async fn read_settled<T, F, Fut>(mut read: F) -> gonzalo_core::Result<T>
190where
191 F: FnMut() -> Fut,
192 Fut: std::future::Future<Output = gonzalo_core::Result<T>>,
193{
194 let mut last = read().await;
195 for _ in 0..2 {
196 if last.is_ok() {
197 break;
198 }
199 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
200 last = read().await;
201 }
202 last
203}
204
205pub async fn collect_lifecycle(
211 replicas: &[Arc<dyn Store>],
212 cfg: &WorkloadConfig,
213) -> Vec<FinalLifecycle> {
214 let mut out = Vec::with_capacity(cfg.lifecycle_keys);
215 for i in 0..cfg.lifecycle_keys {
216 let key_id = format!("life-{i}");
217 let key = RecordKey::new(&cfg.namespace, &cfg.collection, &key_id);
218 let mut views = Vec::with_capacity(replicas.len());
219 for store in replicas {
220 let raw = raw_state(read_settled(|| store.get_raw(&key)).await);
221 let consumer_live = read_settled(|| store.get(&key))
222 .await
223 .ok()
224 .map(|r| r.is_some());
225 views.push(ReplicaView { raw, consumer_live });
226 }
227 out.push(FinalLifecycle {
228 key: key_id,
229 replicas: views,
230 });
231 }
232 out
233}
234
235struct WriterResult {
236 ops: Vec<OpRecord>,
237 lifecycle: Vec<LifecycleRecord>,
238 unique_acked: Vec<(String, Vec<u8>)>,
239 unique_deleted: Vec<String>,
240 conflicts: u64,
241}
242
243async fn writer(
244 w: usize,
245 d: Arc<Dispatcher>,
246 cfg: WorkloadConfig,
247 op_ids: Arc<AtomicU64>,
248 life_ids: Arc<AtomicU64>,
249) -> WriterResult {
250 let mut ops = Vec::with_capacity(cfg.ops_per_writer);
251 let mut lifecycle = Vec::with_capacity(cfg.lifecycle_ops_per_writer);
252 let mut conflicts = 0u64;
253 let rounds = cfg.ops_per_writer.max(cfg.lifecycle_ops_per_writer);
254 for i in 0..rounds {
255 if i < cfg.ops_per_writer {
256 let op_id = op_ids.fetch_add(1, Ordering::Relaxed);
257 let key_id = format!("shared-{}", (op_id as usize) % cfg.shared_keys.max(1));
258 let (result, seen) = rmw_append(&d, &cfg, &key_id, op_id).await;
259 conflicts += seen;
260 ops.push(OpRecord {
261 key: key_id,
262 op_id,
263 result,
264 });
265 }
266 if i < cfg.lifecycle_ops_per_writer && cfg.lifecycle_keys > 0 {
267 let life_id = life_ids.fetch_add(1, Ordering::Relaxed);
268 let key_id = lifecycle_key_for(life_id, cfg.lifecycle_keys);
269 let op = lifecycle_op_for(life_id);
270 lifecycle.push(lifecycle_step(&d, &cfg, &key_id, life_id, op).await);
271 }
272 }
273
274 let mut unique_acked = Vec::with_capacity(cfg.unique_per_writer);
275 for i in 0..cfg.unique_per_writer {
276 let key_id = format!("unique-{w}-{i}");
277 let value = key_id.clone().into_bytes();
278 if create(&d, &cfg, &key_id, &value).await {
279 unique_acked.push((key_id, value));
280 }
281 }
282
283 let mut unique_deleted = Vec::new();
284 for (key_id, _) in unique_acked.iter().take(cfg.unique_deletes_per_writer) {
285 if delete_unique(&d, &cfg, key_id).await {
286 unique_deleted.push(key_id.clone());
287 }
288 }
289
290 WriterResult {
291 ops,
292 lifecycle,
293 unique_acked,
294 unique_deleted,
295 conflicts,
296 }
297}
298
299async fn rmw_append(
303 d: &Dispatcher,
304 cfg: &WorkloadConfig,
305 key_id: &str,
306 op_id: u64,
307) -> (OpResult, u64) {
308 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
309 let mut conflicts = 0u64;
310 for _ in 0..=cfg.max_conflict_retries {
311 let current = match d.get(&key).await {
312 Ok(c) => c,
313 Err(_) => return (OpResult::Failed, conflicts),
314 };
315 let (mut members, expected) = match ¤t {
316 Some(rec) => (parse_members(rec.body.bytes()), Some(rec.revision.clone())),
317 None => (Vec::new(), None),
318 };
319 if !members.contains(&op_id) {
320 members.push(op_id);
321 }
322 let record = build_record(&key, &encode_members(&members), expected.clone());
323 match d.put(record, expected).await {
324 Ok(PutResult::Committed(_)) => return (OpResult::Committed, conflicts),
325 Ok(PutResult::Conflict(_)) => {
326 conflicts += 1;
327 continue;
328 }
329 Err(_) => return (OpResult::Failed, conflicts),
330 }
331 }
332 (OpResult::Conflict, conflicts)
334}
335
336async fn lifecycle_step(
340 d: &Dispatcher,
341 cfg: &WorkloadConfig,
342 key_id: &str,
343 life_id: u64,
344 op: LifecycleOp,
345) -> LifecycleRecord {
346 let current = match lifecycle_read(d, cfg, key_id).await {
347 Ok(c) => c,
348 Err(_) => {
349 return LifecycleRecord {
350 key: key_id.to_string(),
351 op_id: life_id,
352 op,
353 result: LifecycleResult::Failed,
354 base: None,
355 delete_wrote: None,
356 };
357 }
358 };
359 lifecycle_write(d, cfg, key_id, life_id, op, current).await
360}
361
362async fn lifecycle_read(
365 d: &Dispatcher,
366 cfg: &WorkloadConfig,
367 key_id: &str,
368) -> gonzalo_core::Result<Option<Record>> {
369 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
370 d.get(&key).await
371}
372
373async fn lifecycle_write(
380 d: &Dispatcher,
381 cfg: &WorkloadConfig,
382 key_id: &str,
383 life_id: u64,
384 op: LifecycleOp,
385 current: Option<Record>,
386) -> LifecycleRecord {
387 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
388 let base = if op == LifecycleOp::Recreate {
392 None
393 } else {
394 current.as_ref().map(|rec| rev_string(&rec.revision))
395 };
396 tokio::task::yield_now().await;
399 let body = format!("op-{life_id}");
400 let mut delete_wrote = None;
401 let result = match (op, current) {
402 (LifecycleOp::Delete, Some(rec)) => match d.delete(&key, Some(rec.revision)).await {
403 Ok(DeleteResult::Deleted) => {
404 delete_wrote = delete_write_evidence(d, &key, base.as_deref()).await;
405 LifecycleResult::Committed
406 }
407 Ok(DeleteResult::Conflict(_)) => LifecycleResult::Conflict,
408 Err(_) => LifecycleResult::Failed,
409 },
410 (LifecycleOp::Edit, Some(rec)) => {
411 let record = build_record(&key, body.as_bytes(), Some(rec.revision.clone()));
412 lifecycle_put_result(d.put(record, Some(rec.revision)).await)
413 }
414 (LifecycleOp::Recreate, None) => {
415 let record = build_record(&key, body.as_bytes(), None);
418 lifecycle_put_result(d.put(record, None).await)
419 }
420 (LifecycleOp::Delete, None)
421 | (LifecycleOp::Edit, None)
422 | (LifecycleOp::Recreate, Some(_)) => LifecycleResult::Skipped,
423 };
424 LifecycleRecord {
425 key: key_id.to_string(),
426 op_id: life_id,
427 op,
428 result,
429 base,
430 delete_wrote,
431 }
432}
433
434async fn delete_write_evidence(
441 d: &Dispatcher,
442 key: &RecordKey,
443 base: Option<&str>,
444) -> Option<bool> {
445 match d.get_raw(key).await {
446 Ok(Some(rec)) if rec.is_tombstone() => {
447 Some(rec.parent.as_ref().map(rev_string).as_deref() == base)
448 }
449 _ => None,
450 }
451}
452
453async fn seed_delete_conflict(
466 d: &Dispatcher,
467 cfg: &WorkloadConfig,
468 life_ids: &AtomicU64,
469) -> Vec<LifecycleRecord> {
470 let key_id = "life-0".to_string();
471 let mut out = Vec::with_capacity(3);
472
473 let mut current = match lifecycle_read(d, cfg, &key_id).await {
474 Ok(c) => c,
475 Err(_) => return out, };
477
478 if current.is_none() {
479 let life_id = life_ids.fetch_add(1, Ordering::Relaxed);
480 let recreate = lifecycle_write(d, cfg, &key_id, life_id, LifecycleOp::Recreate, None).await;
481 let result = recreate.result;
482 out.push(recreate);
483 if result != LifecycleResult::Committed {
484 return out; }
486 current = match lifecycle_read(d, cfg, &key_id).await {
487 Ok(c) => c,
488 Err(_) => return out,
489 };
490 }
491
492 let Some(rec) = current else {
493 return out; };
495
496 let edit_id = life_ids.fetch_add(1, Ordering::Relaxed);
497 out.push(
498 lifecycle_write(
499 d,
500 cfg,
501 &key_id,
502 edit_id,
503 LifecycleOp::Edit,
504 Some(rec.clone()),
505 )
506 .await,
507 );
508
509 let delete_id = life_ids.fetch_add(1, Ordering::Relaxed);
514 out.push(lifecycle_write(d, cfg, &key_id, delete_id, LifecycleOp::Delete, Some(rec)).await);
515
516 out
517}
518
519fn lifecycle_put_result(r: gonzalo_core::Result<PutResult>) -> LifecycleResult {
520 match r {
521 Ok(PutResult::Committed(_)) => LifecycleResult::Committed,
522 Ok(PutResult::Conflict(_)) => LifecycleResult::Conflict,
523 Err(CoreError::NotFound(_)) => LifecycleResult::Conflict,
527 Err(_) => LifecycleResult::Failed,
528 }
529}
530
531async fn create(d: &Dispatcher, cfg: &WorkloadConfig, key_id: &str, value: &[u8]) -> bool {
533 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
534 let record = build_record(&key, value, None);
535 matches!(d.put(record, None).await, Ok(PutResult::Committed(_)))
536}
537
538async fn delete_unique(d: &Dispatcher, cfg: &WorkloadConfig, key_id: &str) -> bool {
541 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
542 let Ok(Some(rec)) = d.get(&key).await else {
543 return false;
544 };
545 matches!(
546 d.delete(&key, Some(rec.revision)).await,
547 Ok(DeleteResult::Deleted)
548 )
549}
550
551async fn collect_contended(d: &Dispatcher, cfg: &WorkloadConfig) -> Vec<FinalContended> {
552 let mut out = Vec::new();
553 for i in 0..cfg.shared_keys {
554 let key_id = format!("shared-{i}");
555 let key = RecordKey::new(&cfg.namespace, &cfg.collection, &key_id);
556 let members = match d.get(&key).await {
557 Ok(Some(rec)) => parse_members(rec.body.bytes()),
558 _ => Vec::new(),
559 };
560 out.push(FinalContended {
561 key: key_id,
562 members,
563 });
564 }
565 out
566}
567
568async fn collect_unique(
569 d: &Dispatcher,
570 cfg: &WorkloadConfig,
571 acked: &[(String, Vec<u8>)],
572 deleted: &BTreeSet<String>,
573) -> Vec<FinalUnique> {
574 let mut out = Vec::new();
575 for (key_id, value) in acked {
576 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
577 let readable_with_value = matches!(
578 read_settled(|| d.get(&key)).await,
579 Ok(Some(rec)) if rec.body.bytes() == value.as_slice()
580 );
581 let was_deleted = deleted.contains(key_id);
582 let raw_tombstone = was_deleted
583 && matches!(
584 read_settled(|| d.get_raw(&key)).await,
585 Ok(Some(rec)) if rec.is_tombstone()
586 );
587 out.push(FinalUnique {
588 key: key_id.clone(),
589 acked: true,
590 readable_with_value,
591 deleted: was_deleted,
592 raw_tombstone,
593 });
594 }
595 out
596}
597
598fn build_record(key: &RecordKey, body_bytes: &[u8], parent: Option<Revision>) -> Record {
599 let body = Body::Inline(body_bytes.to_vec());
600 let revision = match &parent {
601 Some(p) => p.next(body.bytes()),
602 None => Revision::initial(body.bytes()),
603 };
604 Record {
605 revision,
606 parent,
607 body,
608 kind: RecordKind::Topic,
609 meta: Meta {
610 author: Identity::new("soak"),
611 origin_system: "soak".into(),
612 created: 0,
613 updated: 0,
614 labels: BTreeMap::new(),
615 },
616 links: Vec::new(),
617 key: key.clone(),
618 ancestors: Vec::new(),
619 deleted_at: None,
620 deleted_blob: None,
621 deleted_kind: None,
622 }
623}
624
625fn parse_members(bytes: &[u8]) -> Vec<u64> {
626 std::str::from_utf8(bytes)
627 .unwrap_or("")
628 .split(',')
629 .filter_map(|s| s.trim().parse::<u64>().ok())
630 .collect()
631}
632
633fn encode_members(members: &[u64]) -> Vec<u8> {
634 members
635 .iter()
636 .map(u64::to_string)
637 .collect::<Vec<_>>()
638 .join(",")
639 .into_bytes()
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::oracle::{LifecycleOp, LifecycleResult, RawState};
646 use gonzalo_core::{DEFAULT_ANCESTOR_CAP, Store, tombstone_of};
647 use gonzalo_store_fs::FsStore;
648 use std::collections::BTreeSet;
649 use std::sync::atomic::AtomicUsize;
650
651 #[test]
652 fn parse_round_trips() {
653 assert_eq!(parse_members(&encode_members(&[3, 1, 2])), vec![3, 1, 2]);
654 assert_eq!(parse_members(b""), Vec::<u64>::new());
655 }
656
657 #[test]
661 fn lifecycle_ops_cover_every_kind_on_every_key() {
662 for keys in [1usize, 2, 3] {
663 let pairs: BTreeSet<(String, String)> = (0..(4 * keys as u64))
664 .map(|id| {
665 (
666 lifecycle_key_for(id, keys),
667 format!("{:?}", lifecycle_op_for(id)),
668 )
669 })
670 .collect();
671 for k in 0..keys {
672 for op in ["Edit", "Delete", "Recreate"] {
673 assert!(
674 pairs.contains(&(format!("life-{k}"), op.to_string())),
675 "keys={keys}: life-{k} never gets {op}: {pairs:?}"
676 );
677 }
678 }
679 }
680 }
681
682 #[test]
683 fn raw_state_classifies_records() {
684 let key = RecordKey::new("soak", "ha", "life-0");
685 let rec = build_record(&key, b"op-1", None);
686 let tomb = tombstone_of(&rec, 1, DEFAULT_ANCESTOR_CAP, None);
687
688 assert_eq!(raw_state(Ok(None)), RawState::Absent);
689 assert_eq!(
690 raw_state(Ok(Some(rec.clone()))),
691 RawState::Live {
692 revision: format!("0:{}", rec.revision.hash.0)
693 }
694 );
695 assert_eq!(
696 raw_state(Ok(Some(tomb.clone()))),
697 RawState::Tombstone {
698 revision: format!("1:{}", tomb.revision.hash.0)
699 }
700 );
701 assert!(matches!(
702 raw_state(Err(gonzalo_core::CoreError::Backend("down".into()))),
703 RawState::ReadFailed { .. }
704 ));
705 }
706
707 #[tokio::test]
710 async fn read_settled_retries_errors_then_returns_the_last() {
711 let calls = AtomicUsize::new(0);
713 let out = read_settled(|| {
714 let n = calls.fetch_add(1, Ordering::SeqCst) + 1;
715 async move {
716 if n < 3 {
717 Err(gonzalo_core::CoreError::Backend("down".into()))
718 } else {
719 Ok(n)
720 }
721 }
722 })
723 .await;
724 assert_eq!(out.unwrap(), 3);
725 assert_eq!(calls.load(Ordering::SeqCst), 3);
726
727 let calls = AtomicUsize::new(0);
729 let out: gonzalo_core::Result<()> = read_settled(|| {
730 calls.fetch_add(1, Ordering::SeqCst);
731 async move { Err(gonzalo_core::CoreError::Backend("down".into())) }
732 })
733 .await;
734 assert!(out.is_err());
735 assert_eq!(calls.load(Ordering::SeqCst), 3);
736
737 let calls = AtomicUsize::new(0);
739 let out = read_settled(|| {
740 calls.fetch_add(1, Ordering::SeqCst);
741 async move { Ok(42) }
742 })
743 .await;
744 assert_eq!(out.unwrap(), 42);
745 assert_eq!(calls.load(Ordering::SeqCst), 1);
746 }
747
748 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
753 async fn in_process_fs_replicas_hold_the_invariant() {
754 let dir = tempfile::tempdir().unwrap();
755 let replicas: Vec<Arc<dyn Store>> = (0..3)
756 .map(|_| Arc::new(FsStore::new(dir.path())) as Arc<dyn Store>)
757 .collect();
758 let dispatcher = Arc::new(Dispatcher::new(replicas));
759
760 let cfg = WorkloadConfig {
761 writers: 6,
762 shared_keys: 3,
763 ops_per_writer: 30,
764 unique_per_writer: 3,
765 lifecycle_keys: 2,
766 lifecycle_ops_per_writer: 40,
770 unique_deletes_per_writer: 1,
771 ..Default::default()
772 };
773 let mut stats = run(dispatcher.clone(), cfg.clone()).await;
774 stats.lifecycle = collect_lifecycle(dispatcher.replicas(), &cfg).await;
775
776 let violations = crate::oracle::check(&stats);
777 assert!(violations.is_empty(), "invariant violated: {violations:?}");
778
779 assert!(
781 stats
782 .lifecycle_ops
783 .iter()
784 .any(|o| o.op == LifecycleOp::Recreate && o.result == LifecycleResult::Committed)
785 );
786 assert_eq!(
787 stats.unique.iter().filter(|u| u.deleted).count(),
788 cfg.writers,
789 "one acked delete per writer"
790 );
791 assert_eq!(stats.lifecycle.len(), cfg.lifecycle_keys);
792 assert!(stats.lifecycle.iter().all(|fl| fl.replicas.len() == 3));
793 }
794
795 #[tokio::test]
803 async fn lifecycle_write_maps_results_and_delete_evidence() {
804 let dir = tempfile::tempdir().unwrap();
805 let replicas: Vec<Arc<dyn Store>> = (0..3)
806 .map(|_| Arc::new(FsStore::new(dir.path())) as Arc<dyn Store>)
807 .collect();
808 let d = Dispatcher::new(replicas);
809 let cfg = WorkloadConfig::default();
810 let key_id = "life-0";
811 let key = RecordKey::new(&cfg.namespace, &cfg.collection, key_id);
812
813 let created = lifecycle_write(&d, &cfg, key_id, 0, LifecycleOp::Recreate, None).await;
815 assert_eq!(created.result, LifecycleResult::Committed);
816 assert_eq!(created.base, None);
817 assert_eq!(created.delete_wrote, None);
818 let r = d.get(&key).await.unwrap().expect("live after create");
819 let r_str = rev_string(&r.revision);
820
821 let w2 = lifecycle_write(&d, &cfg, key_id, 1, LifecycleOp::Edit, Some(r.clone())).await;
823 assert_eq!(w2.result, LifecycleResult::Committed);
824 assert_eq!(w2.base.as_deref(), Some(r_str.as_str()));
825 assert_eq!(w2.delete_wrote, None);
826 let r1 = d.get(&key).await.unwrap().expect("live after edit");
827 let r1_str = rev_string(&r1.revision);
828 assert_ne!(r1_str, r_str);
829
830 let w3 = lifecycle_write(&d, &cfg, key_id, 2, LifecycleOp::Delete, Some(r1.clone())).await;
832 assert_eq!(w3.result, LifecycleResult::Committed);
833 assert_eq!(w3.base.as_deref(), Some(r1_str.as_str()));
834 assert_eq!(w3.delete_wrote, Some(true));
835
836 let w1 = lifecycle_write(&d, &cfg, key_id, 3, LifecycleOp::Delete, Some(r.clone())).await;
838 assert_eq!(w1.result, LifecycleResult::Committed);
839 assert_eq!(w1.base.as_deref(), Some(r_str.as_str()));
840 assert_eq!(w1.delete_wrote, Some(false));
841
842 let key_rec = build_record(&key, b"probe", Some(r1.revision.clone()));
844 assert!(matches!(
845 d.put(key_rec, Some(r1.revision.clone())).await,
846 Err(CoreError::NotFound(_))
847 ));
848 let stale_edit = lifecycle_write(&d, &cfg, key_id, 4, LifecycleOp::Edit, Some(r1)).await;
849 assert_eq!(
850 stale_edit.result,
851 lifecycle_put_result(Err(CoreError::NotFound(key.clone())))
852 );
853 assert_eq!(stale_edit.result, LifecycleResult::Conflict);
854 assert_eq!(stale_edit.base.as_deref(), Some(r1_str.as_str()));
855 assert_eq!(stale_edit.delete_wrote, None);
856
857 let stats = SoakStats {
858 lifecycle_ops: vec![created, w2, w3, w1, stale_edit],
859 ..Default::default()
860 };
861 let violations = crate::oracle::check(&stats);
862 assert!(
863 !violations
864 .iter()
865 .any(|v| matches!(v, crate::oracle::Violation::StaleBaseCommitted { .. })),
866 "a stale no-op delete over a later tombstone is legal: {violations:?}"
867 );
868 }
869
870 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
875 async fn seeded_delete_conflict_is_recorded() {
876 let dir = tempfile::tempdir().unwrap();
877 let replicas: Vec<Arc<dyn Store>> = (0..3)
878 .map(|_| Arc::new(FsStore::new(dir.path())) as Arc<dyn Store>)
879 .collect();
880 let dispatcher = Arc::new(Dispatcher::new(replicas));
881
882 let cfg = WorkloadConfig {
883 writers: 2,
884 shared_keys: 2,
885 ops_per_writer: 5,
886 unique_per_writer: 1,
887 lifecycle_ops_per_writer: 0,
888 unique_deletes_per_writer: 1,
889 seed_delete_conflict: true,
890 ..Default::default()
891 };
892 let mut stats = run(dispatcher.clone(), cfg.clone()).await;
893 stats.lifecycle = collect_lifecycle(dispatcher.replicas(), &cfg).await;
894
895 let committed_edits: Vec<&LifecycleRecord> = stats
896 .lifecycle_ops
897 .iter()
898 .filter(|o| o.op == LifecycleOp::Edit && o.result == LifecycleResult::Committed)
899 .collect();
900 let conflict_deletes: Vec<&LifecycleRecord> = stats
901 .lifecycle_ops
902 .iter()
903 .filter(|o| o.op == LifecycleOp::Delete && o.result == LifecycleResult::Conflict)
904 .collect();
905 assert_eq!(
906 committed_edits.len(),
907 1,
908 "expected exactly one committed Edit: {:?}",
909 stats.lifecycle_ops
910 );
911 assert_eq!(
912 conflict_deletes.len(),
913 1,
914 "expected exactly one Conflict Delete: {:?}",
915 stats.lifecycle_ops
916 );
917 assert!(committed_edits[0].base.is_some(), "{committed_edits:?}");
918 assert_eq!(
919 committed_edits[0].base, conflict_deletes[0].base,
920 "the seeded edit and delete must share one base revision"
921 );
922
923 let violations = crate::oracle::check(&stats);
924 assert!(
925 !violations.contains(&crate::oracle::Violation::NoDeleteConflictsObserved),
926 "seed must satisfy the delete-conflict guard: {violations:?}"
927 );
928 assert!(
929 !violations
930 .iter()
931 .any(|v| matches!(v, crate::oracle::Violation::StaleBaseCommitted { .. })),
932 "the committed edit and conflicted delete must not be mistaken for two commits \
933 sharing a base: {violations:?}"
934 );
935 }
936}