Repository navigation
fix: keep root-time map values when checking out a shallow doc - #1124
Conversation
Checkout skipped map keys (and movable-list values) whose value was equal at both versions even when a different op won, leaving the later op's lamport/peer in the state. Shallow and state-only exports build their root state through that checkout; on import the stale entry collided with the retained later op in the map checkout index and was dropped, so checkout to the root lost the key. Emit the winner change and apply it as a silent metadata update. See "Winner metadata, not just values" in crates/loro-internal/docs/diff_calc.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WASM Size Report
|
The previous commit fetched both the `from` and `to` values for every candidate key and then sent equal-value winner changes through a second state lookup, so equal-value checkout/diff/import regressed ~20%. Compare winners by (lamport, peer) and fetch only the `to` value; the state, which is at `from`, detects equal values in the same `insert`. See "Winner metadata, not just values" in docs/diff_calc.md; benchmark in crates/loro/examples/map_equal_value_bench.rs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Perf follow-up to the adversarial review (743240a → 1e46592, e6a0ef8)Problem743240a made every map key whose winning op changed flow through the generic state apply, even when the value stayed the same. It also still fetched both the Fix (1e46592)
Dropping the NumbersBench: Full suite, 5 alternating rounds, load avg 6.7 → 2.7 (ms):
¹ These paths don't touch the changed calculator code, and their sign flipped between full-suite runs. Focused re-runs with more samples:
A focused run of only the Correctness
🤖 Generated with Claude Code |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Round-2 follow-up:
|
| scenario (ms, median of 12 pairs) | main | PR | Δ | pairs where PR faster |
|---|---|---|---|---|
| cold 20k, per-process median | 2.676 | 2.633 | −1.6% | 8/12 |
| cold 20k, first sample of each process (truly cold) | 3.013 | 2.947 | −2.2% | — |
| sequential 100k set passes | 17.811 | 17.876 | +0.4% | 5/12 |
Raw per-pair values:
- cold main
2.69 2.62 3.02 2.56 2.69 2.60 2.67 5.21 2.84 2.55 2.56 2.69 - cold PR
2.62 2.54 2.78 2.63 2.64 2.85 2.60 2.74 2.76 2.59 2.92 2.62 - seq main
17.15 19.01 22.20 18.05 17.78 18.10 15.63 22.37 17.84 15.63 17.43 15.76 - seq PR
18.62 17.06 17.42 18.20 19.75 17.96 17.79 27.41 15.57 15.67 18.64 15.58
Single outliers (5.21 on main, 27.41 on PR) show up on both sides; this is the same kind of jitter that produced the single-sample +22%.
Branch / CI
- Merged origin/main (
106d9e59: fix: drop dead containers when re-exporting a shallow doc at its cached root #112384885677, fix(loro.js): export the latest state in a snapshot of a detached document #1130) into the branch with no conflicts. - Locally, on the merged branch:
cargo test -p loro -p loro-internalpasses (45 test binaries, 0 failures);pnpm checkis clean. - CI
buildpasses on the head commitae18063c(and on722777debefore it).
🤖 Generated with Claude Code
Summary
A shallow replica could lose a map value when checking out its shallow root. This happened when the key was rewritten after the root with the value it had at the root (for example by
revert_to) and the op that wrote the root value was trimmed. The trim happens whenever the root commit holds more than one op. Found by Codex while reviewing #1120; it also fails onad5b2a6d(the base of #1120), so #1120 did not introduce it.Root cause
MapDiffCalculator::calculate_diff(Checkout/Import) skipped a key whenfrom.value == to.value, even when a different op won. Aftercheckout(root)the state therefore kept the later op's lamport/peer next to the root-time value.MovableListStatehad the same skip forvalue_id.Shallow and state-only exports build their root state through that checkout, so the stale metadata was encoded into the shallow-root state. On import,
ensure_shallow_map_seededinserted the root entry into the map checkout index. That index is aBTreeSetkeyed by (container, key, lamport, peer), so the entry compared equal to the retained later op and was dropped. With no candidate at or below the root version,checkout(root)removed the key.The same stale metadata made
get_last_editor/get_last_editor_atwrong after a checkout on a full-history document, and state-only snapshots recorded the wrong writer.Fix
MapDiffCalculatoralso emits a key when the winning op changed but the value is equal.MapStateandMovableListStateapply such an entry as a silent metadata update with no event entry, so events still report only value changes.MapState::insertkeeps the child-container edge when the same container is re-inserted, which this new path makes reachable.List, Text and Counter carry no per-value winner metadata. Tree moves always get a new position, so there is no equal-state case; the new tests cover Tree too.
The design note is in
crates/loro-internal/docs/diff_calc.md("Winner metadata, not just values"), with a pointer fromcontext/internal-encoding.md.Known limitation: blobs exported by older versions already contain the wrong metadata in their root state. They still lose the value on checkout until they are exported again from a full-history document.
Validation
New
crates/loro/tests/shallow_checkout_equal_value.rs. Each test runs with 0 and 300 padding ops, which covers both the ≤256 path and the >256 latest-state-overlay path. The tests compare shallow replicas with a full-history replica across Map, nested/mergeable maps, MovableList, List, Text, Tree and Counter:difffor every version pair (applied to a fork);get_last_editor/get_last_editor_atafter checkout on full, shallow and state-only replicas.The checkout and re-export tests fail without the fix. The state-only and undo tests pass on
maintoo and are kept as coverage.cargo test -p loro --test shallow_checkout_equal_value --test shallow_root_critical --test shallow_snapshot_concurrency --test shallow_snapshot_deleted_containers --test shallow_lazy_map_checkout_index: passingcargo test -p loro -p loro-internal: passingcargo fmt --all,pnpm check(clippy-Dwarnings): clean🤖 Generated with Claude Code