Caching: a cacheless call takes the store's own gate and refuses under it; the attach holds that gate across its whole body

The call no longer picks its gate from a read of Cache (Held), so a call queued on a cache's gate during an attach that then fails
cannot run beside a second cache's attach. Lock order: cache's gate then the store's, in the attach only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-23 12:16:44 +02:00
commit 694e6fe632

View file

@ -2257,7 +2257,9 @@ namespace GameCult.Caching
{
if (store == null) throw new ArgumentNullException(nameof(store));
homes ??= Array.Empty<Type>();
// The store's own gate too: a cacheless call on it is either finished or waiting, and a waiting one finds the cache and refuses.
// The cache's gate, then the store's, held across the whole body, the failure path that resets Cache included: a cacheless call
// is either finished before the attach starts, or waits on the store's gate and, once the attach is over, finds the cache (and
// refuses) or finds the store free again (and runs).
Held(() => store.WhileDetached(() =>
{
if (store.Loaded != null)
@ -3581,7 +3583,8 @@ namespace GameCult.Caching
// Once attached, a hold on the cache's gate: the store and its cache share one lock, so a load calling back into
// the cache can never take the gate after the store lock, and a direct call publishes what it loaded when it
// returns. File locks are always taken inside it.
// returns. File locks are always taken inside it. A cacheless write does not come through here: this picks
// its gate from a read of Cache, and ForTheCall decides under the store's own gate instead.
protected T Held<T>(Func<T> body)
{
if (Cache is { } cache)
@ -4295,10 +4298,15 @@ namespace GameCult.Caching
});
}
// The registry is the call's argument, not store state: it is attached for the call under the store's gate and detached when
// the call ends, whatever it does. A cacheless store holds none between calls.
private void ForTheCall(CultDocumentRegistry? registry, Action body) =>
Held(() =>
// The one place a cacheless call decides whether it may run. The call takes the store's own gate, never the gate Held would
// pick from a read of Cache, and decides attachment under it. The attach sets Cache only while it holds this same gate (and
// clears it, on failure, before releasing it), so Cache cannot change from the refusal to the end of the call. The registry is
// the call's argument, not store state: it is attached for the call and detached when the call ends, whatever it does. A
// cacheless store holds none between calls. Lock order: an attach takes the cache's gate, then the store's. This takes only the
// store's, and nothing takes the store's gate and then a cache's.
private void ForTheCall(CultDocumentRegistry? registry, Action body)
{
lock (_detachedGate)
{
RefuseWhenAttached();
UseRegistry(registry);
@ -4310,7 +4318,8 @@ namespace GameCult.Caching
{
UseRegistry(null);
}
});
}
}
private void WriteOne(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds)
{