From 045db8f98afc2bb3739d881ed6ec4a49f1e9b47f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 14 Oct 2026 02:07:20 +0200 Subject: [PATCH] Tests: non-home stores judge checks under their lock, Contended before the home writes, refusal-listed foreign version, outside store versions foreign records Co-Authored-By: Claude Opus 5.5 --- .../GameCult.Caching.ConsumerBuild/Program.cs | 36 ++++++ .../ForeignRecordTests.cs | 117 +++++++++++++++++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/tests/GameCult.Caching.ConsumerBuild/Program.cs b/tests/GameCult.Caching.ConsumerBuild/Program.cs index b0f15973..417da1a6 100644 --- a/tests/GameCult.Caching.ConsumerBuild/Program.cs +++ b/tests/GameCult.Caching.ConsumerBuild/Program.cs @@ -5,6 +5,42 @@ using MessagePack; // RefKeyedDictionaryCompilesInAConsumer: this project building is the test. System.Console.WriteLine(new ConsumerFaction().Standing.Count); +// An outside store kind versions its foreign records: this project has no InternalsVisibleTo, so building and running it pins that +// Foreign(record, catalog, version) is all a store kind outside the library needs for ExpectUnchanged to hold over them. +using var outside = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(ConsumerFaction) })); +outside.AddBackingStore(new OutsideStore(), typeof(ConsumerFaction)); +var committed = outside.Commit(batch => +{ + batch.ExpectUnchanged(); + batch.Upsert(new ConsumerFaction { Name = "a" }, new CultRecordHandle(new CultRecordKey("a"))); +}); +if (!committed) + return 1; +return 0; + +// Lists one record under an id no type claims, at version "v1", and commits only when the conditions hold over it. +public sealed class OutsideStore : CacheBackingStore +{ + private static readonly CultSchemaCatalogEntry Entry = new() { SchemaId = "outside.unclaimed", SchemaName = "outside.unclaimed" }; + + public override void PullAll() + { + var record = new CultPersistedRecord { Key = "w", SchemaId = Entry.SchemaId, StoredAt = "t" }; + SetForeignRecords(new[] { Foreign(record, new[] { Entry }, "v1")! }); + } + + public override void Push(CultStoredDocument entry) { } + + public override void Delete(CultStoredDocument entry) { } + + public override void PushAll() { } + + public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => + request.ConditionsHold(new Dictionary { ["w"] = "v1" }, Entries.Values, ForeignRecords) + ? CultCommitOutcome.Committed + : CultCommitOutcome.Mismatch; +} + [CultDocument("tests.consumer_faction", "tests.consumer_faction.v1")] [MessagePackObject] public sealed class ConsumerFaction diff --git a/tests/GameCult.Caching.Tests/ForeignRecordTests.cs b/tests/GameCult.Caching.Tests/ForeignRecordTests.cs index a598ed98..6f530b46 100644 --- a/tests/GameCult.Caching.Tests/ForeignRecordTests.cs +++ b/tests/GameCult.Caching.Tests/ForeignRecordTests.cs @@ -1334,9 +1334,9 @@ namespace GameCult.Caching.Tests Assert.That(cache.Remove(new CultRecordKey("absent")), Is.False); } - // A batch that only checks keys commits through the one writable store; every other store answers the checks from its view. + // A batch that only checks keys commits through the one writable store; a read-only store answers the checks from its last load. [Test] - public void AStoreThatIsNotTheHomeAnswersTheChecksFromItsView([Values] bool directory) + public void AReadOnlyStoreAnswersTheChecksFromItsView([Values] bool directory) { var writable = PathOf("home.cc"); using (var seed = Open(writable, Full, directory)) @@ -1396,5 +1396,118 @@ namespace GameCult.Caching.Tests Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); try { cache.Dispose(); } catch (CultSchemaConflictException) { } } + + // Two writable stores: home.cc holds a deck and takes Deck, other.cc holds a gizmo and takes Gizmo, and, when asked, a widget at + // w that no registered type declares, so the cache lists it foreign at the load. + private (CultCache Cache, string Home, string Other) TwoWritableStores(bool directory, bool otherListsWidget) + { + var home = PathOf("home.cc"); + using (var seed = Open(home, DeckOnly, directory)) + seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + var other = PathOf("other.cc"); + using (var seed = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory)) + { + seed.Commit(batch => batch.Upsert(Gizmo, New(Gizmo, ("Tag", "g")), new CultRecordKey("g"))); + if (otherListsWidget) + seed.Commit(batch => batch.Upsert(Widget, WidgetOf("w"), W)); + } + + var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { Deck, Gizmo })); + cache.AddBackingStore(StoreAt(home, directory, readOnly: false), Deck); + cache.AddBackingStore(StoreAt(other, directory, readOnly: false), Gizmo); + return (cache, home, other); + } + + // Every writable store judges a batch's checks under its own lock, whichever store the batch commits through: a record another + // writer put at a checked key in a store the batch does not write, after the load, refuses the whole commit and writes nothing. + [Test] + public void EveryWritableStoreRefusesACommitThatRemovesItsLateForeignRecord([Values] bool directory, [Values] bool withWrite) + { + var (cache, home, other) = TwoWritableStores(directory, otherListsWidget: false); + using var _ = cache; + using (var late = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory)) + late.Commit(batch => batch.Upsert(Widget, WidgetOf("late"), W)); + var homeBefore = Fingerprint(home); + var otherBefore = Fingerprint(other); + + var refusal = Assert.Throws(() => cache.Commit(batch => + { + if (withWrite) + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }))!; + + Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((W.Value, WidgetId))); + Assert.That(Fingerprint(home), Is.EqualTo(homeBefore)); + Assert.That(Fingerprint(other), Is.EqualTo(otherBefore)); + Assert.That(Read(home).Records.Select(record => record.Key), Does.Not.Contain("d2")); + Assert.That(cache.BackingStores[1].ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + } + + // The non-home judgement reads the store under its lock, not its listing: a foreign record that store listed at the load, + // since replaced by a declared one, no longer refuses the commit and is not removed. + [Test] + public void ANonHomeStoreJudgesACheckUnderItsLockNotFromItsLoad([Values] bool directory) + { + var (cache, home, other) = TwoWritableStores(directory, otherListsWidget: true); + using var _ = cache; + Assert.That(cache.BackingStores[1].ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + using (var late = Open(other, CultDocumentRegistry.ForTypes(new[] { Gizmo, Widget }), directory)) + late.Commit(batch => batch.Upsert(Gizmo, New(Gizmo, ("Tag", "theirs")), W)); + var theirs = Stored(other, W); + + Assert.That(cache.Commit(batch => + { + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }), Is.True); + + Assert.That(Read(home).Records.Select(record => record.Key), Does.Contain("d2")); + Assert.That(Stored(other, W), Is.EqualTo(theirs), "the declared record the checked key now holds is not removed"); + } + + // A store that is not the batch's home and cannot take its lock makes the commit contended before the home writes. + [Test] + public void ANonHomeStoreHeldByAnotherWriterMakesTryCommitContended() + { + var (cache, home, other) = TwoWritableStores(directory: false, otherListsWidget: false); + using var _ = cache; + var homeBefore = Fingerprint(home); + + using (new FileStream(other + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) + { + var outcome = cache.TryCommit(batch => + { + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + batch.Remove(W); + }); + + Assert.That(outcome, Is.EqualTo(CultCommitOutcome.Contended)); + } + + Assert.That(Fingerprint(home), Is.EqualTo(homeBefore)); + } + + // A foreign record a refusal lists carries the version the refusal read, so a conditional commit over the stores that + // list it holds when nothing has changed since. + [Test] + public void ExpectUnchangedHoldsAfterARefusalListedTheForeignRecord([Values] bool directory) + { + var path = PathOf("refusal-listed.cc"); + using (var seed = Open(path, Full, directory)) + seed.Commit(batch => batch.Upsert(Deck, DeckOf("d"), D)); + using var cache = Open(path, DeckOnly, directory); + using (var late = Open(path, Full, directory)) + late.Commit(batch => batch.Upsert(Widget, WidgetOf("late"), W)); + Assert.That(cache.Remove(W), Is.False); + Assert.Throws(() => cache.FlushAsync().GetAwaiter().GetResult()); + Assert.That(StoreOf(cache).ForeignRecords.Select(record => record.Key), Is.EqualTo(new[] { "w" })); + + Assert.That(cache.Commit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(Deck, DeckOf("d2"), new CultRecordKey("d2")); + }), Is.True); + } } }