From 3a320d144d66d2921a1b88c09a50e7bea74c6685 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 21 Oct 2026 21:40:08 +0200 Subject: [PATCH] Caching tests: a store's own rewrite of a drifted entry marks it, including a consumer's store; a merge is judged on another writer's change to a held record Red until stores mark through a protected base member: the directory store's Push+PushAll leaves the entry's persisted schema id stale, and a store outside GameCult.Caching has no way to mark. Co-Authored-By: Claude Opus 5.5 --- .../ConditionalCommitTests.cs | 93 +++++++++++++++++++ tests/GameCult.Caching.Tests/VariantTests.cs | 34 +++++++ 2 files changed, 127 insertions(+) diff --git a/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs b/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs index 28a7dc84..717debca 100644 --- a/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs +++ b/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs @@ -126,6 +126,42 @@ namespace GameCult.Caching.Tests Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "Expect failed after this cache's own flush rewrote the record"); } + // A store rewrites what is pushed to it under the entry's descriptor; the entry then names that schema id. + [TestCase(false)] + [TestCase(true)] + public void OwnPushOfALoadedDriftedEntryKeepsExpectHolding(bool directory) + { + SeedDrift(directory); + using var cache = Drift(directory, NarrowDrift).Open(); + cache.BackingStores[0].Push(cache.GetStored(DriftKey)!); + cache.BackingStores[0].PushAll(); + + var stored = cache.GetStored(DriftKey)!; + Assert.That(stored.PersistedSchemaId, Is.EqualTo(stored.Descriptor.SchemaId), "the rewrite was not recorded"); + Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "Expect failed after this cache's own push rewrote the record"); + } + + // A store written outside the library marks its own rewrite through the base class. + [Test] + public void ACustomStoreMarksItsOwnWholeFileRewrite() + { + SeedDrift(directory: false); + using var cache = new CultCache(NarrowDrift); + var store = new WholeFileStore(PathOf("drift.cc")); + cache.AddBackingStore(store); + store.PullAll(); + var loaded = cache.GetStored(DriftKey)!; + Assert.That(loaded.PersistedSchemaId, Is.Not.EqualTo(loaded.Descriptor.SchemaId), "the record was not loaded under drift"); + + store.Push(loaded); + store.PushAll(); + + Assert.That(cache.GetStored(DriftKey)!.PersistedSchemaId, Is.EqualTo(loaded.Descriptor.SchemaId), "the custom store's rewrite was not recorded"); + Assert.That(DriftCommit(cache, cache.Get(DriftKey)!, 2), Is.True, "Expect failed after the custom store rewrote the record"); + using var reader = Drift(false, NarrowDrift).Open(); + Assert.That(reader.Get(DriftKey)!.Value, Is.EqualTo(2)); + } + [Test] public void OwnUnconditionalCommitKeepsDriftedExpectHolding() { @@ -996,6 +1032,63 @@ namespace GameCult.Caching.Tests batch.Upsert(new ConditionalDriftNarrow { Name = "drift", Value = value }, new CultRecordHandle(DriftKey)); }); + // A store derived from CacheBackingStore alone, as a consumer would write one: it rewrites the whole file on every + // write, in the single-file MessagePack format, and marks each record it rewrote. + private sealed class WholeFileStore : CacheBackingStore + { + private readonly string _path; + + public WholeFileStore(string path) => _path = path; + + private CultPersistedStoreSnapshot Read() => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path)); + + public override void PullAll() => Held(() => + { + var snapshot = Read(); + var loaded = snapshot.Records + .Select(record => ToStoredDocument(record, snapshot.SchemaCatalog, + (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry), out _)) + .ToArray(); + Loaded?.Invoke(loaded, Array.Empty()); + foreach (var stored in loaded) + Entries[stored.Key.Value] = stored; + }); + + public override void Push(CultStoredDocument entry) => Held(() => + { + Entries[entry.Key.Value] = entry; + IsDirty = true; + }); + + public override void Delete(CultStoredDocument entry) => throw new NotSupportedException(); + + public override void PushAll() => Held(Write); + + public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => Held(() => + { + if (!request.ConditionsHold(Read().Records, Entries.Values)) + return CultCommitOutcome.Mismatch; + foreach (var entry in request.Upserts) + Entries[entry.Key.Value] = entry; + Write(); + return CultCommitOutcome.Committed; + }); + + private void Write() + { + var snapshot = new CultPersistedStoreSnapshot + { + Records = Entries.Values.Select(entry => ToPersistedRecord(entry, + document => CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), Registry))).ToArray(), + SchemaCatalog = Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()).ToArray() + }; + File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + foreach (var entry in Entries.Values) + MarkWritten(entry); + MarkFlushSucceeded(); + } + } + // Pauses a pull between reading the file and handing the records to the cache. private sealed class PausingStore : SingleFileMessagePackBackingStore { diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index 8aa85752..7d3a80f3 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -945,6 +945,40 @@ namespace GameCult.Caching.Tests Assert.That(reopened.Get(BigKey)!.Name, Is.EqualTo("laser big")); } + // The cache holds a copy of the base another writer has since renamed; the merge is judged on the renamed base. + [Test] + public void AConditionalCommitIsJudgedOnAnotherWritersChangeToARecordThisCacheHolds() + { + var path = PathOf("gear.cc"); + var other = new CultRecordKey("other"); + using (var seed = Open(path)) + Assert.That(seed.Commit(batch => + { + batch.Upsert(typeof(VariantGear), Laser(), BaseKey); + batch.Upsert(typeof(VariantGear), Named("other", "o1"), other); + }), Is.True); + + using (var a = Open(path)) + { + using (var w = Open(path)) + Assert.That(w.Commit(batch => batch.Upsert(typeof(VariantGear), Named("x", "l1"), BaseKey)), Is.True); + + var refused = Refused(() => a.Commit(batch => + { + batch.Expect(other, a.Get(other)); + batch.UpsertVariant(BigKey, BaseKey, new[] + { + a.Override(nameof(VariantGear.Name), "x"), + a.Override(nameof(VariantGear.Code), "big") + }); + })); + Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("x")); + } + + using var reopened = Open(path, readOnly: true); + Assert.That(reopened.GetStored(BigKey), Is.Null, "nothing landed on the file"); + } + [Test] public void AConditionalCommitThatIsSoundOnTheMergedSetStillLands() {