Repository navigation
fix: resolve a container's parent from the change that created it - #1159
Conversation
A document loaded from a snapshot (or `fork`) parses its change blocks lazily, and the arena only knew a container's parent from a parsed change or from the container's own state entry. The metadata of a tree node created inside an already deleted subtree has neither, so `is_deleted()`, edits, and events touching it panicked with "Parent is not registered" while holding the state lock, aborting the process. The op log now registers a creator resolver on the arena: a normal container's ID is the ID of the op that created it, so loading that op's change registers the parent link. `get_parent` and `get_depth` call it outside the arena lock, and `does_container_exist` uses it for containers not registered yet. A normal container that no op in the history creates has no parent. Fixes #1158. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A failed import can parse old change blocks while computing its diff, which registers the containers their ops use. The rollback drops those arena registrations but kept the parsed blocks, so their ops pointed at indices that new registrations reuse; exporting the history afterwards hit `unreachable!` in the JSON encoder (and could name the wrong container). Keep only the encoded bytes of every kept block and parse again on the next access. Pre-existing on main; the creator resolver adds one more way to parse during an import. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… a full import Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A lazily loaded container that has a state entry is answered without parsing its change block, as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…napshot and fork Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…context Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6e06b57 to
7f22fa9
Compare
The local-revival undo test also passes on main; this one aborts there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
WASM Size Report
|
…-meta-parent Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The creator resolver reaches the change store without the op log lock, which used to serialize every store access. It took `inner` then `external_kv`, while `ensure_block_loaded_in_range`, `ensure_id_lte` and `encode_from` take them the other way round, so querying a meta on one thread and exporting or counting changes on another could deadlock. Every store method now takes `external_kv` before `inner`. Also, from the same review: - roll back the arena under the store's `inner`, so the resolver cannot parse a block between the store and arena rollbacks; - `does_container_exist` decides from its first lookup; a handler created on another thread could register the container in between; - the resolver panics when the history has the op but cannot parse it, instead of reporting the container as deleted; - `get_depth` fails fast on a parent cycle. Loom model in `multi_thread_test.rs`, thread stress in `unregistered_container_parent.rs`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the creator resolver, a container whose parent is unknown reads as deleted instead of panicking. A local op's container is linked by the transaction before the change is committed, so the op log cannot supply a forgotten link. Debug builds now check the links on commit, and `ContainerWrapper::new` names the invariant it relies on. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The import rollback fix makes kept blocks parse again. On main the checkout afterwards aborts at `history_cache.rs` on a value slice past the truncated arena, the #1161 signature. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Changes after the adversarial review of B1, the deadlock (blocking).
I chose one order over "the resolver never holds both locks": with that variant, re-locking Regressions:
Your stress tests pass on this head: 3000 trials for each pairing, 2000 for the mixed stress with a reference. Found while fixing B1:
Non-blocking:
Validation and a performance re-run (6 alternating rounds against |
An import that failed at decode or preflight rolled the arena back directly, outside the change store's `inner` and without dropping the blocks parsed in the meantime. The creator resolver parses without the op log lock, so a block it loaded during the failing import kept indices the rollback freed: `has_container` answered false for existing containers, and a later history export aborted. - `ChangeStore::rollback_arena` rolls the arena back under `inner` and drops the parsed changes of blocks with bytes; `rollback_import` uses it, and the early returns of `import_changes_and_apply_delta_to_state_if_needed` call it through `OpLog::rollback_arena`. - A failed snapshot import retires the replaced store (`ChangeStore::retire`), so a resolver that reached it finds nothing instead of registering the discarded history. - Import rollbacks take `&DocState`, so they run under the state lock: a state-locked query registers containers through the resolver and uses their indices before it returns, and the rollback must not free them in between. - `get_path_to_container` no longer unwraps a registration that a failed import on another thread may have rolled back (#1164). Loom model and thread regression added; see context/arena-parent-links.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The rollback of a failed import dropped the parsed changes of every cached block, so each failed import cost an O(blocks) pass and the next history read reparsed everything. A block parsed before the import's checkpoint can only refer to what the arena held then, so a block now records the arena's extent when it is parsed, and the rollback drops only blocks whose extent goes past the checkpoint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Changes after the second review round of R2-B1 (blocking), the early arena rollbacks. Every arena rollback of a failed import now goes through the change store: The change-store part alone was not enough. With it, your loom model (reproduced in
Regressions:
Your round-2 probes pass on this head: A regression my first R2-B1 fix introduced, now fixed. Dropping the parsed changes of every cached block on each failed import made the failed import itself +1937% and the next history read +258% (a full reparse). A peer sending malformed updates could force that repeatedly. Blocks now record the arena's extent when they are parsed, and the rollback drops only blocks whose extent goes past the checkpoint. Both scenarios are back to Non-blocking:
Validation: |
Fixes #1158. Fixes #1161.
Summary
In a document loaded from a snapshot (or created by
fork()), touching the metadata of a tree node that was created under an already deleted parent panicked withInternalError: Parent is not registered(arena.rs) while the state lock was held, so the process aborted (WASM:RuntimeError: unreachable). Affected:is_deleted(), edits, events for an imported edit of that metadata (state creation,get_path,with_ancestors), and undo/revert touching it.get_container/has_container/getContainerByIdalso disagreed with a full-history import (Noneinstead of the deleted container).It also fixes #1161. After an import rejected by state validation was rolled back, change blocks that the import had parsed kept container indices and value slices past the truncated arena, so a later export, checkout, or import crashed.
Root cause
The arena knew a container's parent only from a parsed change (
register_container_and_parent_link) or, in a document loaded from a snapshot, from the container's own state entry (theInnerStoreparent resolver). A snapshot's change blocks are parsed lazily, and a full snapshot only has state entries for materialized containers. The metadata of a node created inside an already deleted subtree is never materialized, so neither source knew its parent. Shallow snapshots were not affected: shallow export ensures every container of the retention walk, including the metadata of deleted nodes.Fix
A normal container's ID is the ID of the op that created it. The op log now installs a creator resolver on the arena (
ChangeStore::creator_resolver) that loads the change block holding that op; parsing it registers the parent link through the existingregister_container_and_parent_link. The answer comes from the history, the same as for a full-history import, and it also works for snapshots exported by older versions.SharedArena::get_parenttries the registered edge, then the state KV, then the creator resolver.SharedArena::get_depthresolves the ancestor chain the same way before taking the arena lock, because parsing registers containers and would deadlock under it; it fails fast on a parent cycle.DocState::does_container_exist(behindget_container,has_container, WASMgetContainerById) callsfind_created_containerfor a normal ID that is not registered, after the state check, so a container with a state entry is still answered without parsing.ChangeStorenow takes its locks in one documented order:root_history_names,external_kv,inner,external_vv, then the arena's.get_parsed_block_in,flush_and_compact, andget_change_by_lamport_lteused to takeinnerbeforeexternal_kv. That was harmless while the op log lock serialized every caller, but with the resolver,is_deleted()on one thread andlen_changes()/export/vv_to_frontierson another deadlocked (found in review). The resolver looks for a cached block underinneralone, then takesexternal_kvandinnerin order and looks again before it loads the block. It holds weak references, so there is noArccycle;SharedArena::forkdrops it.get_parentreturnsNoneinstead of panicking, but only after the history was asked. The resolver answersLoaded(a block holds the op, now parsed) orAbsent(none does: an ID that is not a container, an op not received yet, or a shallow document whose creating op was trimmed and whose state does not hold the container). Shallow export keeps everything that can be alive or revived, so such a container is dead at the root, and no path leads to it. Callers treat it like a deleted container (is_deleted→true, not cached;get_path/get_depth→None). is_deleted() aborts with "Parent is not registered" on deleted-node metadata in a snapshot-loaded doc #1158's metadata always resolves through its creating op, so this is not an "unresolvable means deleted" stopgap. Broken invariants still fail fast:ContainerWrapper::newpanics for a container without a parent;Import rollback after lazy parsing (pre-existing, #1161)
A failed import can parse old change blocks while computing its diff or validating movable-list ops (on
mainthrough those paths, now also through the resolver). Parsing registers the containers the block's ops use and allocates their values. The import rollback dropped those registrations and values but kept the parsed blocks, so their ops pointed at indices and value slices that later registrations reuse or that no longer exist. Two symptoms follow onmain:unreachable!in the JSON encoder;arena.get_value(..).unwrap(), Honest import panics after an import rejected by state validation was rolled back (arena values) #1161's signature.ChangeStore::rollback_importnow drops the parsed changes of the blocks parsed since the import began, so the next access parses and registers again. A block records the arena's extent (containers, values, text) when it is parsed. A block parsed before the checkpoint can only refer to what was there then, so it stays parsed. An earlier version dropped every parsed block, which made each failed import an O(blocks) pass and the next history read a full reparse (+1937% / +258% in the performance table).The resolver does not take the op log lock, so it can run during any import. Two rules keep that safe:
ChangeStore::rollback_arenarolls the arena back underinner(where blocks are parsed) and drops the parsed changes of the blocks parsed since the checkpoint.rollback_importends with it. The early returns ofimport_changes_and_apply_delta_to_state_if_needednow use it throughOpLog::rollback_arena: a decode error, and updates that depend on history before the shallow root. They used to callSharedArena::rollbackdirectly (found in the second review round). A failed snapshot import retires the replaced store (ChangeStore::retire), so a resolver that reached it before the swap finds nothing instead of registering the discarded history.OpLog::rollback_import,rollback_arena, andreset_to_empty_for_failed_snapshot_importtake&DocStateas proof. A state-locked query (has_container,is_deleted,get_path_to_container) can register containers through the resolver and use their indices before it returns, and a rollback in between would free them. With the change-store part alone, the loom model below still hitget_depthon a freed index. Rolling back already happened under the state lock when state validation fails; the rollbacks at decode errors, preflight errors, movable-list validation,import_batch's reattach, and the snapshot reset's second call site now take it too.DocStateis still not rolled back: its store and dead-container cache keep entries at the freed indices, and the next container registered at one inherits them. That half is pre-existing, the same onmainand here, and tracked in #1164. A related route to #1164 (I could not comment on the issue): a handler created on another thread while an import is failing keeps an index that the rollback frees. Onmaina lateris_deleted()aborts atdead_containers_cache.rs:55, or the handler refers to another container once the index is reused. The resolver's registrations are safe from this now: the query that made them finishes before the rollback, and the next query resolves again.get_path_to_containerused tounwrapits registration and now answersNoneif it is already gone.Rationale and invariants:
context/arena-parent-links.md, linked from the root andloro-internalAGENTS.md. It also records a testing pitfall: undercfg(test),ChangeStore::import_allparses every peer's last change at load, soloro-internalunit tests don't reproduce lazy-parsing bugs; thelorocrate's tests do.Tests
crates/loro/tests/unregistered_container_parent.rs, each test with snapshot, fork, and shallow sources compared against an updates-imported reference:is_deleted, edits (ContainerDeleted),get_path_to_container,has_container,get_container(also by ID only, in a fresh document);revert_toafter the loaded document revives the node itself (the local move registers the meta's parent, so this one also passes onmain), and undo of a local edit after importing another peer's revival + edit of the meta (aborts onmain);unregistered_meta_parent.{snapshot,updates}.bin, node7@40881) via updates, snapshot, and snapshot + fork;failed_import_that_loads_an_old_change) and Honest import panics after an import rejected by state validation was rolled back (arena values) #1161's missing movable-list values on a later checkout (failed_import_that_loads_old_movable_list_values);queries_race_with_failing_imports: queries through the resolver against imports that fail at decode (JSON) or depend on history before a shallow root. The test times each failing import once and sweeps the query thread's start across that window, 120 trials by default. On53a74955(before the round-2 fix) the decode kind failed in 6/6 release and 3/4 debug runs: an existing container answeredhas_container = false, or the exported history differed. The shallow kind did not reproduce there (0 of 4 × 200 trials) and is kept as coverage for that early return. The fix passed 3 × 2000 trials in release and 3 × 2000 in debug;queries_race_with_history_readers, a thread stress. Each trial loads a fresh document, then one thread runsis_deleted, one runshas_container/get_path_to_container, and one runslen_changes/export/frontiers_to_vv, with a watchdog. 500 trials by default (UNREGISTERED_PARENT_THREAD_TRIALS). Before the lock-order fix it deadlocked in 2 of 3 runs of 3000 trials. It also found a check-then-act race indoes_container_exist: a handler created on another thread could register the container between two lookups, andhas_containeransweredfalse. That check now uses its first lookup;has_container,get_container, path, value) must equal the reference, visited in random order, and again after editing every meta and reviving some nodes. 20 seeds by default;UNREGISTERED_PARENT_SEEDS=0..300passes in release. Onmainit aborts with thearena.rspanic.crates/loro/tests/multi_thread_test.rs:resolving_a_meta_parent_while_an_import_fails, the reviewer's loom model ofhas_containeragainst a JSON import that fails at decode after registering a container. It fails on53a74955(the existing meta reads as missing) and passes now. With only the change-store part of the fix, it panicked inget_depthon a freed index. Alsoresolving_a_meta_parent_while_another_thread_reads_the_history, a loom model (pnpm test-loom) ofis_deleted/has_containeragainstlen_changes/export. All three variants reportdeadlockon the code before the lock-order fix and pass after it.arena.rs: the resolver runs outside the arena lock (one that re-enters the arena does not deadlockget_parent/get_depth); a container nobody creates has no parent, for bothLoadedandAbsent; an arena without a resolver still panics; a parent cycle fails fast.parent.rs: the local link check fires.change_store.rs: a retired store resolves nothing, and a rollback drops only the blocks parsed after the checkpoint.crates/loro-wasm/tests/unregistered_container_parent.test.ts: the same entry points throughloro-crdt(snapshot and fork,getContainerById,getPathToContainer, revival, imported-edit events, undo after an imported edit, the dump). On a WASM build ofmain5c7552dcall 6 fail: 5 withRuntimeError: unreachable(Parent is not registered), andgetContainerByIdreturnsundefined.Validation
On
76aadb57, which mergesmain29553d5b(includes #1125 and #1142):cargo test -p loro -p loro-internal: 58 test binaries, 1320 passed, 0 failed.pnpm check(clippy-Dwarnings): pass.rustfmt --checkon the changed files: clean.pnpm test-loom: 9/9, including both new models.UNREGISTERED_PARENT_SEEDS=0..300 cargo test --release -p loro --test unregistered_container_parent: pass.pnpm -C crates/loro-wasm build-release(vitest,tsc --noEmit, deno, bun): 34 files, 440 tests passed; deno 4, bun 4.rv2_window_probe(100 trials, 42 inside the import's window, 0 bad) andrv2_resolver_racespass, the latter with all 17 partner operations inhasandpreregmodes (150 trials each), plus failing imports (JSON decode, state validation, old deps on a shallow source; 300 trials each).rv2_has_container_never_false: 128,000 answers, 0 false;R1125_FORGE=1, debug) passes on the 6 seeds wheremainaborts and on all 13 seeds listed in Honest import panics after an import rejected by state validation was rolled back (arena values) #1161 (panics=0).main29553d5b:InternalError: Parent is not registered;failed_import_that_loads_an_old_changeaborts onunreachable!injson_schema.rs;failed_import_that_loads_old_movable_list_valuesaborts onarena.get_value(..).unwrap()inhistory_cache.rs;container_by_id_in_a_fresh_snapshot_docgetsNone;has_containeranswersfalsefor existing metas;ids_that_no_op_creates_are_not_containers(a guard) andundo_and_revert_touching_the_metapass.Performance
Import, export, and rollback paths (
00a802ebvsmain29553d5b)Release builds in separate target dirs, using the reviewer's round-2 harness plus a failed-import scenario. The two builds alternate over 6 rounds of 9 samples, alternating which goes first; timings are thread CPU time; 5-min loadavg 6.6–9.0. Values are ms, median of per-round medians (min of mins). The "earlier version" column is
45decad0, which dropped every parsed block on rollback (loadavg 9.2–11.2).flush_and_compact)export_json_updates(full history) from a loaded docis_deletedon 20k alive metas / pre-created handlersHot paths and lookups (
ba1f7092vsmain29553d5b)Release builds in separate target dirs, from the reviewer's harness plus one scenario (IDs beyond the history).
main29553d5bandba1f7092(this head minus docs) alternate over 6 rounds of 9 samples, alternating which goes first; timings are thread CPU time; 5-min loadavg 7.6–11.0. Fixture: a 20k-node tree (every 4th meta edited), 200 deleted parents with 10 materialized and 10 never-materialized children each, then 100k map ops. Every sample uses a fresh document loaded from the snapshot. Values are ms, median of per-round medians (min of mins):is_deleted(alive meta)is_deletedon 20k alive metasis_deletedon 20k pre-created meta handlersget_container(meta)on 20k alive metasis_deletedon 2k deleted materialized metashas_containeron 2k in-history IDs that no op creates, first passhas_containeron 2k IDs beyond the historyis_deleted/ 2kis_deletedon never-materialized metasHot paths show no difference beyond noise. Lookups of normal IDs that are not registered now ask the history:
A vv shortcut for out-of-history IDs would have to be exact for every path that writes the store's KV, or a live container would read as deleted, so I left it out. This is documented in the context file.
🤖 Generated with Claude Code