From 0a1486eccd24756f6754f3b8d96ea782fd354cd9 Mon Sep 17 00:00:00 2001 From: Libby Date: Thu, 12 Nov 2026 22:33:01 +0000 Subject: [PATCH] C#: soft flush skips a readable store and keeps staged writes dirty; Mesh and CultNetLocal pass the flag CacheBackingStore.PushAll(bool soft) is the one abstract owner; PushAll() stays as the plain call. Co-Authored-By: Claude Sonnet 5.5 --- .../DirectoryMessagePackBackingStore.cs | 5 +- src/GameCult.Caching/CultCache.cs | 22 ++++-- src/GameCult.Mesh/CultMesh.cs | 2 +- src/GameCult.Networking/CultNetLocal.cs | 2 +- .../GameCult.Caching.ConsumerBuild/Program.cs | 2 +- .../OutsideStoreJudgeTests.cs | 2 +- .../BackingStoreTests.cs | 76 +++++++++++++++++++ .../CultCacheJournalTests.cs | 2 +- .../CultCacheObserverTests.cs | 2 +- .../StoreRoutingTests.cs | 4 +- 10 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 8f76cbae..d16907b6 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -237,7 +237,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore }); } - public override void PushAll() + public override void PushAll(bool soft) { ThrowIfReadOnly(); Held(() => @@ -248,6 +248,9 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore Directory.CreateDirectory(_manifestFile.DirectoryName!); using var commitLease = AcquireCommitLease(wait: true, create: true); var (manifest, catalogBytes) = ReadManifest(); + // A soft flush writes only where no manifest is; a manifest ReadManifest read stays as it is, its staged writes staged. + if (soft && File.Exists(_manifestFile.FullName)) + return; RefuseUndeclaredOnDisk(manifest, StagedKeys, Array.Empty(), Array.Empty(), Array.Empty()); if (IsDirty) WriteGeneration(manifest, catalogBytes); diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 48bad3a3..91dfa9ac 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2537,19 +2537,19 @@ namespace GameCult.Caching return Task.CompletedTask; } - public void FlushAllBackingStores() => Held(() => + public void FlushAllBackingStores(bool soft = false) => Held(() => { foreach (var (store, _) in _stores) { if (!store.IsReadOnly && (store.IsDirty || store.HasStagedChecks)) - store.PushAll(); + store.PushAll(soft); } return true; }); - public Task FlushAsync() + public Task FlushAsync(bool soft = false) { - FlushAllBackingStores(); + FlushAllBackingStores(soft); return Task.CompletedTask; } @@ -3945,7 +3945,13 @@ namespace GameCult.Caching public abstract void PullAll(); public abstract void Push(CultStoredDocument entry); public abstract void Delete(CultStoredDocument entry); - public abstract void PushAll(); + public void PushAll() => PushAll(soft: false); + + /// + /// Applies the staged writes. A soft flush writes only where nothing is at the path: over a store it reads it returns without + /// writing and leaves the staged writes staged and dirty for a later plain flush, and over a file it cannot read it refuses. + /// + public abstract void PushAll(bool soft); /// /// One durable step under the store's exclusive lock: the batch and every staged write land onto the store as it is then, @@ -4184,7 +4190,7 @@ namespace GameCult.Caching /// /// /// A writable store kind that reads its durable state under a lock overrides this to call , and - /// judges at its next or as it judges + /// judges at its next or as it judges /// : under the lock, with the keys it writes, refused through the same refusal, never /// removed. It calls when that step succeeds, which clears them. A read-only store has no /// such step and keeps the default. Both shipped store kinds do this. A batch's own checks are judged through @@ -4661,7 +4667,7 @@ namespace GameCult.Caching // only per staged key. A key whose durable record is under an id this cache does not declare refuses the write, and in a store // holding a variant it lands only onto the store as this cache last read it (both ApplyWriteSet). // A file it cannot read (truncated, trailing bytes, not a store, a format it does not read) is refused and left as it is. - public override void PushAll() + public override void PushAll(bool soft) { ThrowIfReadOnly(); Held(() => @@ -4669,6 +4675,8 @@ namespace GameCult.Caching using (AcquireLock(wait: true)) { var disk = ReadSnapshot(); + if (soft && disk != null) + return; var (upserts, removals) = StagedWrites(Array.Empty(), Array.Empty()); Wrote(ApplyWriteSet(disk, upserts, removals, Array.Empty())); } diff --git a/src/GameCult.Mesh/CultMesh.cs b/src/GameCult.Mesh/CultMesh.cs index e3f23430..6ea48e0d 100644 --- a/src/GameCult.Mesh/CultMesh.cs +++ b/src/GameCult.Mesh/CultMesh.cs @@ -432,7 +432,7 @@ namespace GameCult.Mesh /// public Task FlushAsync(bool soft = false) { - return _host.FlushAsync(); + return _host.FlushAsync(soft); } /// diff --git a/src/GameCult.Networking/CultNetLocal.cs b/src/GameCult.Networking/CultNetLocal.cs index 87942ce8..e884cd8d 100644 --- a/src/GameCult.Networking/CultNetLocal.cs +++ b/src/GameCult.Networking/CultNetLocal.cs @@ -100,7 +100,7 @@ namespace GameCult.Networking /// public Task FlushAsync(bool soft = false) { - return Cache.FlushAsync(); + return Cache.FlushAsync(soft); } /// diff --git a/tests/GameCult.Caching.ConsumerBuild/Program.cs b/tests/GameCult.Caching.ConsumerBuild/Program.cs index 60376068..f09edb8c 100644 --- a/tests/GameCult.Caching.ConsumerBuild/Program.cs +++ b/tests/GameCult.Caching.ConsumerBuild/Program.cs @@ -56,7 +56,7 @@ public sealed class OutsideStore : CacheBackingStore public override void Delete(CultStoredDocument entry) { } - public override void PushAll() { } + public override void PushAll(bool soft) { } public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => request.ConditionsHold(new Dictionary { ["w"] = "v1" }, Entries.Values, ForeignRecords) diff --git a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs index e47e4d5f..bd2fe047 100644 --- a/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs +++ b/tests/GameCult.Caching.OutsideStoreTests/OutsideStoreJudgeTests.cs @@ -136,7 +136,7 @@ namespace GameCult.Caching.OutsideStoreTests IsDirty = true; }); - public override void PushAll() => Held(() => Apply(Array.Empty(), Array.Empty(), Array.Empty())); + public override void PushAll(bool soft) => Held(() => Apply(Array.Empty(), Array.Empty(), Array.Empty())); public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => Held(() => { diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 7e88c283..ffc869e6 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -56,6 +56,82 @@ namespace GameCult.Caching.Tests } } + [Test] + public async Task SoftFlush_Over_Readable_Store_Leaves_It_Byte_For_Byte_And_Keeps_Staged_Writes_Dirty() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + try + { + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + await cache.AddAsync(new NamedTestEntry { Name = "first", Value = "one" }); + await cache.FlushAsync(); + var before = File.ReadAllBytes(filePath); + + var staged = await cache.AddAsync(new NamedTestEntry { Name = "second", Value = "two" }); + await cache.FlushAsync(soft: true); + + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(before), "a soft flush rewrote a store it reads"); + Assert.That(store.IsDirty, Is.True, "a skipped soft flush discarded a staged write"); + + await cache.FlushAsync(); + var reader = new CultCache(); + reader.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)); + await reader.PullAllBackingStoresAsync(); + Assert.That(reader.Get(staged.Key)!.Value, Is.EqualTo("two")); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task SoftFlush_Over_Absent_Path_Writes() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + try + { + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + await cache.AddAsync(new NamedTestEntry { Name = "first", Value = "one" }); + + await cache.FlushAsync(soft: true); + + Assert.That(File.Exists(filePath), Is.True); + Assert.That(store.IsDirty, Is.False); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + + [Test] + public async Task SoftFlush_Over_Unreadable_File_Refuses_And_Leaves_It() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + try + { + var junk = new byte[] { 0xC1, 0x01, 0x02, 0x03 }; + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + await cache.AddAsync(new NamedTestEntry { Name = "first", Value = "one" }); + File.WriteAllBytes(filePath, junk); + + Assert.That(() => store.PushAll(soft: true), Throws.InstanceOf()); + Assert.That(File.ReadAllBytes(filePath), Is.EqualTo(junk)); + Assert.That(store.IsDirty, Is.True); + } + finally + { + if (File.Exists(filePath)) File.Delete(filePath); + } + } + [Test] public async Task SingleFileMessagePackBackingStore_PullAll_SeesFileCreatedAfterReaderOpened() { diff --git a/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs b/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs index 404a75cb..f4009a6c 100644 --- a/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs +++ b/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs @@ -338,7 +338,7 @@ namespace GameCult.Caching.Tests public override void Push(CultStoredDocument entry) { } public override void Delete(CultStoredDocument entry) { } - public override void PushAll() { } + public override void PushAll(bool soft) { } public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => CultCommitOutcome.Committed; } diff --git a/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs b/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs index b2c8e7f5..92861214 100644 --- a/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs +++ b/tests/GameCult.Caching.Tests/CultCacheObserverTests.cs @@ -180,7 +180,7 @@ namespace GameCult.Caching.Tests public override void Push(CultStoredDocument entry) { } public override void Delete(CultStoredDocument entry) { } - public override void PushAll() { } + public override void PushAll(bool soft) { } public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => CultCommitOutcome.Committed; } diff --git a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs index af7e7da7..3ccd7a04 100644 --- a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs +++ b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs @@ -699,10 +699,10 @@ namespace GameCult.Caching.Tests _nested = nested; } - public override void PushAll() + public override void PushAll(bool soft) { _nested(); - base.PushAll(); + base.PushAll(soft); } }