Skip to content

Memory accounting before memory management: ADR 0005 rungs 1-3 - #132

Open
xinaesthete wants to merge 5 commits into
mainfrom
claude/memory-accounting-handoff-adr-5a4377
Open

Memory accounting before memory management: ADR 0005 rungs 1-3#132
xinaesthete wants to merge 5 commits into
mainfrom
claude/memory-accounting-handoff-adr-5a4377

Conversation

@xinaesthete

@xinaesthete xinaesthete commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Implements rungs 1–3 of ADR 0005. Rungs 4–5 are deliberately not built — the ADR defers them until measurement justifies them, and this leaves that intact.

The repo had a memory policy and no memory accounting: DEFAULT_POINTS_MEMORY_CAP is a row count applied to one element kind, and nothing anywhere could answer "how many bytes are resident?". These four commits fix the three things that are broken today and stop there.

Each commit stands alone and is separately reviewable.

06dd876 — rung 2b: rejected parquet promises evict themselves

parquetTableCache caches the table promise before it settles — genuine in-flight dedup — but never cleaned up a rejection, so one transient fetch failure pinned a rejected promise at that path for the lifetime of the source, and every later read replayed a network error that had cleared long ago. The only recovery was a new source.

Reproduced first, then fixed with the evictIfCurrent discipline already used by the dataset-metadata and part-path caches. The rejection still propagates unchanged to the caller that provoked it, so the deliberate skip-vs-fail policy in docs/plans/parquet-io-error-handling.md is untouched — it just stops being the answer for the next caller. Tests cover the retry plus the two behaviours that had to survive it: concurrent dedup, and successful tables staying cached.

7d8592f — rung 1: the MemoryReporting scalar

{ readonly byteLength: number }. No policy, no eviction, no tiers.

The name is the design: byteLength is what TypedArray, ArrayBuffer and DataView already call this, so every payload we hold satisfies it structurally — no wrapper, no import. That is what makes it cheap enough to put on every cache rather than a chosen few. Implementors take on one obligation: keep the number cheap to read (running total on insert/evict, never a scan per read).

5118184 — rung 2: both parquet tiers bounded

parquetTableBytes (compressed bytes) and parquetTableCache (decoded Arrow) were plain Records with no eviction of any kind. A source held both tiers of every parquet file any caller had ever touched, simultaneously, until it was discarded — double memory for zero eviction benefit. Fixing a leak, not building an architecture.

Adds ByteLruCache (framework-free, byte-bounded, MemoryReporting, dispose hook) and puts both tiers behind it. Two semantics the tests pin:

  • An oversized value is admitted, not refused, and left sole resident. Refusing it is the worse failure: loadParquetBytes runs ~20 times per points load, so a file that can never be admitted becomes ~20 refetches of the file that was already too big to fetch once.
  • Sizes need not be known at insertion. The decoded cache holds the in-flight promise (that is the dedup), so it enters at zero bytes and is recounted when the table lands. Arrow's Data.byteLength walks the whole child tree, so it is asked exactly once per table.

Ceilings default to 128 MB encoded / 256 MB decoded, overridable per source via DataSourceParams.parquetCacheLimits. The numbers bound a leak; they are not a measured working set, which is why they are an option rather than a constant you would have to fork to change.

Breaking for direct readers of those two public fields — source.parquetTableBytes[path] becomes .get(path). Nothing outside VTableSource touched either.

7f3c3d9 — rung 3: the chunk-cache seam, filled

fizarrita has always accepted a { get, set } cache and zarrextra has always plumbed it through, but ensureCodecWorkers() called enableWorkerChunkDecode() with no options — so cache was undefined and fizarrita fell back to its no-op. There was no chunk cache at all, not an undersized one. Every tile paid a network round-trip and a re-decode on every pan back over ground already covered. Pure win: nothing to trade against a cache that did not exist.

ByteLruCache satisfies fizarrita's ChunkCache structurally, so it is passed as-is — its get is the lookup and the recency update, exactly what that interface wants. Default 256 MB, overridable on the first call; getChunkCache() exposes it for inspection and clear().

