From 47c7111897a72e2dea220c90ddf972f8d0cbce19 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 14 Sep 2026 12:43:46 +0200 Subject: [PATCH] Delete the TypeScript re-entry guard; state the store-wait rule once The AsyncLocalStorage guard refused a store's change handler or timer writing back to its cache without the store waiting for it, which only queues, and still missed a wait that escapes the async context. The serial queue and its own-promise chain stay. The storePushThatAwaitsItsOwnCacheIsRefusedInsteadOfHanging test is replaced by storeChangeHandlerWritingBackQueuesAfterTheOperation: a store push emits an event whose handler puts without being awaited; both writes land in order and store and cache agree. The contract's Locking section states the rule for all runtimes: a store call must not wait for a write to its own cache; unawaited write-backs queue. Python refuses a same-thread re-entrant mutation; no runtime detects a wait on another thread or async context. The globals row states the two-step legacy retirement with compensation, and that a crash or double failure leaving both records is refused at the next pull. Co-Authored-By: Claude Opus 5 --- packages/cultcache-ts/src/cult-cache.ts | 38 ++++------------- packages/cultcache-ts/test/cult-cache.test.ts | 42 +++++++++++-------- .../Contracts/cultcache-store-composition.md | 11 ++++- 3 files changed, 43 insertions(+), 48 deletions(-) diff --git a/packages/cultcache-ts/src/cult-cache.ts b/packages/cultcache-ts/src/cult-cache.ts index 8b5e87d3..57766be9 100644 --- a/packages/cultcache-ts/src/cult-cache.ts +++ b/packages/cultcache-ts/src/cult-cache.ts @@ -1,5 +1,3 @@ -import { AsyncLocalStorage } from "node:async_hooks"; - import { decode, encode } from "@msgpack/msgpack"; import type { @@ -52,11 +50,6 @@ type HydratedState = { readonly legacyGlobals: Map; }; -// The operations running in the current async flow. A queued call from inside one of this cache's -// own operations would wait on itself, so it is refused at entry. -type RunningOperation = { readonly cache: CultCache; running: boolean }; -const runningOperations = new AsyncLocalStorage(); - // Lookups for every held record of one type under candidate accessors, derived before anything is installed. type DerivedTypeLookups = { readonly entryLookups: Array<[HydratedEntry, EntryLookups]>; @@ -170,34 +163,21 @@ export class CultCache { // so validation, the store call and applying to the cache never interleave with another // mutation of this cache. Reads are not queued. Observer delivery is not scheduled here. // The chain advances on its own promise; the caller gets the operation's promise, untouched, so - // an unawaited failure is still an unhandled rejection. Stores, decoders, accessors and updaters - // must not call a queued method of the cache they are serving: that call throws. + // an unawaited failure is still an unhandled rejection. A call made from inside an operation + // queues behind it; a store call that waits for that call waits on itself and never finishes. #serial(operation: () => T | Promise): Promise { - const outer = runningOperations.getStore() ?? []; - if (outer.some((candidate) => candidate.cache === this && candidate.running)) { - throw new Error( - "CultCache refuses a re-entrant call: an attach, pull, registration, write or delete was started from " + - "inside a store call, decoder, accessor or updater of an operation on the same cache, and would wait on itself.", - ); - } - - const current: RunningOperation = { cache: this, running: false }; let advance!: () => void; const previous = this.#tail; this.#tail = new Promise((resolve) => { advance = resolve; }); - return previous.then(() => - runningOperations.run([...outer, current], async () => { - current.running = true; - try { - return await operation(); - } finally { - current.running = false; - advance(); - } - }), - ); + return previous.then(async () => { + try { + return await operation(); + } finally { + advance(); + } + }); } registerDocumentType( diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index aedcf74b..5932b2f8 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { exec, execFile } from "node:child_process"; +import { EventEmitter } from "node:events"; import { existsSync } from "node:fs"; import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; @@ -866,37 +867,44 @@ test("unawaitedFailingPutIsAnUnhandledRejection", async () => { await assert.rejects(cache.put(itemDocument, "k", { n: "y" }), /disk full/u); }); -test("storePushThatAwaitsItsOwnCacheIsRefusedInsteadOfHanging", async () => { +test("storeChangeHandlerWritingBackQueuesAfterTheOperation", async () => { const itemDocument = defineDocumentType({ type: "item", schema: z.object({ n: z.string() }) }); - const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-reentrant-")), "generic.cc"); + const storePath = join(await mkdtemp(join(tmpdir(), "cultcache-writeback-")), "generic.cc"); const inner = new SingleFileMessagePackBackingStore(storePath); - let cache!: CultCache; + const changes = new EventEmitter(); + const pushed: string[] = []; const store: CacheBackingStore = { pullAll: () => inner.pullAll(), delete: (entry) => inner.delete(entry), push: async (entry) => { - await cache.put(itemDocument, "side", { n: "side" }); await inner.push(entry); + pushed.push(entry.key); + changes.emit("pushed", entry.key); }, }; - cache = CultCache.builder() + const cache = CultCache.builder() .withRegistry(defineDocumentRegistry(itemDocument)) .withGenericStore(store) .build(); + // The handler writes back and the store does not wait for it: that write queues behind the put. + const writeBacks: Promise[] = []; + changes.on("pushed", (key: string) => { + if (key === "k") { + writeBacks.push(cache.put(itemDocument, "side", { n: "side" })); + } + }); - const outcome = await Promise.race([ - cache.put(itemDocument, "k", { n: "x" }).then(() => "resolved", (error: unknown) => error), - new Promise((resolve) => setTimeout(() => resolve("timed out"), 2000)), - ]); + await cache.put(itemDocument, "k", { n: "x" }); + await Promise.all(writeBacks); - assert.ok(outcome instanceof Error, `expected a refusal, got ${String(outcome)}`); - assert.match(outcome.message, /re-entrant call/u); - assert.deepEqual(await inner.pullAll(), []); - assert.deepEqual(cache.snapshot(), []); - // Concurrent callers outside the operation are still queued, not refused. - const plain = CultCache.builder().withRegistry(defineDocumentRegistry(itemDocument)).build(); - await Promise.all([plain.put(itemDocument, "a", { n: "a" }), plain.put(itemDocument, "b", { n: "b" })]); - assert.equal(plain.getAll(itemDocument).length, 2); + assert.equal(writeBacks.length, 1); + assert.deepEqual(pushed, ["k", "side"]); + assert.deepEqual(cache.get(itemDocument, "k"), { n: "x" }); + assert.deepEqual(cache.get(itemDocument, "side"), { n: "side" }); + assert.deepEqual( + (await inner.pullAll()).map((entry) => entry.key).sort(), + cache.snapshot().map((entry) => entry.key).sort(), + ); }); test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () => { diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index afb8c132..dd8a20d3 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -102,6 +102,13 @@ which route wrote it. - Direct calls on an attached store (`Push`, `Delete`, `PushAll`, `CommitBatch`, `PullAll`) take the same gate, so they wait for that cache's pull, flush or commit, and the cache's flush on dispose runs under it. +- In every runtime, a store call must not wait for a write to its own cache to + finish: that write queues behind the operation that is waiting for it, a + cycle. A handler or subscriber that writes back without the store waiting for + it is fine; its write queues and lands after the operation. Python refuses a + mutation started on a thread already inside one of that cache's mutations + with an error; no runtime detects a wait that escapes to another thread or + async context. ## Dirtiness: loading never writes @@ -241,11 +248,11 @@ no serialization; a CultMath encoding, if added, uses this shape. | Zero stores is in memory | implemented | implemented | implemented | implemented | | Home cannot move after admission | implemented | implemented (`add_backing_store` returns `Err` and attaches nothing) | implemented (`addBackingStore` rejects and attaches nothing; it runs in the cache's serial queue, so it cannot move a write that is in flight) | implemented (`add_backing_store`/`add_generic_store` raise and attach nothing; they hold the cache's lock, so they cannot move a write that is in flight) | | Refused at load when not home | implemented | implemented (`pull_all_backing_stores` returns `Err` naming key and both stores; `load_soa` returns `Err` for a type with no home once a store is attached; both admit nothing and leave the prior view intact) | implemented (`pullAllBackingStores` throws naming key and both stores; admits nothing) | implemented (`pull_all_backing_stores` raises naming key and both stores; admits nothing) | -| Refused write changes neither store nor cache | implemented | implemented (`put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` and `delete` validate before the store call and apply infallibly after it; there are no user accessors) | implemented (home, `__global__` key and name and index accessors run before the store call; overwrite and delete use lookup values stored at admission; attach, pull, registration, writes and deletes run through one per-cache promise queue; one of those started from inside a store call, decoder, accessor or updater of the same cache's operation throws instead of waiting on itself, so stores must not await their cache) | implemented (same validation order and stored lookup keys as TypeScript; attach, pull, registration, writes, deletes, name and index resolution and snapshots hold one lock per cache; a mutation started on a thread already inside one of that cache's mutations raises, reads stay reentrant) | +| Refused write changes neither store nor cache | implemented | implemented (`put`, `put_envelope`, `put_raw_envelope`, `put_prepared_batch` and `delete` validate before the store call and apply infallibly after it; there are no user accessors) | implemented (home, `__global__` key and name and index accessors run before the store call; overwrite and delete use lookup values stored at admission; attach, pull, registration, writes and deletes run through one per-cache promise queue; one of those started from inside an operation queues behind it, so a store that waits for it never finishes (see Locking)) | implemented (same validation order and stored lookup keys as TypeScript; attach, pull, registration, writes, deletes, name and index resolution and snapshots hold one lock per cache; a mutation started on a thread already inside one of that cache's mutations raises, reads stay reentrant; a wait on another thread is not detected (see Locking)) | | Attach hydrates; loading never writes | implemented | attach does not read (call `pull_all_backing_stores`); pull loads without writing | attach does not read (call `pullAllBackingStores`); pull loads without writing | attach does not read (call `pull_all_backing_stores`); pull loads without writing | | Runtime type decides schema | implemented | n/a (explicit type ids) | n/a | n/a | | Assignable lookups and watches | implemented | exact type ids | exact type ids | exact type ids | -| Globals never invented, singleton | implemented | no globals | single `__global__`; a write under any other key is refused before any store is touched; replacing writes `__global__` in place; a second persisted global (any keys) is refused on pull; invents nothing; load compatibility: a pull adopts a global stored under another key as `__global__` without writing, and the first write or delete of it removes that record in the same call; removed once no store holds a non-`__global__` global | single `__global__`; `put`, `put_envelope` and `put_envelopes` refuse any other key before any store is touched; a second persisted global (any keys) is refused on pull; invents nothing; load compatibility: a pull adopts a global stored under another key as `__global__` without writing, and the first write or delete of it removes that record in the same call; removed once no store holds a non-`__global__` global | +| Globals never invented, singleton | implemented | no globals | single `__global__`; a write under any other key is refused before any store is touched; replacing writes `__global__` in place; a second persisted global (any keys) is refused on pull; invents nothing; load compatibility: a pull adopts a global stored under another key as `__global__` without writing, and the first write of it pushes `__global__`, then deletes the legacy record, deleting `__global__` again if that delete fails (a delete of it removes only the legacy record). A crash between the two steps, or a failed compensation, leaves both records on disk, and the next pull refuses them loudly as a second global. Removed once no store holds a non-`__global__` global | single `__global__`; `put`, `put_envelope` and `put_envelopes` refuse any other key before any store is touched; a second persisted global (any keys) is refused on pull; invents nothing; load compatibility: a pull adopts a global stored under another key as `__global__` without writing, and the first write of it pushes `__global__`, then deletes the legacy record, deleting `__global__` again if that delete fails (a delete of it removes only the legacy record). A crash between the two steps, or a failed compensation, leaves both records on disk, and the next pull refuses them loudly as a second global. Removed once no store holds a non-`__global__` global | | Explicit batch commit | implemented | `put_prepared_batch`: staged records must share one home; that store receives one `push_all` of its own held records plus the batch; empty key or `stored_at` refused first; all-or-nothing on stores whose `push_all` is atomic, which the trait requires of every store: `push_all` has no default, and the bundled single-file and redb stores replace in one step | not implemented | `put_envelopes`, one type per call; every record validated before one `push_all`, applied only after the store accepts; atomicity on disk is the store's `push_all` | | Conditional commit | implemented | store-level `compare_exchange` family | not implemented | not implemented | | Per-assembly options, value-type arrays | contract | n/a | n/a | n/a |