From ad51e45183d949e49bbd833aa73b360b845b4565 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 06:19:07 +0100 Subject: [PATCH] RetypeEvictionTests: pin the retype as an eviction, the variant and global retypes, the removal bookkeeping and the rebase rule Co-Authored-By: Claude Opus 5.5 --- .../RetypeEvictionTests.cs | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 tests/GameCult.Caching.Tests/RetypeEvictionTests.cs diff --git a/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs b/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs new file mode 100644 index 00000000..644c3d17 --- /dev/null +++ b/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs @@ -0,0 +1,429 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; +using R3; + +namespace GameCult.Caching.Tests +{ + // variants:ruling:one-batch-retype-is-eviction-then-add: a batch that removes a key and adds a record there stages the held + // record's eviction and then the add, so watchers, the batch validators, the store judges and the removal bookkeeping see what they + // see when the two are staged apart (cache.Remove, then UpsertAsync). Where a test runs both shapes it asserts the same answer. + public class RetypeEvictionTests + { + [CultDocument("tests.ev_a", "tests.ev_a.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class EvA + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.ev_b", "tests.ev_b.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class EvB + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + // A type only the second writer registers. + [CultDocument("tests.ev_c", "tests.ev_c.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class EvC + { + [Key(0)] [CultName] public string Name = string.Empty; + } + + [CultDocument("tests.ev_g", "tests.ev_g.v1")] + [MessagePackObject(AllowPrivate = true)] + [CultGlobal] + internal sealed class EvG + { + [Key(0)] public string Value = string.Empty; + } + + // The rebase pair: RbY lists RbX's id compatible, so a plain write of RbY owns an RbX record; RbZ lists nothing. + [CultDocument("tests.rb_x", "tests.rb_x.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RbX + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.rb_y", "tests.rb_y.v1", CompatibleSchemaIds = new[] { "tests.rb_x.v1" })] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RbY + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + [CultDocument("tests.rb_z", "tests.rb_z.v1")] + [MessagePackObject(AllowPrivate = true)] + internal sealed class RbZ + { + [Key(0)] [CultName] public string Name = string.Empty; + [Key(1)] public int Value; + } + + private static readonly CultDocumentRegistry AB = CultDocumentRegistry.ForTypes(new[] { typeof(EvA), typeof(EvB), typeof(EvG) }); + private static readonly CultDocumentRegistry ABC = CultDocumentRegistry.ForTypes(new[] { typeof(EvA), typeof(EvB), typeof(EvC), typeof(EvG) }); + private static readonly CultDocumentRegistry Rb = CultDocumentRegistry.ForTypes(new[] { typeof(RbX), typeof(RbY), typeof(RbZ) }); + private static readonly CultRecordKey K1 = new("k1"); + private static readonly CultRecordKey K2 = new("k2"); + private static readonly CultRecordKey K3 = new("k3"); + + // The stores a retype is judged in, and no store at all. The directory store holds no variant, so variant tests skip it. + public static readonly object[] Kinds = { "single-file", "directory", "storeless" }; + public static readonly object[] VariantKinds = { "single-file", "storeless" }; + + private string _directory = string.Empty; + private int _paths; + + [SetUp] + public void CreateDirectory() + { + _directory = Path.Combine(Path.GetTempPath(), $"cultlib-retypeeviction-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_directory); + } + + [TearDown] + public void DeleteDirectory() + { + if (Directory.Exists(_directory)) + Directory.Delete(_directory, recursive: true); + } + + // A fresh store path per call, so one test can run a scenario twice. + private string NewPath(string kind) => Path.Combine(_directory, $"{kind}-{_paths++}", kind == "directory" ? "store" : "store.cc"); + + private static CultCache Open(string path, string kind, CultDocumentRegistry registry) + { + var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry)); + if (kind != "storeless") + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + cache.AddBackingStore(kind == "directory" ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path)); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + } + + return cache; + } + + private static Type TypeAt(string path, string kind, CultDocumentRegistry registry, CultRecordKey key) + { + using var cache = Open(path, kind, registry); + return cache.GetStored(key)!.Descriptor.DocumentType; + } + + // Retypes a held key as one batch (Remove, then the add the caller stages) or staged apart (cache.Remove, then the add's own call). + private static void Retype(CultCache cache, bool oneBatch, CultRecordKey key, Action add, Action apart) + { + if (oneBatch) + Assert.That(cache.Commit(batch => + { + batch.Remove(key); + add(batch); + }), Is.True); + else + { + Assert.That(cache.Remove(key), Is.True); + apart(); + } + } + + private static void PutB(CultCache cache, CultRecordKey key, int value = 2) => + Assert.That(cache.Commit(batch => batch.Upsert(typeof(EvB), new EvB { Name = key.Value, Value = value }, key)), Is.True); + + // finding retype-not-an-eviction: Watch sees Removed and Watch sees Added, in a batch and staged apart alike. + [TestCaseSource(nameof(Kinds))] + public void ARetypeIsRemovedForTheOldTypeAndAddedForTheNewWhetherOneBatchOrStagedApart(string kind) + { + foreach (var oneBatch in new[] { true, false }) + { + using var cache = Open(NewPath(kind), kind, AB); + PutB(cache, K2); + var removedLog = new List(); + var addedLog = new List(); + using var oldWatch = cache.Watch().Subscribe(change => removedLog.Add(change.Kind)); + using var newWatch = cache.Watch().Subscribe(change => addedLog.Add(change.Kind)); + + Retype(cache, oneBatch, K2, + batch => batch.Upsert(typeof(EvA), new EvA { Name = "k2", Value = 9 }, K2), + () => cache.UpsertAsync(typeof(EvA), new EvA { Name = "k2", Value = 9 }, K2).GetAwaiter().GetResult()); + + Assert.That(removedLog, Is.EqualTo(new[] { CultCacheDocumentChangeKind.Removed }), $"Watch, one batch {oneBatch}"); + Assert.That(addedLog, Is.EqualTo(new[] { CultCacheDocumentChangeKind.Added }), $"Watch, one batch {oneBatch}"); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvA))); + } + } + + // finding retype-not-an-eviction, global key, and finding global-eviction-uncovered: evicting a global frees its slot, and the + // batch that retypes a global's key may land the next global of its type elsewhere, as staged apart. + [TestCaseSource(nameof(Kinds))] + public void EvictingAGlobalFreesItsSlotAndAGlobalKeyRetypesInOneBatch(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + var g1 = new CultRecordKey("g1"); + var g2 = new CultRecordKey("g2"); + Assert.That(cache.Commit(batch => batch.Upsert(typeof(EvG), new EvG { Value = "one" }, g1)), Is.True); + + Assert.Throws( + () => cache.Commit(batch => batch.Upsert(typeof(EvG), new EvG { Value = "two" }, g2)), "a second global while the first stands is refused"); + + Assert.That(cache.Commit(batch => + { + batch.Remove(g1); + batch.Upsert(typeof(EvG), new EvG { Value = "two" }, g2); + }), Is.True, "the eviction freed the slot"); + Assert.That(cache.GetGlobal()!.Value, Is.EqualTo("two")); + Assert.That(cache.GetStored(g1), Is.Null); + + Assert.That(cache.Commit(batch => + { + batch.Remove(g2); + batch.Upsert(typeof(EvA), new EvA { Name = "g2", Value = 1 }, g2); + batch.Upsert(typeof(EvG), new EvG { Value = "three" }, g1); + }), Is.True, "staged apart the retype of the global's key and the next global commit"); + Assert.That(cache.GetStored(g2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvA))); + Assert.That(cache.GetGlobal()!.Value, Is.EqualTo("three")); + } + + // finding retype-not-an-eviction (variant key) and variant-ops-drop-the-removal: a variant key retypes to a plain record of + // another type, a plain key becomes a variant of another type's base, and a key removed, added and then added as a variant + // lands as the variant, each answering as it does staged apart. + [TestCaseSource(nameof(VariantKinds))] + public void VariantKeysRetypeInOneBatchAsStagedApart(string kind) + { + foreach (var oneBatch in new[] { true, false }) + { + using var cache = Open(NewPath(kind), kind, AB); + var baseA = new CultRecordKey("base-a"); + Assert.That(cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "base-a", Value = 1 }, baseA); + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3") }); + batch.Upsert(typeof(EvB), new EvB { Name = "k2", Value = 2 }, K2); + batch.Upsert(typeof(EvB), new EvB { Name = "k1", Value = 2 }, K1); + }), Is.True); + + Retype(cache, oneBatch, K3, + batch => batch.Upsert(typeof(EvB), new EvB { Name = "k3", Value = 3 }, K3), + () => cache.UpsertAsync(typeof(EvB), new EvB { Name = "k3", Value = 3 }, K3).GetAwaiter().GetResult()); + Assert.That(cache.GetStored(K3)!.Variant, Is.Null, $"one batch {oneBatch}"); + Assert.That(cache.GetStored(K3)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB))); + + Retype(cache, oneBatch, K2, + batch => batch.UpsertVariant(K2, baseA, new[] { cache.Override(nameof(EvA.Name), "v2") }), + () => cache.UpsertVariantAsync(K2, baseA, new[] { cache.Override(nameof(EvA.Name), "v2") }).GetAwaiter().GetResult()); + Assert.That(cache.GetStored(K2)!.Variant?.BaseKey, Is.EqualTo(baseA.Value), $"one batch {oneBatch}"); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvA))); + + // Removed and then added as a variant (in one batch, with a plain add of the same type between): the variant lands. + Retype(cache, oneBatch, K1, + batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "k1", Value = 2 }, K1); + batch.UpsertVariant(K1, baseA, new[] { cache.Override(nameof(EvA.Name), "v1") }); + }, + () => cache.UpsertVariantAsync(K1, baseA, new[] { cache.Override(nameof(EvA.Name), "v1") }).GetAwaiter().GetResult()); + Assert.That(cache.GetStored(K1)!.Variant?.BaseKey, Is.EqualTo(baseA.Value), $"one batch {oneBatch}"); + } + } + + [TestCaseSource(nameof(VariantKinds))] + public void AFlattenAfterARemovalFlattensNothingInOneBatchAndStagedApart(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + var baseA = new CultRecordKey("base-a"); + cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "base-a", Value = 1 }, baseA); + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3") }); + }); + + Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K3); + batch.Flatten(K3); + })); + Assert.That(cache.GetStored(K3)!.Variant, Is.Not.Null, "the refused batch changed nothing"); + Assert.That(cache.Remove(K3), Is.True); + Assert.Throws(() => cache.FlattenAsync(K3).GetAwaiter().GetResult()); + } + + // finding removal-bookkeeping-unpinned, mutant X2 (a retype elsewhere in the batch skips every key's staging claim): a batch + // holding two retypes commits, and a cross-type add at a key it did not remove is still refused beside a retype. + [TestCaseSource(nameof(Kinds))] + public void EachKeyOfABatchIsJudgedByItsOwnRemoval(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + PutB(cache, K1); + PutB(cache, K2); + PutB(cache, K3); + + Assert.That(cache.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(typeof(EvA), new EvA { Name = "k1" }, K1); + batch.Remove(K2); + batch.Upsert(typeof(EvA), new EvA { Name = "k2" }, K2); + }), Is.True, "two retypes"); + + var bad = Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(typeof(EvB), new EvB { Name = "k1" }, K1); + batch.Upsert(typeof(EvA), new EvA { Name = "k3" }, K3); + }))!; + Assert.That(bad.RecordKey, Is.EqualTo(K3.Value), "one key's removal licenses no other key's retype"); + Assert.That(cache.GetStored(K3)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB))); + Assert.That(cache.GetStored(K1)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvA)), "the refused batch changed nothing"); + + Assert.That(cache.Commit(batch => + { + batch.Upsert(typeof(EvB), new EvB { Name = "k3", Value = 5 }, K3); + batch.Remove(K3); + }), Is.True, "an add and then a removal removes"); + Assert.That(cache.GetStored(K3), Is.Null); + } + + // finding removal-bookkeeping-unpinned, mutant X13 (any second add counts as a replacement): two adds of one batch with no + // removal are an upsert, judged against what the key holds. + [TestCaseSource(nameof(Kinds))] + public void TwoAddsWithNoRemovalAreAnUpsert(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + PutB(cache, K2); + + Assert.Throws(() => cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "k2", Value = 1 }, K2); + batch.Upsert(typeof(EvA), new EvA { Name = "k2", Value = 2 }, K2); + })); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB))); + } + + // finding removal-bookkeeping-unpinned, mutant X18 (no removal noted for a record this cache wrote itself): a cache that wrote + // the key itself, then removes it and stages another type there, lands that type at the flush. + [TestCaseSource(nameof(Kinds))] + public void ARecordThisCacheWroteItselfRetypesStagedApart(string kind) + { + var path = NewPath(kind); + using (var cache = Open(path, kind, AB)) + { + PutB(cache, K2); + cache.FlushAllBackingStores(); + Assert.That(cache.Remove(K2), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k2", Value = 9 }, K2).GetAwaiter().GetResult(); + cache.FlushAllBackingStores(); + } + + if (kind != "storeless") + Assert.That(TypeAt(path, kind, AB, K2), Is.EqualTo(typeof(EvA))); + } + + // finding removal-bookkeeping-unpinned, mutant M (a refusal keeps the staged removal): a removal the store refused is gone, so + // a later add at a key another writer has since rewritten is judged as an upsert, not as the stale removal. + [TestCaseSource(nameof(Kinds))] + public void ARefusedRemovalIsNotReusedByALaterAdd(string kind) + { + if (kind == "storeless") + Assert.Ignore("a refusal is a store's."); + var path = NewPath(kind); + using (var seed = Open(path, kind, ABC)) + PutB(seed, K1); + + using var cache = Open(path, kind, AB); + using var other = Open(path, kind, ABC); + Assert.That(cache.Remove(K1), Is.True); + + // Another writer retypes the key to a type this cache does not know: the cache's staged removal meets a record it may not remove. + Assert.That(other.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(typeof(EvC), new EvC { Name = "k1" }, K1); + }), Is.True); + Assert.Throws(() => cache.FlushAllBackingStores()); + + // The writer removes it again and puts a B there; this cache pulls and then stages an A with no removal in front of it. + Assert.That(other.Commit(batch => batch.Remove(K1)), Is.True); + cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); + Assert.That(other.Commit(batch => batch.Upsert(typeof(EvB), new EvB { Name = "k1", Value = 7 }, K1)), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k1", Value = 8 }, K1).GetAwaiter().GetResult(); + + Assert.Throws(() => cache.FlushAllBackingStores()); + Assert.That(TypeAt(path, kind, AB, K1), Is.EqualTo(typeof(EvB))); + } + + // finding removal-bookkeeping-unpinned, mutant X9 (a refusal clears every staged removal): a refusal of one key leaves another + // key's staged retype, and the next flush lands it. + [TestCaseSource(nameof(Kinds))] + public void ARefusedFlushKeepsAnotherKeysStagedRetype(string kind) + { + if (kind == "storeless") + Assert.Ignore("a refusal is a store's."); + var path = NewPath(kind); + using (var seed = Open(path, kind, ABC)) + { + PutB(seed, K1); + PutB(seed, K2); + } + + using var cache = Open(path, kind, AB); + using var other = Open(path, kind, ABC); + Assert.That(cache.Remove(K2), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k2", Value = 9 }, K2).GetAwaiter().GetResult(); + + Assert.That(other.Commit(batch => + { + batch.Remove(K1); + batch.Upsert(typeof(EvC), new EvC { Name = "k1" }, K1); + }), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k1", Value = 1 }, K1).GetAwaiter().GetResult(); + + var refusal = Assert.Throws(() => cache.FlushAllBackingStores())!; + Assert.That(refusal.RecordKey, Is.EqualTo(K1.Value), "k1 holds a type this cache does not know"); + cache.FlushAllBackingStores(); + + Assert.That(TypeAt(path, kind, AB, K2), Is.EqualTo(typeof(EvA)), "the retype of k2 survived the refusal of k1"); + } + + // finding rebase-check-was-reachable, ruling variant-rebase-follows-plain-write: a variant rebased onto a base of another type + // follows the plain-write rule. Where RbY lists RbX's id (a plain write of RbY would own an RbX record) the rebase commits and + // the variant becomes an RbY; where it would be refused (RbZ) the rebase is refused and the variant is untouched. + [TestCaseSource(nameof(VariantKinds))] + public void ARebaseChangesTypeExactlyWhereAPlainWriteMay(string kind) + { + using var cache = Open(NewPath(kind), kind, Rb); + var baseX = new CultRecordKey("base-x"); + var baseY = new CultRecordKey("base-y"); + var baseZ = new CultRecordKey("base-z"); + var variant = new CultRecordKey("v"); + Assert.That(cache.Commit(batch => + { + batch.Upsert(typeof(RbX), new RbX { Name = "base-x" }, baseX); + batch.Upsert(typeof(RbY), new RbY { Name = "base-y" }, baseY); + batch.Upsert(typeof(RbZ), new RbZ { Name = "base-z" }, baseZ); + batch.UpsertVariant(variant, baseX, new[] { cache.Override(nameof(RbX.Name), "v") }); + }), Is.True); + + // The plain write of the same types gives the same two answers. + Assert.Throws(() => cache.Commit(batch => batch.Upsert(typeof(RbZ), new RbZ { Name = "base-x" }, baseX))); + Assert.Throws( + () => cache.Commit(batch => batch.UpsertVariant(variant, baseZ, new[] { cache.Override(nameof(RbZ.Name), "v") }))); + Assert.That(cache.GetStored(variant)!.Descriptor.DocumentType, Is.EqualTo(typeof(RbX))); + Assert.That(cache.GetStored(variant)!.Variant!.BaseKey, Is.EqualTo(baseX.Value)); + + Assert.That(cache.Commit(batch => batch.UpsertVariant(variant, baseY, new[] { cache.Override(nameof(RbY.Name), "v") })), Is.True); + Assert.That(cache.GetStored(variant)!.Descriptor.DocumentType, Is.EqualTo(typeof(RbY))); + Assert.That(cache.GetStored(variant)!.Variant!.BaseKey, Is.EqualTo(baseY.Value)); + } + } +}