Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dead-container-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"loro-crdt": patch
---

Fix containers that stayed deleted after being revived. After `isDeleted()` was called on the metadata of a tree node under a deleted ancestor, moving the subtree back (locally or through an import) left that metadata reported as deleted, and edits to it threw until the document was reloaded. The same happened to a movable-list child revived by importing a concurrent move. Cached deletions of tree metadata and movable-list children are now dropped whenever a tree move or a movable-list change is applied.
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec.
- Bounded decoded-value cache in `InnerStore` (second-chance FIFO, eviction
safety contract, loro-dev/loro#1092):
[context/container-value-cache.md](context/container-value-cache.md).
- Which deletions `DocState::is_deleted` caches, and how tree/movable-list
children are revived: [context/dead-container-cache.md](context/dead-container-cache.md).
- Movable-list `apply_diff`/undo: delete + re-insert of a child becomes a move,
cursor invariant, pitfalls: [context/movable-list-apply-diff.md](context/movable-list-apply-diff.md).
- Context backlog: [context/CONTEXT-GAPS.md](context/CONTEXT-GAPS.md).
Expand Down
78 changes: 78 additions & 0 deletions context/dead-container-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Dead Container Cache

Verified against code 2026-09-28.

`DocState::is_deleted` (`crates/loro-internal/src/state/dead_containers_cache.rs`)
answers whether a container is unreachable from a root: it walks the arena
parent chain and asks each parent whether it still holds the child
(`DocState::contains_logical_child`). It runs for every local op
(`Transaction::apply_local_op` rejects ops on deleted containers) and for each
handler's `is_deleted()`.

## What is cached

A deleted answer is cached for the container whose edge is broken and every
container below it on the walked chain, in one of two sets:

- `final_deletions`: the parent that lost the chain is a Map (a key only
changes to a newer value, so a replaced or deleted child never becomes the
value again) or a List (deletes are final). While the state is at the
oplog's latest version, no later version gives these back. While it is
behind (after `checkout`, or detached while imports arrive), a container
created after the state's version is also cut at its map or list parent,
and the next checkout forward brings it in.
- `revivable`: the parent is a Tree or a MovableList. Moving a node, or one of
its ancestors, out of a deleted subtree revives the node's metadata and
everything below it (a local `mov`, an imported move, or a concurrent move
whose lamport is greater than the delete's). A movable-list delete
concurrent with a move of the same element keeps the element when the move
has the greater lamport, so importing the move revives it.

Not cached: children behind a mergeable edge (the parent map's marker can come
back) and containers without a parent in the arena (a later op can still
attach them).

Release builds return a cached `true` without walking; debug builds always
walk and assert that no cached entry on the chain contradicts the result.

## Invalidation

- `DocState::apply_diff` clears both sets when the direction mode is
`Checkout` (moving backwards can revive anything) or the diff comes from a
checkout (`EventTriggerKind::Checkout`). The latter covers forward
checkouts (`attach`, `checkout_to_latest`, `checkout` to a newer version,
and `import_batch`'s reattach), whose direction mode is `Import`, `Linear`,
or `ImportGreaterUpdates`. The only checkout triggered as `Import` is the
snapshot import into an empty document, whose state is new.
- `DocState::apply_diff` in any other mode clears `revivable` when the batch
touches a Tree or MovableList container.
- `DocState::apply_local_op` clears `revivable` before a tree `Move`. A local
op cannot address a deleted movable-list element.
- Resets replace the cache.

`clear_revivable` is O(1) when the set is empty, so documents that never query
deleted containers pay nothing.

Undo, redo, `revert_to`, and `apply_diff` do not revive container IDs by
themselves: `Handler::apply_diff` creates a new tree node for a deleted target
and remaps its metadata, and map/list children are recreated with new IDs.
Their tree moves still go through `apply_local_op`. Only mergeable children
keep their IDs, and those are not cached.

Before 2026-09-28 every deletion went into one set that forward transitions
never cleared (`clear_alive` removed nothing, since only deletions were
stored), so after `is_deleted()` a revived container still counted as deleted
in release builds and rejected edits until the document was reloaded. The
same held for a container queried at an older version and brought in by a
forward checkout.

The debug assertion runs while the caller holds the state lock, so a stale
entry aborts the process (the unwinding code locks the poisoned state again),
like any other internal assertion under that lock.

## Tests

- `crates/loro/tests/dead_container_cache.rs`: every revival route. Run it with
`--release` as well; debug builds recompute the answer.
- Unit tests in `dead_containers_cache.rs` check the cache entries directly,
so they also fail in debug builds when an invalidation is missing.
42 changes: 25 additions & 17 deletions crates/loro-internal/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use tracing::{info_span, instrument, warn};

use crate::{
configure::{Configure, DefaultRandom, SecureRandomGenerator},
container::{idx::ContainerIdx, richtext::config::StyleConfigMap},
container::{idx::ContainerIdx, richtext::config::StyleConfigMap, tree::tree_op::TreeOp},
cursor::{Cursor, PosType},
delta::TreeExternalDiff,
diff_calc::{DiffCalculator, DiffMode},
Expand All @@ -27,7 +27,7 @@ use crate::{
handler::ValueOrHandler,
id::PeerID,
lock::{LoroLockGroup, LoroMutex},
op::{Op, RawOp},
op::{Op, RawOp, RawOpContent},
version::{Frontiers, VersionVector},
ContainerDiff, ContainerType, DocDiff, InternalString, LoroDocInner, LoroValue, OpLog,
};
Expand Down Expand Up @@ -681,17 +681,22 @@ impl DocState {
}
// `diff_mode` here is the DIRECTION mode (`origin_diff_mode` from
// `calc_diff_internal`), not the mode the calculators computed with:
// Checkout means the transition may go backwards, so any cached
// dead/alive knowledge can be invalidated; every other mode implies a
// forward transition, where alive-markers may change but dead
// containers stay dead unless a diff revives them.
match diff_mode {
DiffMode::Checkout => {
self.dead_containers_cache.clear();
}
_ => {
self.dead_containers_cache.clear_alive();
}
// Checkout means the transition may go backwards, which can revive any
// container. Any checkout also clears it, even a forward one
// (`attach`, `checkout_to_latest`): while the state was behind the
// oplog, a container created later looked cut at its map or list
// parent. Every other forward transition keeps the state at the
// oplog's latest version, where only a tree or movable-list move can
// revive a container (see `dead_containers_cache.rs`).
if diff_mode == DiffMode::Checkout || diff.by == EventTriggerKind::Checkout {
self.dead_containers_cache.clear();
} else if diffs.iter().any(|d| {
matches!(
d.idx.get_type(),
ContainerType::Tree | ContainerType::MovableList
)
}) {
self.dead_containers_cache.clear_revivable();
}
self.pre_txn(diff.origin.clone(), diff.by);

Expand Down Expand Up @@ -997,11 +1002,14 @@ impl DocState {
if self.in_txn {
self.changed_idx_in_txn.insert(op.container);
}
let ret = state.apply_local_op(raw_op, op)?;
if !ret.deleted_containers.is_empty() {
self.dead_containers_cache.clear_alive();
// A local move can take a node out of a deleted subtree. Movable-list
// elements cannot be revived locally: a deleted one has no index.
if let RawOpContent::Tree(tree_op) = &raw_op.content {
if matches!(**tree_op, TreeOp::Move { .. }) {
self.dead_containers_cache.clear_revivable();
}
}

state.apply_local_op(raw_op, op)?;
Ok(())
}

Expand Down
6 changes: 4 additions & 2 deletions crates/loro-internal/src/state/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ before changing mergeable child behavior.
[../../../../context/shallow-snapshot-style-redaction.md](../../../../context/shallow-snapshot-style-redaction.md)
before changing the richtext snapshot codec or that pass.
- `mergeable.rs`: logical child edge resolution for mergeable containers.
- `dead_containers_cache.rs`: dead/alive tracking and marker-driven mergeable
reactivation.
- `dead_containers_cache.rs`: dead/alive tracking. Map/list deletions are
final; tree and movable-list ones are dropped whenever a move could revive
them; mergeable ones are never cached:
[../../../../context/dead-container-cache.md](../../../../context/dead-container-cache.md).
- `unknown_state.rs` and `../diff_calc/unknown.rs`: forward compatibility for
unknown container types.

Expand Down
Loading
Loading