From 13706c795fe1ca88e8de282a1ec97b9d707f535b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 23 Oct 2026 04:07:10 +0200 Subject: [PATCH] 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 --- src/GameCult.Caching/CultCache.cs | 15 +++++-- .../CachelessSingleFileTests.cs | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 69ebb65d..ee8a0b43 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2257,7 +2257,8 @@ namespace GameCult.Caching { if (store == null) throw new ArgumentNullException(nameof(store)); homes ??= Array.Empty(); - 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(Func 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().ToCatalogEntry(); ForTheCall(resolvedRegistry, () => @@ -4294,6 +4300,7 @@ namespace GameCult.Caching private void ForTheCall(CultDocumentRegistry? registry, Action body) => Held(() => { + RefuseWhenAttached(); UseRegistry(registry); try { diff --git a/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs b/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs index f95dd427..defcc133 100644 --- a/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs +++ b/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs @@ -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(() => store.WriteDocument(new CultRecordKey("x"), new IdDeck { Name = "x" })); + Assert.Throws(() => store.WriteRecord(Record("a", One, 1), One)); + Assert.That(store.Attached, Is.SameAs(cacheRegistry)); + } + // WriteDocument 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]