RasterElement.getStore() is now memoized, and that is load-bearing rather than tidiness: fizarrita keys chunks store_N:{path}:{chunkKey} with N from a WeakMap on the store instance, while createPrefixedStore returns a fresh object literal per call. A view per caller would key one chunk differently per view and fill the cache with duplicates that never hit.

Two upstream gaps left standing, and documented at the call site rather than silently inherited: absent chunks are cached as full zero-filled arrays (bounded now, but still real bytes), and the chunk path still has no in-flight dedup. Both are filed upstream.

Verification

  • Full unit suite green: 99 files / 825 tests. biome ci and lint:react clean. All packages build.
  • Verified live in the vis demo against the remote Visium HD dataset: the chunk cache is wired end to end through fizarrita — 256 MB ceiling, one decoded chunk resident at 95.7 MB under key store_0:/0:0/0/0. Before this branch there was no cache object to inspect at all.

One thing I could not demonstrate in-app: the duplicate-key counterfactual for the getStore() memoization. Removing the memoization and toggling the layer still produced a single store_0 entry, because the app caches the image loader so getStore() is not called again on a toggle. The memoization is correct and is what the ADR asks for, but the only direct evidence is the unit test that it returns a stable instance — there is no in-app repro of the failure it prevents.

Not in scope

Rungs 4 (encoded tier / evict-decoded-keep-encoded) and 5 (tiered ResidencyReport, global ceiling, degrade-to-fit) are untouched by design. The ADR's own rationale — tgpu-htj2k built and unit-tested a budget solver it then never called in production — is the reason.

The ADR still reads Status: proposed. Rungs 1–3 landing does not by itself settle 4–5, so that felt like a call to make separately.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added byte-limited LRU caching for Parquet data and decoded Zarr chunks.
    • Added configurable cache size limits and cache inspection/clearing APIs.
    • Added memory usage reporting through resident byte counts.
    • Added stable store reuse for raster data access.
  • Bug Fixes

    • Failed Parquet loads no longer permanently prevent future retries.
    • In-flight Parquet requests continue to be deduplicated while successful results remain cached.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds shared memory accounting and byte-bounded LRU caches. It applies the caches to Parquet data in @spatialdata/core and decoded Zarr chunks in @spatialdata/vis. It also memoizes raster store views and adds configuration, exports, tests, and changesets.

Changes

Cache infrastructure and integrations

