From 929937f6cd9faa129cf40829530856883ecc008b Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 04:50:04 +0800 Subject: [PATCH 01/11] perf: build shallow root state by forward replay instead of reverse checkout --- .../shallow-export-forward-root-state.md | 18 ++ context/internal-encoding.md | 14 ++ crates/loro-internal/Cargo.toml | 4 + .../loro-internal/benches/shallow_export.rs | 71 +++++++ .../src/encoding/shallow_snapshot.rs | 181 ++++++++++++++---- 5 files changed, 254 insertions(+), 34 deletions(-) create mode 100644 .changeset/shallow-export-forward-root-state.md create mode 100644 crates/loro-internal/benches/shallow_export.rs diff --git a/.changeset/shallow-export-forward-root-state.md b/.changeset/shallow-export-forward-root-state.md new file mode 100644 index 000000000..fa497482f --- /dev/null +++ b/.changeset/shallow-export-forward-root-state.md @@ -0,0 +1,18 @@ +--- +"loro-crdt": patch +"loro-crdt-map": patch +--- + +Speed up `export({ mode: "shallow-snapshot" })` by ~20x on container-heavy documents. + +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 took ~3.2s versus ~1.5ms for a +full snapshot. 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 same export drops to +~159ms and the produced blob is slightly smaller (~17% on the same fixture). +Exported blobs remain logically equivalent; detached or already-shallow source +docs keep the previous code path. diff --git a/context/internal-encoding.md b/context/internal-encoding.md index c884f4cb9..150930119 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -212,6 +212,20 @@ 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 and its state is already at the latest +version, `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). The replay doc mirrors the live store's root container +entries so accessed-but-op-less root containers still ship. 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..bd3ef8bc7 --- /dev/null +++ b/crates/loro-internal/benches/shallow_export.rs @@ -0,0 +1,71 @@ +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(); +} + +criterion_group!(benches, shallow_export); +criterion_main!(benches); diff --git a/crates/loro-internal/src/encoding/shallow_snapshot.rs b/crates/loro-internal/src/encoding/shallow_snapshot.rs index f69c2dc16..0e7a1bef6 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}, @@ -40,7 +41,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 +83,20 @@ 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. + let pre_root_updates = + (state_frontiers == latest_frontiers && oplog.shallow_since_vv().is_empty()).then(|| { + let spans: Vec = root_vv + .iter() + .filter(|(_, counter)| **counter > 0) + .map(|(peer, counter)| IdSpan::new(*peer, 0, *counter)) + .collect(); + export_fast_updates_in_range(&oplog, &spans) + }); 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 +157,63 @@ 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(); + // 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. + { + let mut live_state = doc.app_state().lock(); + for cid in live_state.store.iter_all_container_ids() { + if matches!(cid, ContainerID::Root { .. }) { + 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 +227,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 +251,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(_))) } From 979fde80ee3c4603e970d3bb70f840e8b1292752 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 05:42:52 +0800 Subject: [PATCH 02/11] test: assert shallow export forward-replay path matches the checkout path --- .../integration_test/shallow_snapshot_test.rs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/loro/tests/integration_test/shallow_snapshot_test.rs b/crates/loro/tests/integration_test/shallow_snapshot_test.rs index abb819336..ad9c15deb 100644 --- a/crates/loro/tests/integration_test/shallow_snapshot_test.rs +++ b/crates/loro/tests/integration_test/shallow_snapshot_test.rs @@ -771,3 +771,85 @@ 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): enough ops to force the export to carry the encoded + // latest state as an overlay. + for i in 0..20 { + doc.get_map("map").insert(&format!("k{i}"), i as i64)?; + } + 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(); + + for (name, blob) in [("forward", &fast), ("checkout", &old)] { + let meta = LoroDoc::decode_import_blob_meta(blob, false)?; + assert_eq!(meta.start_frontiers, f, "{name}: start frontiers"); + } + + 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); + + 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. + for (name, d) in [("forward", &fast_doc), ("checkout", &old_doc)] { + assert!(d.is_shallow(), "{name}"); + assert_eq!(d.get_text("empty_text").to_string(), "", "{name}"); + } + + Ok(()) +} From 9a8815d6077deb4b8ebddc25f9b1721e0a1bf99e Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 18:19:49 +0800 Subject: [PATCH 03/11] fix: gate shallow forward-replay by retained ops and mirror deleted roots - Mirror the live doc's deleted_root_containers into the replay doc so a root deleted before the shallow root is dropped at flush instead of being resurrected as an empty entry. - Mirror root containers via a root-only key scan (existing_retention_roots) instead of iter_all_container_ids, which called load_all and defeated lazy imports. - Only use forward replay when >= 65536 ops are retained since the root; below that the checkout path ties in time and peaks at ~4x less memory (measured on the 66k-container fixture at F = 50%..100% of history). - Extend the path-equivalence test past the overlay threshold with full metadata and retained-history comparison, add deleted-root parity tests, and add a lazy-import benchmark entry. --- .../shallow-export-forward-root-state.md | 19 +- context/internal-encoding.md | 37 ++-- .../loro-internal/benches/shallow_export.rs | 26 ++- .../src/encoding/shallow_snapshot.rs | 41 ++++- crates/loro-internal/src/state.rs | 4 +- .../integration_test/shallow_snapshot_test.rs | 162 ++++++++++++++++-- 6 files changed, 249 insertions(+), 40 deletions(-) diff --git a/.changeset/shallow-export-forward-root-state.md b/.changeset/shallow-export-forward-root-state.md index fa497482f..548882f79 100644 --- a/.changeset/shallow-export-forward-root-state.md +++ b/.changeset/shallow-export-forward-root-state.md @@ -3,16 +3,23 @@ "loro-crdt-map": patch --- -Speed up `export({ mode: "shallow-snapshot" })` by ~20x on container-heavy documents. +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 took ~3.2s versus ~1.5ms for a -full snapshot. The root state is now reconstructed by replaying the pre-root +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 same export drops to -~159ms and the produced blob is slightly smaller (~17% on the same fixture). -Exported blobs remain logically equivalent; detached or already-shallow source -docs keep the previous code path. +~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/context/internal-encoding.md b/context/internal-encoding.md index 150930119..696246315 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -212,19 +212,30 @@ 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 and its state is already at the latest -version, `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). The replay doc mirrors the live store's root container -entries so accessed-but-op-less root containers still ship. 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. +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 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 diff --git a/crates/loro-internal/benches/shallow_export.rs b/crates/loro-internal/benches/shallow_export.rs index bd3ef8bc7..b8057e61f 100644 --- a/crates/loro-internal/benches/shallow_export.rs +++ b/crates/loro-internal/benches/shallow_export.rs @@ -67,5 +67,29 @@ fn shallow_export(c: &mut Criterion) { g.finish(); } -criterion_group!(benches, shallow_export); +/// 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(); +} + +criterion_group!(benches, shallow_export, shallow_export_lazy); criterion_main!(benches); diff --git a/crates/loro-internal/src/encoding/shallow_snapshot.rs b/crates/loro-internal/src/encoding/shallow_snapshot.rs index 0e7a1bef6..537ede5f0 100644 --- a/crates/loro-internal/src/encoding/shallow_snapshot.rs +++ b/crates/loro-internal/src/encoding/shallow_snapshot.rs @@ -24,6 +24,19 @@ 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; + #[tracing::instrument(skip_all)] pub(crate) fn export_shallow_snapshot( doc: &LoroDoc, @@ -87,9 +100,13 @@ pub(crate) fn export_shallow_snapshot_inner( // 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. - let pre_root_updates = - (state_frontiers == latest_frontiers && oplog.shallow_since_vv().is_empty()).then(|| { + // 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). + let pre_root_updates = (state_frontiers == latest_frontiers + && oplog.shallow_since_vv().is_empty() + && ops_num >= MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE) + .then(|| { let spans: Vec = root_vv .iter() .filter(|(_, counter)| **counter > 0) @@ -173,17 +190,25 @@ pub(crate) fn export_shallow_snapshot_inner( .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. + // 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 cid in live_state.store.iter_all_container_ids() { - if matches!(cid, ContainerID::Root { .. }) { - root_state.store.ensure_container(&cid); - } + 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()?; 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 ad9c15deb..af90a5c30 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 @@ -800,11 +800,14 @@ fn shallow_export_forward_replay_matches_checkout_path() -> anyhow::Result<()> { doc.commit(); let f = doc.oplog_frontiers(); - // Phase 2 (after F): enough ops to force the export to carry the encoded - // latest state as an overlay. - for i in 0..20 { - doc.get_map("map").insert(&format!("k{i}"), i as i64)?; - } + // 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)?; @@ -818,10 +821,21 @@ fn shallow_export_forward_replay_matches_checkout_path() -> anyhow::Result<()> { let old = doc.export(ExportMode::shallow_snapshot(&f))?; doc.checkout_to_latest(); - for (name, blob) in [("forward", &fast), ("checkout", &old)] { - let meta = LoroDoc::decode_import_blob_meta(blob, false)?; - assert_eq!(meta.start_frontiers, f, "{name}: start frontiers"); - } + // 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)?; @@ -833,6 +847,10 @@ fn shallow_export_forward_replay_matches_checkout_path() -> anyhow::Result<()> { 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 @@ -853,3 +871,125 @@ fn shallow_export_forward_replay_matches_checkout_path() -> anyhow::Result<()> { 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(()) +} From 3c4927312c9ec7863f486a5affc223844264c339 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 18:27:07 +0800 Subject: [PATCH 04/11] chore: ignore symlinked node_modules in gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 9e483fa834ccce027d9a9763b78c578506b30d7e Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 20:52:12 +0800 Subject: [PATCH 05/11] perf: bound the pre-root prefix eligible for shallow forward replay The retained-ops gate alone could select the forward-replay path for a doc whose pre-root history is huge but unrelated to the tail (e.g. millions of same-key Map overwrites before the root, one 65k-atom Text insert after it), re-encoding and replaying the whole prefix while the checkout path only walks the tail. Cap the prefix absolutely (1M ops) and relative to the tail (16x; measured crossover: forward wins at ratio 9, loses at 19). Add a scalar-prefix-heavy benchmark entry as a regression guard: 280ms un-gated vs 56ms gated on a 2M-op prefix + 70k-atom tail fixture. --- .../shallow-export-forward-root-state.md | 5 ++- context/internal-encoding.md | 7 ++++ .../loro-internal/benches/shallow_export.rs | 39 ++++++++++++++++++- .../src/encoding/shallow_snapshot.rs | 25 +++++++++++- 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/.changeset/shallow-export-forward-root-state.md b/.changeset/shallow-export-forward-root-state.md index 548882f79..8aff5c5f6 100644 --- a/.changeset/shallow-export-forward-root-state.md +++ b/.changeset/shallow-export-forward-root-state.md @@ -13,7 +13,10 @@ 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 same export drops to +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), so a document whose pre-root history is huge 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 diff --git a/context/internal-encoding.md b/context/internal-encoding.md index 696246315..61ff7a309 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -225,6 +225,13 @@ empty per touched container (the `should_rebuild` path in 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) and `pre_root_ops <= 1_000_000` +(`MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY`; bounds peak memory on wasm32). A huge +unrelated scalar prefix with a large tail must stay on the checkout path — +see the `shallow_export_scalar_prefix` bench. 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 diff --git a/crates/loro-internal/benches/shallow_export.rs b/crates/loro-internal/benches/shallow_export.rs index b8057e61f..ea5c69445 100644 --- a/crates/loro-internal/benches/shallow_export.rs +++ b/crates/loro-internal/benches/shallow_export.rs @@ -91,5 +91,42 @@ fn shallow_export_lazy(c: &mut Criterion) { g.finish(); } -criterion_group!(benches, shallow_export, shallow_export_lazy); +/// 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(); +} + +criterion_group!( + benches, + shallow_export, + shallow_export_lazy, + shallow_export_scalar_prefix_heavy +); criterion_main!(benches); diff --git a/crates/loro-internal/src/encoding/shallow_snapshot.rs b/crates/loro-internal/src/encoding/shallow_snapshot.rs index 537ede5f0..316927e39 100644 --- a/crates/loro-internal/src/encoding/shallow_snapshot.rs +++ b/crates/loro-internal/src/encoding/shallow_snapshot.rs @@ -37,6 +37,19 @@ 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; + #[tracing::instrument(skip_all)] pub(crate) fn export_shallow_snapshot( doc: &LoroDoc, @@ -102,10 +115,18 @@ pub(crate) fn export_shallow_snapshot_inner( // 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). + // 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(); let pre_root_updates = (state_frontiers == latest_frontiers && oplog.shallow_since_vv().is_empty() - && ops_num >= MIN_RETAINED_OPS_FOR_FORWARD_ROOT_STATE) + && 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) .then(|| { let spans: Vec = root_vv .iter() From 7ad3dfcf9a57c4d39e36c76167d3a1d54f6826f4 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 21:26:19 +0800 Subject: [PATCH 06/11] perf: gate shallow forward replay on encoded prefix bytes too Op-atom counts miss value sizes: a Map write is one atom regardless of how large its Binary/String payload is, so a byte-heavy low-op prefix could bypass the op-count gates and be fully re-encoded and replayed into the temp doc. Encode the (cheap, block-copied) prefix blob first, then drop it when it exceeds 32 MiB. The gate logic is extracted into a pure predicate with unit tests, plus a byte-heavy low-op prefix export-correctness test and a byte-prefix benchmark entry. Also assert the op-less root container's existence with has_container before materializing it in the path-equivalence test. --- .../shallow-export-forward-root-state.md | 6 +- context/internal-encoding.md | 14 ++- .../loro-internal/benches/shallow_export.rs | 34 +++++- .../src/encoding/shallow_snapshot.rs | 100 ++++++++++++++++-- .../integration_test/shallow_snapshot_test.rs | 8 +- 5 files changed, 143 insertions(+), 19 deletions(-) diff --git a/.changeset/shallow-export-forward-root-state.md b/.changeset/shallow-export-forward-root-state.md index 8aff5c5f6..7c4b1bbe4 100644 --- a/.changeset/shallow-export-forward-root-state.md +++ b/.changeset/shallow-export-forward-root-state.md @@ -15,8 +15,10 @@ 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), so a document whose pre-root history is huge and unrelated to -the tail keeps the previous checkout path. The same export drops to +(at most 1M ops) and encoded size (at most 32 MiB — 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 diff --git a/context/internal-encoding.md b/context/internal-encoding.md index 61ff7a309..6da03e0f5 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -228,10 +228,16 @@ 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) and `pre_root_ops <= 1_000_000` -(`MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY`; bounds peak memory on wasm32). A huge -unrelated scalar prefix with a large tail must stay on the checkout path — -see the `shallow_export_scalar_prefix` bench. +ratio 9, loses at 19), `pre_root_ops <= 1_000_000` +(`MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY`), and an encoded-byte cap on the +pre-encoded prefix blob (`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. Note the byte cap sees LZ4-compressed +sizes, so highly compressible payloads pass it; it bounds realistic +(incompressible) binary payloads, not adversarial repeated-byte ones. +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 diff --git a/crates/loro-internal/benches/shallow_export.rs b/crates/loro-internal/benches/shallow_export.rs index ea5c69445..b6acbd329 100644 --- a/crates/loro-internal/benches/shallow_export.rs +++ b/crates/loro-internal/benches/shallow_export.rs @@ -123,10 +123,42 @@ fn shallow_export_scalar_prefix_heavy(c: &mut Criterion) { 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_scalar_prefix_heavy, + shallow_export_byte_prefix_heavy ); criterion_main!(benches); diff --git a/crates/loro-internal/src/encoding/shallow_snapshot.rs b/crates/loro-internal/src/encoding/shallow_snapshot.rs index 316927e39..f2f3e6f8c 100644 --- a/crates/loro-internal/src/encoding/shallow_snapshot.rs +++ b/crates/loro-internal/src/encoding/shallow_snapshot.rs @@ -50,6 +50,28 @@ const MAX_PRE_ROOT_TO_RETAINED_OPS_RATIO: usize = 16; /// on wasm32. const MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY: usize = 1_000_000; +/// Cap on the encoded pre-root updates blob. 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. The blob is +/// copied into a temporary doc for replay, so bound its actual encoded size. +/// Encoding the blob is cheap (block-level copy), so it is encoded first and +/// then filtered. +#[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; + +/// 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, @@ -122,19 +144,24 @@ pub(crate) fn export_shallow_snapshot_inner( .iter() .map(|(_, counter)| (*counter).max(0) as usize) .sum(); + // Cheap op-count legs first (encoding the prefix blob on every export + // would regress the small-tail cases the checkout path handles best); + // the byte leg runs on the encoded blob afterwards. let pre_root_updates = (state_frontiers == latest_frontiers && oplog.shallow_since_vv().is_empty() - && 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) - .then(|| { - let spans: Vec = root_vv - .iter() - .filter(|(_, counter)| **counter > 0) - .map(|(peer, counter)| IdSpan::new(*peer, 0, *counter)) - .collect(); - export_fast_updates_in_range(&oplog, &spans) - }); + && 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(); + export_fast_updates_in_range(&oplog, &spans) + }) + // 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 above. + .filter(|bytes| bytes.len() <= MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY); 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)) = @@ -996,4 +1023,55 @@ 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 (one huge 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); + map.insert("k", big.as_str()).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); + } } diff --git a/crates/loro/tests/integration_test/shallow_snapshot_test.rs b/crates/loro/tests/integration_test/shallow_snapshot_test.rs index af90a5c30..326ab58d2 100644 --- a/crates/loro/tests/integration_test/shallow_snapshot_test.rs +++ b/crates/loro/tests/integration_test/shallow_snapshot_test.rs @@ -863,9 +863,15 @@ fn shallow_export_forward_replay_matches_checkout_path() -> anyhow::Result<()> { }; 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. + // 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}"); } From f04a537b13aed2e6b118485d98e02842f203da46 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 23:14:57 +0800 Subject: [PATCH 07/11] perf: check the shallow forward-replay byte cap before copying prefix values The encoded-byte filter ran only after export_fast_updates_in_range had already slice-copied every prefix value into a fresh ChangeStore, so the cap could not prevent the large allocation it was meant to avoid. Replace it with a decoded-size estimate that walks op payloads by reference (new SharedArena::with_values) and short-circuits past the cap, so a rejected prefix costs one bounded walk and zero payload copies. Add an estimator unit test. --- .../shallow-export-forward-root-state.md | 9 +- context/internal-encoding.md | 14 +- crates/loro-internal/src/arena.rs | 7 + .../src/encoding/shallow_snapshot.rs | 120 ++++++++++++++++-- 4 files changed, 126 insertions(+), 24 deletions(-) diff --git a/.changeset/shallow-export-forward-root-state.md b/.changeset/shallow-export-forward-root-state.md index 7c4b1bbe4..da88b5ada 100644 --- a/.changeset/shallow-export-forward-root-state.md +++ b/.changeset/shallow-export-forward-root-state.md @@ -15,10 +15,11 @@ 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 encoded size (at most 32 MiB — 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 +(at most 1M ops) and decoded payload size (at most 32 MiB, estimated by +walking op payloads 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 diff --git a/context/internal-encoding.md b/context/internal-encoding.md index 6da03e0f5..b80cfca65 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -229,12 +229,14 @@ 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 an encoded-byte cap on the -pre-encoded prefix blob (`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. Note the byte cap sees LZ4-compressed -sizes, so highly compressible payloads pass it; it bounds realistic -(incompressible) binary payloads, not adversarial repeated-byte ones. +(`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) with an 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. 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 f2f3e6f8c..b985de826 100644 --- a/crates/loro-internal/src/encoding/shallow_snapshot.rs +++ b/crates/loro-internal/src/encoding/shallow_snapshot.rs @@ -50,17 +50,79 @@ const MAX_PRE_ROOT_TO_RETAINED_OPS_RATIO: usize = 16; /// on wasm32. const MAX_PRE_ROOT_OPS_FOR_FORWARD_REPLAY: usize = 1_000_000; -/// Cap on the encoded pre-root updates blob. 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. The blob is -/// copied into a temporary doc for replay, so bound its actual encoded size. -/// Encoding the blob is cheap (block-level copy), so it is encoded first and -/// then filtered. +/// 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 all op payloads in `spans`, following +/// arena slices by reference and never copying values. Returns early with a +/// value greater than `cap` once 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 op in oplog.iter_ops(span) { + total = total.saturating_add(rich_op_content_bytes(oplog, &op)); + if total > cap { + break 'outer; + } + } + } + total +} + +/// Byte size of an op's payload plus a small flat per-op overhead. Arena +/// slices are measured by reference; nothing is copied. +fn rich_op_content_bytes(oplog: &crate::OpLog, op: &crate::op::RichOp) -> usize { + const PER_OP_OVERHEAD: usize = 16; + fn value_bytes(v: &loro_common::LoroValue) -> usize { + match v { + loro_common::LoroValue::String(s) => s.len(), + loro_common::LoroValue::Binary(b) => b.len(), + _ => PER_OP_OVERHEAD, + } + } + + PER_OP_OVERHEAD + + match &op.raw_op().content { + crate::op::InnerContent::Map(map) => { + map.key.len() + map.value.as_ref().map_or(0, value_bytes) + } + 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| { + values.iter().map(value_bytes).sum() + }), + InnerListOp::InsertText { slice, .. } => slice.len(), + InnerListOp::Set { value, .. } => value_bytes(value), + _ => 0, + }, + _ => 0, + } +} + /// 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 @@ -144,9 +206,12 @@ pub(crate) fn export_shallow_snapshot_inner( .iter() .map(|(_, counter)| (*counter).max(0) as usize) .sum(); - // Cheap op-count legs first (encoding the prefix blob on every export - // would regress the small-tail cases the checkout path handles best); - // the byte leg runs on the encoded blob afterwards. + // 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)) @@ -156,12 +221,15 @@ pub(crate) fn export_shallow_snapshot_inner( .filter(|(_, counter)| **counter > 0) .map(|(peer, counter)| IdSpan::new(*peer, 0, *counter)) .collect(); - export_fast_updates_in_range(&oplog, &spans) + 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)) }) - // 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 above. - .filter(|bytes| bytes.len() <= MAX_PRE_ROOT_BYTES_FOR_FORWARD_REPLAY); + .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)) = @@ -1074,4 +1142,28 @@ mod tests { 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); + } } From 5feae79431c26fa5481aa592168dbe05a87f39cc Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 23:35:42 +0800 Subject: [PATCH 08/11] perf: make the shallow forward-replay byte estimate recursive and cap-aware The estimator counted only top-level String/Binary payloads; nested LoroValue::List/Map were charged a flat 16 bytes while the encoder recurses into them, so { payload: } still bypassed the byte cap. Count nested values recursively, include StyleStart values and commit messages, and give every counting step a remaining budget so the walk short-circuits past the cap. The byte-heavy prefix regression test now nests its payload one level down. --- .../shallow-export-forward-root-state.md | 5 +- context/internal-encoding.md | 9 +- .../src/encoding/shallow_snapshot.rs | 172 +++++++++++++++--- crates/loro-internal/src/oplog.rs | 7 + 4 files changed, 160 insertions(+), 33 deletions(-) diff --git a/.changeset/shallow-export-forward-root-state.md b/.changeset/shallow-export-forward-root-state.md index da88b5ada..aa744e5f6 100644 --- a/.changeset/shallow-export-forward-root-state.md +++ b/.changeset/shallow-export-forward-root-state.md @@ -16,8 +16,9 @@ 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 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 +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). diff --git a/context/internal-encoding.md b/context/internal-encoding.md index b80cfca65..03356c0dd 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -233,10 +233,11 @@ ratio 9, loses at 19), `pre_root_ops <= 1_000_000` (`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) with an 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. +walks op payloads by reference (arena slices are never copied), recursing into +nested `LoroValue::List`/`Map` and counting style 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. diff --git a/crates/loro-internal/src/encoding/shallow_snapshot.rs b/crates/loro-internal/src/encoding/shallow_snapshot.rs index b985de826..7a4816b9f 100644 --- a/crates/loro-internal/src/encoding/shallow_snapshot.rs +++ b/crates/loro-internal/src/encoding/shallow_snapshot.rs @@ -63,10 +63,12 @@ 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 all op payloads in `spans`, following -/// arena slices by reference and never copying values. Returns early with a -/// value greater than `cap` once exceeded, so a rejected prefix costs one -/// bounded walk instead of a full copy. +/// 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 { @@ -82,10 +84,27 @@ fn estimate_ops_content_bytes(oplog: &crate::OpLog, spans: &[IdSpan], cap: usize continue; } - for op in oplog.iter_ops(span) { - total = total.saturating_add(rich_op_content_bytes(oplog, &op)); - if total > cap { - break 'outer; + 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; + } } } } @@ -93,36 +112,93 @@ fn estimate_ops_content_bytes(oplog: &crate::OpLog, spans: &[IdSpan], cap: usize } /// Byte size of an op's payload plus a small flat per-op overhead. Arena -/// slices are measured by reference; nothing is copied. -fn rich_op_content_bytes(oplog: &crate::OpLog, op: &crate::op::RichOp) -> usize { +/// 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; - fn value_bytes(v: &loro_common::LoroValue) -> usize { - match v { - loro_common::LoroValue::String(s) => s.len(), - loro_common::LoroValue::Binary(b) => b.len(), - _ => PER_OP_OVERHEAD, - } + if remaining < PER_OP_OVERHEAD { + return remaining + 1; } PER_OP_OVERHEAD + match &op.raw_op().content { crate::op::InnerContent::Map(map) => { - map.key.len() + map.value.as_ref().map_or(0, value_bytes) + let mut used = map.key.len(); + if let Some(value) = map.value.as_ref() { + used = used.saturating_add(value_bytes_capped( + value, + remaining.saturating_sub(PER_OP_OVERHEAD + 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| { - values.iter().map(value_bytes).sum() - }), + 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, + remaining.saturating_sub(PER_OP_OVERHEAD + used), + )); + if PER_OP_OVERHEAD + used > remaining { + break; + } + } + used + }, + ), InnerListOp::InsertText { slice, .. } => slice.len(), - InnerListOp::Set { value, .. } => value_bytes(value), + InnerListOp::Set { value, .. } | InnerListOp::StyleStart { value, .. } => { + value_bytes_capped(value, remaining.saturating_sub(PER_OP_OVERHEAD)) + } _ => 0, }, _ => 0, } } +/// 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 @@ -1113,16 +1189,20 @@ mod tests { assert!(forward_replay_gate(min, 1, max_bytes)); } - /// A byte-heavy, low-op prefix (one huge 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. + /// 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); - map.insert("k", big.as_str()).unwrap(); + // 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(); @@ -1166,4 +1246,42 @@ mod tests { 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}" + ); + } } 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| { From f92bf18fa6dce300cbd80ea3e289b9ad5fc639ef Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 5 Sep 2026 00:05:43 +0800 Subject: [PATCH 09/11] perf: count every variable-length field in the shallow prefix byte estimate The estimator missed fields the block encoder copies: StyleStart keys (which go into the block's key register), TreeOp fractional indexes, root container names (copied into the block's container arena), and unknown-future payload bytes. All are now counted with the same cap-aware budget, covering the huge-style-key-on-deleted-pre-root-text scenario. --- context/internal-encoding.md | 6 +- .../src/encoding/shallow_snapshot.rs | 109 ++++++++++++++++-- 2 files changed, 104 insertions(+), 11 deletions(-) diff --git a/context/internal-encoding.md b/context/internal-encoding.md index 03356c0dd..ab5ea4781 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -234,8 +234,10 @@ ratio 9, loses at 19), `pre_root_ops <= 1_000_000` 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 style values and commit messages, -with a budget-aware early exit past the cap, while +nested `LoroValue::List`/`Map` and counting everything the block encoder +copies: map and style keys, style values, fractional indexes, root container +names, unknown-future bytes, 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 — diff --git a/crates/loro-internal/src/encoding/shallow_snapshot.rs b/crates/loro-internal/src/encoding/shallow_snapshot.rs index 7a4816b9f..dd04566d9 100644 --- a/crates/loro-internal/src/encoding/shallow_snapshot.rs +++ b/crates/loro-internal/src/encoding/shallow_snapshot.rs @@ -111,23 +111,35 @@ fn estimate_ops_content_bytes(oplog: &crate::OpLog, spans: &[IdSpan], cap: usize total } -/// Byte size of an op's payload plus a small flat per-op overhead. 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. +/// 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, - remaining.saturating_sub(PER_OP_OVERHEAD + used), + after_fixed.saturating_sub(used), )); } used @@ -140,9 +152,9 @@ fn rich_op_content_bytes(oplog: &crate::OpLog, op: &crate::op::RichOp, remaining for value in values { used = used.saturating_add(value_bytes_capped( value, - remaining.saturating_sub(PER_OP_OVERHEAD + used), + after_fixed.saturating_sub(used), )); - if PER_OP_OVERHEAD + used > remaining { + if used > after_fixed { break; } } @@ -150,15 +162,57 @@ fn rich_op_content_bytes(oplog: &crate::OpLog, op: &crate::op::RichOp, remaining }, ), InnerListOp::InsertText { slice, .. } => slice.len(), - InnerListOp::Set { value, .. } | InnerListOp::StyleStart { value, .. } => { - value_bytes_capped(value, remaining.saturating_sub(PER_OP_OVERHEAD)) + 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, }, - _ => 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)), + OwnedValue::Future(owned) => match owned { + crate::encoding::value::OwnedFutureValue::Unknown { data, .. } => { + OVERHEAD.saturating_add(data.len()) + } + }, + _ => 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. @@ -1284,4 +1338,41 @@ mod tests { "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}" + ); + } } From ad446ea2922445603280e3fe98d06b674d807d88 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 5 Sep 2026 00:21:40 +0800 Subject: [PATCH 10/11] perf: count MarkStart/ListSet payloads in the shallow prefix byte estimate owned_value_bytes_capped folded every other OwnedValue variant to a flat 16 bytes, but the encoder writes MarkStart keys into the block key register and recurses into MarkStart/ListSet values, so unknown-container ops (whose decoder accepts any Value) could still smuggle a huge payload past the byte cap. Count both, with the same cap-aware recursion. TreeMove/ListMove keep a flat charge: they carry only fixed-size indices. --- .../src/encoding/shallow_snapshot.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/crates/loro-internal/src/encoding/shallow_snapshot.rs b/crates/loro-internal/src/encoding/shallow_snapshot.rs index dd04566d9..235b835fe 100644 --- a/crates/loro-internal/src/encoding/shallow_snapshot.rs +++ b/crates/loro-internal/src/encoding/shallow_snapshot.rs @@ -204,11 +204,27 @@ fn owned_value_bytes_capped(value: &crate::encoding::value::OwnedValue, remainin 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, } } @@ -1375,4 +1391,59 @@ mod tests { "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}" + ); + } } From f39b5bd1b2beffe57fd47295489d455d88b0d94f Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 5 Sep 2026 00:22:05 +0800 Subject: [PATCH 11/11] docs: note MarkStart/ListSet coverage in the prefix byte estimate --- context/internal-encoding.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/context/internal-encoding.md b/context/internal-encoding.md index ab5ea4781..ce9f78f43 100644 --- a/context/internal-encoding.md +++ b/context/internal-encoding.md @@ -236,7 +236,8 @@ 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-future bytes, and commit messages, with a budget-aware early +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.