fix: bound the decoded container value cache to stop wasm memory growth on handle reads - #1093
Merged
Conversation
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
Contributor
WASM Size Report
|
…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
This was referenced Sep 4, 2026
…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).
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.
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 untildoc.free(). 331k containers → ~1.2 GiB; ~1M containers traps wasm32 at the 4 GiB limit withRuntimeError: unreachable.Root cause
Every handle read funnels through
InnerStore::with_container_for_read, which decodes the container's value intoContainerWrapper's lazy value cache and pins the wrapper inInnerStore.storeforever — no eviction, released only bydoc.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
InnerStoreis now bounded by a second-chance FIFO (MAX_CACHED_CONTAINER_VALUES = 2048):Statewrappers and are never evicted, so unflushed edits can never be lost.load_state(the!= AllLoadedgates were removed), so evicted entries stay reachable for GC/shallow-snapshot docs and afterload_allas well.storeis now strictly a cache overkv.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()'sAllLoadedshort-circuit had become incorrect under eviction: it was correct whenAllLoadedimplied "store contains every kv entry", but eviction breaks that implication, soiter_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 viaiter_all_container_ids(), so evicted overlay containers were silently dropped from the export and gone on the next import.Fix:
InnerStorenow tracksevicted_since_full_load(set on every eviction, cleared bydecode/decode_twice/ a fullload_allscan);load_all()re-scanskvwhen the flag is set instead of trustingAllLoaded. 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 everyiter_all_container_idscall (hot-ish paths); not evicting whileAllLoadedwould reintroduce the #1092 leak for GC/shallow-snapshot docs.load_all()'s "content instoreis newer thankv" skip stays sound — evicted entries are absent fromstore, so they are rebuilt fromkv.New regression tests (both P1 repros verified to fail without the fix):
evicted_entries_stay_readable_when_all_loadednow also assertsiter_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.handle_walk_memory.rsasserts the exact 13,260-handle count per walk;walk_mem.test.tsasserts the walk result deep-equalstoJSON().history.Validation
Repro from the issue (release wasm build, this machine):
RuntimeError: unreachable(wasm32 4 GiB)new Mirrorfull init (single)No CPU regression from eviction — walks and Mirror init are ~2x faster (bounded working set, better locality, less allocator pressure).
container_store.rs): cache stays bounded during a full container walk; evicted containers re-read and edit correctly; eviction works inAllLoadedmode; snapshot round-trip after evictions;first_lazy_read_caches_valuenow pins the bounded contract.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).walk_mem.test.ts: ~100k-container handle walk keeps external memory within a small multiple of thetoJSON()delta — fails at ~286 MB on the pre-fix build, passes at ~13 MB.cargo test -p loro-internalandcargo test -p loro: all green.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.