Layer / File(s) Summary
Memory reporting and byte-bounded LRU cache
packages/core/src/memory/*, packages/core/src/index.ts, packages/core/tests/byteLruCache.spec.ts, .changeset/memory-reporting-scalar.md
Adds the MemoryReporting interface and ByteLruCache with byte accounting, LRU eviction, disposal, recounting, and oversized-entry handling.
Byte-bounded Parquet caches
packages/core/src/Vutils.ts, packages/core/src/models/VTableSource.ts, packages/core/tests/parquetTableCache.spec.ts, .changeset/bound-parquet-caches.md, .changeset/parquet-table-cache-rejection-cleanup.md
Adds configurable encoded and decoded Parquet cache limits. SpatialDataTableSource caches encoded bytes and decoded tables, deduplicates in-flight loads, recounts decoded entries, and removes failed current entries.
Memoized raster store views
packages/core/src/models/index.ts, packages/core/tests/rasterElementStore.spec.ts
RasterElement.getStore() reuses one prefixed store view per element.
Bounded decoded chunk cache
packages/vis/src/codecWorkers.ts, packages/vis/src/index.ts, packages/vis/tests/codecWorkers.spec.ts, .changeset/fill-chunk-cache-seam.md
Adds configurable decoded-chunk caching, getChunkCache(), public exports, and tests for initialization, byte accounting, eviction, and one-time configuration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SpatialDataTableSource
  participant ByteLruCache
  participant BackingStore
  participant ParquetDecoder
  SpatialDataTableSource->>ByteLruCache: Check encoded and decoded caches
  ByteLruCache-->>SpatialDataTableSource: Return resident entry or miss
  SpatialDataTableSource->>BackingStore: Read missing parquet bytes
  BackingStore-->>SpatialDataTableSource: Return parquet bytes
  SpatialDataTableSource->>ParquetDecoder: Decode and cache in-flight promise
  ParquetDecoder-->>SpatialDataTableSource: Return Arrow table
  SpatialDataTableSource->>ByteLruCache: Recount decoded table bytes
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the memory-accounting changes and their scope as ADR 0005 rungs 1–3.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/memory-accounting-handoff-adr-5a4377

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (3)
packages/core/tests/rasterElementStore.spec.ts (2)

56-58: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use one backing store for the distinct-view test.

The two elements currently use different root stores. Pass the same fakeStore() result to both elements. This verifies that RasterElement creates separate prefixed views even when both elements share one backing store.

Proposed test change
 it('gives different elements different views', () => {
-  expect(createImageElement().getStore()).not.toBe(createImageElement().getStore());
+  const store = fakeStore();
+  expect(createImageElement(store).getStore()).not.toBe(createImageElement(store).getStore());
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/tests/rasterElementStore.spec.ts` around lines 56 - 58, Update
the “gives different elements different views” test to create one shared
fakeStore instance and pass it to both RasterElement instances, then compare
their getStore() results. Keep the assertion that the resulting views are
distinct while ensuring both elements use the same backing store.

34-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the test as any assertions.

ImageElement expects source: StoreReference on sdata, so line 39 bypasses the SDataProps.source contract. For line 65, use a local absolute-path helper/narrower instead of bypassing Zarrita’s zarr.AbsolutePath contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/tests/rasterElementStore.spec.ts` around lines 34 - 42, Remove
the `as any` assertions from the raster element tests. Update
`createImageElement` to provide a valid `source: StoreReference` on `sdata`, and
replace the line 65 cast with a local absolute-path helper or type-narrowing
approach that satisfies Zarrita’s `zarr.AbsolutePath` contract.

Source: Coding guidelines

packages/vis/tests/codecWorkers.spec.ts (1)

18-24: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the fixture type assertion.

as Chunk<DataType> suppresses validation of the mock chunk shape. Make the object satisfy Chunk<DataType> directly so changes to zarrita’s chunk contract fail this test at compile time.

Proposed change
-function chunkOf(bytes: number): Chunk<DataType> {
+function chunkOf(bytes: number) {
   return {
     data: new Uint8Array(bytes),
     shape: [bytes],
     stride: [1],
-  } as Chunk<DataType>;
+  } satisfies Chunk<DataType>;
 }

As per coding guidelines, “Avoid type assertions (as)” when a narrower contract can express the same fact.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vis/tests/codecWorkers.spec.ts` around lines 18 - 24, Update the
chunkOf fixture to satisfy the Chunk<DataType> return type without using a type
assertion, preserving its existing data, shape, and stride fields so the
compiler validates the mock against the chunk contract.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/bound-parquet-caches.md:
- Around line 1-3: Update the changeset release level for `@spatialdata/core` from
minor to major to reflect the breaking parquetTableBytes access change from
bracket indexing to get(path). Do not retain the minor classification unless the
implementation preserves a compatible record-style API.

In `@packages/core/src/memory/byteLruCache.ts`:
- Around line 126-129: Update the eviction and clear flows in the byte LRU
cache, including the replacement path around onDispose and the logic at the
referenced clear-related sections, to complete removals and residentBytes
bookkeeping before invoking any disposal callbacks. Capture the first callback
error, continue invoking all required callbacks, and rethrow the captured error
only after eviction or clearing has fully completed.
- Around line 69-73: Update the ByteLruCache constructor and each sizeOf call to
validate that maxBytes and computed entry sizes are finite, non-negative
numbers, rejecting invalid values before they affect eviction or byte
accounting. Ensure NaN, infinities, and negative counts cannot enter cache state
or allow unbounded growth.

In `@packages/vis/src/codecWorkers.ts`:
- Around line 85-87: Validate options?.chunkCacheMaxBytes before constructing
ByteLruCache: accept only finite, non-negative values, while preserving
DEFAULT_CHUNK_CACHE_MAX_BYTES when the option is absent. Reject Infinity, NaN,
and negative limits before the maxBytes value reaches the cache configuration.
- Around line 15-16: Update the contract for chunkCacheMaxBytes and the
initialization flow in ensureCodecWorkers so the option is read on the first
call that enables workers, rather than the first invocation when Worker is
unavailable. Ensure a later worker-enabled call can apply its value, or persist
the value from the first call and use it when workers become available.
- Around line 23-31: Update chunkByteLength to calculate the actual encoded byte
size of each string payload rather than using data.length, while preserving
typed-array byteLength handling for numeric chunks. Use the existing chunk data
flow and add a test covering variable-length strings to verify the reported size
and maxBytes eviction behavior.

---

Nitpick comments:
In `@packages/core/tests/rasterElementStore.spec.ts`:
- Around line 56-58: Update the “gives different elements different views” test
to create one shared fakeStore instance and pass it to both RasterElement
instances, then compare their getStore() results. Keep the assertion that the
resulting views are distinct while ensuring both elements use the same backing
store.
- Around line 34-42: Remove the `as any` assertions from the raster element
tests. Update `createImageElement` to provide a valid `source: StoreReference`
on `sdata`, and replace the line 65 cast with a local absolute-path helper or
type-narrowing approach that satisfies Zarrita’s `zarr.AbsolutePath` contract.

In `@packages/vis/tests/codecWorkers.spec.ts`:
- Around line 18-24: Update the chunkOf fixture to satisfy the Chunk<DataType>
return type without using a type assertion, preserving its existing data, shape,
and stride fields so the compiler validates the mock against the chunk contract.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ee834d9d-cefd-4c1a-9a41-f42d472b850d

📥 Commits

Reviewing files that changed from the base of the PR and between 17ee174 and 7f3c3d9.

📒 Files selected for processing (17)
  • .changeset/bound-parquet-caches.md
  • .changeset/fill-chunk-cache-seam.md
  • .changeset/memory-reporting-scalar.md
  • .changeset/parquet-table-cache-rejection-cleanup.md
  • packages/core/src/Vutils.ts
  • packages/core/src/index.ts
  • packages/core/src/memory/byteLruCache.ts
  • packages/core/src/memory/index.ts
  • packages/core/src/memory/memoryReporting.ts
  • packages/core/src/models/VTableSource.ts
  • packages/core/src/models/index.ts
  • packages/core/tests/byteLruCache.spec.ts
  • packages/core/tests/parquetTableCache.spec.ts
  • packages/core/tests/rasterElementStore.spec.ts
  • packages/vis/src/codecWorkers.ts
  • packages/vis/src/index.ts
  • packages/vis/tests/codecWorkers.spec.ts

Comment thread .changeset/bound-parquet-caches.md
Comment thread packages/core/src/memory/byteLruCache.ts
Comment on lines +126 to +129
if (previous) {
this.onDispose?.(previous.value, key);
}
this.evictToBudget();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Complete cache bookkeeping before disposal callbacks run.

onDispose can throw before evictToBudget() completes. For example, replacing a 50-byte entry with a 200-byte entry under a 100-byte limit leaves the new entry resident when the replacement disposer throws. A throwing disposer in clear() also prevents disposal of later entries.

Remove all required entries and update residentBytes before invoking callbacks. Invoke every callback even if one fails. Propagate a captured callback error only after cache bookkeeping completes.

Also applies to: 170-174, 181-187

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/memory/byteLruCache.ts` around lines 126 - 129, Update the
eviction and clear flows in the byte LRU cache, including the replacement path
around onDispose and the logic at the referenced clear-related sections, to
complete removals and residentBytes bookkeeping before invoking any disposal
callbacks. Capture the first callback error, continue invoking all required
callbacks, and rethrow the captured error only after eviction or clearing has
fully completed.

Comment thread packages/vis/src/codecWorkers.ts Outdated
Comment on lines +23 to +31
function chunkByteLength(chunk: Chunk<DataType>): number {
// Numeric dtypes give a typed array, which reports `byteLength` for free —
// that structural match is the whole point of `MemoryReporting`. String dtypes
// give a plain array instead, and `length` is the conservative floor there: one
// byte per element at minimum. Reporting zero would make such entries invisible
// to the budget and so unevictable by size, which is the one way a bounded
// cache quietly goes back to being unbounded.
const data: { byteLength?: number; length: number } = chunk.data;
return typeof data.byteLength === 'number' ? data.byteLength : data.length;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Account for string payload bytes.

For string chunks, data.length counts elements, not bytes. Long string values can retain substantially more memory than maxBytes reports, so eviction does not enforce the configured byte limit. Size each string payload before inserting it, and add a variable-length string test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vis/src/codecWorkers.ts` around lines 23 - 31, Update
chunkByteLength to calculate the actual encoded byte size of each string payload
rather than using data.length, while preserving typed-array byteLength handling
for numeric chunks. Use the existing chunk data flow and add a test covering
variable-length strings to verify the reported size and maxBytes eviction
behavior.

Comment on lines +85 to +87
chunkCache = new ByteLruCache<Chunk<DataType>>({
maxBytes: options?.chunkCacheMaxBytes ?? DEFAULT_CHUNK_CACHE_MAX_BYTES,
sizeOf: chunkByteLength,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject non-finite cache limits.

Infinity and NaN are valid number values. residentBytes > Infinity and residentBytes > NaN never evict entries, so a host can disable the cache bound accidentally. Validate that chunkCacheMaxBytes is finite and non-negative before constructing ByteLruCache.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/vis/src/codecWorkers.ts` around lines 85 - 87, Validate
options?.chunkCacheMaxBytes before constructing ByteLruCache: accept only
finite, non-negative values, while preserving DEFAULT_CHUNK_CACHE_MAX_BYTES when
the option is absent. Reject Infinity, NaN, and negative limits before the
maxBytes value reaches the cache configuration.

xinaesthete and others added 5 commits August 10, 2026 11:52
`parquetTableCache` caches the table promise before it settles — genuine
in-flight dedup — but never cleaned up a rejection, so one transient fetch
failure pinned a rejected promise at that path for the lifetime of the source
and every later read replayed a network error that had cleared long ago.

The entry now evicts itself on rejection, and only if it is still current, so a
retry that superseded it is not clobbered by the earlier promise's late
rejection — the same discipline `evictIfCurrent` already applies to the dataset
metadata and part-path caches.

The rejection still propagates unchanged to the caller that provoked it, so the
skip-vs-fail policy in `docs/plans/parquet-io-error-handling.md` is untouched.
Tests cover the retry as well as the two behaviours that must survive it:
concurrent dedup, and successful tables staying cached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`{ readonly byteLength: number }`, exported from `@spatialdata/core`. One number,
named so that every `TypedArray`, `ArrayBuffer` and `DataView` satisfies it
structurally — no wrapper, no import — which is what makes it cheap enough to put
on every cache rather than a chosen few.

No policy, no eviction, no tiers: the ADR gates rungs 2 and 3 on this existing,
and is emphatic that this rung cannot be over-engineered. The tiered
`ResidencyReport` and the global ceiling stay deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`parquetTableBytes` and `parquetTableCache` were plain `Record`s with no
eviction: a source held both the compressed and the decoded tier of every
parquet file any caller had ever touched, simultaneously, until the source was
discarded. Double memory, zero eviction benefit.

Adds `ByteLruCache` — framework-free, byte-bounded, `MemoryReporting`, with a
dispose hook — and puts both tiers behind it. Two semantics the tests pin:

- An oversized value is admitted and left sole resident rather than refused.
  `loadParquetBytes` runs ~20 times per points load, so a value that can never
  be admitted becomes ~20 refetches of the file too big to fetch once.
- Sizes need not be known at insertion. The decoded cache holds the in-flight
  promise (that is the dedup), so it enters at zero bytes and is recounted when
  the table lands. Arrow's `Data.byteLength` walks the child tree, so it is
  asked once per table and the total is incremental from there.

Ceilings default to 128 MB encoded / 256 MB decoded, overridable per source via
`DataSourceParams.parquetCacheLimits`. The numbers bound a leak; they are not a
measured working set, which is why they are an option and not a constant.

Breaking for direct readers of those two fields — `Record` index access becomes
`.get()`. Nothing outside `VTableSource` touched either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ensureCodecWorkers()` called `enableWorkerChunkDecode()` with no options, so
`cache` was undefined and fizarrita fell back to its no-op — the seam was
exported, documented, typed end to end, and empty. There was no chunk cache at
all, so every tile paid a network round-trip and a re-decode each time it came
back into view. Filling it is a pure win: nothing to trade against a cache that
did not exist.

It now takes a `ByteLruCache`, which satisfies fizarrita's `ChunkCache`
structurally — `get` is the lookup and the recency update in one, which is
exactly what that interface asks for. Default 256 MB, overridable on the first
call; `getChunkCache()` exposes it for inspection and `clear()`.

`RasterElement.getStore()` is memoized, and that is the load-bearing half.
fizarrita keys chunks `store_N:{path}:{chunkKey}` with `N` from a `WeakMap` on
the store instance, while `createPrefixedStore` returns a fresh object literal
per call — so a view per caller would key one chunk differently per view and
fill the cache with duplicates that never hit.

Two upstream gaps left standing and documented: absent chunks are cached as
full zero-filled arrays (bounded now, but still real bytes), and the chunk path
still has no in-flight dedup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ByteLruCache` trusted every number handed to it. A `NaN` ceiling or a `sizeOf`
that returned `NaN` made every `resident > max` comparison false, silently
disabling eviction for the life of the cache — a bounded cache quietly becoming
an unbounded one, which is the exact failure it exists to prevent. Both are now
refused at the one place a bad number can enter, along with Infinity and
negatives.

Disposal errors no longer abandon the operation that triggered them. Removal and
byte bookkeeping already completed before `onDispose` ran, but a throwing
callback could abort an eviction pass halfway — leaving the cache over its
ceiling — or skip the rest of a `clear`. Eviction and clearing now run to
completion and rethrow the first error afterwards.

Test-only, from the same review: the "different elements different views" case
now shares one backing store, so it asserts something about the per-element view
rather than about two elements holding different stores; and the `as any` casts
are gone from both new specs, verified with tsc rather than assumed (the specs
are outside the package tsconfig, so the suite alone would not have caught it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xinaesthete
xinaesthete force-pushed the claude/memory-accounting-handoff-adr-5a4377 branch from 95fad4b to bfbf793 Compare August 10, 2026 10:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/core/tests/byteLruCache.spec.ts (1)

154-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a rejected-size case for an existing key.

This test covers only an empty cache. The replacement path is untested: when sizeOf throws for a key that is already resident, set currently drops the previous entry without disposal. See the finding on packages/core/src/memory/byteLruCache.ts Line 167-176 for the root cause.

🧪 Proposed test
   it('refuses a size that would corrupt the running total', () => {
     const cache = new ByteLruCache<string>({ maxBytes: 100, sizeOf: () => Number.NaN });
 
     expect(() => cache.set('a', 'x')).toThrow(RangeError);
     expect(cache.byteLength).toBe(0);
   });
+
+  it('keeps the previous entry when a replacement size is refused', () => {
+    let bytes = 10;
+    const onDispose = vi.fn();
+    const cache = new ByteLruCache<string>({ maxBytes: 100, sizeOf: () => bytes, onDispose });
+    cache.set('a', 'first');
+
+    bytes = Number.NaN;
+    expect(() => cache.set('a', 'second')).toThrow(RangeError);
+
+    expect(cache.peek('a')).toBe('first');
+    expect(cache.byteLength).toBe(10);
+    expect(onDispose).not.toHaveBeenCalled();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/tests/byteLruCache.spec.ts` around lines 154 - 159, Add a test
alongside the existing rejected-size case that first inserts a key, then makes
its replacement `sizeOf` throw or return an invalid size, and verifies
`ByteLruCache.set` preserves the resident entry, byteLength, and disposal
behavior. Anchor the test to the existing `ByteLruCache` test cases and ensure
it specifically exercises replacement of an already-present key.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/memory/byteLruCache.ts`:
- Around line 167-176: Validate the replacement value with this.measure(value)
before mutating state in ByteLruCache.set, then remove the previous entry and
adjust residentBytes only after measurement succeeds. In
packages/core/src/memory/byteLruCache.ts lines 167-176, preserve the existing
entry and avoid dispose calls when measurement throws. In
packages/core/tests/byteLruCache.spec.ts lines 154-159, add coverage for a
resident key whose sizeOf throws, asserting the old value remains cached and
onDispose is not called.

---

Nitpick comments:
In `@packages/core/tests/byteLruCache.spec.ts`:
- Around line 154-159: Add a test alongside the existing rejected-size case that
first inserts a key, then makes its replacement `sizeOf` throw or return an
invalid size, and verifies `ByteLruCache.set` preserves the resident entry,
byteLength, and disposal behavior. Anchor the test to the existing
`ByteLruCache` test cases and ensure it specifically exercises replacement of an
already-present key.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 858699d7-7707-406d-a9a9-0c7d6bef11bd

📥 Commits

Reviewing files that changed from the base of the PR and between 7f3c3d9 and bfbf793.

📒 Files selected for processing (5)
  • packages/core/src/memory/byteLruCache.ts
  • packages/core/tests/byteLruCache.spec.ts
  • packages/core/tests/rasterElementStore.spec.ts
  • packages/vis/src/codecWorkers.ts
  • packages/vis/tests/codecWorkers.spec.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/core/tests/rasterElementStore.spec.ts
  • packages/vis/tests/codecWorkers.spec.ts
  • packages/vis/src/codecWorkers.ts

Comment on lines +167 to +176
set(key: string, value: V): void {
const previous = this.entries.get(key);
if (previous) {
this.entries.delete(key);
this.residentBytes -= previous.bytes;
}
const bytes = this.measure(value);
this.entries.set(key, { value, bytes });
this.residentBytes += bytes;
const disposeError = previous ? this.disposeQuietly(previous.value, key) : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

set mutates cache state before it validates the new size. measure runs after the previous entry is deleted and its bytes are subtracted, so a rejected size drops the displaced value without calling onDispose. The test suite covers the rejected-size case only on an empty cache, so the replacement path is not exercised.

  • packages/core/src/memory/byteLruCache.ts#L167-L176: call this.measure(value) first, then delete the previous entry and adjust residentBytes.
  • packages/core/tests/byteLruCache.spec.ts#L154-L159: add a case where sizeOf throws for an already-resident key, and assert that the previous value stays resident and onDispose is not called.
📍 Affects 2 files
  • packages/core/src/memory/byteLruCache.ts#L167-L176 (this comment)
  • packages/core/tests/byteLruCache.spec.ts#L154-L159
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/memory/byteLruCache.ts` around lines 167 - 176, Validate
the replacement value with this.measure(value) before mutating state in
ByteLruCache.set, then remove the previous entry and adjust residentBytes only
after measurement succeeds. In packages/core/src/memory/byteLruCache.ts lines
167-176, preserve the existing entry and avoid dispose calls when measurement
throws. In packages/core/tests/byteLruCache.spec.ts lines 154-159, add coverage
for a resident key whose sizeOf throws, asserting the old value remains cached
and onDispose is not called.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant