Skip to content

feat(data): injectable memory allocator for the new ECS (wasm + SharedArrayBuffer) - #190

Open
krisnye wants to merge 2 commits into
mainfrom
krisnye/shared-array-buffer
Open

feat(data): injectable memory allocator for the new ECS (wasm + SharedArrayBuffer)#190
krisnye wants to merge 2 commits into
mainfrom
krisnye/shared-array-buffer

Conversation

@krisnye

@krisnye krisnye commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds the ability to inject a custom memory allocator into the new ECS Database / Store factory, with off-the-shelf implementations that allocate numeric component storage into WebAssembly memory or a growable SharedArrayBuffer. This mirrors the precedent already present in old-ecs (createECS / core-ecs accept { allocator?: MemoryAllocator }), which the new typed-buffer-backed ECS previously lacked.

The point of the SharedArrayBuffer path: every numeric column becomes a fixed (offset, length) view into one shared backing buffer, so in principle that buffer can be posted to a worker once and its numeric components read/written across threads without copying — a foundation for future parallel system scheduling.

What's included

  • Reuses the existing cache/memory-allocator.ts MemoryAllocator interface (allocate / refresh / release / needsRefresh) rather than inventing a parallel abstraction.
  • New createSharedArrayBufferAllocator() (growable-SAB arena) + isSharedArrayBufferAllocatorSupported(); createSimpleMemoryAllocator (default, per-column buffers) and createWasmMemoryAllocator retained.
  • wasm + SAB allocators now share one internal first-fit arena (free list over a single backing buffer).
  • Correctness fix over the legacy wasm allocator: a detached TypedArray reports byteOffset/length as 0, so the old refresh could not recover a view's location after memory.grow. The arena now keeps a WeakMap<view, {offset,length,ctor}> so refresh rebuilds detached views correctly, and zero-fills reused blocks to match the always-zeroed semantics of the simple allocator.
  • Threaded an optional allocator through createDatabase(plugin, { allocator })createStore(schema, { allocator })createCorecreateArchetypecreateTablecreateTypedBuffer → number / struct / boolean / enum buffers. createDataView32 is now offset-aware so struct columns can be arena sub-regions.
  • All new parameters are optional / additive — default behavior is byte-identical (each column owns its own SharedArrayBuffer/ArrayBuffer), and the whole monorepo typechecks.

Test evidence

New unit tests, all green (full @adobe/data suite: 3293 passed):

  • cache/memory-allocator.test.ts — simple/wasm/SAB allocators, shared-arena contract, detached-view refresh after grow, zeroing of reused blocks, arena exhaustion, feature detection.
  • typed-buffer/typed-buffer-allocator.test.ts — each storage buffer preserves data across a reallocating grow under wasm/SAB allocators; columns share one backing buffer.
  • ecs/store/store-allocator.test.tscreateStore / createDatabase route component columns into the injected arena; correctness after growth past initial row capacity; default path stays per-column.

Repo-wide pnpm run lint and pnpm -r run typecheck both pass.

Known limitations / follow-ups

  1. Subscription lifecycle: each column subscribes to needsRefresh and never unsubscribes... Resolved in e2385272: added TypedBuffer.dispose() (unsubscribe + release the arena block), wired through archetype.fromData to dispose replaced columns. See the review-response comment below.
  2. Non-arena paths: .copy() (detached snapshots), the persistence codec (register-typed-buffer-codecs), and remapArchetypeColumn allocate with the default allocator, so post-load / post-migration columns are not placed in an injected arena. (Still out of scope; a dispose() lifecycle now exists to build on.)
  3. Cross-worker liveness: actually surviving a main-thread column grow from a worker view (grow reallocates a column's block) is not solved here — this PR is the foundation, not the sync protocol.

Related PRs

None.

krisnye and others added 2 commits August 26, 2026 21:06
…ble SharedArrayBuffer arenas)

Thread an optional MemoryAllocator through createDatabase → store → core →
archetype → table → typed buffers so numeric component storage can live in a
shareable arena. Reuses the legacy cache/ MemoryAllocator interface; adds a
growable-SharedArrayBuffer arena allocator and rebuilds the wasm allocator on a
shared free-list with a WeakMap so refresh() recovers detached views after grow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… arena safety

Adds TypedBuffer.dispose() (unsubscribe from needsRefresh + release the arena
block) and wires archetype.fromData to dispose replaced columns, closing the
subscription/arena leaks. Arena allocator now grows geometrically, ignores
foreign views on release (no free-list corruption), tracks zeroed blocks to skip
redundant fill, keeps the free list sorted on insert, supports a reserved wasm
base offset, aligns SAB bounds, and throws an actionable wasm-exhaustion error.
Each fix has red/green unit coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@krisnye

krisnye commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the fable review findings in e2385272 — each with red/green unit coverage:

# Finding Fix Test
HIGH needsRefresh subscription leak TypedBuffer.dispose() stores + calls the unsubscribe dispose > unsubscribes from needsRefresh
HIGH No arena-block release on disposal archetype.fromData disposes replaced (non-retained) columns; dispose() releases the block archetype-allocator-dispose.test.ts, dispose > releases the arena block
MED Non-geometric grow storms growArena prefers ≥ doubling the usable arena arena growth is geometric
MED release() foreign-view corruption unknown views are ignored (no free-list insert) release() ignores foreign views
MED wasm assumes it owns whole Memory createWasmMemoryAllocator(memory, { byteOffset }) reserves a base region reserved base offset
LOW Unconditional fill(0) free blocks carry a zeroed flag; fresh-from-grow blocks skip the fill covered by hands back zeroed memory even when reusing a freed block
LOW mergeFreeBlocks full re-sort sorted-insert + adjacent-neighbor merge only arena-contract suite
LOW capacity = 0 / zero-size cruft zero-length allocation returns an untracked empty view arena zero-length allocation
NIT initial/max not alignment-validated both bounds alignUp'd in the SAB allocator alignment
NIT inconsistent exhaustion errors wasm grow now throws an actionable "exhausted" error exhaustion

Notes:

  • The two LOW internal refinements (fill(0) skip, sorted-insert merge) are non-behavioral — output is identical either way — so they're verified by the existing arena-correctness tests rather than a behavioral red/green.
  • dispose() on a default-allocated buffer is a no-op (unsubscribe + release are no-ops there), so the default path is unchanged; the full suite (3315 tests) stays green.

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