From 54f45890117e3227b204a8ebf70af3af55142503 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 00:07:49 +0800 Subject: [PATCH 1/2] test: pin down shallow snapshot merge semantics with concurrent peers --- context/internal-encoding.md | 5 + .../tests/shallow_snapshot_concurrency.rs | 320 ++++++++++++++++++ docs/shallow-snapshot-concurrency.md | 88 +++++ 3 files changed, 413 insertions(+) create mode 100644 crates/loro/tests/shallow_snapshot_concurrency.rs create mode 100644 docs/shallow-snapshot-concurrency.md diff --git a/context/internal-encoding.md b/context/internal-encoding.md index 01270e5a2..c884f4cb9 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -216,6 +216,11 @@ Pre-shallow frontier safety lives in `loro.rs`: `checkout`, `diff`, and `revert_to` must return `SwitchToVersionBeforeShallowRoot` instead of traversing history before the shallow root. +Merge semantics of a shallow replica meeting concurrent full-history peers +(which updates apply, pend, or are rejected, and why a never-synced peer can +never merge): [docs/shallow-snapshot-concurrency.md](../docs/shallow-snapshot-concurrency.md) +with tests in `crates/loro/tests/shallow_snapshot_concurrency.rs`. + ## JSON Updates `json_schema.rs` is not wrapped in the binary `loro` envelope. Its diff --git a/crates/loro/tests/shallow_snapshot_concurrency.rs b/crates/loro/tests/shallow_snapshot_concurrency.rs new file mode 100644 index 000000000..7ef4310cb --- /dev/null +++ b/crates/loro/tests/shallow_snapshot_concurrency.rs @@ -0,0 +1,320 @@ +//! Merge semantics of a shallow-snapshot-bootstrapped doc when it meets +//! concurrent peers that still hold full history. +//! +//! These tests pin down the behavior a sync layer relies on when it uploads +//! `shallow-snapshot` blobs instead of full snapshots. The design note +//! `docs/shallow-snapshot-concurrency.md` summarizes the guarantees and +//! references these tests by name. +//! +//! Scenario vocabulary used throughout: +//! - `A`: full-history doc. `V` and `F` are versions of A with `V < F`; F is +//! the shallow root. +//! - `B`: doc bootstrapped by importing A's shallow snapshot at F. +//! - `C`: a peer holding full history up to some version, making updates on +//! top of it. + +use loro::{ExportMode, Frontiers, IdSpan, LoroDoc, LoroError, TreeParentId, VersionVector}; + +/// Build the shared fixture: doc A (peer 1) with map/list/text/movable-list/ +/// tree content, edited in three phases. Returns the doc plus the version +/// vectors and frontiers at V (after phase 1), F (after phase 2, the shallow +/// root), and the tip (after phase 3). +fn build_doc_a() -> (LoroDoc, VersionVector, Frontiers, VersionVector, Frontiers) { + let a = LoroDoc::new(); + a.set_peer_id(1).unwrap(); + + // Phase 1: content that ends up strictly before the shallow root. + a.get_map("map").insert("a", 1).unwrap(); + a.get_list("list").insert(0, "l0").unwrap(); + a.get_text("text").insert(0, "hello").unwrap(); + let movable = a.get_movable_list("movable"); + movable.insert(0, "m0").unwrap(); + movable.insert(1, "m1").unwrap(); + let tree = a.get_tree("tree"); + tree.enable_fractional_index(0); + let root = tree.create(TreeParentId::Root).unwrap(); + a.commit(); + let v_vv = a.oplog_vv(); + let v_frontiers = a.oplog_frontiers(); + + // Phase 2: content between V and F; F becomes the shallow root. + a.get_map("map").insert("b", 2).unwrap(); + a.get_list("list").insert(1, "l1").unwrap(); + a.get_text("text").insert(5, " world").unwrap(); + movable.mov(0, 1).unwrap(); + let child = tree.create(root).unwrap(); + a.commit(); + let f_vv = a.oplog_vv(); + let f_frontiers = a.oplog_frontiers(); + + // Phase 3: retained history after the shallow root. + a.get_map("map").insert("c", 3).unwrap(); + a.get_list("list").insert(2, "l2").unwrap(); + a.get_text("text").insert(11, "!").unwrap(); + movable.insert(2, "m2").unwrap(); + tree.create(child).unwrap(); + a.commit(); + + (a, v_vv, v_frontiers, f_vv, f_frontiers) +} + +/// Bootstrap B from A's shallow snapshot at F and assert the root metadata. +fn bootstrap_b(a: &LoroDoc, f_vv: &VersionVector, f_frontiers: &Frontiers) -> LoroDoc { + let blob = a.export(ExportMode::shallow_snapshot(f_frontiers)).unwrap(); + let b = LoroDoc::new(); + b.import(&blob).unwrap(); + assert!(b.is_shallow()); + assert_eq!(b.shallow_since_frontiers(), *f_frontiers); + // The ops included by shallow_since_vv are NOT in the doc; the shallow + // root frontier op itself is retained, so the vv ends at the frontier + // counter (exclusive of it) rather than at the vv of F. + let mut expected = f_vv.clone(); + for id in f_frontiers.iter() { + expected.insert(id.peer, id.counter); + } + for (peer, counter) in expected.iter() { + assert_eq!( + b.shallow_since_vv().get(peer).copied(), + Some(*counter), + "shallow_since_vv must exclude exactly the ops before the shallow root" + ); + } + // The shallow replica shows the same latest state as the full doc. + assert_eq!(b.get_deep_value(), a.get_deep_value()); + b +} + +/// Case 1b/1c companions: history already included in the shallow root is a +/// no-op, and the shallow root metadata does not move. +#[test] +fn shallow_bootstrap_and_rereading_old_history_is_noop() -> anyhow::Result<()> { + let (a, _v_vv, v_frontiers, f_vv, f_frontiers) = build_doc_a(); + let b = bootstrap_b(&a, &f_vv, &f_frontiers); + let b_value = b.get_deep_value(); + + // Case 1c: re-importing updates whose causal past is entirely before F + // (already included in the shallow root state) is a no-op. + let old_history = a.export(ExportMode::snapshot_at(&v_frontiers))?; + let status = b.import(&old_history)?; + assert!(status.pending.is_none()); + assert_eq!(b.get_deep_value(), b_value); + assert!(b.is_shallow()); + assert_eq!(b.shallow_since_frontiers(), f_frontiers); + + // Re-importing A's full snapshot is also a no-op for B. + let status = b.import(&a.export(ExportMode::Snapshot)?)?; + assert!(status.pending.is_none()); + assert_eq!(b.get_deep_value(), b_value); + assert_eq!(b.shallow_since_frontiers(), f_frontiers); + + Ok(()) +} + +/// Case 1a: updates whose causal past is before the shallow root are rejected +/// with `ImportUpdatesThatDependsOnOutdatedVersion`; they are neither applied +/// nor parked as pending. +#[test] +fn update_based_on_version_before_shallow_root_is_rejected() -> anyhow::Result<()> { + let (a, v_vv, v_frontiers, f_vv, f_frontiers) = build_doc_a(); + let b = bootstrap_b(&a, &f_vv, &f_frontiers); + let b_value = b.get_deep_value(); + + // C holds full history up to V < F and edits on top of it. + let c = LoroDoc::new(); + c.import(&a.export(ExportMode::snapshot_at(&v_frontiers))?)?; + c.set_peer_id(3)?; + c.get_map("map").insert("from_c", true)?; + c.commit(); + let c_updates = c.export(ExportMode::updates(&v_vv))?; + + let err = b.import(&c_updates).unwrap_err(); + assert!( + matches!(err, LoroError::ImportUpdatesThatDependsOnOutdatedVersion), + "expected ImportUpdatesThatDependsOnOutdatedVersion, got {err:?}" + ); + // Nothing applied, nothing pending, root metadata untouched. + assert_eq!(b.get_deep_value(), b_value); + assert!(b.is_shallow()); + assert_eq!(b.shallow_since_frontiers(), f_frontiers); + assert!(b.get_map("map").get("from_c").is_none()); + + // A peer that never synced with A at all is rejected the same way: its + // genesis change has empty deps, which the shallow doc treats as rooted + // before the shallow root. + let d = LoroDoc::new(); + d.set_peer_id(9)?; + d.get_text("text").insert(0, "stranger")?; + d.commit(); + let err = b.import(&d.export(ExportMode::all_updates())?).unwrap_err(); + assert!(matches!( + err, + LoroError::ImportUpdatesThatDependsOnOutdatedVersion + )); + + Ok(()) +} + +/// Case 4: updates with missing dependencies that are NOT before the shallow +/// root are parked as pending and applied once the dependency arrives; the +/// import status reports both transitions. +#[test] +fn pending_updates_after_root_apply_when_dependency_arrives() -> anyhow::Result<()> { + let (a, _v_vv, _v_frontiers, f_vv, f_frontiers) = build_doc_a(); + let b = bootstrap_b(&a, &f_vv, &f_frontiers); + let b_value = b.get_deep_value(); + + // C syncs with A exactly at F, then commits two changes in sequence. + let c = LoroDoc::new(); + c.import(&a.export(ExportMode::snapshot_at(&f_frontiers))?)?; + c.set_peer_id(11)?; + c.get_map("map").insert("step1", 1)?; + c.commit(); + let end1 = *c.oplog_vv().get(&11).unwrap(); + c.get_map("map").insert("step2", 2)?; + c.commit(); + let end2 = *c.oplog_vv().get(&11).unwrap(); + let first = c.export(ExportMode::updates_in_range(vec![IdSpan::new(11, 0, end1)]))?; + let second = c.export(ExportMode::updates_in_range(vec![IdSpan::new( + 11, end1, end2, + )]))?; + + // Importing the second change first: pending, not applied, no error. + let status = b.import(&second)?; + let pending = status.pending.expect("second change should be pending"); + assert_eq!(pending.get(&11), Some(&(end1, end2))); + assert_eq!(b.get_deep_value(), b_value); + + // Once the missing dependency arrives, both changes apply. + let status = b.import(&first)?; + assert!(status.pending.is_none()); + assert_eq!(status.success.get(&11), Some(&(0, end2))); + assert_eq!( + b.get_map("map").get("step1").map(|v| v.get_deep_value()), + Some(1.into()) + ); + assert_eq!( + b.get_map("map").get("step2").map(|v| v.get_deep_value()), + Some(2.into()) + ); + + Ok(()) +} + +/// A concurrent peer's updates that depend on its own pre-root history become +/// pending, but can never be applied: the chain's genesis change is rejected +/// as rooted before the shallow root. This is the one lossy case; see +/// docs/shallow-snapshot-concurrency.md. +#[test] +fn concurrent_chain_rooted_before_shallow_root_can_never_merge() -> anyhow::Result<()> { + let (a, _v_vv, _v_frontiers, f_vv, f_frontiers) = build_doc_a(); + let b = bootstrap_b(&a, &f_vv, &f_frontiers); + let b_value = b.get_deep_value(); + + // D never synced with A: d1 is a genesis change, d2 depends on d1. Both + // are concurrent with F. + let d = LoroDoc::new(); + d.set_peer_id(13)?; + d.get_map("map").insert("d1", 1)?; + d.commit(); + let end1 = *d.oplog_vv().get(&13).unwrap(); + d.get_map("map").insert("d2", 2)?; + d.commit(); + let end2 = *d.oplog_vv().get(&13).unwrap(); + let d1 = d.export(ExportMode::updates_in_range(vec![IdSpan::new(13, 0, end1)]))?; + let d2 = d.export(ExportMode::updates_in_range(vec![IdSpan::new( + 13, end1, end2, + )]))?; + + // d2's deps are unknown to B but not before the shallow root, so d2 is + // parked as pending rather than rejected. + let status = b.import(&d2)?; + assert!(status.pending.is_some()); + assert_eq!(b.get_deep_value(), b_value); + + // d1 is a genesis change; B rejects it as rooted before the shallow root, + // so the parked d2 can never be unlocked. + let err = b.import(&d1).unwrap_err(); + assert!(matches!( + err, + LoroError::ImportUpdatesThatDependsOnOutdatedVersion + )); + assert_eq!(b.get_deep_value(), b_value); + assert!(b.get_map("map").get("d1").is_none()); + assert!(b.get_map("map").get("d2").is_none()); + + Ok(()) +} + +/// Case 1b: updates causally after the shallow root apply normally. +#[test] +fn updates_causally_after_shallow_root_apply() -> anyhow::Result<()> { + let (a, _v_vv, _v_frontiers, f_vv, f_frontiers) = build_doc_a(); + let b = bootstrap_b(&a, &f_vv, &f_frontiers); + + // C holds full history up to F and edits on top of it. + let c = LoroDoc::new(); + c.import(&a.export(ExportMode::snapshot_at(&f_frontiers))?)?; + c.set_peer_id(5)?; + c.get_map("map").insert("after_f", "yes")?; + c.get_text("text").insert(0, "C:")?; + c.commit(); + let c_updates = c.export(ExportMode::updates(&f_vv))?; + + let status = b.import(&c_updates)?; + assert!(status.pending.is_none()); + // The shallow doc converges with a full-history doc that saw the same + // updates. + let full = LoroDoc::new(); + full.import(&a.export(ExportMode::Snapshot)?)?; + full.import(&c_updates)?; + assert_eq!(b.get_deep_value(), full.get_deep_value()); + // Importing post-root updates does not move the shallow root. + assert!(b.is_shallow()); + assert_eq!(b.shallow_since_frontiers(), f_frontiers); + + Ok(()) +} + +/// Case 2: the reverse direction. Updates exported from the shallow doc apply +/// to a full-history doc, and importing the shallow doc's shallow snapshot +/// into a full-history doc transfers the retained history without making the +/// target shallow. +#[test] +fn shallow_doc_merges_back_into_full_history_doc() -> anyhow::Result<()> { + let (a, _v_vv, _v_frontiers, f_vv, f_frontiers) = build_doc_a(); + let b = bootstrap_b(&a, &f_vv, &f_frontiers); + + // B edits on top of its shallow state. + b.set_peer_id(7)?; + b.get_map("map").insert("from_b", "b")?; + b.get_list("list").insert(3, "l3")?; + b.commit(); + + // 2a: A' has full history and never saw the shallow snapshot. B's updates + // (causally after F) apply normally. + let a_prime = LoroDoc::new(); + a_prime.import(&a.export(ExportMode::Snapshot)?)?; + let b_updates = b.export(ExportMode::updates(&a_prime.oplog_vv()))?; + let status = a_prime.import(&b_updates)?; + assert!(status.pending.is_none()); + assert_eq!(a_prime.get_deep_value(), b.get_deep_value()); + assert!(!a_prime.is_shallow()); + + // 2b: A'' has full history (more than the shallow root) and imports B's + // shallow snapshot (re-exported at the same root F) directly. The snapshot + // is routed through its retained changes, so A'' receives B's edits but + // keeps its full history. + let a_second = LoroDoc::new(); + a_second.import(&a.export(ExportMode::Snapshot)?)?; + let b_shallow = b.export(ExportMode::shallow_snapshot(&f_frontiers))?; + let status = a_second.import(&b_shallow)?; + assert!(status.pending.is_none()); + assert_eq!(a_second.get_deep_value(), b.get_deep_value()); + assert!(!a_second.is_shallow()); + assert!(a_second.shallow_since_vv().iter().next().is_none()); + // Full history is still available: a checkout to V works on A'' while it + // is rejected on B. + assert!(a_second.checkout(&Frontiers::default()).is_ok()); + + Ok(()) +} diff --git a/docs/shallow-snapshot-concurrency.md b/docs/shallow-snapshot-concurrency.md new file mode 100644 index 000000000..1b18aa908 --- /dev/null +++ b/docs/shallow-snapshot-concurrency.md @@ -0,0 +1,88 @@ +# Shallow snapshots and concurrent peers + +Verified against code 2026-09-04. Test references: +`crates/loro/tests/shallow_snapshot_concurrency.rs`. + +This note states what a doc bootstrapped from a `shallow-snapshot` export +guarantees when it exchanges updates with peers that still hold full history. +It is written for sync-layer authors deciding whether to upload +`shallow-snapshot` blobs in place of full snapshots. + +## Vocabulary + +- `A`: a full-history doc. `V < F` are versions of A; `F` is the shallow root. +- `B`: a doc bootstrapped by importing A's `shallow-snapshot` export at F. +- `C`: a peer with full history up to some version, editing on top of it. + +A shallow doc retains history only since its root F. `shallow_since_vv()` / +`shallow_since_frontiers()` describe that boundary: the ops *included* by +`shallow_since_vv()` are not in the doc, and the root frontier op itself is the +first retained op. + +## Guarantees + +1. **Bootstrap is faithful.** B's latest state equals A's latest state, and B + reports `is_shallow() == true` with the root at F. + (`shallow_bootstrap_and_rereading_old_history_is_noop`) + +2. **Updates causally after F apply normally.** If C synced with A at F (or + later) and edits on top, importing C's updates into B applies them, and B + converges with a full-history doc that saw the same updates. Importing + post-root updates never moves B's shallow root. + (`updates_causally_after_shallow_root_apply`) + +3. **Updates causally before F are a no-op.** Re-importing history that the + shallow root state already includes (an older `snapshot_at`, or A's full + snapshot) returns `Ok` with nothing pending and changes nothing. + (`shallow_bootstrap_and_rereading_old_history_is_noop`) + +4. **Updates based on a version before F are rejected, not parked.** If C + edits on top of V < F, importing those updates into B fails with + `LoroError::ImportUpdatesThatDependsOnOutdatedVersion`. The offending + changes are dropped: they are not applied and they do not become pending, + so they can never be delivered to B later. B's state and shallow root are + unchanged. The same rejection applies to a peer that never synced with A at + all, because its genesis change has no dependencies and is treated as + rooted before F. (`update_based_on_version_before_shallow_root_is_rejected`) + +5. **Post-root updates with missing dependencies are parked as pending.** If + an update's dependencies are not before F but are simply not delivered yet, + `import` returns `Ok` with `ImportStatus.pending` set. When the missing + dependency arrives, the parked changes apply in the same call, and the + returned `ImportStatus.success` covers both the new and the unlocked + changes. (`pending_updates_after_root_apply_when_dependency_arrives`) + +6. **Reverse direction is safe.** Updates B exports (causally after F) import + normally into a full-history doc A′ that never saw the shallow snapshot. + Importing B's shallow snapshot into A′ transfers the retained history (so + A′ receives B's edits) but does not make A′ shallow: A′ keeps its full + history and can still check out versions before F. + (`shallow_doc_merges_back_into_full_history_doc`) + +## The one lossy case + +A peer whose updates are **concurrent with F** and anchored in its own +pre-root history can never merge into B +(`concurrent_chain_rooted_before_shallow_root_can_never_merge`). Importing +such an update out of order looks deceptively fine — it is parked as pending — +but the chain's genesis change is rejected under guarantee 4, so the parked +change stays pending forever and its edits never become visible. + +This is inherent to shallow snapshots, not an implementation bug: applying +concurrent edits whose causal past lies before F would require changing the +state at F retroactively, which the shallow doc cannot represent. The +practical consequence for a sync layer: once you publish a shallow root at F, +every peer must sync up to some version ≥ F before its further edits can +reach shallow replicas. Peers that forked away before F and kept editing in +isolation need to rebase onto the post-root document (or the shallow replicas +will never see their work). + +## Non-goals + +- `checkout`, `diff`, and `revert_to` to versions before F fail with + `SwitchToVersionBeforeShallowRoot` on a shallow doc (covered by + `cargo test -p loro --test issue issue_928` and + `cargo test -p loro --test contracts shallow`). +- A shallow snapshot imported into a **non-empty** doc only contributes its + retained changes; its state sections are used only when initializing an + empty doc. From 23c6271872542575cb0097181174c3aaf5fa2819 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 5 Sep 2026 17:11:35 +0800 Subject: [PATCH 2/2] fix: reject shallow updates concurrent with the root Backport the dependency-boundary fix from PR #1091 so the guarantees and tests in PR #1090 hold independently. Verify that valid post-root imports still succeed after rejection. Model: gpt-6 --- .../shallow-import-root-dependencies.md | 8 ++++ crates/loro-internal/src/oplog/loro_dag.rs | 26 +++++++++++ .../src/tests/import_atomicity.rs | 45 +++++++++++++++++++ .../tests/shallow_snapshot_concurrency.rs | 35 +++++++++++++++ docs/shallow-snapshot-concurrency.md | 6 +++ 5 files changed, 120 insertions(+) create mode 100644 .changeset/shallow-import-root-dependencies.md diff --git a/.changeset/shallow-import-root-dependencies.md b/.changeset/shallow-import-root-dependencies.md new file mode 100644 index 000000000..d0d87466c --- /dev/null +++ b/.changeset/shallow-import-root-dependencies.md @@ -0,0 +1,8 @@ +--- +"loro-crdt": patch +--- + +Reject updates concurrent with the shallow root frontier with +ImportUpdatesThatDependsOnOutdatedVersion instead of queuing them and panicking +while resolving a dependency that has already been trimmed. Rejected updates do +not enter pending storage; subsequent valid post-root updates still apply. diff --git a/crates/loro-internal/src/oplog/loro_dag.rs b/crates/loro-internal/src/oplog/loro_dag.rs index bafc28d3e..d89be2307 100644 --- a/crates/loro-internal/src/oplog/loro_dag.rs +++ b/crates/loro-internal/src/oplog/loro_dag.rs @@ -802,6 +802,17 @@ impl AppDag { return true; } + // Deps equal to the root's own deps describe a change CONCURRENT with + // the root frontier op: its causal past is covered by the root state, + // so the boundary shortcut in `frontiers_to_vv` would resolve it to + // the shallow vv below. But the dep ids themselves are trimmed from + // the dag, so no lamport can be computed for such a change — it would + // be parked as pending and then panic in `calc_unknown_lamport_change`. + // Reject it like any other pre-root update. + if deps == &self.shallow_root_frontiers_deps { + return true; + } + let shallow_vv = VersionVector::from_im_vv(&self.shallow_since_vv); if let Some(vv) = self.frontiers_to_vv(deps) { return !vv.includes_vv(&shallow_vv); @@ -1453,6 +1464,21 @@ mod ensure_vv_for_tests { assert!(dag.import_deps_before_shallow_root(&deps)); } + /// Deps exactly equal to the root's own deps describe a change concurrent + /// with the root frontier op. The boundary shortcut in `frontiers_to_vv` + /// resolves them to the shallow vv, but the dep ids are trimmed from the + /// dag, so the change could never get a lamport — reject it. + #[test] + fn import_deps_before_shallow_root_rejects_deps_equal_to_root_deps() { + let dag = make_shallow_dag_for_import_deps(); + let deps = Frontiers::from_id(ID::new(1, 1)); + + // The boundary shortcut resolves these deps to the shallow vv... + assert!(dag.frontiers_to_vv(&deps).is_some()); + // ...but the import check must still reject them. + assert!(dag.import_deps_before_shallow_root(&deps)); + } + #[test] fn import_deps_before_shallow_root_allows_boundary_with_missing_peer() { let dag = make_shallow_dag_for_import_deps(); diff --git a/crates/loro-internal/src/tests/import_atomicity.rs b/crates/loro-internal/src/tests/import_atomicity.rs index 6068fa7a0..a3e70db45 100644 --- a/crates/loro-internal/src/tests/import_atomicity.rs +++ b/crates/loro-internal/src/tests/import_atomicity.rs @@ -666,3 +666,48 @@ fn snapshot_import_rejects_corrupt_inner_sstable_with_valid_envelope_checksum() .is_some_and(|value| value.is_empty())); } } + +/// A change whose deps are before the shallow root is rejected AND dropped: +/// it must not linger in the pending store, where it could never be unlocked. +/// Locks in the "dropped, not pending" guarantee documented by +/// `loro/tests/shallow_snapshot_concurrency.rs`. +#[test] +fn outdated_update_on_shallow_doc_is_dropped_not_pending() { + let a = LoroDoc::new_auto_commit(); + a.set_peer_id(1).unwrap(); + a.get_map("m").insert("a", 1).unwrap(); + a.commit_then_renew(); + let v_vv = a.oplog_vv(); + let v_frontiers = a.oplog_frontiers(); + a.get_map("m").insert("b", 2).unwrap(); + a.commit_then_renew(); + let f = a.oplog_frontiers(); + a.get_map("m").insert("c", 3).unwrap(); + a.commit_then_renew(); + + // B is bootstrapped from the shallow snapshot at F. + let b = LoroDoc::new(); + b.import(&a.export(ExportMode::shallow_snapshot(&f)).unwrap()) + .unwrap(); + assert_eq!(pending_len(&b), 0); + + // C holds full history up to V and edits on top of it, concurrent with F. + let c = LoroDoc::new(); + c.import(&a.export(ExportMode::snapshot_at(&v_frontiers)).unwrap()) + .unwrap(); + c.set_peer_id(2).unwrap(); + c.get_map("m").insert("from_c", true).unwrap(); + c.commit_then_renew(); + let updates = c.export(ExportMode::updates(&v_vv)).unwrap(); + + let err = b.import(&updates).unwrap_err(); + assert!(matches!( + err, + LoroError::ImportUpdatesThatDependsOnOutdatedVersion + )); + assert_eq!( + pending_len(&b), + 0, + "outdated changes must be dropped, not parked as pending" + ); +} diff --git a/crates/loro/tests/shallow_snapshot_concurrency.rs b/crates/loro/tests/shallow_snapshot_concurrency.rs index 7ef4310cb..0d1b1956b 100644 --- a/crates/loro/tests/shallow_snapshot_concurrency.rs +++ b/crates/loro/tests/shallow_snapshot_concurrency.rs @@ -318,3 +318,38 @@ fn shallow_doc_merges_back_into_full_history_doc() -> anyhow::Result<()> { Ok(()) } + +/// The change immediately concurrent with F must be rejected too: its deps +/// equal F's own deps, which have already been trimmed from the shallow DAG. +#[test] +fn update_concurrent_with_root_frontier_is_rejected() -> anyhow::Result<()> { + let a = LoroDoc::new(); + a.set_peer_id(1)?; + a.get_map("m").insert("before", 1)?; + a.commit(); + let v = a.oplog_frontiers(); + let vv = a.oplog_vv(); + a.get_map("m").insert("root", 2)?; + a.commit(); + let f = a.oplog_frontiers(); + let b = LoroDoc::from_snapshot(&a.export(ExportMode::shallow_snapshot(&f))?)?; + let before = b.get_deep_value(); + let c = LoroDoc::from_snapshot(&a.export(ExportMode::snapshot_at(&v))?)?; + c.set_peer_id(2)?; + c.get_map("m").insert("concurrent", true)?; + c.commit(); + let err = b.import(&c.export(ExportMode::updates(&vv))?).unwrap_err(); + assert!(matches!( + err, + LoroError::ImportUpdatesThatDependsOnOutdatedVersion + )); + assert_eq!(b.get_deep_value(), before); + assert_eq!(b.shallow_since_frontiers(), f); + // Rejection leaves the doc usable for valid post-root updates. + a.get_map("m").insert("after", 3)?; + a.commit(); + let status = b.import(&a.export(ExportMode::updates(&b.oplog_vv()))?)?; + assert!(status.pending.is_none()); + assert_eq!(b.get_deep_value(), a.get_deep_value()); + Ok(()) +} diff --git a/docs/shallow-snapshot-concurrency.md b/docs/shallow-snapshot-concurrency.md index 1b18aa908..1981b9e82 100644 --- a/docs/shallow-snapshot-concurrency.md +++ b/docs/shallow-snapshot-concurrency.md @@ -44,6 +44,12 @@ first retained op. unchanged. The same rejection applies to a peer that never synced with A at all, because its genesis change has no dependencies and is treated as rooted before F. (`update_based_on_version_before_shallow_root_is_rejected`) + The boundary case is included: a change whose deps are exactly the root's + own deps — i.e. concurrent with the root frontier op itself — is rejected + too, because its dep ids are trimmed from the DAG and no lamport can be + computed for it. (`outdated_update_on_shallow_doc_is_dropped_not_pending`, + which also asserts the rejected change never enters the pending store, and + `import_deps_before_shallow_root_rejects_deps_equal_to_root_deps`) 5. **Post-root updates with missing dependencies are parked as pending.** If an update's dependencies are not before F but are simply not delivered yet,