fix: decode correct block lamport range end to avoid checkout hang#1041
Conversation
ChangesBlock::from_bytes set lamport_range.1 to the start lamport of the block's last change (header.lamports has n entries, unlike header.counters which has n + 1). For single-change blocks the range was empty, and the lamport binary search in get_change_by_lamport_lte misclassified the block containing the target and looped forever, e.g. when the movable-list diff calculator resolved historical positions during checkout after a snapshot import of a block-split change. Also cap the binary search steps and fall back to the external kv scan if it fails to converge, so inconsistent block metadata can no longer hang the process. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WASM Size Report
|
When the lamport binary search bails out (step cap or hole), the fallback only scanned the external kv store. Local changes can exist solely in mem_parsed_kv before a flush, so a lookup targeting a lamport gap (e.g. peer 1 change, large imported peer 2 change, another peer 1 change) incorrectly returned None until compact_change_store ran. The fallback now picks the candidate with the greatest start counter from both mem_parsed_kv and the external kv store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed a review finding in d642be3: when the lamport binary search bails out (step cap or hole), the fallback only scanned The fallback now collects a candidate block from both Added |
Summary
Fixes an infinite loop in
ChangeStore::get_change_by_lamport_ltethat hangscheckoutafter importing a snapshot.Repro: a single commit whose ops span more than
MAX_BLOCK_SIZE * 8lamports (so the change is split across multiple change-store blocks and lamport lookups engage the binary-search path), exported as a snapshot, imported into a fresh doc, then checked out to an old frontier. The movable-list diff calculator resolves historical positions viaMovableListHistoryCache::last_pos→OpLog::idlp_to_id, which never returns.Root cause
ChangesBlock::from_bytessetlamport_range.1toheader.lamports.last(). Butheader.lamportsonly stores the start lamport of each change (n entries), unlikeheader.counterswhich has n + 1 entries ending with the block's end counter. For single-change blocks (the common case when a big change is block-split) the decoded lamport range was empty, e.g.(0, 0)for a block really spanning lamports 0..3939.The hand-rolled binary search in
get_change_by_lamport_ltethen classified the block containing the target lamport as "entirely below target" (lamport_range.1 <= target), and sincelower_bound = (lower + upper) / 2is a fixed point onceupper == lower + 1, it spun forever with no progress.Blocks built via the local append path (
push_change) anddecode_block_rangealready computed the end correctly; only thefrom_bytesdecode path was wrong, which is why only imported docs hang.Changes
ChangesBlock::from_bytes: compute the exclusive end lamport as the last change's start lamport plus its length (derived fromheader.counters), returningErron malformed input instead of panicking.decoded_block_lamport_range_matches_counter_range(loro-internal unit test) pins the decoded block metadata and exercises lamport lookups across the binary-search path; it fails fast (assert, no hang) if the decode bug is reintroduced.checkout_after_importing_block_split_change(crates/loro integration test) reproduces the original user scenario end-to-end.loro-crdt(patch).Validation
cargo test -p loro-internal --lib: 295 passed.cargo test -p loro --test change_store_test, checkout-relatedloro_rust_testfilters: pass.cargo fmtclean on touched files.🤖 Generated with Claude Code