Cacheless calls and attach share the store's gate: an attach waits for a call, a call that finds the cache is refused

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-23 04:07:10 +02:00
commit 13706c795f
2 changed files with 51 additions and 4 deletions

View file

@ -2257,7 +2257,8 @@ namespace GameCult.Caching
{
if (store == null) throw new ArgumentNullException(nameof(store));
homes ??= Array.Empty<Type>();
Held(() =>
// 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.
Held(() => store.WhileDetached(() =>
{
if (store.Loaded != null)
throw new InvalidOperationException($"Backing store {store} is already attached to a cache.");
@ -2302,7 +2303,7 @@ namespace GameCult.Caching
throw;
}
return true;
});
}));
}
public Task PullAllBackingStoresAsync()
@ -3591,6 +3592,13 @@ namespace GameCult.Caching
protected void Held(Action body) => Held(() => { body(); return true; });
// The store's cacheless gate, taken by the attach so that no cacheless call is mid-flight while the store gains a cache.
internal T WhileDetached<T>(Func<T> body)
{
lock (_detachedGate)
return body();
}
private protected void UseRegistry(CultDocumentRegistry? registry) => _registry = registry;
internal void AttachRegistry(CultDocumentRegistry registry)
@ -4237,7 +4245,6 @@ namespace GameCult.Caching
{
if (record == null) throw new ArgumentNullException(nameof(record));
if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry));
RefuseWhenAttached();
ForTheCall(registry, () => WriteOne(record, catalogEntry, holdsIds));
}
@ -4261,7 +4268,6 @@ namespace GameCult.Caching
where TDocument : class
{
if (document == null) throw new ArgumentNullException(nameof(document));
RefuseWhenAttached();
var resolvedRegistry = registry ?? CultDocumentRegistry.Shared;
var catalogEntry = resolvedRegistry.GetRequired<TDocument>().ToCatalogEntry();
ForTheCall(resolvedRegistry, () =>
@ -4294,6 +4300,7 @@ namespace GameCult.Caching
private void ForTheCall(CultDocumentRegistry? registry, Action body) =>
Held(() =>
{
RefuseWhenAttached();
UseRegistry(registry);
try
{

View file

@ -163,6 +163,46 @@ namespace GameCult.Caching.Tests
Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.Select(record => record.Key), Is.EquivalentTo(new[] { "a", "first", "second" }));
}
// An attach during a cacheless call waits for it: the call finishes on the registry it was given, the store then gains the
// cache's registry, and the cache's next flush works.
[Test]
public async Task AnAttachDuringACachelessCallWaitsForIt()
{
var mine = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: mine);
var store = new GatedStore(_path);
var write = Task.Run(() => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: mine));
Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the call reached the reader");
using var cache = new CultCache(cacheRegistry);
var attach = Task.Run(() => cache.AddBackingStore(store));
Assert.That(attach.Wait(TimeSpan.FromMilliseconds(500)), Is.False, "the attach waits while a cacheless call is inside the store");
store.Release.Set();
await write;
await attach;
Assert.That(store.RegistryAfterWait, Is.SameAs(mine), "the call kept its own registry to its end");
Assert.That(store.Attached, Is.SameAs(cacheRegistry), "the call's end did not detach the cache's registry");
await cache.AddAsync(new IdDeck { Name = "b" });
Assert.DoesNotThrow(() => cache.FlushAllBackingStores());
}
// A cacheless call that finds the store attached by the time it holds the gate is refused, and leaves the cache's registry.
[Test]
public void ACachelessCallOnAnAttachedStoreIsRefusedAndLeavesTheRegistry()
{
var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var store = new GatedStore(_path);
store.Release.Set();
using var cache = new CultCache(cacheRegistry);
cache.AddBackingStore(store);
Assert.Throws<InvalidOperationException>(() => store.WriteDocument(new CultRecordKey("x"), new IdDeck { Name = "x" }));
Assert.Throws<InvalidOperationException>(() => store.WriteRecord(Record("a", One, 1), One));
Assert.That(store.Attached, Is.SameAs(cacheRegistry));
}
// WriteDocument<T> stores the document as T: the schema id, the id plan, the header and the bytes all come from the one
// declared type, as they did when CultMesh encoded typeof(T).
[Test]