Skip to main content

gonzalo_core/
sync.rs

1//! Reconcile two `Store`s. Any store can be a sync peer.
2//!
3//! Sync reads through the replication surface (`list_raw` / `get_raw`), so it
4//! sees tombstones. For a key held by both sides it uses each record's bounded
5//! `ancestors` list (spec §3.4) to tell "one side is behind" (overwrite it)
6//! from "the sides diverged". Diverged tombstones converge on the higher
7//! revision. A tombstone against a live edit is a conflict. Two diverged live
8//! records take the class-aware merge: append-only kinds union, and
9//! structured/opaque divergences surface as conflicts.
10//! [`sync_with_ancestry`] 3-way-merges structured bodies against their real
11//! common ancestor when an [`AncestryStore`](crate::AncestryStore) retains it;
12//! [`sync`] uses an empty base (ADR 0016).
13
14use crate::tombstone::tombstone_winner;
15use crate::{
16    BlobStore, Body, CoreError, Identity, KeyPrefix, MergeOutcome, PutResult, Record, RecordKey,
17    Result, Revision, Store, merge,
18};
19use std::collections::BTreeSet;
20
21/// A divergence that could not be auto-merged and needs caller/CLI resolution:
22/// an unmergeable body divergence, or a delete (one side a tombstone) against a
23/// concurrent edit. Neither side is written.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct SyncConflict {
26    pub key: RecordKey,
27    pub a: Box<Record>,
28    pub b: Box<Record>,
29}
30
31/// What a sync run did.
32#[derive(Clone, Debug, Default, PartialEq, Eq)]
33#[must_use = "a SyncReport may contain unresolved conflicts that must be handled"]
34pub struct SyncReport {
35    /// Keys copied into store A (were only in B). Includes tombstones.
36    pub copied_to_a: Vec<RecordKey>,
37    /// Keys copied into store B (were only in A). Includes tombstones.
38    pub copied_to_b: Vec<RecordKey>,
39    /// Keys where A was behind B's revision chain and was overwritten with B's
40    /// record. Includes tombstones.
41    pub fast_forwarded_to_a: Vec<RecordKey>,
42    /// Keys where B was behind A's revision chain and was overwritten with A's
43    /// record. Includes tombstones.
44    pub fast_forwarded_to_b: Vec<RecordKey>,
45    /// Keys whose divergence was reconciled and written to both stores: an
46    /// auto-merged live record, or two diverged tombstones converged on the
47    /// winner.
48    pub merged: Vec<RecordKey>,
49    /// Divergences needing manual resolution.
50    pub conflicts: Vec<SyncConflict>,
51    /// Keys whose write still lost a race on the final pass, when sync gave up
52    /// after [`MAX_SYNC_PASSES`]. Empty on every run that reached a clean pass,
53    /// so a non-empty list means **the stores may still disagree on these keys,
54    /// even when `conflicts` is empty** (gonzalo#290). Re-run sync once the
55    /// writers settle.
56    pub unconverged: Vec<RecordKey>,
57}
58
59impl SyncReport {
60    /// Whether sync reached a pass in which no write lost a race. `false` means
61    /// the pass limit was exhausted and the stores may still disagree on
62    /// [`unconverged`](Self::unconverged); it says nothing about `conflicts`,
63    /// which are a settled outcome a caller resolves.
64    #[must_use]
65    pub fn converged(&self) -> bool {
66        self.unconverged.is_empty()
67    }
68}
69
70/// Upper bound on sync passes before giving up on a non-quiescent pair.
71///
72/// Each pass re-reads both stores, so a store that settles converges within
73/// one extra pass. The cap only bites when writers never stop racing the merge
74/// window (livelock guard): rather than spin forever, sync returns the last
75/// pass's best-effort report.
76const MAX_SYNC_PASSES: usize = 16;
77
78/// Reconcile stores `a` and `b`. After a run with no `conflicts` that did not
79/// exhaust [`MAX_SYNC_PASSES`], both stores hold the same record (live or
80/// tombstone) for every key.
81///
82/// Check [`SyncReport::converged`] as well as `conflicts`: an exhausted run
83/// reports the still-racing keys in [`SyncReport::unconverged`], and treating
84/// its empty `conflicts` list as "in sync" would be wrong (gonzalo#290).
85///
86/// Stores need not be quiescent. A single pass can lose a write that lands in
87/// the read→merge→write window (the OCC `put_raw` returns `Conflict`, or
88/// `NotFound` when the key was purged meanwhile); sync re-runs the pass until one
89/// completes without any such race (a fixpoint), bounded by
90/// [`MAX_SYNC_PASSES`] so continuous concurrent writes can't livelock it.
91pub async fn sync(a: &dyn Store, b: &dyn Store) -> Result<SyncReport> {
92    sync_with_ancestry(a, b, None).await
93}
94
95/// As [`sync`], but 3-way-merges divergent `Structured` bodies against their
96/// real common ancestor when one is available. `ancestry` is a content-addressed
97/// store of past bodies keyed by revision hash (see
98/// [`AncestryStore`](crate::AncestryStore)): when two records diverge from a
99/// shared parent revision whose body it holds, that body is the merge base;
100/// otherwise sync falls back to the empty base (ADR 0016).
101pub async fn sync_with_ancestry(
102    a: &dyn Store,
103    b: &dyn Store,
104    ancestry: Option<&dyn BlobStore>,
105) -> Result<SyncReport> {
106    let mut report = SyncReport::default();
107    for _ in 0..MAX_SYNC_PASSES {
108        let (pass, raced) = sync_pass(a, b, ancestry).await?;
109        report = pass;
110        if raced.is_empty() {
111            break; // quiescent: this pass landed cleanly, stores have converged.
112        }
113        // Carried on the report only if this turns out to be the last pass:
114        // a later clean pass replaces the whole report, leaving it empty.
115        report.unconverged = raced;
116    }
117    Ok(report)
118}
119
120/// The merge base for a divergence: the body of `a`/`b`'s shared parent revision
121/// when `ancestry` retains it, else an empty base (the base-agnostic fallback,
122/// correct for `AppendOnly` and safe for the rest).
123async fn ancestry_base(ancestry: Option<&dyn BlobStore>, rec_a: &Record, rec_b: &Record) -> Body {
124    if let Some(anc) = ancestry
125        && let (Some(pa), Some(pb)) = (&rec_a.parent, &rec_b.parent)
126        && pa == pb
127        && let Ok(Some(bytes)) = anc.get_blob(&pa.hash).await
128    {
129        return Body::Inline(bytes);
130    }
131    Body::Inline(Vec::new())
132}
133
134/// How two present records for the same key relate by revision chain (§3.4).
135#[derive(Debug, PartialEq, Eq)]
136enum Relation {
137    /// Equal revisions.
138    InSync,
139    /// B's revision is in A's ancestors: B is behind.
140    AAhead,
141    /// A's revision is in B's ancestors: A is behind.
142    BAhead,
143    /// Neither chain contains the other's revision: diverged, or the chain is
144    /// unknown (legacy empty list, or truncated past the cap). Fails safe.
145    Diverged,
146}
147
148fn relate(a: &Record, b: &Record) -> Relation {
149    if a.revision == b.revision {
150        Relation::InSync
151    } else if a.ancestors.contains(&b.revision) {
152        Relation::AAhead
153    } else if b.ancestors.contains(&a.revision) {
154        Relation::BAhead
155    } else {
156        Relation::Diverged
157    }
158}
159
160/// The cap sync folds with: large enough never to truncate. Each input list is
161/// already bounded by its own store's cap, and each destination store's
162/// `plan_put_raw` truncates to that store's cap on write, so the store's cap
163/// wins.
164fn lossless_cap(a: &Record, b: &Record) -> usize {
165    a.ancestors.len() + b.ancestors.len() + 2
166}
167
168/// One reconciliation pass over the union of raw keys. Returns the pass's
169/// report and the keys whose write lost a race (non-empty ⇒ a store changed
170/// mid-pass, so the caller should re-loop). A `SyncConflict` is a terminal
171/// divergence (surfaced in the report), not a race, and does not trigger a
172/// re-loop.
173async fn sync_pass(
174    a: &dyn Store,
175    b: &dyn Store,
176    ancestry: Option<&dyn BlobStore>,
177) -> Result<(SyncReport, Vec<RecordKey>)> {
178    let mut report = SyncReport::default();
179    let mut raced: Vec<RecordKey> = Vec::new();
180
181    // Raw reads only: consumer reads hide tombstones, and a tombstone that sync
182    // cannot see is copied over by the peer's live record (resurrection).
183    let mut keys: BTreeSet<RecordKey> = BTreeSet::new();
184    keys.extend(a.list_raw(&KeyPrefix::default()).await?);
185    keys.extend(b.list_raw(&KeyPrefix::default()).await?);
186
187    for key in keys {
188        let ra = a.get_raw(&key).await?;
189        let rb = b.get_raw(&key).await?;
190        match (ra, rb) {
191            (Some(rec), None) => {
192                if copy(b, &rec).await? {
193                    report.copied_to_b.push(key);
194                } else {
195                    raced.push(key);
196                }
197            }
198            (None, Some(rec)) => {
199                if copy(a, &rec).await? {
200                    report.copied_to_a.push(key);
201                } else {
202                    raced.push(key);
203                }
204            }
205            (Some(rec_a), Some(rec_b)) => match relate(&rec_a, &rec_b) {
206                Relation::InSync => {}
207                Relation::AAhead => {
208                    if overwrite(b, &rec_a, &rec_b.revision).await? {
209                        report.fast_forwarded_to_b.push(key);
210                    } else {
211                        raced.push(key);
212                    }
213                }
214                Relation::BAhead => {
215                    if overwrite(a, &rec_b, &rec_a.revision).await? {
216                        report.fast_forwarded_to_a.push(key);
217                    } else {
218                        raced.push(key);
219                    }
220                }
221                Relation::Diverged => match (rec_a.is_tombstone(), rec_b.is_tombstone()) {
222                    (true, true) => {
223                        let winner = tombstone_winner(&rec_a, &rec_b, lossless_cap(&rec_a, &rec_b));
224                        // Written to both sides, including the one that already
225                        // holds the winning revision, so both carry the folded
226                        // chain. `fold_ancestors` excludes the stored revision.
227                        let la = overwrite(a, &winner, &rec_a.revision).await?;
228                        let lb = overwrite(b, &winner, &rec_b.revision).await?;
229                        if la && lb {
230                            report.merged.push(key);
231                        } else {
232                            raced.push(key);
233                        }
234                    }
235                    (true, false) | (false, true) => {
236                        // Delete vs concurrent edit: no side wins without
237                        // losing someone's intent (spec §5.4).
238                        report.conflicts.push(SyncConflict {
239                            key,
240                            a: Box::new(rec_a),
241                            b: Box::new(rec_b),
242                        });
243                    }
244                    (false, false) => {
245                        let base = ancestry_base(ancestry, &rec_a, &rec_b).await;
246                        match merge(rec_a.kind.merge_class(), &base, &rec_a.body, &rec_b.body) {
247                            MergeOutcome::Merged(body) => {
248                                let merged = build_merged(&key, &rec_a, &rec_b, body);
249                                let la = overwrite(a, &merged, &rec_a.revision).await?;
250                                let lb = overwrite(b, &merged, &rec_b.revision).await?;
251                                if la && lb {
252                                    report.merged.push(key);
253                                } else {
254                                    // At least one side raced; re-loop to
255                                    // reconcile the store that moved against
256                                    // the now-merged peer.
257                                    raced.push(key);
258                                }
259                            }
260                            MergeOutcome::NeedsResolution => {
261                                report.conflicts.push(SyncConflict {
262                                    key,
263                                    a: Box::new(rec_a),
264                                    b: Box::new(rec_b),
265                                });
266                            }
267                        }
268                    }
269                },
270            },
271            (None, None) => {}
272        }
273    }
274    Ok((report, raced))
275}
276
277/// Create `rec` in `dst`, where the raw read found the key absent, through the
278/// replication write. Returns `false` if `dst` gained a record or a tombstone
279/// since that read (`put_raw` → `Conflict`, nothing written), signalling the
280/// caller to re-loop. `put_raw` never re-stamps, so a copy can't recreate over
281/// a tombstone that arrived mid-pass.
282async fn copy(dst: &dyn Store, rec: &Record) -> Result<bool> {
283    Ok(matches!(
284        dst.put_raw(rec.clone(), None).await?,
285        PutResult::Committed(_)
286    ))
287}
288
289/// Conditionally overwrite `dst` with `rec` (stored verbatim), expecting
290/// revision `expected`, through the replication write. Returns `false` if a
291/// concurrent mutation raced the write window: `Conflict` (the key moved, maybe
292/// to a tombstone), or `NotFound` (the key was purged after sync's read).
293async fn overwrite(dst: &dyn Store, rec: &Record, expected: &Revision) -> Result<bool> {
294    match dst.put_raw(rec.clone(), Some(expected.clone())).await {
295        Ok(PutResult::Committed(_)) => Ok(true),
296        Ok(PutResult::Conflict(_)) => Ok(false),
297        Err(CoreError::NotFound(_)) => Ok(false),
298        Err(e) => Err(e),
299    }
300}
301
302/// The merged record for a diverged live-vs-live body merge (spec §3.4, §3.5),
303/// via [`reconciled_record`](crate::tombstone::reconciled_record).
304fn build_merged(key: &RecordKey, a: &Record, b: &Record, body: Body) -> Record {
305    debug_assert_eq!(&a.key, key);
306    debug_assert_eq!(&b.key, key);
307    crate::tombstone::reconciled_record(
308        a,
309        b,
310        body,
311        Identity::new("gonzalo-sync"),
312        "sync",
313        lossless_cap(a, b),
314    )
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::memstore::MemStore;
321    use crate::{CoreError, DeleteResult, Meta, PutResult, RecordKind, store::Conflict};
322    use async_trait::async_trait;
323    use std::collections::{BTreeMap, HashSet};
324    use std::sync::Mutex;
325
326    /// How a `RacyStore` interferes with writes. `put` and `put_raw` both
327    /// consult it.
328    // Test-only double: a short-lived value never stored in a collection, so
329    // the size difference between variants doesn't matter.
330    #[allow(clippy::large_enum_variant)]
331    enum Race {
332        /// One spurious `Conflict` on the first conditional write per key.
333        FlakyOnce(Mutex<HashSet<RecordKey>>),
334        /// Every write conflicts, except a create into a raw-absent key. A
335        /// conditional write to a raw-absent key returns `Err(NotFound)`.
336        Always,
337        /// The first unconditional `put_raw` for this tombstone's key commits
338        /// the tombstone first (a third peer's delete arriving mid-copy). Raw
339        /// only.
340        TombstoneArrives(Mutex<Option<Record>>),
341    }
342
343    /// A store whose writes are interfered with per `race`, delegating
344    /// everything else to the reference `MemStore`. See [`Race`].
345    struct RacyStore {
346        inner: MemStore,
347        race: Race,
348    }
349
350    impl RacyStore {
351        /// A store that returns one spurious `Conflict` on the first
352        /// conditional (`expected.is_some()`) write per key, like a concurrent
353        /// writer racing the first overwrite, then behaves like the reference
354        /// `MemStore`. Forces the sync re-loop to retry and still converge. It
355        /// races both `put` and `put_raw`, so it behaves the same before and
356        /// after sync moves to raw writes.
357        fn flaky_once() -> Self {
358            Self {
359                inner: MemStore::new(),
360                race: Race::FlakyOnce(Mutex::new(HashSet::new())),
361            }
362        }
363
364        /// A store whose every write *always* races (a concurrent writer that
365        /// never stops), except a create into a raw-absent key, which commits.
366        /// Used to prove the re-loop is bounded and terminates. Races both
367        /// `put` and `put_raw`.
368        fn always() -> Self {
369            Self {
370                inner: MemStore::new(),
371                race: Race::Always,
372            }
373        }
374
375        /// A destination that a third peer's tombstone `arriving` reaches mid
376        /// pass. The first unconditional `put_raw` for `arriving`'s key first
377        /// commits that tombstone, then runs the copy. This lands exactly in
378        /// the window between sync's `get_raw` (which saw the key absent) and
379        /// its write. Raw only.
380        fn tombstone_arrives(arriving: Record) -> Self {
381            Self {
382                inner: MemStore::new(),
383                race: Race::TombstoneArrives(Mutex::new(Some(arriving))),
384            }
385        }
386
387        /// Interference shared by `put` and `put_raw`. `raw` distinguishes them
388        /// for `Race::TombstoneArrives`, which only interferes with `put_raw`.
389        /// Returns `Some(result)` to short-circuit the write with that result;
390        /// `None` lets the write proceed to the inner store unmodified.
391        async fn interfere(
392            &self,
393            record: &Record,
394            expected: &Option<Revision>,
395            raw: bool,
396        ) -> Result<Option<PutResult>> {
397            match &self.race {
398                Race::FlakyOnce(tripped) => {
399                    let trip = expected.is_some() && {
400                        let mut g = tripped.lock().unwrap();
401                        g.insert(record.key.clone())
402                    };
403                    if trip && let Some(current) = self.inner.get_raw(&record.key).await? {
404                        return Ok(Some(PutResult::Conflict(Box::new(Conflict {
405                            key: record.key.clone(),
406                            expected: expected.clone(),
407                            current,
408                        }))));
409                    }
410                    Ok(None)
411                }
412                Race::Always => match self.inner.get_raw(&record.key).await? {
413                    None if expected.is_none() => Ok(None),
414                    None => Err(CoreError::NotFound(record.key.clone())),
415                    Some(current) => Ok(Some(PutResult::Conflict(Box::new(Conflict {
416                        key: record.key.clone(),
417                        expected: expected.clone(),
418                        current,
419                    })))),
420                },
421                Race::TombstoneArrives(slot) => {
422                    if raw {
423                        let arriving = if expected.is_none() {
424                            let mut guard = slot.lock().unwrap();
425                            match guard.as_ref() {
426                                Some(t) if t.key == record.key => guard.take(),
427                                _ => None,
428                            }
429                        } else {
430                            None
431                        };
432                        if let Some(t) = arriving {
433                            let _ = self.inner.put_raw(t, None).await?;
434                        }
435                    }
436                    Ok(None)
437                }
438            }
439        }
440    }
441
442    #[async_trait]
443    impl Store for RacyStore {
444        async fn get(&self, key: &RecordKey) -> Result<Option<Record>> {
445            self.inner.get(key).await
446        }
447        async fn put(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
448            if let Some(raced) = self.interfere(&record, &expected, false).await? {
449                return Ok(raced);
450            }
451            self.inner.put(record, expected).await
452        }
453        async fn put_raw(&self, record: Record, expected: Option<Revision>) -> Result<PutResult> {
454            if let Some(raced) = self.interfere(&record, &expected, true).await? {
455                return Ok(raced);
456            }
457            self.inner.put_raw(record, expected).await
458        }
459        async fn list(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
460            self.inner.list(prefix).await
461        }
462        async fn delete_as(
463            &self,
464            key: &RecordKey,
465            expected: Option<Revision>,
466            author: Option<Identity>,
467        ) -> Result<DeleteResult> {
468            self.inner.delete_as(key, expected, author).await
469        }
470        async fn get_raw(&self, key: &RecordKey) -> Result<Option<Record>> {
471            self.inner.get_raw(key).await
472        }
473        async fn list_raw(&self, prefix: &KeyPrefix) -> Result<Vec<RecordKey>> {
474            self.inner.list_raw(prefix).await
475        }
476        async fn purge(&self, key: &RecordKey, expected: Revision) -> Result<DeleteResult> {
477            self.inner.purge(key, expected).await
478        }
479    }
480
481    fn rec(id: &str, kind: RecordKind, payload: &str) -> Record {
482        let body = Body::Inline(payload.as_bytes().to_vec());
483        Record {
484            revision: Revision::initial(body.bytes()),
485            parent: None,
486            body,
487            kind,
488            key: RecordKey::new("ns", "col", id),
489            meta: Meta {
490                author: Identity::new("t"),
491                origin_system: "test".into(),
492                created: 0,
493                updated: 0,
494                labels: BTreeMap::new(),
495            },
496            links: Vec::new(),
497            ancestors: Vec::new(),
498            deleted_at: None,
499            deleted_blob: None,
500            deleted_kind: None,
501        }
502    }
503
504    #[tokio::test]
505    async fn copies_one_sided_records_both_directions() {
506        let a = MemStore::new();
507        let b = MemStore::new();
508        let _ = a
509            .put(rec("only_a", RecordKind::Topic, "x"), None)
510            .await
511            .unwrap();
512        let _ = b
513            .put(rec("only_b", RecordKind::Topic, "y"), None)
514            .await
515            .unwrap();
516
517        let report = sync(&a, &b).await.unwrap();
518        assert_eq!(
519            report.copied_to_b,
520            vec![RecordKey::new("ns", "col", "only_a")]
521        );
522        assert_eq!(
523            report.copied_to_a,
524            vec![RecordKey::new("ns", "col", "only_b")]
525        );
526        assert!(
527            a.get(&RecordKey::new("ns", "col", "only_b"))
528                .await
529                .unwrap()
530                .is_some()
531        );
532        assert!(
533            b.get(&RecordKey::new("ns", "col", "only_a"))
534                .await
535                .unwrap()
536                .is_some()
537        );
538    }
539
540    #[tokio::test]
541    async fn append_only_divergence_auto_merges() {
542        let a = MemStore::new();
543        let b = MemStore::new();
544        let _ = a
545            .put(rec("t", RecordKind::Topic, "base\nfrom_a\n"), None)
546            .await
547            .unwrap();
548        let _ = b
549            .put(rec("t", RecordKind::Topic, "base\nfrom_b\n"), None)
550            .await
551            .unwrap();
552
553        let report = sync(&a, &b).await.unwrap();
554        assert_eq!(report.merged, vec![RecordKey::new("ns", "col", "t")]);
555        assert!(report.conflicts.is_empty());
556        let merged = a
557            .get(&RecordKey::new("ns", "col", "t"))
558            .await
559            .unwrap()
560            .unwrap();
561        let text = String::from_utf8(merged.body.bytes().to_vec()).unwrap();
562        assert!(text.contains("from_a") && text.contains("from_b") && text.contains("base"));
563        // Both stores converge to the same revision.
564        let mb = b
565            .get(&RecordKey::new("ns", "col", "t"))
566            .await
567            .unwrap()
568            .unwrap();
569        assert_eq!(merged.revision, mb.revision);
570    }
571
572    #[tokio::test]
573    async fn checkpoint_divergence_surfaces_conflict() {
574        let a = MemStore::new();
575        let b = MemStore::new();
576        let _ = a
577            .put(rec("c", RecordKind::Checkpoint, "a"), None)
578            .await
579            .unwrap();
580        let _ = b
581            .put(rec("c", RecordKind::Checkpoint, "b"), None)
582            .await
583            .unwrap();
584
585        let report = sync(&a, &b).await.unwrap();
586        assert_eq!(report.conflicts.len(), 1);
587        assert_eq!(report.conflicts[0].key, RecordKey::new("ns", "col", "c"));
588        assert!(report.merged.is_empty());
589    }
590
591    #[tokio::test]
592    async fn memory_tier_divergence_surfaces_conflict() {
593        let a = MemStore::new();
594        let b = MemStore::new();
595        let _ = a
596            .put(rec("m", RecordKind::MemoryTier, "a"), None)
597            .await
598            .unwrap();
599        let _ = b
600            .put(rec("m", RecordKind::MemoryTier, "b"), None)
601            .await
602            .unwrap();
603
604        let report = sync(&a, &b).await.unwrap();
605        assert_eq!(report.conflicts.len(), 1);
606        assert!(report.merged.is_empty());
607    }
608
609    #[tokio::test]
610    async fn session_divergence_auto_merges() {
611        let a = MemStore::new();
612        let b = MemStore::new();
613        let _ = a
614            .put(rec("s", RecordKind::Session, "base\nfrom_a\n"), None)
615            .await
616            .unwrap();
617        let _ = b
618            .put(rec("s", RecordKind::Session, "base\nfrom_b\n"), None)
619            .await
620            .unwrap();
621
622        let report = sync(&a, &b).await.unwrap();
623        assert_eq!(report.merged, vec![RecordKey::new("ns", "col", "s")]);
624        assert!(report.conflicts.is_empty());
625    }
626
627    #[tokio::test]
628    async fn session_with_blank_and_duplicate_lines_survives_divergent_sync() {
629        // Regression for #133: a Session whose committed body legitimately holds
630        // a blank line and a repeated line must survive a divergent sync intact.
631        // `sync` merges against an empty base, so the old split+dedup path
632        // silently dropped the blank line and collapsed the repeat, corrupting
633        // BOTH stores (ADR 0005 violation). The merge must be side A verbatim
634        // plus side B's divergent tail.
635        let a = MemStore::new();
636        let b = MemStore::new();
637        let _ = a
638            .put(
639                rec("s", RecordKind::Session, "a\n\nyes\nyes\nfrom_a\n"),
640                None,
641            )
642            .await
643            .unwrap();
644        let _ = b
645            .put(
646                rec("s", RecordKind::Session, "a\n\nyes\nyes\nfrom_b\n"),
647                None,
648            )
649            .await
650            .unwrap();
651
652        let report = sync(&a, &b).await.unwrap();
653        let key = RecordKey::new("ns", "col", "s");
654        assert_eq!(report.merged, vec![key.clone()]);
655        assert!(report.conflicts.is_empty());
656
657        let ma = a.get(&key).await.unwrap().unwrap();
658        let text = String::from_utf8(ma.body.bytes().to_vec()).unwrap();
659        // Blank line preserved, "yes\nyes" not collapsed, both appends present.
660        assert_eq!(text, "a\n\nyes\nyes\nfrom_a\nfrom_b\n");
661        // Both stores converge to the same merged revision.
662        let mb = b.get(&key).await.unwrap().unwrap();
663        assert_eq!(ma.revision, mb.revision);
664        assert_eq!(ma.body, mb.body);
665    }
666
667    #[tokio::test]
668    async fn re_loops_until_a_racing_store_converges() {
669        // B races the first overwrite (non-quiescent during the merge window).
670        // A single pass would swallow that conflict and leave B un-synced; the
671        // re-loop must retry until both stores converge.
672        let a = MemStore::new();
673        let b = RacyStore::flaky_once();
674        let _ = a
675            .put(rec("t", RecordKind::Topic, "base\nfrom_a\n"), None)
676            .await
677            .unwrap();
678        let _ = b
679            .put(rec("t", RecordKind::Topic, "base\nfrom_b\n"), None)
680            .await
681            .unwrap();
682
683        let report = sync(&a, &b).await.unwrap();
684
685        assert!(report.conflicts.is_empty());
686        let key = RecordKey::new("ns", "col", "t");
687        let ra = a.get(&key).await.unwrap().unwrap();
688        let rb = b.get(&key).await.unwrap().unwrap();
689        // Both stores converged despite B racing the first overwrite.
690        assert_eq!(ra.revision, rb.revision);
691        let text = String::from_utf8(rb.body.bytes().to_vec()).unwrap();
692        assert!(text.contains("from_a") && text.contains("from_b"));
693        // Pass 1 merged into A and lost the race on B. Pass 2 found B behind
694        // A's chain and fast-forwarded it; the report is the last pass's.
695        assert_eq!(report.fast_forwarded_to_b, vec![key]);
696        assert!(report.merged.is_empty());
697    }
698
699    /// Build ours/theirs Structured records diverging from a shared base
700    /// revision (disjoint field edits), plus the base body to retain.
701    fn structured_divergence() -> (Record, Record, &'static str) {
702        let base = rec("m", RecordKind::MemoryTier, r#"{"name":"a","content":"x"}"#);
703        let base_rev = base.revision.clone();
704        let mut ours = rec("m", RecordKind::MemoryTier, r#"{"name":"b","content":"x"}"#);
705        ours.parent = Some(base_rev.clone());
706        let mut theirs = rec("m", RecordKind::MemoryTier, r#"{"name":"a","content":"y"}"#);
707        theirs.parent = Some(base_rev);
708        (ours, theirs, r#"{"name":"a","content":"x"}"#)
709    }
710
711    #[tokio::test]
712    async fn structured_divergence_merges_with_ancestry() {
713        use crate::ancestry::tests::Mem;
714        let a = Mem::default();
715        let b = Mem::default();
716        let ancestry = Mem::default();
717        let (ours, theirs, base_body) = structured_divergence();
718        // Retain the shared base body under its revision hash.
719        ancestry.put_blob(base_body.as_bytes()).await.unwrap();
720        let _ = a.put(ours, None).await.unwrap();
721        let _ = b.put(theirs, None).await.unwrap();
722
723        let report = sync_with_ancestry(&a, &b, Some(&ancestry)).await.unwrap();
724
725        let key = RecordKey::new("ns", "col", "m");
726        assert_eq!(report.merged, vec![key.clone()], "3-way merged");
727        assert!(report.conflicts.is_empty());
728        // Disjoint field edits both applied against the real base.
729        let merged = a.get(&key).await.unwrap().unwrap();
730        let v: serde_json::Value = serde_json::from_slice(merged.body.bytes()).unwrap();
731        assert_eq!(v, serde_json::json!({"name": "b", "content": "y"}));
732    }
733
734    #[tokio::test]
735    async fn structured_divergence_conflicts_without_ancestry() {
736        use crate::ancestry::tests::Mem;
737        let a = Mem::default();
738        let b = Mem::default();
739        let (ours, theirs, _) = structured_divergence();
740        let _ = a.put(ours, None).await.unwrap();
741        let _ = b.put(theirs, None).await.unwrap();
742
743        // No ancestry → empty base → the Structured merge cannot tell a one-sided
744        // edit from a real conflict, so it surfaces a conflict.
745        let report = sync(&a, &b).await.unwrap();
746        assert_eq!(report.conflicts.len(), 1);
747        assert!(report.merged.is_empty());
748    }
749
750    #[tokio::test]
751    async fn bounded_retry_terminates_under_continuous_writes() {
752        // Both stores race *every* overwrite — a non-quiescent pair that never
753        // settles. The re-loop must be bounded: sync returns (does not hang)
754        // and reports the divergence as unresolved rather than spinning.
755        let a = RacyStore::always();
756        let b = RacyStore::always();
757        let _ = a
758            .put(rec("t", RecordKind::Topic, "base\nfrom_a\n"), None)
759            .await
760            .unwrap();
761        let _ = b
762            .put(rec("t", RecordKind::Topic, "base\nfrom_b\n"), None)
763            .await
764            .unwrap();
765
766        let report = sync(&a, &b).await.unwrap();
767
768        // No overwrite ever committed, so nothing converged.
769        let key = RecordKey::new("ns", "col", "t");
770        let ra = a.get(&key).await.unwrap().unwrap();
771        let rb = b.get(&key).await.unwrap().unwrap();
772        assert_ne!(ra.revision, rb.revision);
773        assert!(report.merged.is_empty());
774    }
775
776    fn k(id: &str) -> RecordKey {
777        RecordKey::new("ns", "col", id)
778    }
779
780    async fn commit(store: &dyn Store, r: Record, expected: Option<Revision>) -> Revision {
781        let PutResult::Committed(rev) = store.put(r, expected).await.unwrap() else {
782            panic!("put did not commit");
783        };
784        rev
785    }
786
787    /// An ordinary application update of a live record: next revision, parent =
788    /// current, expected = current. The store folds the replaced revision into
789    /// `ancestors`.
790    async fn edit(store: &dyn Store, id: &str, kind: RecordKind, payload: &str) -> Revision {
791        let cur = store
792            .get(&k(id))
793            .await
794            .unwrap()
795            .expect("edit needs a live record");
796        let mut r = rec(id, kind, payload);
797        r.revision = cur.revision.next(payload.as_bytes());
798        r.parent = Some(cur.revision.clone());
799        commit(store, r, Some(cur.revision)).await
800    }
801
802    async fn delete(store: &dyn Store, id: &str, expected: Revision) -> Record {
803        assert!(matches!(
804            store.delete(&k(id), Some(expected)).await.unwrap(),
805            DeleteResult::Deleted
806        ));
807        let t = store.get_raw(&k(id)).await.unwrap().unwrap();
808        assert!(t.is_tombstone());
809        t
810    }
811
812    // ---- §6.2: tombstones ----
813
814    #[tokio::test]
815    async fn stale_peer_takes_the_tombstone() {
816        let a = MemStore::new();
817        let b = MemStore::new();
818        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
819        let _ = sync(&a, &b).await.unwrap();
820        let tomb = delete(&a, "d", r0).await;
821
822        let report = sync(&a, &b).await.unwrap();
823
824        assert_eq!(report.fast_forwarded_to_b, vec![k("d")]);
825        assert!(report.conflicts.is_empty() && report.merged.is_empty());
826        assert!(report.copied_to_a.is_empty() && report.copied_to_b.is_empty());
827        assert!(
828            b.get(&k("d")).await.unwrap().is_none(),
829            "hidden from consumers"
830        );
831        let tb = b.get_raw(&k("d")).await.unwrap().unwrap();
832        assert!(tb.is_tombstone());
833        assert_eq!(tb.revision, tomb.revision);
834        // Converged: a further sync does nothing.
835        assert_eq!(sync(&a, &b).await.unwrap(), SyncReport::default());
836    }
837
838    #[tokio::test]
839    async fn stale_peer_takes_the_tombstone_in_either_sync_direction() {
840        let a = MemStore::new();
841        let b = MemStore::new();
842        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
843        let _ = sync(&a, &b).await.unwrap();
844        let tomb = delete(&a, "d", r0).await;
845
846        // Same state, arguments swapped: the deleted store is now side B.
847        let report = sync(&b, &a).await.unwrap();
848
849        assert_eq!(report.fast_forwarded_to_a, vec![k("d")]);
850        assert!(report.conflicts.is_empty());
851        assert!(b.get(&k("d")).await.unwrap().is_none());
852        assert_eq!(
853            b.get_raw(&k("d")).await.unwrap().unwrap().revision,
854            tomb.revision
855        );
856        assert!(a.get(&k("d")).await.unwrap().is_none(), "never copied back");
857        assert_eq!(
858            a.get_raw(&k("d")).await.unwrap().unwrap().revision,
859            tomb.revision
860        );
861    }
862
863    #[tokio::test]
864    async fn recreation_after_delete_propagates_live() {
865        let a = MemStore::new();
866        let b = MemStore::new();
867        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
868        let _ = sync(&a, &b).await.unwrap();
869        let _ = delete(&a, "d", r0).await;
870        let _ = sync(&a, &b).await.unwrap();
871
872        // Recreate with a fresh counter-0 record; the store re-stamps it.
873        let r2 = commit(&a, rec("d", RecordKind::Topic, "again\n"), None).await;
874        assert_eq!(r2.counter, 2);
875
876        let report = sync(&a, &b).await.unwrap();
877
878        assert_eq!(report.fast_forwarded_to_b, vec![k("d")]);
879        assert!(report.conflicts.is_empty());
880        let live = b
881            .get(&k("d"))
882            .await
883            .unwrap()
884            .expect("recreated record is live on b");
885        assert_eq!(live.revision, r2);
886        assert_eq!(live.body.bytes(), b"again\n");
887    }
888
889    #[tokio::test]
890    async fn delete_vs_concurrent_edit_is_a_conflict_and_writes_nothing() {
891        // Topic is AppendOnly: if this reached the body merge it would "merge".
892        let a = MemStore::new();
893        let b = MemStore::new();
894        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
895        let _ = sync(&a, &b).await.unwrap();
896        let tomb = delete(&a, "d", r0).await;
897        let edited = edit(&b, "d", RecordKind::Topic, "v0\nedit\n").await;
898
899        let report = sync(&a, &b).await.unwrap();
900
901        assert_eq!(report.conflicts.len(), 1);
902        assert_eq!(report.conflicts[0].key, k("d"));
903        assert!(report.conflicts[0].a.is_tombstone());
904        assert!(!report.conflicts[0].b.is_tombstone());
905        assert!(report.merged.is_empty());
906        assert!(report.fast_forwarded_to_a.is_empty() && report.fast_forwarded_to_b.is_empty());
907        assert_eq!(
908            a.get_raw(&k("d")).await.unwrap().unwrap().revision,
909            tomb.revision
910        );
911        assert_eq!(b.get(&k("d")).await.unwrap().unwrap().revision, edited);
912    }
913
914    #[tokio::test]
915    async fn edit_vs_concurrent_delete_on_b_is_a_conflict_and_writes_nothing() {
916        // Mirror of `delete_vs_concurrent_edit_is_a_conflict_and_writes_nothing`
917        // with the sides swapped: the edit is on A, the delete is on B. Topic is
918        // AppendOnly: if this reached the body merge it would "merge".
919        let a = MemStore::new();
920        let b = MemStore::new();
921        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
922        let _ = sync(&a, &b).await.unwrap();
923        let edited = edit(&a, "d", RecordKind::Topic, "v0\nedit\n").await;
924        let tomb = delete(&b, "d", r0).await;
925
926        let report = sync(&a, &b).await.unwrap();
927
928        assert_eq!(report.conflicts.len(), 1);
929        assert_eq!(report.conflicts[0].key, k("d"));
930        assert!(!report.conflicts[0].a.is_tombstone());
931        assert!(report.conflicts[0].b.is_tombstone());
932        assert!(report.merged.is_empty());
933        assert!(report.fast_forwarded_to_a.is_empty() && report.fast_forwarded_to_b.is_empty());
934        // Neither store was written.
935        assert_eq!(a.get(&k("d")).await.unwrap().unwrap().revision, edited);
936        assert_eq!(
937            b.get_raw(&k("d")).await.unwrap().unwrap().revision,
938            tomb.revision
939        );
940    }
941
942    #[tokio::test]
943    async fn concurrent_tombstones_converge_on_the_higher_one() {
944        let a = MemStore::new();
945        let b = MemStore::new();
946        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
947        let _ = sync(&a, &b).await.unwrap();
948        // A edits then deletes (tombstone counter 2); B deletes r0 (counter 1).
949        let r1 = edit(&a, "d", RecordKind::Topic, "v0\nv1\n").await;
950        let ta = delete(&a, "d", r1.clone()).await;
951        let tb = delete(&b, "d", r0.clone()).await;
952        assert_eq!(ta.revision.counter, 2);
953        assert_eq!(tb.revision.counter, 1);
954
955        let report = sync(&a, &b).await.unwrap();
956
957        assert_eq!(report.merged, vec![k("d")]);
958        assert!(report.conflicts.is_empty());
959        for store in [&a, &b] {
960            let got = store.get_raw(&k("d")).await.unwrap().unwrap();
961            assert!(got.is_tombstone());
962            assert_eq!(got.revision, ta.revision);
963            assert!(got.ancestors.contains(&tb.revision));
964            assert!(got.ancestors.contains(&r1));
965            assert!(got.ancestors.contains(&r0));
966            assert!(!got.ancestors.contains(&ta.revision));
967        }
968        assert_eq!(sync(&a, &b).await.unwrap(), SyncReport::default());
969    }
970
971    #[tokio::test]
972    async fn one_sided_tombstone_is_copied() {
973        let a = MemStore::new();
974        let b = MemStore::new();
975        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
976        let tomb = delete(&a, "d", r0).await;
977
978        let report = sync(&a, &b).await.unwrap();
979
980        assert_eq!(report.copied_to_b, vec![k("d")]);
981        assert!(b.get(&k("d")).await.unwrap().is_none());
982        assert_eq!(
983            b.get_raw(&k("d")).await.unwrap().unwrap().revision,
984            tomb.revision
985        );
986        assert_eq!(sync(&a, &b).await.unwrap(), SyncReport::default());
987    }
988
989    // ---- §6.2: ancestry-driven ordering for ordinary records ----
990
991    #[tokio::test]
992    async fn opaque_fast_forward_no_longer_conflicts() {
993        let a = MemStore::new();
994        let b = MemStore::new();
995        let _ = commit(&a, rec("c", RecordKind::Checkpoint, "c0"), None).await;
996        let _ = sync(&a, &b).await.unwrap();
997
998        // A moves ahead; B is simply behind.
999        let c1 = edit(&a, "c", RecordKind::Checkpoint, "c1").await;
1000        let report = sync(&a, &b).await.unwrap();
1001        assert!(report.conflicts.is_empty(), "behind is not diverged");
1002        assert_eq!(report.fast_forwarded_to_b, vec![k("c")]);
1003        assert_eq!(b.get(&k("c")).await.unwrap().unwrap().revision, c1);
1004
1005        // And the other way round.
1006        let c2 = edit(&b, "c", RecordKind::Checkpoint, "c2").await;
1007        let report = sync(&a, &b).await.unwrap();
1008        assert!(report.conflicts.is_empty());
1009        assert_eq!(report.fast_forwarded_to_a, vec![k("c")]);
1010        assert_eq!(a.get(&k("c")).await.unwrap().unwrap().revision, c2);
1011    }
1012
1013    #[tokio::test]
1014    async fn checkpoint_true_divergence_from_a_shared_parent_still_conflicts() {
1015        let a = MemStore::new();
1016        let b = MemStore::new();
1017        let _ = commit(&a, rec("c", RecordKind::Checkpoint, "c0"), None).await;
1018        let _ = sync(&a, &b).await.unwrap();
1019        let ca = edit(&a, "c", RecordKind::Checkpoint, "from_a").await;
1020        let cb = edit(&b, "c", RecordKind::Checkpoint, "from_b").await;
1021
1022        let report = sync(&a, &b).await.unwrap();
1023
1024        assert_eq!(report.conflicts.len(), 1);
1025        assert!(report.fast_forwarded_to_a.is_empty() && report.fast_forwarded_to_b.is_empty());
1026        assert_eq!(a.get(&k("c")).await.unwrap().unwrap().revision, ca);
1027        assert_eq!(b.get(&k("c")).await.unwrap().unwrap().revision, cb);
1028    }
1029
1030    #[tokio::test]
1031    async fn legacy_record_without_ancestors_takes_the_merge_path() {
1032        let a = MemStore::new();
1033        let b = MemStore::new();
1034        let r0 = commit(&a, rec("l", RecordKind::Topic, "base\n"), None).await;
1035        // B holds a descendant of r0 written by a pre-0.7 binary: parent set,
1036        // ancestors empty. Put into an empty store, so nothing is folded in.
1037        let mut legacy = rec("l", RecordKind::Topic, "base\nmore\n");
1038        legacy.revision = r0.next(b"base\nmore\n");
1039        legacy.parent = Some(r0);
1040        let _ = commit(&b, legacy, None).await;
1041        assert!(
1042            b.get_raw(&k("l"))
1043                .await
1044                .unwrap()
1045                .unwrap()
1046                .ancestors
1047                .is_empty()
1048        );
1049
1050        let report = sync(&a, &b).await.unwrap();
1051
1052        assert_eq!(
1053            report.merged,
1054            vec![k("l")],
1055            "no chain: merge, not fast-forward"
1056        );
1057        assert!(report.fast_forwarded_to_a.is_empty() && report.fast_forwarded_to_b.is_empty());
1058        assert_eq!(
1059            a.get(&k("l")).await.unwrap().unwrap().body.bytes(),
1060            b"base\nmore\n"
1061        );
1062    }
1063
1064    #[tokio::test]
1065    async fn merge_folds_both_sides_ancestors() {
1066        let a = MemStore::new();
1067        let b = MemStore::new();
1068        let r0 = commit(&a, rec("t", RecordKind::Topic, "base\n"), None).await;
1069        let _ = sync(&a, &b).await.unwrap();
1070        let ra = edit(&a, "t", RecordKind::Topic, "base\nfrom_a\n").await;
1071        let rb = edit(&b, "t", RecordKind::Topic, "base\nfrom_b\n").await;
1072
1073        let report = sync(&a, &b).await.unwrap();
1074
1075        assert_eq!(report.merged, vec![k("t")]);
1076        let ma = a.get_raw(&k("t")).await.unwrap().unwrap();
1077        let mb = b.get_raw(&k("t")).await.unwrap().unwrap();
1078        assert_eq!(ma.revision, mb.revision);
1079        assert_eq!(ma.ancestors, mb.ancestors, "byte-identical on both sides");
1080        assert!(ma.ancestors.contains(&ra));
1081        assert!(ma.ancestors.contains(&rb));
1082        assert!(ma.ancestors.contains(&r0));
1083        assert!(!ma.ancestors.contains(&ma.revision));
1084    }
1085
1086    #[tokio::test]
1087    async fn truncated_chain_never_overwrites() {
1088        let a = MemStore::new().with_ancestor_cap(2);
1089        let b = MemStore::new();
1090        let c0 = commit(&a, rec("c", RecordKind::Checkpoint, "c0"), None).await;
1091        let _ = sync(&a, &b).await.unwrap();
1092        // Three edits on a cap-2 store push c0 out of A's chain.
1093        let _ = edit(&a, "c", RecordKind::Checkpoint, "c1").await;
1094        let _ = edit(&a, "c", RecordKind::Checkpoint, "c2").await;
1095        let _ = edit(&a, "c", RecordKind::Checkpoint, "c3").await;
1096        let ra = a.get_raw(&k("c")).await.unwrap().unwrap();
1097        assert_eq!(ra.ancestors.len(), 2);
1098        assert!(!ra.ancestors.contains(&c0));
1099
1100        let report = sync(&a, &b).await.unwrap();
1101
1102        // Fails safe: an unknown chain looks diverged (Opaque → conflict).
1103        assert_eq!(report.conflicts.len(), 1);
1104        assert!(report.fast_forwarded_to_b.is_empty());
1105        assert_eq!(b.get(&k("c")).await.unwrap().unwrap().revision, c0);
1106    }
1107
1108    #[tokio::test]
1109    async fn mixed_ancestor_caps_converge() {
1110        let a = MemStore::new().with_ancestor_cap(4);
1111        let b = MemStore::new().with_ancestor_cap(32);
1112        let _ = commit(&a, rec("t", RecordKind::Topic, "0\n"), None).await;
1113        let _ = sync(&a, &b).await.unwrap();
1114
1115        // B edits 10 times (chain of 10 fits in 32): B is ahead of A.
1116        for i in 1..=10 {
1117            let _ = edit(&b, "t", RecordKind::Topic, &format!("{i}\n")).await;
1118        }
1119        let report = sync(&a, &b).await.unwrap();
1120        assert!(report.conflicts.is_empty());
1121        assert_eq!(report.fast_forwarded_to_a, vec![k("t")]);
1122        assert_eq!(
1123            a.get_raw(&k("t")).await.unwrap().unwrap().ancestors.len(),
1124            4
1125        );
1126
1127        // A edits twice: its 4-entry chain still holds B's revision.
1128        let _ = edit(&a, "t", RecordKind::Topic, "11\n").await;
1129        let r12 = edit(&a, "t", RecordKind::Topic, "12\n").await;
1130        let report = sync(&a, &b).await.unwrap();
1131        assert!(report.conflicts.is_empty());
1132        assert_eq!(report.fast_forwarded_to_b, vec![k("t")]);
1133
1134        let ra = a.get_raw(&k("t")).await.unwrap().unwrap();
1135        let rb = b.get_raw(&k("t")).await.unwrap().unwrap();
1136        assert_eq!(ra.revision, r12);
1137        assert_eq!(rb.revision, r12);
1138        assert_eq!(ra.ancestors.len(), 4, "A truncates to its own cap");
1139        assert_eq!(
1140            rb.ancestors.len(),
1141            12,
1142            "B keeps r0..r11 under its larger cap"
1143        );
1144        assert_eq!(sync(&a, &b).await.unwrap(), SyncReport::default());
1145    }
1146
1147    // ---- §6.2: racing doubles, tombstone variants ----
1148
1149    #[tokio::test]
1150    async fn tombstone_reaches_a_peer_that_races_the_first_overwrite() {
1151        let a = MemStore::new();
1152        let b = RacyStore::flaky_once();
1153        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
1154        let _ = sync(&a, &b).await.unwrap(); // copy: unconditional, never trips
1155        let tomb = delete(&a, "d", r0).await;
1156
1157        let report = sync(&a, &b).await.unwrap();
1158
1159        assert!(report.conflicts.is_empty());
1160        assert_eq!(report.fast_forwarded_to_b, vec![k("d")]);
1161        let tb = b.get_raw(&k("d")).await.unwrap().unwrap();
1162        assert!(tb.is_tombstone());
1163        assert_eq!(tb.revision, tomb.revision);
1164    }
1165
1166    #[tokio::test]
1167    async fn tombstone_sync_against_an_always_racing_peer_terminates() {
1168        let a = MemStore::new();
1169        let b = RacyStore::always();
1170        let r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
1171        let _ = commit(&b, rec("d", RecordKind::Topic, "v0\n"), None).await;
1172        let tomb = delete(&a, "d", r0.clone()).await;
1173
1174        // Every overwrite of B races; sync must still return.
1175        let report = sync(&a, &b).await.unwrap();
1176
1177        assert!(report.fast_forwarded_to_b.is_empty());
1178        assert!(report.conflicts.is_empty());
1179        assert_eq!(b.get(&k("d")).await.unwrap().unwrap().revision, r0);
1180        assert_eq!(
1181            a.get_raw(&k("d")).await.unwrap().unwrap().revision,
1182            tomb.revision
1183        );
1184
1185        // The stores still disagree — B holds the live record, A the tombstone —
1186        // and `conflicts` is empty, so the report must say so itself (#290).
1187        assert!(
1188            !report.converged(),
1189            "an exhausted run must not look converged"
1190        );
1191        assert_eq!(report.unconverged, vec![k("d")]);
1192    }
1193
1194    #[tokio::test]
1195    async fn a_settled_pair_reports_convergence() {
1196        // The ordinary case: one clean pass, so nothing is left racing.
1197        let a = MemStore::new();
1198        let b = MemStore::new();
1199        commit(&a, rec("x", RecordKind::Topic, "v0\n"), None).await;
1200
1201        let report = sync(&a, &b).await.unwrap();
1202
1203        assert_eq!(report.copied_to_b, vec![k("x")]);
1204        assert!(report.converged());
1205        assert!(report.unconverged.is_empty());
1206    }
1207
1208    #[tokio::test]
1209    async fn a_pass_that_settles_after_a_race_reports_convergence() {
1210        // B races the first overwrite, then behaves. The later clean pass
1211        // replaces the racing pass's whole report, so no stale unconverged
1212        // keys survive into the result.
1213        let a = MemStore::new();
1214        let b = RacyStore::flaky_once();
1215        let _ = a
1216            .put(rec("t", RecordKind::Topic, "base\nfrom_a\n"), None)
1217            .await
1218            .unwrap();
1219        let _ = b
1220            .put(rec("t", RecordKind::Topic, "base\nfrom_b\n"), None)
1221            .await
1222            .unwrap();
1223
1224        let report = sync(&a, &b).await.unwrap();
1225
1226        assert!(report.conflicts.is_empty());
1227        assert!(report.converged(), "the retry pass landed cleanly");
1228        assert!(report.unconverged.is_empty());
1229    }
1230
1231    // ---- write helpers treat store movement as a race ----
1232
1233    #[tokio::test]
1234    async fn overwrite_treats_not_found_and_conflict_as_a_race() {
1235        let b = MemStore::new();
1236        // Absent (e.g. purged) key + Some(expected) → plan_put_raw NotFound.
1237        let stray = rec("x", RecordKind::Topic, "x\n");
1238        assert!(
1239            !overwrite(&b, &stray, &Revision::initial(b"gone"))
1240                .await
1241                .unwrap()
1242        );
1243        assert!(b.get_raw(&k("x")).await.unwrap().is_none());
1244
1245        // Tombstoned key + Some(other) → plan_put_raw Conflict { current: t }.
1246        let r0 = commit(&b, rec("d", RecordKind::Topic, "v0\n"), None).await;
1247        let tomb = delete(&b, "d", r0).await;
1248        let incoming = rec("d", RecordKind::Topic, "v1\n");
1249        assert!(
1250            !overwrite(&b, &incoming, &Revision::initial(b"stale"))
1251                .await
1252                .unwrap()
1253        );
1254        assert_eq!(
1255            b.get_raw(&k("d")).await.unwrap().unwrap().revision,
1256            tomb.revision
1257        );
1258    }
1259
1260    #[tokio::test]
1261    async fn copy_over_a_tombstone_conflicts_and_writes_nothing() {
1262        let b = MemStore::new();
1263        let r0 = commit(&b, rec("d", RecordKind::Topic, "v0\n"), None).await;
1264        let tomb = delete(&b, "d", r0).await;
1265        // As if the tombstone arrived after sync's raw read saw the key absent.
1266        assert!(
1267            !copy(&b, &rec("d", RecordKind::Topic, "v0\n"))
1268                .await
1269                .unwrap()
1270        );
1271        let still = b.get_raw(&k("d")).await.unwrap().unwrap();
1272        assert!(still.is_tombstone(), "put_raw never recreates");
1273        assert_eq!(still.revision, tomb.revision);
1274    }
1275
1276    #[tokio::test]
1277    async fn tombstone_arriving_mid_copy_is_not_resurrected() {
1278        // A third peer C deleted r0. A still holds live r0. B is empty when
1279        // sync reads it, but C's tombstone reaches B between that raw read
1280        // and sync's copy of A's r0.
1281        let c = MemStore::new();
1282        let r0 = commit(&c, rec("d", RecordKind::Topic, "v0\n"), None).await;
1283        let tomb = delete(&c, "d", r0.clone()).await;
1284
1285        let a = MemStore::new();
1286        let a_r0 = commit(&a, rec("d", RecordKind::Topic, "v0\n"), None).await;
1287        assert_eq!(a_r0, r0);
1288        let b = RacyStore::tombstone_arrives(tomb.clone());
1289
1290        let report = sync(&a, &b).await.unwrap();
1291
1292        // Pass 1: the copy conflicts on the arrived tombstone and re-loops.
1293        // Pass 2: A's r0 is in the tombstone's chain, so A fast-forwards.
1294        assert!(report.conflicts.is_empty());
1295        assert!(report.copied_to_b.is_empty());
1296        assert_eq!(report.fast_forwarded_to_a, vec![k("d")]);
1297        for store in [&a as &dyn Store, &b as &dyn Store] {
1298            assert!(
1299                store.get(&k("d")).await.unwrap().is_none(),
1300                "not resurrected"
1301            );
1302            let got = store.get_raw(&k("d")).await.unwrap().unwrap();
1303            assert!(got.is_tombstone());
1304            assert_eq!(got.revision, tomb.revision);
1305        }
1306        assert_eq!(sync(&a, &b).await.unwrap(), SyncReport::default());
1307    }
1308}