From 3c5f1fe0c26720f2b4250098b06065d12782fe3c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 14:01:43 +0200 Subject: [PATCH] Caching docs: the lock order is cache gate, store gate, file lock; the gate owner re-checks after locking (contracts, CHANGELOG) Co-Authored-By: Claude Opus 5.5 --- .../Contracts/cultcache-persistence-format.md | 3 ++- .../Contracts/cultcache-store-composition.md | 26 +++++++++++++++---- unity/org.gamecult.cultlib/CHANGELOG.md | 1 + 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 7afc1abe..bb78f421 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -109,7 +109,8 @@ parity cuts land. A store that fails to hydrate on open (corrupt bytes, unresolvable schema) makes the open throw and is left byte-identical; it is never silently overwritten. Within one process the lock order is the cache's gate, then the -store's file lock; a store's I/O blocks its cache's readers. +store's gate, then the file lock; nothing under a store's gate enters a cache's +gate. A store's I/O blocks its cache's readers. C# implements batch and conditional commit (`CultCache.Commit`, `TryCommit`, `CultCacheBatch`). Rust implements both. TypeScript and Python implement diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 7bb33a58..bd11662c 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -126,10 +126,26 @@ which route wrote it. ## Locking -- One lock order: the cache's gate, then the store's lock. A store attached to a - cache takes that cache's gate as its own lock, so a store's load callback into - the cache cannot take the two out of order. A store must not call `Loaded` - while holding its own lock outside the cache's hold. +- One lock order: the cache's gate, then the store's gate, then the file lock. A + thread holds at most one cache's gate and at most one store's gate. A store's + gate may be taken inside a cache's gate (the attach; a journal writing a + cacheless file), never the reverse: while a thread holds a store's gate it + enters no cache gate it does not already hold, reads included, and no other + store's gate. Breaking the order throws `InvalidOperationException`. This is + checked at run time, not by comment: user code a cacheless call or an attach + runs under the store's gate (a document's getter, a constructor while a copied + record is read) is the way in. +- One owner picks the gate a store call holds. A store attached to a cache takes + that cache's gate as its own lock, so a store's load callback into the cache + cannot take the two out of order; a cacheless store takes its own gate. The + store reads which one applies, takes it, and checks the answer again under it: + `Cache` is written only by the attach, holding both gates, so under the cache's + gate `Cache` naming that cache cannot change, and under the store's gate it + cannot change at all. A call whose guess went stale (it queued on a cache that + then failed to attach, or on a store that then gained a cache) releases and + retries; a cacheless write that finds a cache is refused in that same hold. A + store must not call `Loaded` while holding its own lock outside the cache's + hold. - A journal runs under the cache's gate, so a slow journal slows every writer of that cache. It must not enter the cache, and must not wait for anything that needs the cache or its gate: another thread's write, a lock a writer holds. @@ -354,7 +370,7 @@ example `SingleFileMessagePackBackingStore`): `ReadStoreSnapshot()`, `WriteRecor and `WriteDocument(key, document, storedAt, registry)`. Decoding a stored payload needs no file, so it is on the codec, not the store: `CultCodec.DecodeDocument(key, payload)` (`CultCacheMessagePack.CreateCodec(registry)` makes the codec). -- `ReadStoreSnapshot()` reads the file once, under the store's gate, and returns the durable snapshot (records, schema catalog, +- `ReadStoreSnapshot()` reads the file once, under the store's gate (or its cache's gate when attached, validated after it is taken), and returns the durable snapshot (records, schema catalog, format stamp), or null when nothing is at the path. It throws `CultStoreUnreadableException` for a file the reader refuses. - `WriteRecord` writes one record under the store's lock: the record replaces the one at its key, every other record and catalog entry is copied forward as stored, and the file is replaced atomically. It takes a payload the caller already encoded, so a diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index d687d3d5..6972cced 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -23,6 +23,7 @@ Releases as 3.0.0 (major). ### Added +- A cacheless `WriteRecord` or `WriteDocument`, and an attach, hold the store's gate while they run, and user code they run (a document's property getters, a constructor while a copied record is read) now throws `InvalidOperationException` if it enters a cache it does not hold or a second store; it used to wait forever against an attach of the same store. A store call that queued on an attach that then failed runs under the gate that owns the store, not beside a cacheless call or a second cache's attach. - `SingleFileBackingStore.ReadStoreSnapshot()` reads a store file once, under the store's in-process gate, with the reader a cache's store opens with, and returns the durable snapshot or null when nothing is at the path. `SingleFileBackingStore.WriteRecord(record, catalogEntry, holdsIds, registry)` writes one record without a cache: under the store's lock every other record and catalog entry is copied as stored, the file is replaced atomically, and the header is decided as a flush decides it. It refuses on a store attached to a cache. - `SingleFileBackingStore.WriteDocument(key, document, storedAt, registry)` writes one typed document without a cache, as a cache write does: it plans element ids, encodes the document as `T` (the type its schema id and ids are taken from, also for a derived instance) and marks the store when it holds an id; a failed write gives the minted ids back. It refuses on a store attached to a cache. The `registry` of `WriteRecord` and `WriteDocument` serves that call only; a store keeps none. - `CultCodec.DecodeDocument(key, payload)` decodes a stored payload as a cache load does (`CultCacheMessagePack.CreateCodec(registry)` makes the codec). It reads no file and holds no state. `CultElementIds` stays internal.