Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-dead-cache-clear.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/fix-fork-default-text-style.md
Original file line number Diff line number Diff line change
@@ -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()`.
16 changes: 13 additions & 3 deletions context/dead-container-cache.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
5 changes: 4 additions & 1 deletion crates/loro-internal/src/configure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
8 changes: 4 additions & 4 deletions crates/loro-internal/src/state/dead_containers_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
89 changes: 89 additions & 0 deletions crates/loro/tests/dead_container_cache_perf.rs
Original file line number Diff line number Diff line change
@@ -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");
}
102 changes: 102 additions & 0 deletions crates/loro/tests/fork_text_style.rs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading