Skip to content
Open
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/big-insert-import.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions crates/loro-internal/src/container/richtext/tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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));
}
Expand Down
67 changes: 67 additions & 0 deletions crates/loro/tests/big_insert_import.rs
Original file line number Diff line number Diff line change
@@ -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<u8>) {
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");
}