Caching: a failed attach takes its registry back (one UseRegistry writer), and a gate retry that never settles throws instead of looping

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-23 16:33:01 +02:00
commit ba6589650f
2 changed files with 12 additions and 9 deletions

View file

@ -2293,7 +2293,7 @@ namespace GameCult.Caching
"attach routed stores before the untyped store.");
}
store.AttachRegistry(_registry);
store.UseRegistry(_registry);
store.Cache = this;
store.Loaded = (loaded, dropped) => Admit(loaded, dropped, store, (_, _) => CultCommitOutcome.Committed);
_stores.Add(candidate);
@ -2306,6 +2306,7 @@ namespace GameCult.Caching
_stores.Remove(candidate);
store.Loaded = null;
store.Cache = null;
store.UseRegistry(null);
throw;
}
return true;
@ -3630,10 +3631,15 @@ namespace GameCult.Caching
// and a direct call publishes what it loaded when it returns. Detached, it holds the store's gate; a cacheless call (WriteRecord,
// WriteDocument) that finds a cache there is refused in that same hold. File locks are always taken inside either gate. Retries
// are bounded: Cache changes only by attaches.
// Far beyond what attaches can cost a call; it turns a broken validation into a thrown error instead of a loop that never ends.
private const int MaxStaleGuesses = 1_000_000;
private T Gate<T>(Func<T> body, bool cacheless)
{
while (true)
for (var stale = 0; ; stale++)
{
if (stale == MaxStaleGuesses)
throw new InvalidOperationException("A store call's gate changed under it more often than any sequence of attaches can explain.");
var cache = cacheless ? null : Volatile.Read(ref Cache);
if (cache != null)
{
@ -3679,12 +3685,9 @@ namespace GameCult.Caching
// 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) => CultCache.InStoreHold(_storeGate, body);
private protected void UseRegistry(CultDocumentRegistry? registry) => _registry = registry;
internal void AttachRegistry(CultDocumentRegistry registry)
{
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
}
// The one writer of the 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.
internal void UseRegistry(CultDocumentRegistry? registry) => _registry = registry;
public abstract void PullAll();
public abstract void Push(CultStoredDocument entry);

View file

@ -902,7 +902,7 @@ namespace GameCult.Caching.Tests
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "read with a codec, a name override holds no id");
var blind = new SingleFileMessagePackBackingStore(path);
blind.AttachRegistry(Registry);
blind.UseRegistry(Registry);
blind.PullAll();
blind.PushAll();
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "a flush that staged nothing copies the variant and its header");