diff --git a/.changeset/fix-dead-cache-clear.md b/.changeset/fix-dead-cache-clear.md new file mode 100644 index 000000000..3e3265845 --- /dev/null +++ b/.changeset/fix-dead-cache-clear.md @@ -0,0 +1,5 @@ +--- +"loro-crdt": patch +--- + +Fix quadratic local tree moves when interleaved with queries or failed edits on deleted containers by releasing the revivable deletion cache's table on invalidation. diff --git a/.changeset/fix-fork-default-text-style.md b/.changeset/fix-fork-default-text-style.md new file mode 100644 index 000000000..644c2dfe0 --- /dev/null +++ b/.changeset/fix-fork-default-text-style.md @@ -0,0 +1,5 @@ +--- +"loro-crdt": patch +--- + +Preserve the full text style configuration, including the default for previously unused style keys, when calling `fork()` or `forkAt()`. diff --git a/context/dead-container-cache.md b/context/dead-container-cache.md index 2582d5749..28d74ceba 100644 --- a/context/dead-container-cache.md +++ b/context/dead-container-cache.md @@ -1,6 +1,6 @@ # Dead Container Cache -Verified against code 2026-09-30. +Verified against code 2026-10-01. `DocState::is_deleted` (`crates/loro-internal/src/state/dead_containers_cache.rs`) answers whether a container is unreachable from a root: it walks the arena @@ -55,8 +55,14 @@ walk and assert that no cached entry on the chain contradicts the result. rollback drops is not true of the kept history. See [failed-import-arena-indices.md](failed-import-arena-indices.md). -`clear_revivable` is O(1) when the set is empty, so documents that never query -deleted containers pay nothing. +`DeadContainersCache::clear_revivable` replaces the set with a fresh empty set, +releasing its allocation. Its entries are plain `ContainerIdx` values, so this +does not walk the table. The full `clear` also uses this path for `revivable`; +it retains the `final_deletions` allocation. Reusing the revivable table with +`HashSet::clear` would scan its peak capacity whenever a deleted-container +query inserts even one entry before the next move, making a loop of queries +and moves quadratic after caching many deleted containers. Empty caches need +no allocation. Undo, redo, `revert_to`, and `apply_diff` do not revive container IDs by themselves: `Handler::apply_diff` creates a new tree node for a deleted target @@ -81,3 +87,7 @@ like any other internal assertion under that lock. `--release` as well; debug builds recompute the answer. - Unit tests in `dead_containers_cache.rs` check the cache entries directly, so they also fail in debug builds when an invalidation is missing. +- `crates/loro/tests/dead_container_cache_perf.rs`: ignored release-mode + regression that measures three repeats at 32k and 128k deleted nodes, with + one deletion query per local tree move and a moves-only control. It asserts + a median `time(4n)/time(n)` ratio below 8, not a wall-clock limit. diff --git a/crates/loro-internal/src/configure.rs b/crates/loro-internal/src/configure.rs index 90955f79c..7ed6c8c5b 100644 --- a/crates/loro-internal/src/configure.rs +++ b/crates/loro-internal/src/configure.rs @@ -19,7 +19,10 @@ pub struct Configure { impl LoroDoc { pub(crate) fn set_config(&self, config: &Configure) { - self.config_text_style(config.text_style_config.read().clone()); + // Forks need both explicit styles and the fallback for unused keys. + // config_text_style intentionally only replaces the explicit map. + let text_style_config = config.text_style_config.read().clone(); + *self.config.text_style_config.write() = text_style_config; self.set_record_timestamp(config.record_timestamp()); self.set_change_merge_interval(config.merge_interval()); self.set_detached_editing(config.detached_editing()); diff --git a/crates/loro-internal/src/state/dead_containers_cache.rs b/crates/loro-internal/src/state/dead_containers_cache.rs index e2ccaa448..33976d68e 100644 --- a/crates/loro-internal/src/state/dead_containers_cache.rs +++ b/crates/loro-internal/src/state/dead_containers_cache.rs @@ -19,15 +19,15 @@ impl DeadContainersCache { /// revive any container. pub fn clear(&mut self) { self.final_deletions.clear(); - self.revivable.clear(); + self.clear_revivable(); } /// Must be called whenever a tree or movable-list change is applied going /// forward, because it may move a deleted node or element back. pub fn clear_revivable(&mut self) { - if !self.revivable.is_empty() { - self.revivable.clear(); - } + // Reusing a large table makes interleaved deletion queries and moves + // repeatedly clear its peak capacity, even with only one live entry. + self.revivable = FxHashSet::default(); } fn contains(&self, idx: &ContainerIdx) -> bool { diff --git a/crates/loro/tests/dead_container_cache_perf.rs b/crates/loro/tests/dead_container_cache_perf.rs new file mode 100644 index 000000000..911a5bb15 --- /dev/null +++ b/crates/loro/tests/dead_container_cache_perf.rs @@ -0,0 +1,89 @@ +//! Run with `CARGO_BUILD_JOBS=4 cargo test --release -p loro +//! --test dead_container_cache_perf -- --ignored --nocapture`. + +use loro::{ContainerTrait, LoroDoc, TreeParentId}; +use std::{hint::black_box, time::Instant}; + +fn measure(n: usize) -> (f64, f64) { + let doc = LoroDoc::new(); + doc.set_peer_id(1).unwrap(); + let tree = doc.get_tree("tree"); + let deleted: Vec<_> = (0..n) + .map(|_| tree.create(TreeParentId::Root).unwrap()) + .collect(); + for id in &deleted { + tree.delete(*id).unwrap(); + } + let p1 = tree.create(TreeParentId::Root).unwrap(); + let p2 = tree.create(TreeParentId::Root).unwrap(); + let target = tree.create(p1).unwrap(); + doc.commit(); + let metas: Vec<_> = deleted + .iter() + .map(|id| tree.get_meta(*id).unwrap()) + .collect(); + + // Grow the cache to n entries, warm invalidation, then refill it. A clear + // that retains the peak allocation scans that table on every later move. + for meta in &metas { + assert!(meta.is_deleted()); + } + for _ in 0..8 { + assert!(black_box(metas[0].is_deleted())); + tree.mov(target, p2).unwrap(); + tree.mov(target, p1).unwrap(); + } + for meta in &metas { + assert!(meta.is_deleted()); + } + + let start = Instant::now(); + for i in 0..n { + assert!(black_box(metas[0].is_deleted())); + tree.mov(target, if i % 2 == 0 { p2 } else { p1 }).unwrap(); + } + let interleaved = start.elapsed().as_secs_f64(); + + // Same moves without deletion queries, to expose unrelated machine load. + let start = Instant::now(); + for i in 0..n { + tree.mov(target, if i % 2 == 0 { p2 } else { p1 }).unwrap(); + } + let control = start.elapsed().as_secs_f64(); + for meta in &metas { + assert!(meta.is_deleted()); + } + assert!(!tree.is_node_deleted(&target).unwrap()); + (interleaved, control) +} + +fn median(samples: &mut [f64]) -> f64 { + samples.sort_by(f64::total_cmp); + samples[samples.len() / 2] +} + +#[test] +#[ignore = "release-only scaling measurement; builds 32k and 128k deleted nodes"] +fn is_deleted_interleaved_with_moves() { + assert!(!cfg!(debug_assertions), "run this test with --release"); + let n = 32_000; + let mut small = Vec::new(); + let mut large = Vec::new(); + // Alternate sizes to reduce drift from other sessions sharing the host. + for run in 0..3 { + for size in if run % 2 == 0 { [n, 4 * n] } else { [4 * n, n] } { + let (secs, control) = measure(size); + println!("RESULT n={size} run={run} secs={secs:.6} control_secs={control:.6}"); + if size == n { + small.push(secs); + } else { + large.push(secs); + } + } + } + let ratio = median(&mut large) / median(&mut small); + println!("RESULT scaling_ratio={ratio:.3}"); + // Linear work should scale near 4x; allow host noise while rejecting the + // retained-table regression, whose repeated clearing approaches 16x. + assert!(ratio < 8.0, "time(4n)/time(n) = {ratio:.3}, expected < 8"); +} diff --git a/crates/loro/tests/fork_text_style.rs b/crates/loro/tests/fork_text_style.rs new file mode 100644 index 000000000..af14fbe36 --- /dev/null +++ b/crates/loro/tests/fork_text_style.rs @@ -0,0 +1,102 @@ +use loro::{ExpandType, LoroDoc, StyleConfig, StyleConfigMap, TextDelta}; + +fn assert_fork_keeps_styles(fork: &LoroDoc) { + let text = fork.get_text("text"); + // Neither key was used in the source's history, so snapshot decoding + // cannot infer its config from existing marks. + text.mark(0..2, "new_custom_key", true).unwrap(); + text.mark(0..2, "explicit", true).unwrap(); + text.insert(0, "L").unwrap(); + text.insert(text.len_unicode(), "R").unwrap(); + assert_eq!( + text.to_delta(), + vec![ + TextDelta::Insert { + insert: "L".into(), + attributes: Some( + [("new_custom_key".into(), true.into())] + .into_iter() + .collect(), + ), + }, + TextDelta::Insert { + insert: "ab".into(), + attributes: Some( + [ + ("new_custom_key".into(), true.into()), + ("explicit".into(), true.into()) + ] + .into_iter() + .collect(), + ), + }, + TextDelta::Insert { + insert: "R".into(), + attributes: None, + }, + ] + ); +} + +#[test] +fn fork_and_fork_at_keep_default_and_explicit_text_styles() { + let doc = LoroDoc::new(); + let mut styles = StyleConfigMap::new(); + styles.insert( + "explicit".into(), + StyleConfig::new().expand(ExpandType::None), + ); + doc.config_text_style(styles); + doc.config_default_text_style(Some(StyleConfig::new().expand(ExpandType::Before))); + doc.get_text("text").insert(0, "ab").unwrap(); + doc.commit(); + let version = doc.state_frontiers(); + doc.get_map("later").insert("value", 1).unwrap(); + doc.commit(); + + let fork = doc.fork(); + let historical = doc.fork_at(&version).unwrap(); + doc.checkout(&version).unwrap(); + let detached = doc.fork(); + doc.checkout_to_latest(); + + // Copies must also be independent of later source config changes. + doc.config_default_text_style(None); + doc.config_text_style(StyleConfigMap::new()); + for fork in [&fork, &historical, &detached] { + assert_fork_keeps_styles(fork); + } +} + +#[test] +fn source_and_fork_accept_a_previously_unused_style_key() { + let doc = LoroDoc::new(); + doc.config_default_text_style(Some(StyleConfig::new())); + doc.get_text("text").insert(0, "ab").unwrap(); + doc.commit(); + let fork = doc.fork(); + let historical = doc.fork_at(&doc.state_frontiers()).unwrap(); + for doc in [&doc, &fork, &historical] { + doc.get_text("text").mark(0..2, "unused", true).unwrap(); + } +} + +#[test] +fn wasm_facing_internal_document_forks_keep_default_text_style() { + // The WASM wrapper delegates both fork methods directly to this Rust API. + use loro_internal::{cursor::PosType, LoroDoc as InternalDoc}; + + let doc = InternalDoc::new_auto_commit(); + doc.config_default_text_style(Some(StyleConfig::new())); + doc.get_text("text") + .insert(0, "ab", PosType::Unicode) + .unwrap(); + doc.commit_then_renew(); + let fork = doc.fork(); + let historical = doc.fork_at(&doc.state_frontiers()).unwrap(); + for doc in [&doc, &fork, &historical] { + doc.get_text("text") + .mark(0, 2, "unused", true.into(), PosType::Unicode) + .unwrap(); + } +}