From 7d7ea233cb9f0352c1586f99d6f83d23a11a9c4f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 09:48:25 +0200 Subject: [PATCH] Tests: pin the version's byte definition, an Expect over a removed record, the cache's own written version, the no-condition hash cost, unstored uniqueness and the staleness check over a writer's own upserts Co-Authored-By: Claude Sonnet 5.5 --- tests/GameCult.Caching.Tests/WriteSetTests.cs | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/tests/GameCult.Caching.Tests/WriteSetTests.cs b/tests/GameCult.Caching.Tests/WriteSetTests.cs index 1b7a6bd3..3abb248c 100644 --- a/tests/GameCult.Caching.Tests/WriteSetTests.cs +++ b/tests/GameCult.Caching.Tests/WriteSetTests.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; @@ -648,6 +649,221 @@ namespace GameCult.Caching.Tests }), Is.False); } + // A version is the SHA-256 of the bytes as the file holds them, not of a re-encoding. A writer may legally encode a record + // wider than needed (a str16 for a short string, a bin32, an array16); the file below holds every record that way. Each + // held record's version is the hash of its slice of the file; a pull over the unchanged file replaces nothing; and a write + // copies the records it did not stage byte for byte, so they keep their version and the next write is not refused. + [Test] + public void AVersionIsTheHashOfTheBytesTheFileHoldsWhateverTheirEncoding() + { + var path = PathOf("wide.cc"); + Seed(path, false, "a", "k"); + using (var seed = Open(path, false)) + AddVariant(seed, "v", A); + File.WriteAllBytes(path, WidenEveryRecord(File.ReadAllBytes(path), out var slices)); + Assert.That(slices["k"], Is.Not.EqualTo(CultDocumentMessagePackSerialization.SerializePersistedRecord( + Read(path).Records.Single(record => record.Key == "k"))), "the stored bytes are not the canonical encoding of the record"); + + using var cache = Open(path, false); + foreach (var (key, slice) in slices) + Assert.That(cache.GetStored(new CultRecordKey(key))!.StoredVersion, Is.EqualTo(HexOfSha256(slice)), key); + + var before = cache.GetStored(K); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + Assert.That(ReferenceEquals(cache.GetStored(K), before), Is.True, "an unchanged file is not read as changed"); + + Assert.That(cache.Commit(batch => + { + batch.Expect(K, cache.Get(K)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X); + }), Is.True); + var file = File.ReadAllBytes(path); + foreach (var key in new[] { "a", "v" }) + Assert.That(Contains(file, slices[key]), Is.True, $"{key} was copied as the file held it"); + Land(cache, false, typeof(WsItem), new WsItem { Name = "name-x", Note = "again" }, X); + Assert.That(Keys(path), Is.EqualTo(new[] { "a", "k", "v", "x" }), "a second write into the variant store is not refused"); + } + + private static string HexOfSha256(byte[] bytes) => + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)).ToLowerInvariant(); + + private static bool Contains(byte[] haystack, byte[] needle) => haystack.AsSpan().IndexOf(needle) >= 0; + + // The same store with every array, string and byte string written in its widest MessagePack form, and each record's slice. + private static byte[] WidenEveryRecord(byte[] bytes, out Dictionary slices) + { + slices = new Dictionary(StringComparer.Ordinal); + var output = new List(); + var reader = new MessagePackReader(bytes); + var top = reader.ReadArrayHeader(); + output.AddRange(new byte[] { 0xdc, (byte)(top >> 8), (byte)top }); + for (var slot = 0; slot < top; slot++) + { + if (slot != 2) + { + var from = (int)reader.Consumed; + reader.Skip(); + output.AddRange(bytes.Skip(from).Take((int)reader.Consumed - from)); + continue; + } + + var count = reader.ReadArrayHeader(); + output.AddRange(new byte[] { 0xdc, (byte)(count >> 8), (byte)count }); + for (var index = 0; index < count; index++) + { + var start = output.Count; + var fields = reader.ReadArrayHeader(); + output.AddRange(new byte[] { 0xdc, (byte)(fields >> 8), (byte)fields }); + string? key = null; + for (var field = 0; field < fields; field++) + { + if (reader.NextMessagePackType == MessagePackType.String) + { + var text = reader.ReadString()!; + key ??= text; + var utf8 = System.Text.Encoding.UTF8.GetBytes(text); + output.AddRange(new byte[] { 0xda, (byte)(utf8.Length >> 8), (byte)utf8.Length }); + output.AddRange(utf8); + } + else if (reader.NextMessagePackType == MessagePackType.Binary) + { + var blob = reader.ReadBytes()!.Value.ToArray(); + output.AddRange(new byte[] { 0xc6, (byte)(blob.Length >> 24), (byte)(blob.Length >> 16), (byte)(blob.Length >> 8), (byte)blob.Length }); + output.AddRange(blob); + } + else + { + var from = (int)reader.Consumed; + reader.Skip(); + output.AddRange(bytes.Skip(from).Take((int)reader.Consumed - from)); + } + } + + slices[key!] = output.Skip(start).ToArray(); + } + } + + return output.ToArray(); + } + + // A condition on a record fails when another writer removed it: the removal stands and the writer does not bring it back. + [Test] + public void AnExpectFailsWhenAnotherWriterRemovedTheRecord([Values] bool directory) + { + var path = PathOf("gone.cc"); + Seed(path, directory, "k"); + using var a = Open(path, directory); + var observed = a.Get(K)!; + using (var b = Open(path, directory)) + Assert.That(b.Commit(batch => batch.Remove(K)), Is.True); + + var committed = a.Commit(batch => + { + batch.Expect(K, observed); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "resurrected" }, K); + }); + + Assert.That(committed, Is.False); + Assert.That(Keys(path), Is.Empty); + } + + // The cache's own held record, not only the store's, learns the version of the bytes written. A variant staged and then + // re-resolved (its base edited before the flush) is held by the cache as a new object the store never saw, so only the + // cache's own line gives it the version of the bytes it was written as. + [Test] + public void AVariantReResolvedBeforeItsFlushIsKnownByItsWrittenVersion() + { + var path = PathOf("reresolved.cc"); + Seed(path, false, "a"); + using var cache = Open(path, false); + var v = new CultRecordKey("v"); + cache.UpsertVariantAsync(v, A, new[] { cache.Override(nameof(WsItem.Name), "name-v") }).GetAwaiter().GetResult(); + var staged = cache.GetStored(v); + cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-a", Note = "edited" }, A).GetAwaiter().GetResult(); + Assert.That(ReferenceEquals(cache.GetStored(v), staged), Is.False, "the base edit re-resolved the variant into a new held copy"); + cache.FlushAllBackingStores(); + + var held = cache.GetStored(v)!; + Assert.That(held.StoredVersion, Is.EqualTo(HexOfSha256(CultDocumentMessagePackSerialization.SerializePersistedRecord( + Read(path).Records.Single(record => record.Key == "v"))))); + Assert.That(cache.Commit(batch => + { + batch.Expect(v, cache.Get(v)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X); + }), Is.True, "the writer's own condition on the variant holds"); + } + + private sealed class CountingStore : SingleFileMessagePackBackingStore + { + public CountingStore(string path) : base(path) { } + + public int Encoded; + + protected override byte[] SerializeRecord(CultPersistedRecord record) + { + Encoded++; + return base.SerializeRecord(record); + } + } + + // A commit with no condition decides nothing about the other records, so it hashes none of them: only the record it writes + // is encoded to be named. A commit with a condition compares every durable record's version. + [Test] + public void AnUnconditionalCommitHashesOnlyTheRecordsItWrites() + { + var path = PathOf("cost.cc"); + Seed(path, false, "a", "b", "c", "d", "e"); + var store = new CountingStore(path); + using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); + cache.AddBackingStore(store); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + + store.Encoded = 0; + Assert.That(cache.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)), Is.True); + Assert.That(store.Encoded, Is.EqualTo(1)); + + store.Encoded = 0; + Assert.That(cache.Commit(batch => + { + batch.Expect(X, cache.Get(X)!); + batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x", Note = "again" }, X); + }), Is.True); + Assert.That(store.Encoded, Is.GreaterThanOrEqualTo(6), "a condition hashes every durable record, which is how the count proves itself"); + } + + // Records no store has read or written are told apart however many are made in one clock tick. + [Test] + public void UnstoredRecordsMadeBackToBackHaveDistinctVersions() + { + using var cache = new CultCache(Registry); + var keys = Enumerable.Range(0, 2000).Select(index => new CultRecordKey("r" + index)).ToArray(); + foreach (var key in keys) + cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "same" }, key).GetAwaiter().GetResult(); + + Assert.That(keys.Select(key => cache.GetStored(key)!.StoredVersion).Distinct().Count(), Is.EqualTo(keys.Length)); + } + + // A write into a store holding a variant is refused when any record it read has moved, including one the writer is itself + // writing: it must not overwrite another writer's edit with a copy it read before that edit. + [Test] + public void AWriteIntoAVariantStoreIsRefusedWhenARecordItWritesMovedAfterItRead() + { + var path = PathOf("own-upsert.cc"); + Seed(path, false, "a"); + using var a = Open(path, false); + AddVariant(a, "v", A); + using (var peer = Open(path, false)) + Assert.That(peer.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-a", Note = "by-peer" }, A)), Is.True); + var before = File.ReadAllBytes(path); + + var conflict = Assert.Throws(() => + Land(a, true, typeof(WsItem), new WsItem { Name = "name-a", Note = "stale" }, A))!; + + Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { "a" })); + Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); + } + // A commit that removes a key this cache staged removes it everywhere: the staged write does not survive the batch. [Test] public void ACommitThatRemovesAStagedKeyRemovesIt()