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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 12:43:46 +02:00
commit 47c7111897
3 changed files with 43 additions and 48 deletions

View file

@ -1,5 +1,3 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { decode, encode } from "@msgpack/msgpack"; import { decode, encode } from "@msgpack/msgpack";
import type { import type {
@ -52,11 +50,6 @@ type HydratedState = {
readonly legacyGlobals: Map<string, CultCacheEnvelope>; readonly legacyGlobals: Map<string, CultCacheEnvelope>;
}; };
// 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<readonly RunningOperation[]>();
// Lookups for every held record of one type under candidate accessors, derived before anything is installed. // Lookups for every held record of one type under candidate accessors, derived before anything is installed.
type DerivedTypeLookups = { type DerivedTypeLookups = {
readonly entryLookups: Array<[HydratedEntry, EntryLookups]>; 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 // 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. // 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 // 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 // an unawaited failure is still an unhandled rejection. A call made from inside an operation
// must not call a queued method of the cache they are serving: that call throws. // queues behind it; a store call that waits for that call waits on itself and never finishes.
#serial<T>(operation: () => T | Promise<T>): Promise<T> { #serial<T>(operation: () => T | Promise<T>): Promise<T> {
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; let advance!: () => void;
const previous = this.#tail; const previous = this.#tail;
this.#tail = new Promise<void>((resolve) => { this.#tail = new Promise<void>((resolve) => {
advance = resolve; advance = resolve;
}); });
return previous.then(() => return previous.then(async () => {
runningOperations.run([...outer, current], async () => { try {
current.running = true; return await operation();
try { } finally {
return await operation(); advance();
} finally { }
current.running = false; });
advance();
}
}),
);
} }
registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>( registerDocumentType<TDefinition extends AnyCultCacheDocumentDefinition>(

View file

@ -1,5 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { exec, execFile } from "node:child_process"; import { exec, execFile } from "node:child_process";
import { EventEmitter } from "node:events";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os"; 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); 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 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); const inner = new SingleFileMessagePackBackingStore(storePath);
let cache!: CultCache; const changes = new EventEmitter();
const pushed: string[] = [];
const store: CacheBackingStore = { const store: CacheBackingStore = {
pullAll: () => inner.pullAll(), pullAll: () => inner.pullAll(),
delete: (entry) => inner.delete(entry), delete: (entry) => inner.delete(entry),
push: async (entry) => { push: async (entry) => {
await cache.put(itemDocument, "side", { n: "side" });
await inner.push(entry); await inner.push(entry);
pushed.push(entry.key);
changes.emit("pushed", entry.key);
}, },
}; };
cache = CultCache.builder() const cache = CultCache.builder()
.withRegistry(defineDocumentRegistry(itemDocument)) .withRegistry(defineDocumentRegistry(itemDocument))
.withGenericStore(store) .withGenericStore(store)
.build(); .build();
// The handler writes back and the store does not wait for it: that write queues behind the put.
const writeBacks: Promise<unknown>[] = [];
changes.on("pushed", (key: string) => {
if (key === "k") {
writeBacks.push(cache.put(itemDocument, "side", { n: "side" }));
}
});
const outcome = await Promise.race([ await cache.put(itemDocument, "k", { n: "x" });
cache.put(itemDocument, "k", { n: "x" }).then(() => "resolved", (error: unknown) => error), await Promise.all(writeBacks);
new Promise((resolve) => setTimeout(() => resolve("timed out"), 2000)),
]);
assert.ok(outcome instanceof Error, `expected a refusal, got ${String(outcome)}`); assert.equal(writeBacks.length, 1);
assert.match(outcome.message, /re-entrant call/u); assert.deepEqual(pushed, ["k", "side"]);
assert.deepEqual(await inner.pullAll(), []); assert.deepEqual(cache.get(itemDocument, "k"), { n: "x" });
assert.deepEqual(cache.snapshot(), []); assert.deepEqual(cache.get(itemDocument, "side"), { n: "side" });
// Concurrent callers outside the operation are still queued, not refused. assert.deepEqual(
const plain = CultCache.builder().withRegistry(defineDocumentRegistry(itemDocument)).build(); (await inner.pullAll()).map((entry) => entry.key).sort(),
await Promise.all([plain.put(itemDocument, "a", { n: "a" }), plain.put(itemDocument, "b", { n: "b" })]); cache.snapshot().map((entry) => entry.key).sort(),
assert.equal(plain.getAll(itemDocument).length, 2); );
}); });
test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () => { test("legacyKeyGlobalLoadsWithoutWritingAndFirstWriteLeavesOneGlobal", async () => {

View file

@ -102,6 +102,13 @@ which route wrote it.
- Direct calls on an attached store (`Push`, `Delete`, `PushAll`, `CommitBatch`, - 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 `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. 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 ## 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 | | 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) | | 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 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 | | 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 | | 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 | | 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` | | 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 | | 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 | | Per-assembly options, value-type arrays | contract | n/a | n/a | n/a |