Store gate: Cache owns the attach window; pin the three-gate bound; fix the unseen-cache wording

A cacheless write that re-enters its own store's hold now consults Cache first, so a journal's WriteDocument or WriteRecord gets the same refusal during an attach as after it (the attached text); the re-entry text no longer names an attach's pull. InStoreHold takes the store instead of its gate.
CacheBackingStore.GateTakes (internal, thread-static) counts the gates Gate takes; OneStoreCallTakesAtMostThreeGatesUnderFailingAttachChurn asserts at most three per call under failing-attach churn, which pins the stale-guess loop (M4b).
The contract and AddBackingStore say what holds: other threads may guess the tentative cache, but none runs a body against it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-25 22:35:58 +01:00
commit 193cf1c39e
4 changed files with 75 additions and 13 deletions

View file

@ -146,15 +146,17 @@ which route wrote it.
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: it publishes `Cache`
before its pull and takes it back in the same hold if the pull fails, so no
other thread ever sees the cache come and go. Under the store's gate `Cache` is
before its pull and takes it back in the same hold if the pull fails. Another
thread's unlocked guess may read the tentative cache, but none runs a body
against it: the guess is checked under that cache's gate, which the attach
holds, and fails once the pull retracts it. Under the store's gate `Cache` is
settled, and a settled cache never changes, because nothing detaches. A call
takes at most three gates: the guessed cache's, the store's, the settled
cache's. The pull reads the registry the attach lent, not `Cache`; a variant it
loads carries no codec, and the cache resolves it with its own. During an
attach the store is attached, so a journal the pull runs that calls the store
is refused as it would be afterwards; a cacheless write that finds a cache is
refused in that same hold. A
is refused as it would be afterwards; a cacheless write that finds a cache
published, during an attach or after it, is refused with the same text. 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

View file

@ -2264,7 +2264,8 @@ namespace GameCult.Caching
// The only writer of store.Cache, holding the cache's gate and the store's. It publishes Cache before the pull, so the store is
// attached for the whole of its hold and every entry point agrees: a journal the pull runs that calls the store is a journal
// entering its cache and is refused, as it is after the attach; every other thread waits on the cache's or the store's gate.
// A failed pull takes Cache back in that same hold, so no other thread ever sees the cache come and go, and there is no detach.
// A failed pull takes Cache back in that same hold. Another thread's unlocked guess may read the tentative cache, but none runs a
// body against it: the guess is checked under that cache's gate, which the attach holds, and fails once the pull retracts it.
Held(() => store.UnderStoreGate(() =>
{
if (store.Cache != null)
@ -2824,15 +2825,19 @@ namespace GameCult.Caching
// The one lock of a store's gate. A thread holds one store's gate at a time; a store call may be entered again from inside it,
// except a cacheless write: it owns its store outright, so it is the outermost hold of the store's gate.
internal static T InStoreHold<T>(object storeGate, Func<T> body, bool cacheless = false)
// A cacheless write that re-enters its own store's hold is refused by whichever fact is true: Cache is the one attachment fact, so
// a store with a cache published (an attach's pull included) gets the attached refusal, and one without gets the re-entry refusal.
internal static T InStoreHold<T>(CacheBackingStore store, Func<T> body, bool cacheless = false)
{
var storeGate = store._storeGate;
if (_storeHeld != null)
{
if (ReferenceEquals(_storeHeld, storeGate))
{
if (cacheless)
throw new InvalidOperationException(
"A cacheless write was made inside a call that already holds this store (an attach's pull, or a store call whose user code made it); write through the cache.");
throw new InvalidOperationException(store.Cache != null
? CacheBackingStore.AttachedRefusal
: "A cacheless write was made inside a call that already holds this store (a store call whose user code made it); write through the cache.");
return body();
}
throw new InvalidOperationException(
@ -3586,7 +3591,13 @@ namespace GameCult.Caching
// any of it; if the cache refuses a record the call throws and the store keeps its previous view.
protected internal Action<IReadOnlyList<CultStoredDocument>, IReadOnlyList<CultStoredDocument>>? Loaded;
private readonly object _storeGate = new();
internal readonly object _storeGate = new();
// Gates the current thread has taken for store calls, read by tests (InternalsVisibleTo) to pin the bound of three per call.
[ThreadStatic]
internal static int GateTakes;
internal const string AttachedRefusal = "The store is attached to a cache, write through the cache.";
// Written only by CultCache.AddBackingStore, holding the cache's gate and this store's: set before the pull, and set back to null
// in the same hold if the pull fails. Read under this store's gate it is settled, and a settled cache never changes, because
// nothing detaches.
@ -3647,23 +3658,26 @@ namespace GameCult.Caching
var guess = cacheless ? null : Volatile.Read(ref Cache);
if (guess != null)
{
GateTakes++;
var (ran, result) = guess.Held(() => Cache == guess ? (true, body()) : (false, default(T)!));
if (ran)
return result;
}
GateTakes++;
var (done, value, settled) = UnderStoreGate(() =>
{
var cache = Cache;
if (cache == null)
return (true, body(), (CultCache?)null);
if (cacheless)
throw new InvalidOperationException("The store is attached to a cache, write through the cache.");
throw new InvalidOperationException(AttachedRefusal);
return (false, default(T)!, cache);
}, cacheless);
if (done)
return value;
GateTakes++;
return settled!.Held(() => Cache == settled
? body()
: throw new InvalidOperationException(
@ -3692,7 +3706,7 @@ namespace GameCult.Caching
}, cacheless: true);
// The store's own gate, taken only here. Lock order: a cache's gate, then a store's; nothing takes a store's gate and then a cache's.
internal T UnderStoreGate<T>(Func<T> body, bool cacheless = false) => CultCache.InStoreHold(_storeGate, body, cacheless);
internal T UnderStoreGate<T>(Func<T> body, bool cacheless = false) => CultCache.InStoreHold(this, body, cacheless);
// The one writer of the lent registry: a cacheless call for the call, an attach for as long as it holds the store, and a failed
// attach puts it back, so a cacheless store holds none between calls and a pull on one loads nothing. Nothing else is lent, and the