Skip to main content

gonzalo_soak/
workload.rs

1//! The concurrent write workload and result collection for the [`oracle`].
2//!
3//! Each writer interleaves three op streams against the replica [`Dispatcher`]:
4//!
5//! - **Contended RMW** on a small set of shared keys — read the current record,
6//!   append this op's globally-unique id to the record's comma-separated set,
7//!   and conditionally `put` against the revision just read. On `Conflict`,
8//!   re-read and retry (bounded). This is the arbitration proof: if conditional
9//!   writes are correct, every *committed* op-id survives in the final set.
10//! - **Lifecycle ops** (#203) on a small set of `life-*` keys — edit, delete, or
11//!   recreate, chosen by a per-run lifecycle counter ([`lifecycle_op_for`]). Each
12//!   op reads the key, then issues one conditional write against what it read. A
13//!   lost race is recorded as `Conflict` and not retried. Deletes therefore race
14//!   edits and recreations across replicas, and afterwards every replica must
15//!   agree on whether each key is deleted.
16//! - **Unique-key writes** — disjoint keys written once, later read back to prove
17//!   no acked write is lost across replica kills. The first
18//!   `unique_deletes_per_writer` of each writer's acked keys are then deleted and
19//!   must still read as tombstones at the end (no resurrection under churn).
20//!
21//! After all writers finish, [`run`] reads the contended and unique keys through
22//! the dispatcher. The per-replica lifecycle read, [`collect_lifecycle`], is a
23//! separate call because it must reach **every** replica directly. The caller runs
24//! it once all replicas are live again.
25//!
26//! [`oracle`]: crate::oracle
27
28use 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/// Workload shape. The bounded gate and the deep soak both use this; they differ
42/// only in the magnitudes and (for the deep soak) running against a duration.
43#[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    /// Number of `life-*` keys the lifecycle stream races on. Keep it small so
53    /// deletes genuinely contend.
54    pub lifecycle_keys: usize,
55    /// Lifecycle ops (edit/delete/recreate) per writer. `0` disables the stream
56    /// and the oracle's deletion checks.
57    pub lifecycle_ops_per_writer: usize,
58    /// How many of each writer's acked unique keys it deletes afterwards.
59    pub unique_deletes_per_writer: usize,
60    /// Run one scripted read → committed edit → stale conditional delete on a
61    /// dedicated lifecycle key before the concurrent writers, guaranteeing at
62    /// least one store-arbitrated delete conflict.
63    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
84/// The op a lifecycle id performs: a quarter deletes, a quarter recreations, and
85/// half edits (so deletes race live writes, which is what makes them conflict).
86pub 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
94/// The key a lifecycle id targets. Divides by 4 (the op cycle length) before
95/// taking the modulus so the key and the op are decorrelated: every key sees
96/// every op kind.
97pub 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
101/// Classify one raw read for the oracle. Revisions render as `"<counter>:<hash>"`.
102pub 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
121/// Run the workload to completion and collect stats for the oracle. `dispatcher`
122/// fans ops across the live replicas; chaos (replica kills) is driven separately
123/// by the caller while this runs. `lifecycle` is left empty: fill it with
124/// [`collect_lifecycle`] once every replica is live.
125pub 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    // Seed one deterministic delete conflict before the chaotic writers start,
130    // so `NoDeleteConflictsObserved` never depends on scheduling. Ids come from
131    // the same counter the random stream uses below, so they stay unique;
132    // running before any writer spawns means nothing else can race the seed
133    // itself.
134    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
182/// Retry a final read up to three attempts, 200 ms apart, on errors only, so a
183/// connection pooled to a restarted replica can't fail the soak. A read that
184/// keeps failing still returns its last error (a `FinalReadFailed` violation).
185///
186/// Takes a plain closure returning a future rather than an `AsyncFnMut`: the
187/// latter's lending signature makes callers inside `tokio::spawn` fail the
188/// `Send` check.
189async 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
205/// Read every lifecycle key from **every** replica directly (raw and consumer).
206/// Call only after the writers have stopped and all replicas are live: a final
207/// read retries up to 3 attempts, 200 ms apart, on errors only, so a connection
208/// pooled to a just-restarted replica doesn't fail the soak on its own; a
209/// replica that stays unreadable is still reported (`FinalReadFailed`).
210pub 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
299/// Read-modify-write: append `op_id` to a shared key's set under a conditional
300/// put, retrying on `Conflict`. Returns the op's final outcome and the number of
301/// transient conflicts observed while racing to commit it.
302async 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 &current {
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    // Exhausted the retry budget without winning the race — did not commit.
333    (OpResult::Conflict, conflicts)
334}
335
336/// One lifecycle op: read, then a single conditional write against what was
337/// read. A thin wrapper over [`lifecycle_read`] and [`lifecycle_write`] for the
338/// random lifecycle stream, which always runs both back to back.
339async 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
362/// The read phase of one lifecycle op: the key's current record, or `None` if
363/// it reads as absent (including tombstoned — the consumer read filters those).
364async 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
373/// The write phase of one lifecycle op: a single conditional write against
374/// `current`, whatever the read phase saw. Returns the recorded op: its
375/// outcome, the rendered base revision it conditioned its write on (`None` for
376/// `Recreate`, and for any op that read the key as absent), and, for an
377/// acknowledged delete, the write evidence — see
378/// [`LifecycleRecord::base`] and [`LifecycleRecord::delete_wrote`].
379async 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    // The base this op read and conditioned its write on. Recreate never
389    // conditions on a live revision even in its Skipped arm below (the key
390    // was found live, so recreation didn't apply).
391    let base = if op == LifecycleOp::Recreate {
392        None
393    } else {
394        current.as_ref().map(|rec| rev_string(&rec.revision))
395    };
396    // A scheduling point between read and write; contention comes from
397    // overlapping store I/O.
398    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            // Over a tombstone the store re-stamps this counter-0 revision to
416            // continue the chain (spec §3.2), so no special handling here.
417            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
434/// Evidence that an acknowledged conditional delete on `base` actually wrote
435/// its tombstone: one raw read through the dispatcher, straight after the
436/// delete, with no retries. `Some(true)` when the key is a tombstone whose
437/// parent renders as `base`, `Some(false)` when it is a tombstone with another
438/// parent (the delete was a no-op over an existing tombstone), and `None` when
439/// the read fails or finds the key live or absent (a later op raced it).
440async 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
453/// Guarantee one store-arbitrated delete conflict per run:
454/// read → committed edit → stale conditional delete, on `life-0` — a key the
455/// random lifecycle stream also targets, so the final per-replica collection
456/// and the oracle see it like any other lifecycle op. Runs sequentially before
457/// any writer is spawned, so nothing else can race it; ids are drawn from
458/// `life_ids` so the random stream's ids start after these.
459///
460/// If `life-0` reads as absent or a tombstone, it is recreated first and that
461/// op is recorded as a normal `Recreate`. The scripted delete's real result is
462/// always recorded, never hidden: `Conflict` is the guaranteed delete
463/// conflict; `Committed` means the store accepted a stale conditional delete
464/// (a real bug, left for the oracle's `StaleBaseCommitted` check to flag).
465async 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, // store unreachable before writers even start; skip the seed
476    };
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; // couldn't establish a live key to seed the conflict on
485        }
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; // still absent immediately after a committed recreate: give up on the seed
494    };
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    // The stale conditional delete: still conditioned on `rec`'s (pre-edit)
510    // revision, so on a correct store this loses to the edit that just
511    // committed above. Its real result, and write evidence if it was
512    // acknowledged, are recorded as-is (see doc comment).
513    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        // A consumer put with `Some(expected)` over a tombstone is always
524        // `NotFound` (the key reads as absent): an edit that lost a race to a
525        // delete. That's a lost race, not a failure.
526        Err(CoreError::NotFound(_)) => LifecycleResult::Conflict,
527        Err(_) => LifecycleResult::Failed,
528    }
529}
530
531/// Create a unique key once. Returns `true` if the write was acked (`Committed`).
532async 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
538/// Delete an (uncontended) unique key against its current revision. Returns
539/// `true` if the delete was acked.
540async 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    /// Key and op choice must be decorrelated: every lifecycle key must see every
658    /// op kind, or a key that is never deleted (or never recreated) would make
659    /// the deletion invariant vacuous for it.
660    #[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    /// `read_settled` retries only on error, up to three attempts 200 ms apart,
708    /// and returns the last outcome either way.
709    #[tokio::test]
710    async fn read_settled_retries_errors_then_returns_the_last() {
711        // Errors twice, then Ok: returns Ok after exactly 3 calls.
712        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        // Always errors: returns Err after exactly 3 calls.
728        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        // Immediate Ok: exactly 1 call.
738        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    /// End-to-end workload against three in-process `FsStore` replicas over one
749    /// shared directory — real concurrency + real conditional-write arbitration,
750    /// no external S3 backend. Proves the RMW/lifecycle/oracle/dispatch
751    /// integration holds every invariant, including replicated deletion.
752    #[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            // 40, not 80: `seed_delete_conflict` (default true) already
767            // guarantees a delete conflict, so the random stream no longer
768            // needs to be inflated to make one likely.
769            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        // Belt-and-braces on top of the oracle: the new streams really ran.
780        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    /// Scripted against in-process `FsStore` replicas, each lifecycle write
796    /// maps the store's answer to a deterministic record: an edit over a
797    /// tombstone (`put` with `Some(expected)` returns `NotFound`) is a
798    /// `Conflict`; a delete that writes is `Committed` with
799    /// `delete_wrote: Some(true)`; a stale delete over a later tombstone is
800    /// `Committed` with `delete_wrote: Some(false)`. The resulting W1/W2/W3
801    /// records must not trip `StaleBaseCommitted`.
802    #[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        // Live revision r.
814        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        // W2: edit r → r1 commits.
822        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        // W3: delete on r1 genuinely writes the tombstone.
831        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        // W1: the stale delete on r reaches the tombstone and writes nothing.
837        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        // An edit over the tombstone: `put(Some(r1))` returns `NotFound`.
843        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    /// With the random lifecycle stream disabled, `seed_delete_conflict`
871    /// alone must produce exactly one committed Edit and one Conflict Delete
872    /// sharing a base, and the oracle must see a real store-arbitrated delete
873    /// conflict (not a flake) plus no false `StaleBaseCommitted`.
874    #[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}