Skip to content

fix: decode correct block lamport range end to avoid checkout hang#1041

Merged
zxch3n merged 2 commits into
mainfrom
fix-lamport-range-decode
Jul 15, 2026
Merged

fix: decode correct block lamport range end to avoid checkout hang#1041
zxch3n merged 2 commits into
mainfrom
fix-lamport-range-decode

Conversation

@zxch3n

@zxch3n zxch3n commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

Fixes an infinite loop in ChangeStore::get_change_by_lamport_lte that hangs checkout after importing a snapshot.

Repro: a single commit whose ops span more than MAX_BLOCK_SIZE * 8 lamports (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 via MovableListHistoryCache::last_posOpLog::idlp_to_id, which never returns.

Root cause

ChangesBlock::from_bytes set lamport_range.1 to header.lamports.last(). But header.lamports only stores the start lamport of each change (n entries), unlike header.counters which 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_lte then classified the block containing the target lamport as "entirely below target" (lamport_range.1 <= target), and since lower_bound = (lower + upper) / 2 is a fixed point once upper == lower + 1, it spun forever with no progress.

Blocks built via the local append path (push_change) and decode_block_range already computed the end correctly; only the from_bytes decode 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 from header.counters), returning Err on malformed input instead of panicking.
  • Harden the binary search: cap its steps and fall back to the (always correct) external kv scan if it fails to converge, so inconsistent block metadata can degrade to a slower lookup instead of a hang.
  • Regression tests:
    • 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.
  • Changeset for loro-crdt (patch).

Validation

  • Both new tests fail (fast, no hang) with the fix reverted and pass with it applied.
  • cargo test -p loro-internal --lib: 295 passed.
  • cargo test -p loro --test change_store_test, checkout-related loro_rust_test filters: pass.
  • cargo fmt clean on touched files.

🤖 Generated with Claude Code

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>
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3053.29 KB
  • Gzipped size: 1009.20 KB
  • Brotli size: 707.65 KB

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>
@zxch3n

zxch3n commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Addressed a review finding in d642be3: when the lamport binary search bails out (step cap or hole), the fallback only scanned external_kv, but local changes can exist solely in mem_parsed_kv before a flush — so a lookup targeting a lamport gap (peer 1 change → large imported peer 2 change → another peer 1 change) incorrectly returned None until compact_change_store() ran (also visible as WASM getChangeAtLamport returning undefined).

The fallback now collects a candidate block from both mem_parsed_kv and external_kv (bounded by the search's upper_bound) and uses the one with the greatest start counter — within a peer, lamport grows with counter, so that block holds the greatest matching lamport. On a tie the in-memory block wins, since it may contain unflushed changes appended after the flushed copy.

Added lamport_lookup_finds_unflushed_mem_blocks reproducing the scenario before any flush; it fails with the external-only fallback and passes now. Full cargo test -p loro-internal --lib (296) and the integration suite still pass.

@zxch3n
zxch3n merged commit c37e76a into main Jul 15, 2026
1 check passed
@zxch3n
zxch3n deleted the fix-lamport-range-decode branch July 15, 2026 03:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant