Skip to content
Open
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
39 changes: 39 additions & 0 deletions .changeset/bound-parquet-caches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@spatialdata/core': minor
---
Comment thread
xinaesthete marked this conversation as resolved.

Bound the two parquet caches on `SpatialDataTableSource` by resident bytes
([ADR 0005](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/docs/adr/0005-memory-accounting-before-management.md)
rung 2), and add the `ByteLruCache` they are built on.

`parquetTableBytes` (compressed file bytes) and `parquetTableCache` (decoded
Arrow tables) were plain `Record`s with no eviction of any kind. A source held
**both tiers of every parquet file any caller had ever touched**, simultaneously,
until the source itself was discarded — double memory for zero eviction benefit.
That is a leak, and this fixes it rather than building an architecture around it:
both are now byte-bounded LRUs that report `byteLength`, and memory is
assertable in a test for the first time.

**Breaking for anyone reading those two fields directly.** They are no longer
plain objects: `source.parquetTableBytes[path]` becomes
`source.parquetTableBytes.get(path)`, with `peek` for a read that should not
count as a use, plus `has`, `delete`, `clear`, `size` and `byteLength`. Nothing
in this repository outside `VTableSource` touched either one.

Ceilings default to 128 MB encoded and 256 MB decoded per source, overridable
via the new `parquetCacheLimits` field on `DataSourceParams`. The numbers are
guesses that bound a leak, not a measured working set — the ADR is explicit that
they stay guesses until something measures them, so they are a constructor
option rather than a constant you would have to fork the library to change.

Two semantics worth knowing:

- **A value larger than the whole budget is admitted, not refused**, and left as
the sole resident. Refusing it would be the worse failure: `loadParquetBytes`
runs roughly twenty times per points load, so a file that can never be admitted
becomes twenty refetches of the file that was already too big to fetch once.
- **Entries are inserted before their size is known.** The decoded cache holds
the in-flight promise — that is what dedupes concurrent callers onto one WASM
decode — so it is sized at zero until the table lands, then recounted. Arrow's
`Data.byteLength` walks the whole child tree, so it is asked exactly once per
table and the total is maintained incrementally from there.
38 changes: 38 additions & 0 deletions .changeset/fill-chunk-cache-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
'@spatialdata/vis': minor
'@spatialdata/core': patch
---

Give zarr imagery a decoded chunk cache
([ADR 0005](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/docs/adr/0005-memory-accounting-before-management.md)
rung 3). There was not one before — not an undersized one, none at all.

fizarrita has always accepted a `{ get, set }` cache on `getWorker`, and
`zarrextra` has always plumbed it through `enableWorkerChunkDecode({ cache })`.
`ensureCodecWorkers()` called that 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. Every tile therefore paid a network round-trip *and* a
re-decode every time it came back into view.

It is now filled with a byte-bounded LRU, default 256 MB, overridable with
`ensureCodecWorkers({ chunkCacheMaxBytes })` on the first call. `getChunkCache()`
returns it for inspection (`byteLength` is what it currently holds) or for
`clear()`.

`RasterElement.getStore()` is now memoized, and that is load-bearing rather than
tidiness: fizarrita keys chunks as `store_N:{array path}:{chunk key}`, where `N`
comes from a `WeakMap` on the **store instance**, and `createPrefixedStore`
returns a fresh object literal on every call. Handing out a new view per caller
would give one chunk a different key per view, so the cache would fill with
duplicates and never hit. One stable view per element is what makes it a cache.

Two limits worth stating plainly:

- **Absent chunks are cached as data.** fizarrita materialises a full zero-filled
typed array for a missing chunk and caches it like any other, so a sparse array
can spend real bytes on nothing. The byte bound makes that survivable; it does
not make it free.
- **In-flight requests are still not deduped.** fizarrita reads the cache while
building its task list and writes back only after the worker returns, so two
concurrent requests for the same chunk both fetch and both decode. That is an
upstream gap this seam cannot close.
20 changes: 20 additions & 0 deletions .changeset/memory-reporting-scalar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@spatialdata/core': minor
---

Add `MemoryReporting` — `{ readonly byteLength: number }` — the first rung of
[ADR 0005](https://github.com/Taylor-CCB-Group/SpatialData.js/blob/main/docs/adr/0005-memory-accounting-before-management.md).

The library 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 right now?". This is that answer, and only that
answer — no tiers, no eviction, no ceiling.

The name is doing the work. `byteLength` is what `TypedArray`, `ArrayBuffer` and
`DataView` already call this, so every payload we actually hold satisfies the
interface structurally, with no wrapper and no import. That is what makes it
cheap enough to put on every cache rather than on a chosen few.

Implementors take on one obligation: keep the number cheap to read — a running
total maintained on insert and evict, not a scan of the residents per read — so
that callers can poll it freely.
27 changes: 27 additions & 0 deletions .changeset/parquet-table-cache-rejection-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@spatialdata/core': patch
---

Stop a transient parquet fetch failure from poisoning `loadParquetTable` for the
lifetime of the source.

`parquetTableCache` stores the table promise *before* it settles. That is
deliberate and correct — it is what makes concurrent callers for the same file
share one `readParquet` + `tableFromIPC` decode instead of racing two WASM
parses of the same bytes. What was missing is the other half: nothing ever
removed a promise that settled as a *rejection*. A single failed read — a
dropped connection, a 503, a store not yet warm — left a rejected promise
parked at that path forever, and every subsequent read of that element replayed
a network error that had long since cleared. The only recovery was to construct
a new source.

The cached promise now evicts itself on rejection, and only if it is still the
current entry for that path, so a retry that already superseded it is not
clobbered by the earlier promise's late rejection. This is the same
`evictIfCurrent` discipline `loadParquetDatasetMetadata` and
`discoverMultipartPartPaths` already use.

In-flight dedup and the caching of successful tables are unchanged, and so is
the deliberate skip-vs-fail policy in `docs/plans/parquet-io-error-handling.md`:
the rejection still propagates unchanged to the caller that provoked it. It just
stops being the answer given to the next one.
16 changes: 16 additions & 0 deletions packages/core/src/Vutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ export function basename(path: string) {
return result;
}

/**
* Resident-byte ceilings for the two parquet caches a source holds.
*
* Both default to values chosen to bound a leak, not to fit a measured working
* set — ADR 0005 is explicit that the numbers stay guesses until something
* measures them. Raise or lower them per source when you know better.
*/
export type ParquetCacheLimits = {
/** Ceiling for cached compressed parquet file bytes. */
encodedMaxBytes?: number;
/** Ceiling for cached decoded Arrow tables. */
decodedMaxBytes?: number;
};

export type DataSourceParams = {
url?: string;
/** Options to pass to fetch calls. */
Expand All @@ -35,4 +49,6 @@ export type DataSourceParams = {
store?: Readable;
/** The file type. */
fileType: string; // '.zip' | '.h5ad' etc...
/** Optional overrides for the parquet cache byte ceilings. */
parquetCacheLimits?: ParquetCacheLimits;
};
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

// Resource Resolver contracts (ADR 0004).
export * from './engine/index.js';
// Memory accounting (ADR 0005).
export * from './memory/index.js';
export * from './models/index.js';
// The semantics that need the tree guards, not just the guards themselves: a
// consumer enumerating obs columns has to know that a group might be a
Expand Down
Loading
Loading