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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-23 14:01:43 +02:00
commit 3c5f1fe0c2
3 changed files with 24 additions and 6 deletions

View file

@ -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

View file

@ -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<T>(key, document, storedAt, registry)`. Decoding a stored payload needs no file, so it is on the codec, not the store:
`CultCodec.DecodeDocument<T>(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

View file

@ -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<T>(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<T>(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.