From f2b5b83345d5fe352ec798dba157e108fde00f86 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 08:23:31 +0200 Subject: [PATCH] Tests: version identity (rewrite at the same storedAt, pull arrival, ExpectUnchanged, staleness check, own-write version, in-memory, SHA-256 definition) Co-Authored-By: Claude Sonnet 5.5 --- .../ConditionalCommitTests.cs | 18 +++ .../StoreReadabilityTests.cs | 42 ++++++ tests/GameCult.Caching.Tests/WriteSetTests.cs | 140 ++++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs b/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs index 7492fe23..0350e1e8 100644 --- a/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs +++ b/tests/GameCult.Caching.Tests/ConditionalCommitTests.cs @@ -258,6 +258,24 @@ namespace GameCult.Caching.Tests Assert.That(reads, Is.GreaterThan(0)); } + // With no backing store a record's version is minted with it: an Expect on the held record commits, and once the record is + // replaced after it was observed (here by the same document again) the Expect no longer holds. + [Test] + public void InMemoryExpectHoldsUntilTheRecordIsReplaced() + { + using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(Counter) })); + cache.Commit(batch => batch.Upsert(new Counter { Name = "counter", Value = 1 }, new CultRecordHandle(Key))); + var first = cache.Get(Key)!; + + Assert.That(cache.Commit(batch => batch.Expect(Key, first)), Is.True); + + Assert.That(cache.Commit(batch => + { + batch.Expect(Key, first); + cache.UpsertAsync(first, new CultRecordHandle(Key)).GetAwaiter().GetResult(); + }), Is.False); + } + [Test] public void StoredAtIsStrictlyIncreasingPerKey() { diff --git a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs index 32d3b1d7..4aef72a1 100644 --- a/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs +++ b/tests/GameCult.Caching.Tests/StoreReadabilityTests.cs @@ -621,6 +621,48 @@ namespace GameCult.Caching.Tests Assert.That(Header(path), Is.EqualTo(header)); } + // A record's version is the SHA-256 of the bytes it is stored as: in a single file the record's own slice of the file, read + // here with the MessagePack reader and not the serializer; in a directory the hash the manifest indexes its page under. + [Test] + public void AVersionIsTheSha256OfTheRecordsStoredBytes([Values] bool directory) + { + var source = File.ReadAllBytes(Path.Combine(VectorRoot(), "..", "v3-base.msgpack")); + var slices = new Dictionary(); + var reader = new MessagePackReader(source); + reader.ReadArrayHeader(); + reader.Skip(); + reader.Skip(); + var count = reader.ReadArrayHeader(); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(source); + for (var i = 0; i < count; i++) + { + var start = (int)reader.Consumed; + reader.Skip(); + slices[snapshot.Records[i].Key] = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(source.AsSpan(start, (int)reader.Consumed - start))).ToLowerInvariant(); + } + + var path = Path.Combine(_directory, "version.cc"); + File.WriteAllBytes(path, source); + if (directory) + { + // The same records, written as a directory store: its versions are the manifest's page hashes. + File.Delete(path); + using (var seed = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true })) + seed.Commit(batch => + { + foreach (var key in slices.Keys) + batch.Upsert(typeof(VectorItem), new VectorItem { Name = key }, new CultRecordKey(key)); + }); + slices = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records + .ToDictionary(record => record.Key, record => Convert.ToHexString(record.Payload).ToLowerInvariant()); + } + + using var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory }); + + Assert.That(cache.AllStoredDocuments.ToDictionary(stored => stored.Key.Value, stored => stored.StoredVersion), Is.EqualTo(slices)); + Assert.That(slices, Is.Not.Empty); + } + // Every runtime's tests walk the manifest, so a vector without a row would go untested. [Test] public void EveryVectorInTheFolderHasAManifestRow() diff --git a/tests/GameCult.Caching.Tests/WriteSetTests.cs b/tests/GameCult.Caching.Tests/WriteSetTests.cs index 6749966d..1b7a6bd3 100644 --- a/tests/GameCult.Caching.Tests/WriteSetTests.cs +++ b/tests/GameCult.Caching.Tests/WriteSetTests.cs @@ -508,6 +508,146 @@ namespace GameCult.Caching.Tests Is.False, "B's copy of k is no longer what the store holds"); } + // Rule 2: a record's version is the SHA-256 of its stored bytes. Another writer rewrites the record to other bytes yet leaves + // its storedAt as it was (a peer whose clock equals, or a tool): the bytes are all that tells the two records apart. + private void RewriteAtItsOwnStoredAt(string path, bool directory, string key, string note) + { + var storedAt = StoredAtOf(path, key); + using (var writer = Open(path, directory)) + writer.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-" + key, Note = note }, new CultRecordKey(key))); + var snapshot = Read(path); + var record = snapshot.Records.Single(candidate => candidate.Key == key); + if (!directory) + { + record.StoredAt = storedAt; + Write(path, snapshot); + return; + } + + var pages = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path); + var page = CultDocumentMessagePackSerialization.DeserializePersistedRecord( + File.ReadAllBytes(Path.Combine(pages, Convert.ToHexString(record.Payload).ToLowerInvariant() + ".msgpack"))); + page.StoredAt = storedAt; + var bytes = CultDocumentMessagePackSerialization.SerializePersistedRecord(page); + var hash = System.Security.Cryptography.SHA256.HashData(bytes); + File.WriteAllBytes(Path.Combine(pages, Convert.ToHexString(hash).ToLowerInvariant() + ".msgpack"), bytes); + record.StoredAt = storedAt; + record.Payload = hash; + Write(path, snapshot); + } + + private static string NoteOnDisk(string path, bool directory) + { + using var fresh = Open(path, directory); + return fresh.Get(K)!.Note; + } + + private static readonly CultRecordKey K = new("k"); + + // A reads k; another writer rewrites it at its own storedAt; A's Expect on what it read fails and the disk keeps the rewrite. + [Test] + public void AStaleExpectFailsWhenAnotherWriterRewroteTheRecordAtItsOwnStoredAt([Values] bool directory) + { + var path = PathOf("t1.cc"); + Seed(path, directory, "k"); + using var a = Open(path, directory); + var observed = a.Get(K)!; + RewriteAtItsOwnStoredAt(path, directory, "k", "by-c"); + + var committed = a.Commit(batch => + { + batch.Expect(K, observed); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "stale" }, K); + }); + + Assert.That(committed, Is.False); + Assert.That(NoteOnDisk(path, directory), Is.EqualTo("by-c")); + } + + // A pull serves the rewritten record although nothing about it but its bytes changed. + [Test] + public void APullServesARecordRewrittenAtItsOwnStoredAt([Values] bool directory) + { + var path = PathOf("t2.cc"); + Seed(path, directory, "k"); + using var a = Open(path, directory); + RewriteAtItsOwnStoredAt(path, directory, "k", "by-c"); + + a.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + Assert.That(a.Get(K)!.Note, Is.EqualTo("by-c")); + } + + // ExpectUnchanged compares every record's version: a rewrite at the same storedAt fails it for an unrelated key. + [Test] + public void ExpectUnchangedFailsWhenARecordWasRewrittenAtItsOwnStoredAt([Values] bool directory) + { + var path = PathOf("t3.cc"); + Seed(path, directory, "k"); + using var a = Open(path, directory); + RewriteAtItsOwnStoredAt(path, directory, "k", "by-c"); + + var committed = a.Commit(batch => + { + batch.ExpectUnchanged(); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X); + }); + + Assert.That(committed, Is.False); + Assert.That(Keys(path), Is.EqualTo(new[] { "k" })); + } + + // The staleness check of a store holding a variant compares versions: a rewrite at the record's own storedAt refuses the + // write, names the record, leaves the file as it was, and the cache then holds the rewrite so a retry lands. + [Test] + public void AWriteIntoAVariantStoreIsRefusedWhenARecordWasRewrittenAtItsOwnStoredAt() + { + var path = PathOf("t4.cc"); + Seed(path, false, "a"); + using var a = Open(path, false); + AddVariant(a, "v", A); + using (var peer = Open(path, false)) + Assert.That(peer.Get(A), Is.Not.Null); + RewriteAtItsOwnStoredAt(path, false, "a", "by-c"); + var before = File.ReadAllBytes(path); + var y = new CultRecordKey("y"); + + var conflict = Assert.Throws(() => + Land(a, true, typeof(WsItem), new WsItem { Name = "name-y" }, y))!; + + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { "a" })); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + Assert.That(a.Get(A)!.Note, Is.EqualTo("by-c"), "the refusal reloaded the record by version"); + Land(a, true, typeof(WsItem), new WsItem { Name = "name-y" }, y); + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "v", "y" })); + } + + // A write teaches the writer's cache the version of the bytes it wrote, by flush or by commit, in either store kind: the + // writer's own Expect holds without a pull, and fails once anyone else writes the record. + [Test] + public void AnOwnWriteTeachesTheCacheItsVersion([Values] bool directory, [Values] bool commit) + { + var path = PathOf("t5.cc"); + Seed(path, directory, "a"); + using var a = Open(path, directory); + Land(a, commit, typeof(WsItem), new WsItem { Name = "name-k", Note = "by-a" }, K); + + Assert.That(a.Commit(batch => + { + batch.Expect(K, a.Get(K)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "again" }, K); + }), Is.True, "A's own condition holds without a pull"); + + var seen = a.Get(K)!; + using (var b = Open(path, directory)) + b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-b" }, K)); + Assert.That(a.Commit(batch => + { + batch.Expect(K, seen); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "stale" }, K); + }), Is.False); + } + // A commit that removes a key this cache staged removes it everywhere: the staged write does not survive the batch. [Test] public void ACommitThatRemovesAStagedKeyRemovesIt()