Repository navigation
fix: drop cached container deletions that a move can undo - #1153
Merged
Merged
Conversation
`DocState::is_deleted` cached every deletion and kept the entries across commits and imports. A tree node under a deleted ancestor is revived by moving it (or the ancestor) out, and a movable-list element deleted by one peer is revived by a concurrent move with a greater lamport, so after an `is_deleted()` query the revived metadata or child stayed "deleted" in release builds and rejected edits until the document was reloaded. Deletions cut at a Tree or MovableList edge now go into a separate set that is dropped before every local tree move and on every forward diff batch touching a tree or movable list. Map/list deletions stay cached until a checkout. Debug builds assert that no cached entry on the walked chain contradicts the result. Rationale in context/dead-container-cache.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Contributor
WASM Size Report
|
…che-revival Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`DocState::apply_diff` cleared the cache only when the direction mode was `Checkout`, but forward checkouts (`attach`, `checkout_to_latest`, a checkout to a newer version) use `Import`/`Linear` modes. A container queried while the state was behind the oplog, created after the state's version, is cut at its map or list parent and was cached as a final deletion; it stayed "deleted" after going forward, and debug builds hit the stale-entry assertion. Clear on any checkout too (`EventTriggerKind::Checkout`). Rationale in context/dead-container-cache.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Member
Author
|
Review follow-up:
🤖 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>
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
After
isDeleted()was called on the metadata of a tree node under a deleted ancestor, moving the subtree back left that metadata reported as deleted:isNodeDeletedreturnedfalse,data.isDeleted()returnedtrue, and edits threwContainerDeleteduntil the document was reloaded. Found by theloro.jsdifferential fuzz (#1141, "Rust-side findings"; related to the revival semantics in #1119).The same happened for an imported move that revives the subtree, a concurrent move with a greater lamport than the delete, a movable-list child revived by importing a concurrent move, and (review finding, fixed in 77a75f3) a container queried at an older version or while detached and brought in by a forward checkout (
checkout(old)→isDeleted()→checkoutToLatest(), ordetach→import→ query →attach).Root cause
DocState::is_deletedcaches deleted containers (dead_containers_cache.rs), and release builds return a cachedtruewithout walking.DocState::apply_diffcleared the cache only forCheckout; for forward transitions it calledclear_alive(), which retained deleted entries (and since the cache only ever storedtrue, did nothing).apply_local_opdid the same. The assumption was that deleted containers stay deleted going forward, which is false for:mov, imported move, concurrent move);attach,checkout_to_latest, a checkout to a newer version) have anImport/Linear/ImportGreaterUpdatesdirection mode, notCheckout.Debug builds recompute the answer, and their cross-check only compared the last container on the walked chain, so the stale entry went unnoticed in tests.
Fix
Deletions are cached in two sets, by the kind of parent that no longer holds the chain:
Moveand on every forward diff batch that touches a tree or movable list. Dropping is O(1) when nothing was queried.Any checkout clears both: the direction mode is
Checkoutor the diff comes from a checkout (EventTriggerKind::Checkout, which covers forward checkouts andimport_batch's reattach). The only checkout triggered asImportis a snapshot import into an empty document, whose state is new. Children behind a mergeable edge are still never cached, and neither are containers with no parent in the arena yet (a later op can attach them; they were cached before).clear_aliveis gone: the cache only ever stored deletions, so it removed nothing. Debug builds now assert that no cached entry anywhere on the walked chain contradicts the result; before, they compared only the chain's last container, which is why debug tests never saw the stale entry. The assertion runs while the caller holds the state lock, so when it fires the process aborts (unwinding locks the poisoned state again), like any other internal assertion under that lock; it is an internal-invariant check (fail fast), and with 77a75f3 the reviewer's harness no longer reaches it.Revival routes checked:
movout of a deleted subtreecheckoutToLatest,attach, newer version) after a query at an older version or while detachedrevertTo/applyDiffHandler::apply_diffcreates a new tree node for a deleted target and remaps its metadata; map/list children get new IDs. Their tree moves still go throughapply_local_opRationale:
context/dead-container-cache.md, linked from the root andstate/AGENTS.md.Tests
crates/loro/tests/dead_container_cache.rs: local move (also with the edit in the same transaction), imported move, concurrent move, undo,revert_to, checkout, movable-list import, and map/list deletions staying cached. Onmainin release the local, imported, concurrent, and movable-list tests fail (debug recomputes, so run it with--releasetoo).dead_containers_cache.rs(local_tree_move_drops_revivable_deletions,movable_list_import_drops_revivable_deletions,map_and_list_child_deletions_are_cached) check the cache entries directly, so a missing invalidation fails in debug builds too.crates/loro/tests/dead_container_cache.rsalso covers the forward checkouts:checkout(v1)→ query →checkout_to_latest/attach/checkout(v2)for a map child and a text in tree metadata created afterv1, anddetach→import→ query →attach. Without theEventTriggerKind::Checkoutclear they fail in release (staletrue) and hit the stale-entry assertion in debug.forward_checkout_drops_deletions_cached_behind_the_oplogchecks the cache entry is gone aftercheckout_to_latest.crates/loro-wasm/tests/dead_container_cache.test.ts: local move, imported move, movable list (all three fail on a WASM build ofmain,expected true to be false), plus the two forward-checkout cases.Validation
Branch merged with
main1eb14b3 (includes #1134), clean.cargo test -p loro -p loro-internal: pass;cargo test --release -p loro --test dead_container_cache: 11/11; unit tests indead_containers_cache.rs: 7/7.pnpm check(clippy-Dwarnings): pass.cargo test -p fuzz(whole crate, exercising the stricter debug assertion): pass.main+ fix: take the tree checkout window from the two versions #1152 + this PR (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.isDeleted()for every container ever seen vs an independent reachability walk; queries while detached included), main + both PRs, one seed per process: release 148/150 and debug 59/60 pass; the aborts (seeds 5 and 116) are the pre-existing "Parent is not registered" panic in snapshot-loaded docs, identical onmain(filed as is_deleted() aborts with "Parent is not registered" on deleted-node metadata in a snapshot-loaded doc #1158). No mismatch and no stale-entry assertion. Before 77a75f3 all 150 failed on the forward-checkout finding.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/ #1152 / this PR run alternately in 5 rounds, thread CPU time (1-min loadavg at run start 7.4–11.5). ms: median of the per-round medians, min of mins in parentheses.isDeletedon 10k alive tree metas (depth ≤ 21) ×10isDeletedon 10k metas under a deleted node ×10isDeletedon 10k deleted map children ×10The one slower row is expected: a local tree move drops the revivable set, so the next query on each deleted meta walks its chain again, which costs what a query on an alive meta always costs (about 0.12 µs here).
mainis fast there only because it returns the stale cached answer. Queries without an interleaved move stay cached.Merge order and conflicts
Independent of #1152 (the tree checkout window fix for the other two findings); 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. #1125's note that a movable-list move of a deleted element is applied rather than rejected is the same behavior that makes movable-list deletions revivable here.🤖 Generated with Claude Code