diff --git a/.changeset/big-insert-import.md b/.changeset/big-insert-import.md new file mode 100644 index 000000000..b568fa2d2 --- /dev/null +++ b/.changeset/big-insert-import.md @@ -0,0 +1,5 @@ +--- +"loro-crdt": patch +--- + +Fix importing one big text insert made right after a concurrent change taking quadratic time (3M characters: ~50 s, now ~0.1 s). The text tracker updated the id-to-cursor map once per rope leaf report, and the rope reported the same leaf once per 256-op fragment; each update walked the whole insert. Repeated leaves are now updated once. diff --git a/crates/loro-internal/src/container/richtext/tracker.rs b/crates/loro-internal/src/container/richtext/tracker.rs index 942f28730..3ff591ba4 100644 --- a/crates/loro-internal/src/container/richtext/tracker.rs +++ b/crates/loro-internal/src/container/richtext/tracker.rs @@ -6,6 +6,7 @@ use generic_btree::{ }; use loro_common::{Counter, HasId, HasIdSpan, IdFull, IdSpan, Lamport, PeerID, ID}; use rle::HasLength as _; +use rustc_hash::FxHashSet; use tracing::instrument; use crate::{cursor::AbsolutePosition, VersionVector}; @@ -186,7 +187,12 @@ impl Tracker { } _ => { let mut updates = Vec::with_capacity(split.len()); + let mut seen = FxHashSet::default(); for &new_leaf_idx in split { + // The rope reports a leaf once per update that touched it; updating it once is enough. + if !seen.insert(new_leaf_idx) { + continue; + } let leaf = self.rope.tree().get_elem(new_leaf_idx).unwrap(); updates.push((leaf.id_span(), new_leaf_idx)); } diff --git a/crates/loro/tests/big_insert_import.rs b/crates/loro/tests/big_insert_import.rs new file mode 100644 index 000000000..384e86fa8 --- /dev/null +++ b/crates/loro/tests/big_insert_import.rs @@ -0,0 +1,67 @@ +//! Importing one big text insert made right after a concurrent change. The rope +//! reported the inserted leaf once per id-to-cursor fragment (256 ops), and each +//! report walked every fragment of the insert, so the import took time quadratic +//! in the insert's length (3M characters: ~50 s). +//! +//! Run the timing check with `cargo test --release -p loro --test big_insert_import +//! -- --ignored --nocapture`. + +use loro::{ExportMode, LoroDoc}; +use std::time::Instant; + +fn text(n: usize) -> String { + (0..n) + .map(|i| (b'a' + ((i as u64 * 2_654_435_761) >> 7) as u8 % 26) as char) + .collect() +} + +/// Peer 1 inserts `n` characters at the start right after importing peer 2's +/// concurrent edit; returns both docs and peer 1's update for the insert. +fn big_insert(n: usize) -> (LoroDoc, LoroDoc, Vec) { + let a = LoroDoc::new(); + a.set_peer_id(1).unwrap(); + a.get_text("text").insert(0, "Hello world").unwrap(); + a.commit(); + let b = LoroDoc::new(); + b.set_peer_id(2).unwrap(); + b.import(&a.export(ExportMode::Snapshot).unwrap()).unwrap(); + b.get_text("text").insert(0, "B ").unwrap(); + b.commit(); + a.import(&b.export(ExportMode::all_updates()).unwrap()) + .unwrap(); + let from = a.oplog_vv(); + a.get_text("text").insert(0, &text(n)).unwrap(); + a.commit(); + let update = a.export(ExportMode::updates(&from)).unwrap(); + (a, b, update) +} + +#[test] +fn big_insert_after_concurrent_edit_imports_the_same_text() { + let (a, b, update) = big_insert(20_000); + b.import(&update).unwrap(); + assert_eq!( + a.get_text("text").to_string(), + b.get_text("text").to_string() + ); + assert_eq!( + a.get_text("text").len_unicode(), + 20_000 + "B Hello world".len() + ); +} + +#[test] +#[ignore] +fn big_insert_import_is_not_quadratic() { + let (a, b, update) = big_insert(1_000_000); + let start = Instant::now(); + b.import(&update).unwrap(); + let elapsed = start.elapsed().as_secs_f64(); + println!("1M characters imported in {elapsed:.3}s"); + assert_eq!( + a.get_text("text").to_string(), + b.get_text("text").to_string() + ); + // ~0.02 s linear on a laptop; ~5 s when quadratic. + assert!(elapsed < 1.0, "1M-character import took {elapsed:.3}s"); +}