Repository navigation
fix: take the tree checkout window from the two versions - #1152
Merged
Merged
Conversation
The tree diff calculator retreated and replayed ops from the change-start lamport of `find_replay_base`. In Checkout mode that base can be a meet that is not a critical version, and an op concurrent with it can have a lower lamport, so the op was skipped: a checkout to a concurrent version kept a node it does not have, and with a subscriber a node's child was created without the node (panic at tree_state.rs:1075). Both cache transitions now start at the smallest lamport that differs between the two versions. Rationale in context/tree-checkout-window.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
WASM Size Report
|
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The retreat walked every tree op in the history at or above the window and tried to take each from the cache, so a checkout between two old versions scaled with all later history (about 3.8 ms per checkout at 300k later moves, 0.1 ms on main). `TreeCacheForDiff::max_lamport` now bounds it: no cached op has a greater lamport. `find_replay_base` is only compiled for the DAG tests now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rejected moves record no op, so fixed counters can be missing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Member
Author
|
Review follow-up (thanks for the adversarial pass):
🤖 Generated with Claude Code |
zxch3n
added a commit
that referenced
this pull request
Sep 28, 2026
…ontainer Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
AVGVSTVS96
added a commit
to AVGVSTVS96/seqno
that referenced
this pull request
Oct 11, 2026
…scriber (loro-dev/loro#1152) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvYXZx7p1R29GnkNVxDwTL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes two
LoroTreecheckout bugs found by theloro.jsdifferential fuzz (#1141, "Rust-side findings"), both reproducible withloro-crdtalone. They have one root cause:checkout([1@3])thencheckout([1@2])panicked attree_state.rs:1075(get_index_by_tree_id(..).unwrap()on aCreate).[3@2](which contains a node's creation) and then the concurrent[1@1](which does not) kept the node. Checking out[1@1]directly was correct.Root cause
TreeDiffCalculator::checkout_diffretreated and replayed only ops at or above the change-start lamport offind_replay_base(from, to). InCheckoutmode that base is the meet even when the meet is not a critical version, and an op concurrent with the meet can have a lower lamport:0@3creates the node at lamport 1 and is concurrent with the meet[2@2](lamport 2). The window started at 2, so the creation was never retreated.[1@3] → [1@2], the meet is[0@2](window starts at 2). The forward pass skippedcreate A(lamport 1) but appliedcreate B under A(lamport 2). Converting that to an event panicked; without a subscriber the state was wrong instead.docs/critical-version-spec.mdQ7 had argued the window was safe because an op concurrent with a meet head always leaves an uncovered branch that forces a critical base. It doesn't: the op's dependencies can reach shared history below the meet, so the walk sees no dead end. The cache's owncheckout(moving it tofrom) had the same problem.Fix
Both transitions of
TreeCacheForDiffnow start atmin_lamport_of_version_diff(from, to): the smallest lamport among ops that are in exactly one of the two versions. Lamports grow along a peer's ops, so this reads only the first op of each per-peer span of the version-vector difference (O(peers)). Everything cached at or above it is retreated newest first, and the target's ops at or above it are replayed oldest first, which keeps each op'seffected(cycle check) flag computed in (lamport, peer) order. Ops below it are in both versions.TreeOpGroup::ops(ordered byIdLp) instead of scanning every node's op set, and the tree calculator no longer callsfind_replay_base, which could walk the whole DAG.TreeCacheForDiff::max_lamport, an upper bound on every cached op's lamport (applyand the shallow-root seeding raise it,retreated_fromlowers it to just below the window). The group holds the whole history, so without the bound a checkout between two old versions walked every later tree op (review finding; fixed in 4d1c6d4).current_vvonly covered the seeded op IDs, soinit_current_vvsets it to the root version before the first transition.get_importing_cache,get_min_lamport_at);find_replay_baseis only compiled for the DAG tests (#[cfg(test)]).Rationale:
context/tree-checkout-window.md. Q7 in the spec and theloro-internalAGENTS.md rule about tree windows are updated.Same class of bug in List / MovableList / Map?
No. The new random test (below) covers map, list, movable list, text, and counter edits on the same concurrent histories and every version pair, with and without a subscriber. None of those diverged on
mainor here: text/list trackers retreat and advance individual ops, and map/movable-list checkouts read the history cache per version.Tests
crates/loro/tests/tree_checkout_path.rs: the two repros. Onmainthe first aborts with thetree_state.rs:1075panic and the second keeps node0@3.crates/loro-wasm/tests/tree_checkout_path.test.ts: the same throughloro-crdt. Both fail on a WASM build ofmain(RuntimeError: unreachable, and the extra node).crates/fuzz/tests/checkout_path.rs: 3 peers make random concurrent edits (tree create/move/delete, moves into and out of deleted subtrees, metadata edits, plus map/list/movable-list/text/counter) and sync at random. For every ordered pair(a, b)of 24 versions, including heads no peer had, a document checks outa, thenb. After each checkout its value must equal a document that imported only that version's changes, and the event mirror (fuzz::actor::Actor's tracker) must equal the state; a second document does the same without a subscriber. 24 fixed seeds run by default (13 s debug). Onmainseeds 19 and 22 fail; a 300-seed release sweep fails 9 seeds onmainand none here (CHECKOUT_PATH_SEEDS=0..300). The existing fuzzers never hit this because they only check out versions from one peer's own history, which are ordered by inclusion.retreat_bound_follows_the_cached_version(unit test intree.rs): after scrubbing between two old versions, every cached op is at or belowmax_lamport, andmax_lamportis at or below the cached version's lamport (it fails withoutretreated_from).crates/loro/tests/perf_tree_checkout_old_versions.rs(#[ignore], like the otherperf_*tests;cargo test --release -p loro --test perf_tree_checkout_old_versions -- --ignored): 40 checkouts between two old versions with 10k vs 200k later moves must not grow more than 4×. Unbounded retreat: 7.0 ms → 82.5 ms (fails); bounded: 3.7 ms → 4.4 ms.tree_checkout_path.test.tsalso scrubs back and forth between three old versions and compares each with a direct checkout (no timing assertions in the WASM suite).checkout_across_non_critical_meet_stays_canonical/checkout_with_low_lamport_concurrent_branch_stays_canonical(issue.rs) updated; both still pass.Validation
Branch merged with
main1eb14b3 (includes #1134), clean.cargo test -p loro -p loro-internal: pass.pnpm check(clippy-Dwarnings): pass.cargo test -p fuzz(whole crate): pass;checkout_path's 24 default seeds pass.main+ this PR + fix: drop cached container deletions that a move can undo #1153 (pnpm -C crates/loro-wasm build-release, then the package test chain): vitest 31 files / 401 tests,tsc --noEmit, deno 4/4, bun 4/4: pass.Createevent on import, identical onmain(filed as Tree import can miss the Create event for a child of a node revived from a deleted subtree #1157).tree215,855 runs anddiff_calc244,433 runs, 181 s each (LORO_FUZZ_SANITIZER=none), no crashes, artifact dirs empty.Performance
Release, isolated target dirs,
main/ this PR / #1153 run alternately in 5 rounds, thread CPU time (the machine is shared; 1-min loadavg at run start 6.9–11.9). ms: median of the per-round medians, min of mins in parentheses. The first rows are the reviewer's harness (single peer, 2k nodes then N random moves).[2500]↔[2510]×20, N = 300k[end-20]↔[end-10]×20, N = 100k[2500]↔latest ×3, N = 100kBefore the retreat bound (f7a9a02), the first row was about 151 ms at N = 300k and 48 ms at N = 100k (review measurement); it is now at
main's level and does not grow with N. The other rows are within the noise of this machine (medians of identical binaries move by about ±10–15% between rounds).Merge order and conflicts
Independent of #1153 (the dead-container cache fix, the third finding); either can merge first, and they merge cleanly together. #1134 is merged into this branch through
main; trial merges with the current heads of #1125 and #1142 are clean. After this lands, the corresponding "Rust-side findings" entries incontext/loro-js-differential-fuzz.md(#1141) can be dropped.🤖 Generated with Claude Code