Skip to content

fix: bound the decoded container value cache to stop wasm memory growth on handle reads - #1093

Merged
zxch3n merged 4 commits into
mainfrom
fix/wasm-handle-walk-memory
Sep 4, 2026
Merged

fix: bound the decoded container value cache to stop wasm memory growth on handle reads#1093
zxch3n merged 4 commits into
mainfrom
fix/wasm-handle-walk-memory

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 4, 2026

Copy link
Copy Markdown
Member

Stack: 1/6 — merge order: #1093#1085#1086#1087#1090#1091

Summary

Closes #1092.

Reading a document container by container through JS handles (LoroMap.keys()/get(), LoroList.get(), LoroText.toJSON() — the pattern loro-mirror's initial state build uses) retained ~4 KB of wasm linear memory per container until doc.free(). 331k containers → ~1.2 GiB; ~1M containers traps wasm32 at the 4 GiB limit with RuntimeError: unreachable.

Root cause

Every handle read funnels through InnerStore::with_container_for_read, which decodes the container's value into ContainerWrapper's lazy value cache and pins the wrapper in InnerStore.store forever — no eviction, released only by doc.free(). Bulk paths (toJSON, deep values, snapshot export) already use ephemeral reads that leave no residue, which is why they stayed modest. It is a one-time pin per container, not a per-access leak: repeating the walk did not grow memory, and freeing JS handles / forcing GC could not help because the pin lives on the Rust side.

Fix

The decoded-value cache in InnerStore is now bounded by a second-chance FIFO (MAX_CACHED_CONTAINER_VALUES = 2048):

  • Only flushed lazy wrappers are evictable — they are pure caches over the KV bytes, so eviction loses nothing; the next read re-creates the wrapper from the KV store. Mutated containers live in State wrappers and are never evicted, so unflushed edits can never be lost.
  • The second-chance bit keeps hot ancestors (e.g. the root list re-read once per item during a deep walk) from being evicted between accesses; a plain FIFO would re-decode them constantly.
  • KV fallback lookups now run regardless of load_state (the != AllLoaded gates were removed), so evicted entries stay reachable for GC/shallow-snapshot docs and after load_all as well. store is now strictly a cache over kv.

doc.free() semantics are unchanged. No public API changes.

Why 2048: worst case that retains ~2048 × ~4 KB ≈ 8 MB — negligible next to any document large enough to matter — while covering deep ancestor chains and per-item working sets during walks; the measurements below show walks got faster, not slower, so a larger bound would only spend memory.

Known remaining pin (pre-existing, much narrower): reading a tree container's value materializes its full State, which is not covered by this bound.

Review finding (P1) and follow-up fix

An independent review found that load_all()'s AllLoaded short-circuit had become incorrect under eviction: it was correct when AllLoaded implied "store contains every kv entry", but eviction breaks that implication, so iter_all_container_ids() / iter_all_containers_mut() silently enumerated only the eviction survivors. This escalates to real data loss: the shallow-snapshot re-export path (encoding/shallow_snapshot.rs) enumerates containers created after the root via iter_all_container_ids(), so evicted overlay containers were silently dropped from the export and gone on the next import.

Fix: InnerStore now tracks evicted_since_full_load (set on every eviction, cleared by decode / decode_twice / a full load_all scan); load_all() re-scans kv when the flag is set instead of trusting AllLoaded. Cost: one bool check per call, plus at most one kv re-scan after the first eviction following a full load. Alternatives rejected: dropping the short-circuit entirely would scan all KV entries on every iter_all_container_ids call (hot-ish paths); not evicting while AllLoaded would reintroduce the #1092 leak for GC/shallow-snapshot docs. load_all()'s "content in store is newer than kv" skip stays sound — evicted entries are absent from store, so they are rebuilt from kv.

New regression tests (both P1 repros verified to fail without the fix):

  • evicted_entries_stay_readable_when_all_loaded now also asserts iter_all_container_ids().count() stays complete after an evicting walk (was 16 instead of 65 pre-fix).
  • reexport_same_shallow_root_after_walk_eviction_keeps_overlay_containers (encoding/shallow_snapshot.rs): the exact escalation — shallow root + overlay-only containers → import → evicting walk → re-export the same shallow root → re-import → every container still present with its value (fails pre-fix).
  • evicted_mergeable_child_and_tree_meta_survive_round_trip: evict a mergeable child and a tree-meta map → re-read via parent marker / tree path → mutate → snapshot round-trip.
  • stale_queue_entry_after_lazy_to_state_conversion: a wrapper mutated while enqueued (Lazy→State) leaves a stale queue entry; eviction must skip it without losing the mutation.
  • Both memory tests now assert walk completeness so an incomplete walk cannot pass the memory thresholds: handle_walk_memory.rs asserts the exact 13,260-handle count per walk; walk_mem.test.ts asserts the walk result deep-equals toJSON().history.

Validation

Repro from the issue (release wasm build, this machine):

loro-crdt 1.15.1 this PR
walk, 190 turns / 62,796 handles 762 ms, external +143 MiB (17→160) 317 ms, external flat at ~42 MiB
walk, 570 turns / 188,386 handles 12,517 ms, external 35→676 MiB, rss +722 MiB 1,279 ms, external flat at ~84 MiB, rss +46 MiB
walk, 3,200 turns / 1,057,601 handles traps: RuntimeError: unreachable (wasm32 4 GiB) 10.3 s, no trap, external ~450 MiB
walk-repeat ×3 (new doc each) external +150 MiB per doc (160→309→459) ~+26 MiB per doc (42→69→95), ≈ per-doc state size
new Mirror full init (single) 1,233 ms, external 27→160 MiB 559 ms, external 27→42 MiB

No CPU regression from eviction — walks and Mirror init are ~2x faster (bounded working set, better locality, less allocator pressure).

  • Rust unit tests (container_store.rs): cache stays bounded during a full container walk; evicted containers re-read and edit correctly; eviction works in AllLoaded mode; snapshot round-trip after evictions; first_lazy_read_caches_value now pins the bounded contract.
  • New tests/handle_walk_memory.rs (dev_utils counting allocator): walk retains 5.1 MiB over ~26k handles, second walk +0.0 MiB (unbounded: ~1 KB per container, would be ~26 MiB here).
  • New wasm test walk_mem.test.ts: ~100k-container handle walk keeps external memory within a small multiple of the toJSON() delta — fails at ~286 MB on the pre-fix build, passes at ~13 MB.
  • cargo test -p loro-internal and cargo test -p loro: all green.
  • Full wasm suite: vitest 347 passed, tsc --noEmit, deno and bun tests green.

WASM size

Release bundle: brotli 724.92 KB → 725.35 KB (+0.43 KB), gzip 1034.08 KB → 1034.48 KB.

Reading containers through JS handlers funnels through
InnerStore::with_container_for_read, which pinned every decoded
container value in the store for the lifetime of the doc (~4 KB per
container). Walking a large document container by container retained
memory superlinearly until doc.free(), trapping wasm32 at the 4 GiB
limit around one million containers (#1092).

Bound the cache with a second-chance FIFO over flushed lazy wrappers,
which are pure caches over KV bytes: evicted wrappers are re-created
from the KV store on the next read, so eviction only costs a re-decode.
KV fallback lookups now run regardless of load_state so evicted entries
stay reachable in AllLoaded mode as well.

Refs #1092
- loro-internal: bounded-cache unit tests (bounded growth during a
  container walk, re-reads and edits after eviction, eviction under
  AllLoaded) with a small cache bound under cfg(test)
- loro-wasm: walk_mem.test.ts asserts a ~100k-container handle walk
  keeps external memory within a small multiple of the toJSON() delta
  (fails at ~286 MB on the pre-fix build, ~13 MB after)
- changeset, context/container-value-cache.md, AGENTS.md links
- drop the temporary mem_probe diagnostic
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3107.74 KB
  • Gzipped size: 1028.46 KB
  • Brotli size: 722.20 KB

…ue cache

- tests/handle_walk_memory.rs uses the dev_utils counting allocator to
  assert the loro-mirror-style handle walk retains bounded memory
  (walk number 2 adds ~0; total far below the unbounded ~1 KB/container)
- extend first_lazy_read_caches_value with the new bounded-cache
  contract: values are still cached on first read, but reads beyond the
  bound evict, and evicted containers re-decode from KV transparently
…iction

Review of the bounded value cache found that the AllLoaded short-circuit
in load_all() became incorrect once eviction exists: AllLoaded no longer
implies store contains every kv entry, so iter_all_container_ids() and
iter_all_containers_mut() silently enumerated only the eviction survivors.
The shallow-snapshot re-export path enumerates containers this way, which
dropped evicted overlay containers from the export — silent data loss on
the next import.

InnerStore now tracks evicted_since_full_load (set on eviction, cleared by
decode/decode_twice/a full load_all scan); load_all() re-scans kv while the
flag is set instead of trusting AllLoaded. Cost: one bool check per call
plus at most one re-scan after the first eviction following a full load.

Regression tests:
- iter_all_container_ids().count() stays complete after an evicting walk
  in AllLoaded mode (was 16 instead of 65 pre-fix).
- shallow root + overlay-only containers -> import -> evicting walk ->
  re-export the same root -> re-import keeps every container (fails
  pre-fix).
- mergeable child / tree-meta map: evict -> read via parent marker / tree
  path -> mutate -> snapshot round-trip.
- stale queue entry after Lazy->State conversion under continued cache
  pressure.
- both memory tests now assert walk completeness (exact handle count in
  handle_walk_memory.rs, deep-equal against toJSON() in walk_mem.test.ts).
@zxch3n
zxch3n merged commit fcd039c into main Sep 4, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant