diff --git a/.changeset/shallow-export-forward-root-state.md b/.changeset/shallow-export-forward-root-state.md new file mode 100644 index 000000000..aa744e5f6 --- /dev/null +++ b/.changeset/shallow-export-forward-root-state.md @@ -0,0 +1,32 @@ +--- +"loro-crdt": patch +"loro-crdt-map": patch +--- + +Speed up `export({ mode: "shallow-snapshot" })` by up to ~20x on container-heavy documents with a large retained history. + +Building the state at the shallow root used to check the live document out +backwards (latest -> root). That reverse diff makes the richtext/list diff +calculators rebuild a full CRDT tracker from empty for every container touched +in the range, which dominated the export cost: on a doc with ~66k containers +and ~720k streaming-edit ops, shallow export at a mid-history root took ~3.2s +versus ~1.5ms for a full snapshot. When at least 64k ops are retained since +the root, the root state is now reconstructed by replaying the pre-root +history forward into a temporary doc, and the latest state is read from the +live store directly without moving the document. The pre-root prefix is bounded +both relative to the tail (at most 16x the retained op count) and absolutely +(at most 1M ops) and decoded payload size (at most 32 MiB, estimated by +walking op payloads — recursing into nested values, style values, and commit +messages — before any value is copied; op counts miss value sizes, since a Map +write is one atom regardless of payload size), so a document whose +pre-root history is huge or byte-heavy and unrelated to the tail keeps the +previous checkout path. The same export drops to +~370ms and the produced blob is slightly smaller (~17% on the same fixture). +With a small retained range — including a root at the latest version — the +previous checkout path is kept: it is then equally fast and peaks at ~4x less +memory, so exporting a lazily imported document no longer materializes its +whole state. Exported blobs remain logically equivalent; detached or +already-shallow source docs also keep the previous code path. + +Also fixes shallow snapshot export resurrecting, as an empty entry, a root +container deleted with `deleteRootContainer` before the shallow root. diff --git a/.gitignore b/.gitignore index 72bf5deee..6e06673fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ moon/_build/ moon_*_fuzz_artifacts*/ dhat-heap.json .DS_Store -node_modules/ +node_modules .idea/ coverage/ trace-*.json diff --git a/context/internal-encoding.md b/context/internal-encoding.md index c884f4cb9..ce9f78f43 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -212,6 +212,50 @@ path reuses the root bytes without that check. Containers introduced after the root are not checked again and can survive either in retained operations (`E`) or as raw/lazy overlay state bytes. +When the source doc is not shallow, its state is already at the latest +version, and at least `MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE` (65536; 16 in +unit tests) ops are retained since the root, `export_shallow_snapshot_inner` +builds the root state by replaying pre-root history forward into a temporary +doc (`export_fast_updates_in_range` pre-encoded under the oplog lock, then +imported), not by checking the live doc out backwards: a reverse checkout +makes the richtext/list diff calculators rebuild a full CRDT tracker from +empty per touched container (the `should_rebuild` path in +`RichtextDiffCalculator::calculate_diff`), which dominated shallow export cost +(~20x slower than forward replay on container-heavy docs). Below the retained-ops +threshold the checkout path is used instead: it ties in time around ~8k +retained ops and peaks at ~4x less memory, which matters for lazily imported +docs (exporting right after import must not materialize the whole state). +The fast path pays for re-encoding and replaying the ENTIRE pre-root history, +so the prefix is also gated: `pre_root_ops <= 16 * ops_num` +(`MAX_PRE_ROOT_TO_RETAINED_OPS_RATIO`; measured crossover — fast wins at +ratio 9, loses at 19), `pre_root_ops <= 1_000_000` +(`MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY`), and a decoded-byte cap on the prefix +(`MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY`, 32 MiB) because op counts miss value +sizes — a Map write is one atom regardless of how large its Binary/String +value is. The byte leg runs BEFORE encoding: `estimate_ops_content_bytes` +walks op payloads by reference (arena slices are never copied), recursing into +nested `LoroValue::List`/`Map` and counting everything the block encoder +copies: map and style keys, style values, fractional indexes, root container +names, unknown-op OwnedValue payloads (including MarkStart keys and +MarkStart/ListSet values), and commit messages, with a budget-aware early +exit past the cap, while +`export_fast_updates_in_range` slice-copies values into a fresh store — so the +cap must be checked before any prefix bytes are copied. +A huge unrelated prefix with a large tail must stay on the checkout path — +see the `shallow_export_scalar_prefix` and `shallow_export_byte_prefix` +benches. +The replay doc mirrors the live store's root container entries via +`DocState::existing_retention_roots` (a root-only key scan — never +`iter_all_container_ids`, which calls `load_all`) so accessed-but-op-less root +containers still ship, and it receives a copy of the live doc's +`deleted_root_containers` config so roots deleted before the root are dropped +at flush instead of being resurrected as empty entries (roots deleted after +the root keep their at-root content because flush only drops entries whose +value is empty). Detached or already-shallow sources keep the old checkout +path (the reuse branch handles cached roots; a shallow source's trimmed +history cannot be forward-replayed). The forward path never moves the live +doc, so no state restore is needed. + Pre-shallow frontier safety lives in `loro.rs`: `checkout`, `diff`, and `revert_to` must return `SwitchToVersionBeforeShallowRoot` instead of traversing history before the shallow root. diff --git a/crates/loro-internal/Cargo.toml b/crates/loro-internal/Cargo.toml index 6f2229a1b..3b457467c 100644 --- a/crates/loro-internal/Cargo.toml +++ b/crates/loro-internal/Cargo.toml @@ -128,3 +128,7 @@ harness = false [[bench]] name = "jsonpath" harness = false + +[[bench]] +name = "shallow_export" +harness = false diff --git a/crates/loro-internal/benches/shallow_export.rs b/crates/loro-internal/benches/shallow_export.rs new file mode 100644 index 000000000..b6acbd329 --- /dev/null +++ b/crates/loro-internal/benches/shallow_export.rs @@ -0,0 +1,164 @@ +use criterion::{criterion_group, criterion_main, Criterion}; +use loro_internal::{encoding::ExportMode, version::Frontiers, LoroDoc}; +use std::hint::black_box; + +/// Build a doc shaped like a real workspace export: a root list of +/// `OUTER_DOCS` maps, each with a nested list of `INNER_ITEMS` maps, each +/// holding `TEXTS_PER_ITEM` short text containers. Every text is written with +/// `OPS_PER_TEXT` single-character inserts to simulate streaming edits. +/// +/// With the defaults this produces ~66k containers and ~720k ops. +fn build_structured_doc() -> (LoroDoc, Frontiers) { + const OUTER_DOCS: usize = 200; + const INNER_ITEMS: usize = 30; + const TEXTS_PER_ITEM: usize = 10; + const OPS_PER_TEXT: usize = 12; + + let doc = LoroDoc::new_auto_commit(); + doc.set_peer_id(1).unwrap(); + let root = doc.get_list("docs"); + let mut mid = None; + for i in 0..OUTER_DOCS { + let map = root + .insert_container(i, loro_internal::handler::MapHandler::new_detached()) + .unwrap(); + let items = map + .insert_container("items", loro_internal::handler::ListHandler::new_detached()) + .unwrap(); + for j in 0..INNER_ITEMS { + let item = items + .insert_container(j, loro_internal::handler::MapHandler::new_detached()) + .unwrap(); + for k in 0..TEXTS_PER_ITEM { + let text = item + .insert_container( + &format!("t{k}"), + loro_internal::handler::TextHandler::new_detached(), + ) + .unwrap(); + for n in 0..OPS_PER_TEXT { + text.insert(n, "x", loro_internal::cursor::PosType::Unicode) + .unwrap(); + } + } + } + if i == OUTER_DOCS / 2 { + mid = Some(doc.oplog_frontiers()); + } + } + (doc, mid.unwrap()) +} + +fn shallow_export(c: &mut Criterion) { + let (doc, mid_frontiers) = build_structured_doc(); + let mut g = c.benchmark_group("shallow_export"); + g.sample_size(10); + g.bench_function("full_snapshot", |b| { + b.iter(|| black_box(doc.export(ExportMode::Snapshot).unwrap())) + }); + g.bench_function("shallow_snapshot", |b| { + b.iter(|| { + black_box( + doc.export(ExportMode::shallow_snapshot(&mid_frontiers)) + .unwrap(), + ) + }) + }); + g.finish(); +} + +/// A document imported from a snapshot and never read stays lazy: exporting a +/// shallow snapshot at the latest version must not materialize the whole +/// state. Each iteration exports from a freshly imported doc, so this measures +/// the cold path (setup time is excluded). +fn shallow_export_lazy(c: &mut Criterion) { + let (doc, _) = build_structured_doc(); + let full = doc.export(ExportMode::Snapshot).unwrap(); + let latest = doc.oplog_frontiers(); + let mut g = c.benchmark_group("shallow_export_lazy"); + g.sample_size(10); + g.bench_function("at_latest", |b| { + b.iter_batched( + || { + let lazy = LoroDoc::new(); + lazy.import(&full).unwrap(); + lazy + }, + |lazy| black_box(lazy.export(ExportMode::shallow_snapshot(&latest)).unwrap()), + criterion::BatchSize::LargeInput, + ) + }); + g.finish(); +} + +/// Regression guard for the forward-replay gate's prefix bound: a huge +/// pre-root prefix of unrelated scalar overwrites must not be re-encoded and +/// replayed just because the retained tail clears the 65536-op threshold. +/// With the prefix/tail ratio and absolute caps this export stays on the +/// checkout path, whose cost is bounded by the tail. +fn shallow_export_scalar_prefix_heavy(c: &mut Criterion) { + const PREFIX_OPS: usize = 2_000_000; + + let doc = LoroDoc::new_auto_commit(); + doc.set_peer_id(1).unwrap(); + let map = doc.get_map("m"); + for i in 0..PREFIX_OPS { + map.insert("k", i as i64).unwrap(); + } + let f = doc.oplog_frontiers(); + // One big-atom insert past the 65536-op retained threshold. + doc.get_text("t") + .insert( + 0, + &"x".repeat(70_000), + loro_internal::cursor::PosType::Unicode, + ) + .unwrap(); + + let mut g = c.benchmark_group("shallow_export_scalar_prefix"); + g.sample_size(10); + g.bench_function("export", |b| { + b.iter(|| black_box(doc.export(ExportMode::shallow_snapshot(&f)).unwrap())) + }); + g.finish(); +} + +/// Regression guard for the gate's byte bound: a byte-heavy but low-op prefix +/// (few large Map values) must not be replayed just because the retained tail +/// clears the op threshold — a Map write is one atom regardless of value +/// size. With the encoded-byte cap this export stays on the checkout path. +fn shallow_export_byte_prefix_heavy(c: &mut Criterion) { + let doc = LoroDoc::new_auto_commit(); + doc.set_peer_id(1).unwrap(); + let map = doc.get_map("m"); + // 64 distinct 1 MiB values to the same key: 64 atoms, ~64 MiB of prefix + // history. Distinct values matter — identical strings dedup in the arena. + for i in 0..64 { + let big = format!("{i:08}{}", "v".repeat(1 << 20)); + map.insert("k", big.as_str()).unwrap(); + } + let f = doc.oplog_frontiers(); + doc.get_text("t") + .insert( + 0, + &"x".repeat(70_000), + loro_internal::cursor::PosType::Unicode, + ) + .unwrap(); + + let mut g = c.benchmark_group("shallow_export_byte_prefix"); + g.sample_size(10); + g.bench_function("export", |b| { + b.iter(|| black_box(doc.export(ExportMode::shallow_snapshot(&f)).unwrap())) + }); + g.finish(); +} + +criterion_group!( + benches, + shallow_export, + shallow_export_lazy, + shallow_export_scalar_prefix_heavy, + shallow_export_byte_prefix_heavy +); +criterion_main!(benches); diff --git a/crates/loro-internal/src/arena.rs b/crates/loro-internal/src/arena.rs index ff68fb0b4..563f7b46b 100644 --- a/crates/loro-internal/src/arena.rs +++ b/crates/loro-internal/src/arena.rs @@ -519,6 +519,13 @@ impl SharedArena { (self.inner.values.lock()[range]).to_vec() } + /// Borrow the values in `range` without cloning them (unlike + /// [`Self::get_values`], which clones into a fresh `Vec`). + #[inline] + pub fn with_values(&self, range: Range, f: impl FnOnce(&[LoroValue]) -> R) -> R { + f(&self.inner.values.lock()[range]) + } + pub fn convert_single_op( &self, container: &ContainerID, diff --git a/crates/loro-internal/src/encoding/shallow_snapshot.rs b/crates/loro-internal/src/encoding/shallow_snapshot.rs index f69c2dc16..235b835fe 100644 --- a/crates/loro-internal/src/encoding/shallow_snapshot.rs +++ b/crates/loro-internal/src/encoding/shallow_snapshot.rs @@ -3,11 +3,12 @@ use rle::HasLength; use rustc_hash::FxHashSet; use std::collections::BTreeSet; -use loro_common::{ContainerID, ContainerType, LoroEncodeError, LoroError, ID}; +use loro_common::{ContainerID, ContainerType, IdSpan, LoroEncodeError, LoroError, ID}; use crate::{ container::{idx::ContainerIdx, list::list_op::InnerListOp}, dag::DagUtils, + encoding::export_fast_updates_in_range, encoding::fast_snapshot::{_encode_snapshot, Snapshot}, state::{ container_store::{ContainerWrapper, FRONTIERS_KEY}, @@ -23,6 +24,262 @@ const MAX_OPS_NUM_TO_ENCODE_WITHOUT_LATEST_STATE: usize = 16; #[cfg(not(test))] const MAX_OPS_NUM_TO_ENCODE_WITHOUT_LATEST_STATE: usize = 256; +/// Minimum number of retained ops (root..latest) for the forward-replay fast +/// path to be worth it. Below this, the old checkout path is used: at F == +/// latest it is trivial (no history to walk back), and on the 66k-container / +/// 720k-op fixture the paths tie at ~8k retained ops while the checkout path +/// peaks at ~4x less memory; forward replay only wins decisively past ~64k +/// (1.25-3.6x at 79k, 10-14x at 393k). Note this fixture has tiny +/// per-container histories; docs with long text histories penalize the +/// checkout path more, shifting the real crossover lower. +#[cfg(test)] +const MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE: usize = 16; +#[cfg(not(test))] +const MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE: usize = 65536; + +/// The fast path re-encodes and replays ALL pre-root history into a temporary +/// doc, so its cost scales with the prefix, not the tail. Cap the prefix/tail +/// ratio: on the fixture the fast path wins at ratio 9 and loses at ratio 19, +/// and an unrelated huge prefix (e.g. millions of same-key Map overwrites +/// before the root) must not be replayed just because the tail is large. +const MAX_PRE_ROOT_TO_RETAINED_OPS_RATIO: usize = 16; + +/// Absolute cap on pre-root ops for the forward-replay path. Replaying ~1M ops +/// costs roughly 0.5-1s and several hundred MiB of peak memory; beyond that +/// the checkout path is safer (its work is bounded by the tail), especially +/// on wasm32. +const MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY: usize = 1_000_000; + +/// Cap on the pre-root prefix's decoded byte size, estimated by walking op +/// payloads by reference BEFORE any value is copied (see +/// `estimate_ops_content_bytes`). Op counts miss value sizes: a Map write is +/// one atom regardless of how large its Binary/String value is, so a +/// byte-heavy but low-op prefix would bypass the op-count gates. Checking only +/// the encoded blob afterwards would be too late — +/// `export_fast_updates_in_range` slice-copies values into a fresh store while +/// building it, which is exactly the allocation this cap exists to prevent. +#[cfg(test)] +const MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY: usize = 1 << 20; +#[cfg(not(test))] +const MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY: usize = 32 << 20; + +/// Estimate the decoded byte size of everything in `spans` — op payloads +/// (recursing into nested `LoroValue::List`/`Map`), style values, and commit +/// messages — following arena slices by reference and never copying values. +/// The walk is cap-aware: every counting step takes a remaining budget and +/// bails as soon as it is exceeded, so a rejected prefix costs one bounded +/// walk instead of a full copy. +fn estimate_ops_content_bytes(oplog: &crate::OpLog, spans: &[IdSpan], cap: usize) -> usize { + let mut total = 0usize; + 'outer: for span in spans { + let mut span = *span; + span.normalize_(); + if span.counter.end <= 0 { + continue; + } + + span.counter.start = span.counter.start.max(0); + span.counter.end = span.counter.end.max(0); + if span.counter.start >= span.counter.end { + continue; + } + + for change in oplog.iter_changes(span) { + let start = + ((span.counter.start - change.id.counter).max(0) as usize).min(change.atom_len()); + let end = + ((span.counter.end - change.id.counter).max(0) as usize).min(change.atom_len()); + if start == end { + continue; + } + + if let Some(msg) = change.commit_msg.as_ref() { + total = total.saturating_add(msg.len()); + if total > cap { + break 'outer; + } + } + + for op in crate::op::RichOp::new_iter_by_cnt_range(change, span.counter) { + total = total.saturating_add(rich_op_content_bytes(oplog, &op, cap - total)); + if total > cap { + break 'outer; + } + } + } + } + total +} + +/// Byte size of an op's variable-length fields plus a small flat per-op +/// overhead: everything the block encoder copies (payload values, map and +/// style keys, fractional indexes, root container names, unknown-future +/// bytes). Arena slices are measured by reference; nothing is copied. +/// `remaining` is the budget left before the cap; the count may stop early +/// once it is exceeded. +fn rich_op_content_bytes(oplog: &crate::OpLog, op: &crate::op::RichOp, remaining: usize) -> usize { + const PER_OP_OVERHEAD: usize = 16; + if remaining < PER_OP_OVERHEAD { + return remaining + 1; + } + + // Root container names are variable-length and copied into the encoded + // block's container arena. + let container_name_len = match oplog.arena.get_container_id(op.raw_op().container) { + Some(ContainerID::Root { name, .. }) => name.len(), + _ => 0, + }; + let after_fixed = remaining.saturating_sub(PER_OP_OVERHEAD + container_name_len); + + PER_OP_OVERHEAD + + container_name_len + + match &op.raw_op().content { + crate::op::InnerContent::Map(map) => { + let mut used = map.key.len(); + if let Some(value) = map.value.as_ref() { + used = used.saturating_add(value_bytes_capped( + value, + after_fixed.saturating_sub(used), + )); + } + used + } + crate::op::InnerContent::List(list) => match list { + InnerListOp::Insert { slice, .. } => oplog.arena.with_values( + slice.0.start as usize..slice.0.end as usize, + |values| { + let mut used = 0usize; + for value in values { + used = used.saturating_add(value_bytes_capped( + value, + after_fixed.saturating_sub(used), + )); + if used > after_fixed { + break; + } + } + used + }, + ), + InnerListOp::InsertText { slice, .. } => slice.len(), + InnerListOp::Set { value, .. } => value_bytes_capped(value, after_fixed), + InnerListOp::StyleStart { key, value, .. } => { + let mut used = key.len(); + used = used.saturating_add(value_bytes_capped( + value, + after_fixed.saturating_sub(used), + )); + used + } + _ => 0, + }, + // Fractional indexes are variable-length and copied by the encoder. + crate::op::InnerContent::Tree(tree_op) => match tree_op.as_ref() { + crate::container::tree::tree_op::TreeOp::Create { position, .. } + | crate::container::tree::tree_op::TreeOp::Move { position, .. } => { + position.as_bytes().len() + } + crate::container::tree::tree_op::TreeOp::Delete { .. } => 0, + }, + crate::op::InnerContent::Future(future) => match future { + crate::op::FutureInnerContent::Unknown { value, .. } => { + owned_value_bytes_capped(value, after_fixed) + } + #[cfg(feature = "counter")] + crate::op::FutureInnerContent::Counter(_) => 0, + }, + } +} + +/// Byte size of an owned future/unknown value, recursing where the payload is +/// a `LoroValue`. `remaining` is the budget left before the cap. +fn owned_value_bytes_capped(value: &crate::encoding::value::OwnedValue, remaining: usize) -> usize { + use crate::encoding::value::OwnedValue; + const OVERHEAD: usize = 16; + if remaining < OVERHEAD { + return remaining + 1; + } + + match value { + OwnedValue::Str(s) => OVERHEAD.saturating_add(s.len()), + OwnedValue::Binary(b) => OVERHEAD.saturating_add(b.len()), + OwnedValue::LoroValue(v) => OVERHEAD.saturating_add(value_bytes_capped(v, remaining)), + // The encoder writes the mark key into the block's key register and + // recurses into the value; both are variable-length. + OwnedValue::MarkStart(mark) => { + let mut used = OVERHEAD.saturating_add(mark.key.len()); + used = used.saturating_add(value_bytes_capped( + &mark.value, + remaining.saturating_sub(used), + )); + used + } + // The encoder recurses into the set value. + OwnedValue::ListSet { value, .. } => { + OVERHEAD.saturating_add(value_bytes_capped(value, remaining)) + } + OwnedValue::Future(owned) => match owned { + crate::encoding::value::OwnedFutureValue::Unknown { data, .. } => { + OVERHEAD.saturating_add(data.len()) + } + }, + // TreeMove/RawTreeMove/ListMove carry only fixed-size indices; the + // fractional-index payloads they point at are small by construction. + _ => OVERHEAD, + } +} + +/// Byte size of a value, recursing into nested lists and maps (the encoder +/// writes them recursively, so the estimate must too). `remaining` is the +/// budget left before the cap; the count may stop early once it is exceeded. +fn value_bytes_capped(value: &loro_common::LoroValue, remaining: usize) -> usize { + const OVERHEAD: usize = 16; + if remaining < OVERHEAD { + return remaining + 1; + } + + let mut used = OVERHEAD; + match value { + loro_common::LoroValue::String(s) => used = used.saturating_add(s.len()), + loro_common::LoroValue::Binary(b) => used = used.saturating_add(b.len()), + loro_common::LoroValue::List(list) => { + for item in list.iter() { + used = + used.saturating_add(value_bytes_capped(item, remaining.saturating_sub(used))); + if used > remaining { + break; + } + } + } + loro_common::LoroValue::Map(map) => { + for (key, item) in map.iter() { + used = used.saturating_add(key.len()); + if used > remaining { + break; + } + used = + used.saturating_add(value_bytes_capped(item, remaining.saturating_sub(used))); + if used > remaining { + break; + } + } + } + _ => {} + } + used +} + +/// Whether the forward-replay path may be used for the given prefix/tail +/// shape. The caller still has to require a non-shallow doc whose state is at +/// the latest version. Extracted as a pure predicate so the gate can be +/// tested directly. +fn forward_replay_gate(ops_num: usize, pre_root_ops: usize, pre_root_bytes: usize) -> bool { + ops_num >= MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE + && pre_root_ops <= MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY + && pre_root_ops <= MAX_PRE_ROOT_TO_RETAINED_OPS_RATIO * ops_num + && pre_root_bytes <= MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY +} + #[tracing::instrument(skip_all)] pub(crate) fn export_shallow_snapshot( doc: &LoroDoc, @@ -40,7 +297,11 @@ pub(crate) fn export_shallow_snapshot_inner( ) -> Result<(Snapshot, Frontiers), LoroEncodeError> { let oplog = doc.oplog().lock(); let start_from = calc_shallow_doc_start(&oplog, start_from); - let mut start_vv = frontiers_to_vv_for_export(&oplog, &start_from, "export_shallow_snapshot")?; + // `root_vv` is the version of the state at the shallow root; `start_vv` + // additionally excludes the frontier ops themselves because the retained + // history must include them. + let root_vv = frontiers_to_vv_for_export(&oplog, &start_from, "export_shallow_snapshot")?; + let mut start_vv = root_vv.clone(); for id in start_from.iter() { // we need to include the ops in start_from, this can make things easier start_vv.insert(id.peer, id.counter); @@ -78,6 +339,43 @@ pub(crate) fn export_shallow_snapshot_inner( let oplog_bytes = oplog.export_change_store_from(&start_vv, &start_from); let latest_vv = oplog.vv(); let ops_num: usize = latest_vv.sub_iter(&start_vv).map(|x| x.atom_len()).sum(); + // Pre-encode the pre-root history for the forward-replay path below while + // the oplog lock is held. Calling `LoroDoc::export` there instead would + // re-enter `with_barrier` and violate the txn lock order. Shallow docs are + // excluded: their pre-root history is trimmed, so forward replay from + // empty cannot reconstruct the root state. Small retained ranges are + // excluded too: the checkout path is then cheap and peaks at far less + // memory (see MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE). The prefix itself + // is bounded absolutely and relative to the tail, because this path pays + // for replaying all of it while the checkout path only walks the tail. + let pre_root_ops: usize = root_vv + .iter() + .map(|(_, counter)| (*counter).max(0) as usize) + .sum(); + // Cheap op-count legs first (even the estimate walks the prefix's ops, so + // it must not run for the small-tail cases the checkout path handles + // best). The byte leg runs BEFORE encoding: the estimate follows arena + // slices by reference, while export_fast_updates_in_range would + // slice-copy every value into a fresh store — the very allocation the cap + // exists to prevent. + let pre_root_updates = (state_frontiers == latest_frontiers + && oplog.shallow_since_vv().is_empty() + && forward_replay_gate(ops_num, pre_root_ops, 0)) + .then(|| { + let spans: Vec = root_vv + .iter() + .filter(|(_, counter)| **counter > 0) + .map(|(peer, counter)| IdSpan::new(*peer, 0, *counter)) + .collect(); + if estimate_ops_content_bytes(&oplog, &spans, MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY) + > MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY + { + return None; + } + + Some(export_fast_updates_in_range(&oplog, &spans)) + }) + .flatten(); if &start_from == oplog.shallow_since_frontiers() && state_frontiers == latest_frontiers { let mut state = doc.app_state().lock(); if let Some((shallow_root_state_bytes, shallow_root_kv)) = @@ -138,6 +436,71 @@ pub(crate) fn export_shallow_snapshot_inner( } drop(oplog); let result = (|| -> Result { + if let Some(pre_root_updates) = pre_root_updates { + // The live state is already at the latest version: build the root + // state by replaying pre-root history forward into a temporary doc + // instead of checking the live doc out backwards. A reverse + // checkout (latest -> root) makes the diff calculators rebuild a + // full CRDT tracker from empty for every list-like container + // touched in the range (the `should_rebuild` path in + // `RichtextDiffCalculator::calculate_diff`), which costs orders of + // magnitude more than forward replay when the doc has many small + // containers. Forward replay also leaves the live doc untouched, + // so no state restore is needed. + let root_doc = LoroDoc::new(); + root_doc + .import(&pre_root_updates) + .map_err(LoroEncodeError::from)?; + let mut root_state = root_doc.app_state().lock(); + // The replay doc does not share the live doc's deleted-root set; + // without it a root container deleted before the root would be + // re-encoded as an empty entry instead of being dropped at flush. + // (`InnerStore::flush` only drops the entry when the value is + // still empty, so roots deleted *after* the root keep their + // at-root content even with the set mirrored.) + *root_state.config.deleted_root_containers.lock() = + doc.config().deleted_root_containers.lock().clone(); + // Root containers exist on the live doc once they are accessed, + // even when they have no ops; the replay doc cannot know about + // those. Mirror the live store's root entries so the exported root + // state ships the same empty root containers the checkout path + // would. `existing_retention_roots` is a root-only key scan — a + // full `load_all` here would defeat lazily imported docs. + { + let mut live_state = doc.app_state().lock(); + for idx in live_state.existing_retention_roots() { + let cid = live_state.arena.get_container_id(idx).unwrap(); + root_state.store.ensure_container(&cid); + } + } + let alive_containers = root_state.ensure_all_alive_containers()?; + if has_unknown_container(alive_containers.iter().copied()) { + return Err(LoroEncodeError::UnknownContainer); + } + let mut alive_c_bytes = alive_indices_to_bytes(&root_state, &alive_containers); + root_state.store.flush(); + let shallow_root_state_kv = root_state.store.get_kv_clone(); + drop(root_state); + + let latest_state_kv = { + let mut state = doc.app_state().lock(); + latest_state_overlay_kv( + &mut state, + ops_num, + &start_from, + &shallow_root_state_kv, + &mut alive_c_bytes, + )? + }; + return encode_shallow_sections( + oplog_bytes, + &start_from, + shallow_root_state_kv, + latest_state_kv, + alive_c_bytes, + ); + } + doc._checkout_without_emitting(&start_from, false, false) .map_err(LoroEncodeError::from)?; let mut state = doc.app_state().lock(); @@ -151,41 +514,23 @@ pub(crate) fn export_shallow_snapshot_inner( drop(state); doc._checkout_without_emitting(&latest_frontiers, false, false) .map_err(LoroEncodeError::from)?; - let latest_state_kv = if ops_num > MAX_OPS_NUM_TO_ENCODE_WITHOUT_LATEST_STATE { + let latest_state_kv = { let mut state = doc.app_state().lock(); - state.ensure_all_alive_containers()?; - state.store.encode(); - // All the containers that are created after start_from need to be encoded - for cid in state.store.iter_all_container_ids() { - if let ContainerID::Normal { peer, counter, .. } = cid { - let temp_id = ID::new(peer, counter); - if !start_from.contains(&temp_id) { - alive_c_bytes.insert(cid.to_bytes()); - } - } else { - alive_c_bytes.insert(cid.to_bytes()); - } - } - - let new_kv = state.store.get_kv_clone(); - new_kv.remove_same(&shallow_root_state_kv); - new_kv.retain_keys(&alive_c_bytes); - Some(new_kv) - } else { - None + latest_state_overlay_kv( + &mut state, + ops_num, + &start_from, + &shallow_root_state_kv, + &mut alive_c_bytes, + )? }; - - shallow_root_state_kv.retain_keys(&alive_c_bytes); - redact_export_states(&shallow_root_state_kv, latest_state_kv.as_ref())?; - let state_bytes = latest_state_kv.map(|kv| kv.export()); - shallow_root_state_kv.insert(FRONTIERS_KEY, start_from.encode().into()); - let shallow_root_state_bytes = shallow_root_state_kv.export(); - - Ok(Snapshot { + encode_shallow_sections( oplog_bytes, - state_bytes, - shallow_root_state_bytes, - }) + &start_from, + shallow_root_state_kv, + latest_state_kv, + alive_c_bytes, + ) })(); restore_export_doc_state(doc, &state_frontiers, is_attached)?; @@ -193,6 +538,61 @@ pub(crate) fn export_shallow_snapshot_inner( Ok((result?, start_from)) } +/// Compute the encoded latest-state overlay shipped alongside the shallow root +/// when the retained history is too large to replay on import. Containers +/// created after `start_from` are added to `alive_c_bytes` so both the root +/// state and the overlay keep them. +fn latest_state_overlay_kv( + state: &mut DocState, + ops_num: usize, + start_from: &Frontiers, + shallow_root_state_kv: &KvWrapper, + alive_c_bytes: &mut BTreeSet>, +) -> Result, LoroEncodeError> { + if ops_num <= MAX_OPS_NUM_TO_ENCODE_WITHOUT_LATEST_STATE { + return Ok(None); + } + + state.ensure_all_alive_containers()?; + state.store.encode(); + // All the containers that are created after start_from need to be encoded + for cid in state.store.iter_all_container_ids() { + if let ContainerID::Normal { peer, counter, .. } = cid { + let temp_id = ID::new(peer, counter); + if !start_from.contains(&temp_id) { + alive_c_bytes.insert(cid.to_bytes()); + } + } else { + alive_c_bytes.insert(cid.to_bytes()); + } + } + + let new_kv = state.store.get_kv_clone(); + new_kv.remove_same(shallow_root_state_kv); + new_kv.retain_keys(alive_c_bytes); + Ok(Some(new_kv)) +} + +fn encode_shallow_sections( + oplog_bytes: Bytes, + start_from: &Frontiers, + shallow_root_state_kv: KvWrapper, + latest_state_kv: Option, + alive_c_bytes: BTreeSet>, +) -> Result { + shallow_root_state_kv.retain_keys(&alive_c_bytes); + redact_export_states(&shallow_root_state_kv, latest_state_kv.as_ref())?; + let state_bytes = latest_state_kv.map(|kv| kv.export()); + shallow_root_state_kv.insert(FRONTIERS_KEY, start_from.encode().into()); + let shallow_root_state_bytes = shallow_root_state_kv.export(); + + Ok(Snapshot { + oplog_bytes, + state_bytes, + shallow_root_state_bytes, + }) +} + fn has_unknown_container(mut idxs: impl Iterator) -> bool { idxs.any(|idx| matches!(idx.get_type(), ContainerType::Unknown(_))) } @@ -837,4 +1237,213 @@ mod tests { assert_eq!(d.get_map(child_id).get("key"), Some((i as i64).into())); } } + + #[test] + fn forward_replay_gate_bounds_prefix_by_ops_ratio_and_bytes() { + let min = MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE; + let max_ops = MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY; + let max_bytes = MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY; + + // Tail below the minimum: never. + assert!(!forward_replay_gate(min - 1, 0, 0)); + // Balanced prefix/tail: allowed. + assert!(forward_replay_gate(min, min, 1)); + // Prefix larger than 16x the tail: blocked even though both op counts + // are small. + assert!(!forward_replay_gate(min, 17 * min, 1)); + // Prefix above the absolute op cap: blocked. + assert!(!forward_replay_gate(max_ops, max_ops + 1, 1)); + // A byte-heavy but low-op prefix (a Map write is one atom regardless + // of value size): blocked only by the byte leg. + assert!(!forward_replay_gate(min, 1, max_bytes + 1)); + assert!(forward_replay_gate(min, 1, max_bytes)); + } + + /// A byte-heavy, low-op prefix (a huge payload nested inside a Map value, + /// later overwritten) must not be replayed into a temporary doc just + /// because the tail clears the retained-ops threshold. The export must + /// still be correct. + #[test] + fn byte_heavy_low_op_prefix_exports_correctly() { + let doc = LoroDoc::new_auto_commit(); + doc.set_peer_id(1).unwrap(); + let map = doc.get_map("m"); + let big = "v".repeat(MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY + 1024); + // Nested one level down: the estimator must recurse into it. + let mut payload = crate::FxHashMap::default(); + payload.insert("payload".to_string(), LoroValue::String(big.into())); + map.insert("k", LoroValue::Map(payload.into())).unwrap(); + map.insert("k", "small").unwrap(); + doc.commit_then_renew(); + let f = doc.oplog_frontiers(); + // Tail just past the (test-scale) retained-ops threshold. + let text = doc.get_text("t"); + text.insert( + 0, + &"x".repeat(MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE + 1), + PosType::Unicode, + ) + .unwrap(); + doc.commit_then_renew(); + + let blob = doc.export(ExportMode::shallow_snapshot(&f)).unwrap(); + let imported = LoroDoc::new(); + imported.import(&blob).unwrap(); + assert_eq!(imported.get_deep_value(), doc.get_deep_value()); + assert_eq!(imported.shallow_since_frontiers(), f); + } + + #[test] + fn prefix_content_bytes_estimate_counts_value_bytes() { + let doc = LoroDoc::new_auto_commit(); + doc.set_peer_id(1).unwrap(); + // One atom carrying 2 MiB of payload. + let big = "v".repeat(2 << 20); + doc.get_map("m").insert("k", big.as_str()).unwrap(); + doc.get_text("t") + .insert(0, "abc", PosType::Unicode) + .unwrap(); + doc.commit_then_renew(); + + let oplog = doc.oplog().lock(); + let spans = vec![IdSpan::new(1, 0, 4)]; + let est = estimate_ops_content_bytes(&oplog, &spans, usize::MAX); + assert!( + est >= (2 << 20) + 3, + "estimate must include the value bytes, got {est}" + ); + // Early exit: a smaller cap stops the walk as soon as it is exceeded. + let capped = estimate_ops_content_bytes(&oplog, &spans, 1024); + assert!(capped > 1024 && capped <= 1024 + (2 << 20) + 64); + } + + #[test] + fn prefix_content_bytes_estimate_counts_nested_style_and_commit_msg_bytes() { + let doc = LoroDoc::new_auto_commit(); + doc.set_peer_id(1).unwrap(); + let big = "v".repeat(1 << 20); + + // One atom whose payload is a nested map/list holding the big string. + let mut inner = crate::FxHashMap::default(); + inner.insert("payload".to_string(), LoroValue::String(big.clone().into())); + let nested = LoroValue::List(vec![LoroValue::Map(inner.into())].into()); + doc.get_map("m").insert("k", nested).unwrap(); + + // A style mark value. + let text = doc.get_text("t"); + text.insert(0, "ab", PosType::Unicode).unwrap(); + text.mark( + 0, + 1, + "comment", + LoroValue::String(big.clone().into()), + PosType::Unicode, + ) + .unwrap(); + + // A commit message. + doc.set_next_commit_message(&big); + doc.commit_then_renew(); + + let oplog = doc.oplog().lock(); + let end = *oplog.vv().get(&1).unwrap(); + let spans = vec![IdSpan::new(1, 0, end)]; + let est = estimate_ops_content_bytes(&oplog, &spans, usize::MAX); + assert!( + est >= 3 * big.len(), + "estimate must count nested values, style values and the commit message, got {est}" + ); + } + + #[test] + fn prefix_content_bytes_estimate_counts_style_keys_and_root_names() { + let doc = LoroDoc::new_auto_commit(); + doc.set_peer_id(1).unwrap(); + let big = "k".repeat(1 << 20); + + // A huge style key: the block encoder copies keys into the block's + // key register. + let mut styles = crate::container::richtext::config::StyleConfigMap::new(); + styles.insert( + big.as_str().into(), + crate::container::richtext::config::StyleConfig { + expand: crate::container::richtext::ExpandType::After, + }, + ); + doc.config_text_style(styles); + let text = doc.get_text("t"); + text.insert(0, "ab", PosType::Unicode).unwrap(); + text.mark(0, 1, big.as_str(), LoroValue::Bool(true), PosType::Unicode) + .unwrap(); + + // A huge root container name: copied into the block's container arena. + doc.get_text(big.as_str()) + .insert(0, "x", PosType::Unicode) + .unwrap(); + doc.commit_then_renew(); + + let oplog = doc.oplog().lock(); + let end = *oplog.vv().get(&1).unwrap(); + let spans = vec![IdSpan::new(1, 0, end)]; + let est = estimate_ops_content_bytes(&oplog, &spans, usize::MAX); + assert!( + est >= 2 * big.len(), + "estimate must count style keys and root container names, got {est}" + ); + } + + /// Unknown-container ops can carry OwnedValue::MarkStart / ListSet with + /// variable-length key/value payloads (the decoder accepts any Value for + /// unknown containers and owns it); the prefix estimator must count them. + #[test] + fn prefix_content_bytes_estimate_counts_unknown_owned_values() { + use crate::change::Change; + use crate::encoding::value::{MarkStart, OwnedValue}; + use crate::op::{FutureInnerContent, InnerContent, Op}; + use rle::RleVec; + + let big = "v".repeat(1 << 20); + let doc = LoroDoc::new(); + let mut oplog = doc.oplog().lock(); + let idx = oplog + .arena + .register_container(&ContainerID::new_root("u", ContainerType::Unknown(7))); + + let mut ops: RleVec<[Op; 1]> = RleVec::new(); + ops.push(Op::new( + ID::new(1, 0), + InnerContent::Future(FutureInnerContent::Unknown { + prop: 0, + value: Box::new(OwnedValue::MarkStart(MarkStart { + len: 1, + key: big.as_str().into(), + value: LoroValue::Null, + info: 0, + })), + }), + idx, + )); + ops.push(Op::new( + ID::new(1, 1), + InnerContent::Future(FutureInnerContent::Unknown { + prop: 0, + value: Box::new(OwnedValue::ListSet { + peer_idx: 0, + lamport: 0, + value: LoroValue::String(big.clone().into()), + }), + }), + idx, + )); + oplog.insert_new_change( + Change::new(ops, Frontiers::default(), ID::new(1, 0), 0, 0), + false, + ); + + let est = estimate_ops_content_bytes(&oplog, &[IdSpan::new(1, 0, 2)], usize::MAX); + assert!( + est >= 2 * big.len(), + "estimate must count MarkStart keys and ListSet values on unknown ops, got {est}" + ); + } } diff --git a/crates/loro-internal/src/oplog.rs b/crates/loro-internal/src/oplog.rs index 6351c9456..2f67df454 100644 --- a/crates/loro-internal/src/oplog.rs +++ b/crates/loro-internal/src/oplog.rs @@ -508,6 +508,13 @@ impl OpLog { change_iter.flat_map(move |c| RichOp::new_iter_by_cnt_range(c, id_span.counter)) } + pub(crate) fn iter_changes( + &self, + id_span: IdSpan, + ) -> impl Iterator + '_ { + self.change_store.iter_changes(id_span) + } + pub(crate) fn get_max_lamport_at(&self, id: ID) -> Lamport { self.get_change_at(id) .map(|c| { diff --git a/crates/loro-internal/src/state.rs b/crates/loro-internal/src/state.rs index afdd5e225..8a6b52e95 100644 --- a/crates/loro-internal/src/state.rs +++ b/crates/loro-internal/src/state.rs @@ -1687,7 +1687,9 @@ impl DocState { self.get_all_alive_container_indices_from_roots(&roots) } - fn existing_retention_roots(&mut self) -> Vec { + /// Root containers that currently have a store entry. Uses a root-only + /// key scan (`load_roots`), so lazily imported docs stay lazy. + pub(crate) fn existing_retention_roots(&mut self) -> Vec { let flag = self.store.load_root_containers(); self.arena .root_containers(flag) diff --git a/crates/loro/tests/integration_test/shallow_snapshot_test.rs b/crates/loro/tests/integration_test/shallow_snapshot_test.rs index abb819336..326ab58d2 100644 --- a/crates/loro/tests/integration_test/shallow_snapshot_test.rs +++ b/crates/loro/tests/integration_test/shallow_snapshot_test.rs @@ -5,8 +5,8 @@ use std::{ use super::gen_action; use loro::{ - cursor::CannotFindRelativePosition, ExpandType, ExportMode, Frontiers, LoroDoc, LoroValue, - StyleConfig, StyleConfigMap, ID, + cursor::CannotFindRelativePosition, ContainerID, ContainerType, ExpandType, ExportMode, + Frontiers, LoroDoc, LoroValue, StyleConfig, StyleConfigMap, ID, }; /// Byte-level scan of an exported blob. Only used for *absence* checks, and @@ -771,3 +771,231 @@ fn shallow_snapshot_redacts_dead_styles_for_all_non_both_expands() -> anyhow::Re } Ok(()) } + +/// The forward-replay fast path (attached doc at latest) and the checkout +/// path (detached doc) must produce shallow snapshots that are semantically +/// identical: both import to the same state with the same shallow metadata. +/// They are not required to be byte-identical. +#[test] +fn shallow_export_forward_replay_matches_checkout_path() -> anyhow::Result<()> { + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + + // Phase 1 (before the shallow root F): content on every container type, + // plus an accessed-but-op-less root container and a text whose content is + // deleted before F — these are the cases where the replay doc's store can + // diverge from the live store. + doc.get_map("map").insert("a", 1)?; + doc.get_list("list").insert(0, "l0")?; + doc.get_text("text").insert(0, "hello")?; + let movable = doc.get_movable_list("movable"); + movable.insert(0, "m0")?; + movable.insert(1, "m1")?; + let tree = doc.get_tree("tree"); + tree.enable_fractional_index(0); + let root = tree.create(loro::TreeParentId::Root)?; + tree.create(root)?; + let _accessed_but_empty = doc.get_text("empty_text"); + doc.get_text("text").delete(0, 2)?; + doc.commit(); + let f = doc.oplog_frontiers(); + + // Phase 2 (after F): one big-atom insert pushes the tail past both the + // 256-op overlay threshold and the 65536-op forward-replay gate + // (`MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE` in + // loro-internal/src/encoding/shallow_snapshot.rs), so the attached export + // really exercises the fast path with a latest-state overlay. A big-atom + // insert keeps the test cheap: 70k atomic ops in a single change. + doc.get_text("text").insert(3, &"y".repeat(70_000))?; + doc.get_map("map").insert("b", 2)?; + doc.get_list("list").insert(1, "l1")?; + doc.get_text("text").insert(3, " world")?; + movable.mov(0, 1)?; + doc.commit(); + + // Fast path: attached, state at latest -> forward replay. + let fast = doc.export(ExportMode::shallow_snapshot(&f))?; + // Checkout path: detach at an older version so state_frontiers differs + // from the latest frontiers. + doc.checkout(&Frontiers::default())?; + let old = doc.export(ExportMode::shallow_snapshot(&f))?; + doc.checkout_to_latest(); + + // Full metadata and retained history must match, not just the state. + let fast_meta = LoroDoc::decode_import_blob_meta(&fast, false)?; + let old_meta = LoroDoc::decode_import_blob_meta(&old, false)?; + assert_eq!(fast_meta.mode, old_meta.mode); + assert_eq!(fast_meta.start_frontiers, f); + assert_eq!(old_meta.start_frontiers, f); + assert_eq!(fast_meta.change_num, old_meta.change_num); + assert_eq!( + format!("{:?}", fast_meta.partial_start_vv), + format!("{:?}", old_meta.partial_start_vv) + ); + assert_eq!( + format!("{:?}", fast_meta.partial_end_vv), + format!("{:?}", old_meta.partial_end_vv) + ); + + let fast_doc = LoroDoc::new(); + fast_doc.import(&fast)?; + let old_doc = LoroDoc::new(); + old_doc.import(&old)?; + + assert_eq!(fast_doc.get_deep_value(), doc.get_deep_value(), "forward"); + assert_eq!(old_doc.get_deep_value(), doc.get_deep_value(), "checkout"); + assert_eq!(fast_doc.get_deep_value(), old_doc.get_deep_value()); + assert_eq!(fast_doc.shallow_since_frontiers(), f); + assert_eq!(old_doc.shallow_since_frontiers(), f); + assert_eq!(fast_doc.len_changes(), old_doc.len_changes()); + assert_eq!(fast_doc.len_ops(), old_doc.len_ops()); + assert_eq!(fast_doc.oplog_vv(), old_doc.oplog_vv()); + assert_eq!(fast_doc.oplog_frontiers(), old_doc.oplog_frontiers()); + + let vv_pairs = |d: &LoroDoc| { + let mut pairs: Vec<(u64, i32)> = d + .shallow_since_vv() + .iter() + .map(|(peer, counter)| (*peer, *counter)) + .collect(); + pairs.sort_unstable(); + pairs + }; + assert_eq!(vv_pairs(&fast_doc), vv_pairs(&old_doc)); + assert!(fast_doc.is_shallow() && old_doc.is_shallow()); + // The accessed-but-op-less root container must survive both paths. Check + // existence BEFORE materializing it: `get_text` would create the root on + // access and prove nothing. + for (name, d) in [("forward", &fast_doc), ("checkout", &old_doc)] { + assert!(d.is_shallow(), "{name}"); + assert!( + d.has_container(&ContainerID::new_root("empty_text", ContainerType::Text)), + "{name}: op-less root container must survive the shallow export" + ); + assert_eq!(d.get_text("empty_text").to_string(), "", "{name}"); + } + + Ok(()) +} + +/// A root container deleted before the shallow root must stay deleted in the +/// exported root state: the forward-replay path mirrors the live doc's +/// deleted-root set, so the replay doc's flush drops the empty entry instead +/// of resurrecting it. +/// +/// Covers deleted-before-F and deleted-after-F, each with and without the +/// latest-state overlay (>256 tail ops). +#[test] +fn shallow_export_deleted_root_containers_match_checkout_path() -> anyhow::Result<()> { + for with_overlay in [false, true] { + // `doomed_before` is deleted before F; `doomed_after` is deleted after + // F but has content at F that must survive in the root state. + let doc = LoroDoc::new(); + doc.set_peer_id(1)?; + doc.get_text("doomed_before").insert(0, "secret")?; + doc.get_text("doomed_after").insert(0, "at-f-content")?; + doc.get_map("kept").insert("before", 1)?; + doc.commit(); + doc.delete_root_container(ContainerID::new_root("doomed_before", ContainerType::Text)); + doc.commit(); + let f = doc.oplog_frontiers(); + + // The forward-replay gate (`MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE` + // in loro-internal/src/encoding/shallow_snapshot.rs) requires >= 65536 + // retained ops, which always implies an overlay; the no-overlay case + // below therefore exercises the checkout path's absolute semantics, + // and the overlay case exercises fast-path vs checkout-path parity. + // The big-atom variant keeps the test cheap: 70k atomic ops in a + // single change. + if with_overlay { + doc.get_text("kept_text").insert(0, &"t".repeat(70_000))?; + } else { + for i in 0..4 { + doc.get_map("kept").insert(&format!("k{i}"), i as i64)?; + } + } + doc.commit(); + doc.delete_root_container(ContainerID::new_root("doomed_after", ContainerType::Text)); + doc.commit(); + + let fast = doc.export(ExportMode::shallow_snapshot(&f))?; + doc.checkout(&Frontiers::default())?; + let slow = doc.export(ExportMode::shallow_snapshot(&f))?; + doc.checkout_to_latest(); + + let fast_doc = LoroDoc::from_snapshot(&fast)?; + let slow_doc = LoroDoc::from_snapshot(&slow)?; + assert_eq!( + fast_doc.get_deep_value(), + slow_doc.get_deep_value(), + "with_overlay={with_overlay}" + ); + // Imported docs don't carry the source's deleted-root display config + // (both export paths behave the same here): a root deleted after F + // replays to an empty value and shows up as such, while the live doc + // hides it. A root deleted before F must not appear at all. + let live = doc.get_deep_value(); + let live_map = live.as_map().unwrap(); + let fast_latest = fast_doc.get_deep_value(); + let fast_map = fast_latest.as_map().unwrap(); + for (k, v) in live_map.iter() { + assert_eq!( + fast_map.get(k), + Some(v), + "with_overlay={with_overlay} key {k}" + ); + } + assert_eq!(fast_map.len(), live_map.len() + 1); + if !with_overlay { + // The retained tail replays the deletion's clear ops, so the root + // ends up empty at the latest version. + assert_eq!( + fast_map.get("doomed_after"), + Some(&LoroValue::String("".into())), + ); + } + assert!( + !fast_map.contains_key("doomed_before"), + "with_overlay={with_overlay}: root deleted before F must not be resurrected" + ); + + // The root state at F must still render the after-F-deleted content: + // check out both imported docs to F and compare. + fast_doc.checkout(&f)?; + slow_doc.checkout(&f)?; + assert_eq!(fast_doc.get_deep_value(), slow_doc.get_deep_value()); + if !with_overlay { + assert_eq!( + fast_doc.get_text("doomed_after").to_string(), + "at-f-content", + "root state must keep content deleted after F" + ); + } + // NOTE: with the overlay, a checkout back to F on the imported doc + // doubles the after-F-deleted content on BOTH paths — a pre-existing + // overlay/checkout quirk reproduced on the base commit, unrelated to + // the forward-replay path. + assert!( + !fast_doc + .get_deep_value() + .as_map() + .unwrap() + .contains_key("doomed_before"), + "with_overlay={with_overlay}: root deleted before F must not be resurrected" + ); + fast_doc.checkout_to_latest(); + slow_doc.checkout_to_latest(); + + // Same retained history and shallow metadata. + let fast_meta = LoroDoc::decode_import_blob_meta(&fast, false)?; + let slow_meta = LoroDoc::decode_import_blob_meta(&slow, false)?; + assert_eq!(fast_meta.start_frontiers, slow_meta.start_frontiers); + assert_eq!(fast_meta.change_num, slow_meta.change_num); + assert_eq!(fast_doc.len_changes(), slow_doc.len_changes()); + assert_eq!(fast_doc.len_ops(), slow_doc.len_ops()); + assert_eq!(fast_doc.oplog_vv(), slow_doc.oplog_vv()); + assert_eq!(fast_doc.oplog_vv(), doc.oplog_vv()); + } + + Ok(()) +}