#nullable enable using System; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; namespace GameCult.Caching.Tests { // variants:ruling:drift-record-write-ownership: a writer owns a record stored under id X when it declares X, or when the type // that reads X reads it whole; it then writes, relabels and removes it, and a write stores it under the type's own id. A record // a type reads only by dropping persisted slots stays read-only until its id is declared compatible. public class RestampOnWriteTests { // One schema name at three versions of its type and nothing declared: Grown reads Narrow's records whole (it adds a slot), // and reads Wide's records only by dropping slot 3. [CultDocument("tests.restamp_note", "tests.restamp_note.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class NoteNarrow { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Value; } [CultDocument("tests.restamp_note", "tests.restamp_note.v2")] [MessagePackObject(AllowPrivate = true)] internal sealed class NoteGrown { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Value; [Key(2)] public string Colour = string.Empty; } [CultDocument("tests.restamp_note", "tests.restamp_note.v3")] [MessagePackObject(AllowPrivate = true)] internal sealed class NoteWide { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Value; [Key(2)] public string Colour = string.Empty; [Key(3)] public string Extra = string.Empty; } // Another schema with Narrow's exact shape: never the same type, whatever the members say. [CultDocument("tests.restamp_other", "tests.restamp_other.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class NoteOther { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Value; } // Grown with the lossy id declared: declaration is the consent to the loss. [CultDocument("tests.restamp_note", "tests.restamp_note.v4", CompatibleSchemaIds = new[] { WideId })] [MessagePackObject(AllowPrivate = true)] internal sealed class NoteGrownDeclaring { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Value; [Key(2)] public string Colour = string.Empty; } private const string WideId = "sha256:82c2b721aceb56e00288baab590fabcdd82c3bda44400f7530fa9e29c4e96252"; private static readonly CultDocumentRegistry NarrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow) }); private static readonly CultDocumentRegistry GrownRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteGrown) }); private static readonly CultDocumentRegistry WideRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteWide) }); private static readonly CultDocumentRegistry DeclaringRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteGrownDeclaring) }); private static readonly CultRecordKey Note = new("note"); private static readonly CultRecordKey Other = new("other"); private string _directory = string.Empty; [SetUp] public void CreateDirectory() { _directory = Path.Combine(Path.GetTempPath(), $"cultlib-restamp-{Guid.NewGuid():N}"); Directory.CreateDirectory(_directory); } [TearDown] public void DeleteDirectory() { if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); } public static readonly object[] StoreKinds = { false, true }; private CacheBackingStore Store(string path, bool directory) => directory ? new DirectoryMessagePackBackingStore(path) : new SingleFileMessagePackBackingStore(path); private CultCache Open(string path, bool directory, CultDocumentRegistry registry) { var cache = new CultCache(registry); cache.AddBackingStore(Store(path, directory)); return cache; } // The schema id each key is stored under, read back through a fresh cache that declares everything it sees. private string StoredIdOf(string path, bool directory, CultDocumentRegistry registry, CultRecordKey key) { using var cache = Open(path, directory, registry); return cache.GetStored(key)!.StoredSchemaId; } private static string Fingerprint(string path) { using var sha = SHA256.Create(); if (File.Exists(path)) return Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(path))); var parts = Directory.GetFiles(path, "*", SearchOption.AllDirectories) .OrderBy(file => file, StringComparer.Ordinal) .Select(file => Path.GetRelativePath(path, file) + ":" + Convert.ToHexString(sha.ComputeHash(File.ReadAllBytes(file)))); return string.Join("|", parts); } // Two records written by a writer that knew Narrow: the one under test and a bystander. private string SeedNarrow(bool directory) { var path = Path.Combine(_directory, directory ? "store" : "store.cc"); using var cache = Open(path, directory, NarrowRegistry); Assert.That(cache.Commit(batch => { batch.Upsert(new NoteNarrow { Name = "note", Value = 1 }, new CultRecordHandle(Note)); batch.Upsert(new NoteNarrow { Name = "other", Value = 7 }, new CultRecordHandle(Other)); }), Is.True); return path; } private string SeedWide(bool directory) { var path = Path.Combine(_directory, directory ? "store" : "store.cc"); using var cache = Open(path, directory, WideRegistry); Assert.That(cache.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 1, Extra = "kept?" }, new CultRecordHandle(Note))), Is.True); return path; } [TestCaseSource(nameof(StoreKinds))] public void ADriftRecordReadWholeIsWrittenUnderItsTypesId(bool directory) { var path = SeedNarrow(directory); var narrowId = NarrowRegistry.GetRequired().SchemaId; var grownId = GrownRegistry.GetRequired().SchemaId; Assert.That(StoredIdOf(path, directory, NarrowRegistry, Note), Is.EqualTo(narrowId)); using (var cache = Open(path, directory, GrownRegistry)) { Assert.That(cache.Get(Note)!.Value, Is.EqualTo(1)); Assert.That(cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2, Colour = "red" }, new CultRecordHandle(Note))), Is.True); } Assert.That(StoredIdOf(path, directory, GrownRegistry, Note), Is.EqualTo(grownId), "the written record moved to its type's id"); Assert.That(StoredIdOf(path, directory, NarrowRegistry, Other), Is.EqualTo(narrowId), "the bystander kept its persisted id (write set)"); using var reopened = Open(path, directory, GrownRegistry); Assert.That(reopened.Get(Note)!.Colour, Is.EqualTo("red")); Assert.That(reopened.Get(Other)!.Value, Is.EqualTo(7)); } [TestCaseSource(nameof(StoreKinds))] public void RemovingADriftRecordReadWholeSucceeds(bool directory) { var path = SeedNarrow(directory); using (var cache = Open(path, directory, GrownRegistry)) Assert.That(cache.Commit(batch => batch.Remove(Note)), Is.True); using var reopened = Open(path, directory, GrownRegistry); Assert.That(reopened.Get(Note), Is.Null); Assert.That(reopened.Get(Other), Is.Not.Null); } [TestCaseSource(nameof(StoreKinds))] public void ADriftRecordReadOnlyByDroppingASlotStaysReadOnly(bool directory) { var path = SeedWide(directory); var before = Fingerprint(path); using var cache = Open(path, directory, GrownRegistry); Assert.That(cache.Get(Note), Is.Not.Null, "a read-only record is still served"); var upsert = Assert.Throws(() => cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle(Note))))!; Assert.That(upsert.RecordKey, Is.EqualTo("note")); Assert.That(upsert.SchemaId, Is.EqualTo(WideRegistry.GetRequired().SchemaId)); Assert.That(upsert.Message, Does.Contain("would drop persisted slots").And.Contain("to accept the loss."), "the refusal names the narrowed rule"); var remove = Assert.Throws(() => cache.Commit(batch => batch.Remove(Note)))!; Assert.That(remove.RecordKey, Is.EqualTo("note")); Assert.That(Fingerprint(path), Is.EqualTo(before), "a refused write leaves the store as it was"); } // A held record read only by dropping a slot is refused before anything is staged: with the store's lock held by another // writer, the write is refused typed instead of waiting on the lock and reporting contention. [TestCaseSource(nameof(StoreKinds))] public void ALossyDriftRecordIsRefusedBeforeTheStoresLockIsAsked(bool directory) { var path = SeedWide(directory); using var cache = Open(path, directory, GrownRegistry); using var held = directory ? new FileStream(Path.Combine(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path), ".commit.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None) : new FileStream(path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); Assert.Throws(() => cache.TryCommit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle(Note)))); } [TestCaseSource(nameof(StoreKinds))] public void ADeclaredLossyDriftRecordIsWritable(bool directory) { var path = SeedWide(directory); var wideId = WideRegistry.GetRequired().SchemaId; Assert.That(wideId, Is.EqualTo(WideId), "the declaration names the lossy record's id: " + wideId); using (var cache = Open(path, directory, DeclaringRegistry)) Assert.That(cache.Commit(batch => batch.Upsert(new NoteGrownDeclaring { Name = "note", Value = 2 }, new CultRecordHandle(Note))), Is.True); Assert.That(StoredIdOf(path, directory, DeclaringRegistry, Note), Is.EqualTo(DeclaringRegistry.GetRequired().SchemaId)); } // Another writer rewrites the record after this cache loaded it: ownership is judged again under the store's lock. [TestCaseSource(nameof(StoreKinds))] public void AnotherWritersDriftRecordIsJudgedUnderTheLock(bool directory) { var path = SeedNarrow(directory); using var cache = Open(path, directory, GrownRegistry); Assert.That(cache.Get(Note), Is.Not.Null); // A writer that knows Wide replaces the record: this cache's loaded copy was read whole, the durable one is not. using (var other = Open(path, directory, WideRegistry)) { Assert.That(other.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 3, Extra = "x" }, new CultRecordHandle(Note))), Is.True); } var refusal = Assert.Throws(() => cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 4 }, new CultRecordHandle(Note))))!; Assert.That(refusal.RecordKey, Is.EqualTo("note")); Assert.That(StoredIdOf(path, directory, WideRegistry, Note), Is.EqualTo(WideRegistry.GetRequired().SchemaId)); } [Test] public void ACachelessWriteRestampsADriftRecordOfItsSchema() { var path = SeedNarrow(directory: false); new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrown { Name = "note", Value = 2 }, registry: GrownRegistry); Assert.That(StoredIdOf(path, false, GrownRegistry, Note), Is.EqualTo(GrownRegistry.GetRequired().SchemaId)); Assert.That(StoredIdOf(path, false, NarrowRegistry, Other), Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); } [Test] public void ACachelessWriteOverALossyDriftRecordIsRefusedAndTheFileIsUntouched() { var path = SeedWide(directory: false); var before = Fingerprint(path); var refusal = Assert.Throws(() => new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrown { Name = "note", Value = 2 }, registry: GrownRegistry))!; Assert.That(refusal.Message, Does.Contain("reads it whole").And.Contain("to accept the loss of its unread slots."), "the refusal names the narrowed rule"); Assert.That(Fingerprint(path), Is.EqualTo(before)); } // A cacheless writer's entry that lists the record's id compatible consents to the loss, as a cache's declaration does. [Test] public void ACachelessWriteByAnEntryListingTheIdCompatibleOverwritesALossyDriftRecord() { var path = SeedWide(directory: false); new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteGrownDeclaring { Name = "note", Value = 2 }, registry: DeclaringRegistry); Assert.That(StoredIdOf(path, false, DeclaringRegistry, Note), Is.EqualTo(DeclaringRegistry.GetRequired().SchemaId)); } // The type that would read the id cannot be named (two registered versions share the schema name and the id declares neither), // so the record is not owned and a write over it is refused typed, not as the resolver's own error. [TestCaseSource(nameof(StoreKinds))] public void ARecordNoRegisteredTypeCanBeNamedForIsNotOwned(bool directory) { var path = SeedNarrow(directory); var both = CultDocumentRegistry.ForTypes(new[] { typeof(NoteNarrow), typeof(NoteGrown) }); using var cache = Open(path, directory, both); using (var other = Open(path, directory, WideRegistry)) Assert.That(other.Commit(batch => batch.Upsert(new NoteWide { Name = "note", Value = 3 }, new CultRecordHandle(Note))), Is.True); Assert.Throws(() => cache.Commit(batch => batch.Upsert(new NoteGrown { Name = "note", Value = 4 }, new CultRecordHandle(Note)))); } [TestCaseSource(nameof(StoreKinds))] public void ExpectAgreesWithRestamp(bool directory) { var path = SeedNarrow(directory); var third = new CultRecordKey("third"); using var cache = Open(path, directory, GrownRegistry); Assert.That(cache.Commit(batch => { batch.Expect(Note, cache.Get(Note)); batch.ExpectUnchanged(); batch.Upsert(new NoteGrown { Name = "third" }, new CultRecordHandle(third)); }), Is.True, "an unchanged drift record satisfies Expect and ExpectUnchanged"); using var second = Open(path, directory, GrownRegistry); Assert.That(cache.Commit(batch => { batch.Expect(Note, cache.Get(Note)); batch.Upsert(new NoteGrown { Name = "note", Value = 2 }, new CultRecordHandle(Note)); }), Is.True, "a batch that Expects the drift record and upserts it commits"); Assert.That(StoredIdOf(path, directory, GrownRegistry, Note), Is.EqualTo(GrownRegistry.GetRequired().SchemaId), "and restamps it"); Assert.That(cache.Commit(batch => { batch.ExpectUnchanged(); batch.Upsert(new NoteGrown { Name = "fourth" }, new CultRecordHandle(new CultRecordKey("fourth"))); }), Is.True, "the writer's later ExpectUnchanged holds"); Assert.That(second.Commit(batch => { batch.Expect(Note, second.Get(Note)); batch.Upsert(new NoteGrown { Name = "note", Value = 3 }, new CultRecordHandle(Note)); }), Is.False, "a cache loaded before the restamp fails its Expect: the bytes changed"); } private static readonly CultRecordKey Base = new("base"); private static readonly CultRecordKey Delta = new("delta"); private CultCache OpenWithCodec(string path, bool directory, CultDocumentRegistry registry) { var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry)); cache.AddBackingStore(Store(path, directory)); return cache; } private string StoredIdOfVariantStore(string path, CultDocumentRegistry registry, CultRecordKey key) { using var cache = OpenWithCodec(path, false, registry); return cache.GetStored(key)!.StoredSchemaId; } // A base and a variant of it, written by a writer that knew Narrow; Grown then reads the variant whole as compatible drift. private string SeedNarrowVariant() { var path = Path.Combine(_directory, "store.cc"); using var cache = OpenWithCodec(path, false, NarrowRegistry); Assert.That(cache.Commit(batch => batch.Upsert(new NoteNarrow { Name = "base", Value = 1 }, new CultRecordHandle(Base))), Is.True); Assert.That(cache.Commit(batch => batch.UpsertVariant(Delta, Base, new[] { cache.Override("Name", "delta"), cache.Override("Value", 5) })), Is.True); return path; } // Variants live in a single file (the directory store refuses them). The staging half asks the flag ToStoredDocument set on a loaded variant, and Resolved and Flatten carry it on: an owned // drift variant is rewritten, flattened and removed like a plain drift record, restamping only what is written. [Test] public async Task AnOwnedDriftVariantIsRewritten() { var path = SeedNarrowVariant(); using (var cache = OpenWithCodec(path, false, GrownRegistry)) { await cache.PullAllBackingStoresAsync(); Assert.That(cache.Commit(batch => batch.UpsertVariant(Delta, Base, new[] { cache.Override("Name", "delta"), cache.Override("Value", 6) })), Is.True); } Assert.That(StoredIdOfVariantStore(path, GrownRegistry, Delta), Is.EqualTo(GrownRegistry.GetRequired().SchemaId)); Assert.That(StoredIdOfVariantStore(path, NarrowRegistry, Base), Is.EqualTo(NarrowRegistry.GetRequired().SchemaId), "the base was not written"); } [Test] public async Task AnOwnedDriftVariantIsFlattened() { var path = SeedNarrowVariant(); using (var cache = OpenWithCodec(path, false, GrownRegistry)) { await cache.PullAllBackingStoresAsync(); Assert.That(cache.Commit(batch => batch.Flatten(Delta)), Is.True); } Assert.That(StoredIdOfVariantStore(path, GrownRegistry, Delta), Is.EqualTo(GrownRegistry.GetRequired().SchemaId)); } [Test] public async Task AnOwnedDriftVariantIsRemoved() { var path = SeedNarrowVariant(); using (var cache = OpenWithCodec(path, false, GrownRegistry)) { await cache.PullAllBackingStoresAsync(); Assert.That(cache.Commit(batch => batch.Remove(Delta)), Is.True); } using var reopened = OpenWithCodec(path, false, GrownRegistry); await reopened.PullAllBackingStoresAsync(); Assert.That(reopened.GetStored(Delta), Is.Null); Assert.That(reopened.GetStored(Base), Is.Not.Null); } // A store whose entry is a newer writer's rename of Narrow's schema, with identical members and the given compatible ids. private string SeedRenamed(bool listsNarrow) { var path = SeedNarrow(directory: false); var snapshot = Snapshot(path); var narrowId = NarrowRegistry.GetRequired().SchemaId; var entry = snapshot.SchemaCatalog.Single(); entry.SchemaId = entry.ContentHash = "tests.restamp.renamed"; entry.SchemaName = "tests.restamp_note_renamed"; entry.SchemaVersion = "tests.restamp_note_renamed.v2"; entry.CompatibleSchemaIds = listsNarrow ? new[] { "tests.restamp.renamed", narrowId } : new[] { "tests.restamp.renamed" }; foreach (var record in snapshot.Records) record.SchemaId = entry.SchemaId; File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); return path; } // One writer of NoteNarrow, with a cache or without one: the same Owns answer for the same record. private void WriteNarrow(string path, bool cacheless) { if (cacheless) { new SingleFileMessagePackBackingStore(path).WriteDocument(Note, new NoteNarrow { Name = "note", Value = 2 }, registry: NarrowRegistry); return; } using var cache = Open(path, false, NarrowRegistry); cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); Assert.That(cache.Commit(batch => batch.Upsert(new NoteNarrow { Name = "note", Value = 2 }, new CultRecordHandle(Note))), Is.True); } [TestCase(false)] [TestCase(true)] public void ARenamedEntryListingTheWritersIdIsOwnedByCacheAndCachelessWriterAlike(bool cacheless) { var path = SeedRenamed(listsNarrow: true); WriteNarrow(path, cacheless); Assert.That(Snapshot(path).Records.Single(record => record.Key == "note").SchemaId, Is.EqualTo(NarrowRegistry.GetRequired().SchemaId)); } [TestCase(false)] [TestCase(true)] public void ARenamedEntryNotListingTheWritersIdIsRefusedByCacheAndCachelessWriterAlike(bool cacheless) { var path = SeedRenamed(listsNarrow: false); var before = Fingerprint(path); Assert.Throws(() => WriteNarrow(path, cacheless)); Assert.That(Fingerprint(path), Is.EqualTo(before)); } // A writer of one schema never overwrites and retypes a record of another schema, however alike their members. [TestCase(false)] [TestCase(true)] public void AWriterOfOneSchemaNeverRetypesASameShapeRecordOfAnother(bool cacheless) { var path = Path.Combine(_directory, "store.cc"); var otherRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(NoteOther) }); using (var seed = Open(path, false, otherRegistry)) Assert.That(seed.Commit(batch => batch.Upsert(new NoteOther { Name = "note", Value = 1 }, new CultRecordHandle(Note))), Is.True); var before = Fingerprint(path); Assert.Throws(() => WriteNarrow(path, cacheless)); Assert.That(Fingerprint(path), Is.EqualTo(before)); } private static string VectorPath(string name) { for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) { var vectors = Path.Combine(dir.FullName, "tests", "vectors", "ref-list-drift"); if (Directory.Exists(vectors)) return Path.Combine(vectors, name); } throw new DirectoryNotFoundException($"tests/vectors/ref-list-drift not found above {TestContext.CurrentContext.TestDirectory}."); } // The store the 1.0.60 writer wrote: its 'list' holder compares compatible with RefListHolder, gaining reference semantics // on a slot, and ignores no persisted slot, so RefListHolder reads it whole and may write it. [Test] public async Task TheCommitted1060VectorsListRecordIsWrittenUnderTheHoldersOwnId() { var path = Path.Combine(_directory, "vector.cc"); File.Copy(VectorPath("v1-0-60-written.cc"), path); var registry = CultDocumentRegistry.ForTypes(new[] { typeof(RefListDriftTests.RefListTarget), typeof(RefListDriftTests.RefListHolder) }); var holderId = registry.GetRequired().SchemaId; var before = Snapshot(path); Assert.That(before.Records.Single(record => record.Key == "list").SchemaId, Is.Not.EqualTo(holderId), "the vector's list is under an older id"); using (var cache = new CultCache(registry)) { cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)); await cache.PullAllBackingStoresAsync(); var holder = cache.GetAll().Single(); Assert.That(cache.Commit(batch => batch.Upsert( new RefListDriftTests.RefListHolder { Name = holder.Name, Items = holder.Items.Reverse().ToArray() }, new CultRecordHandle(new CultRecordKey("list")))), Is.True); } var after = Snapshot(path); Assert.That(after.Records.Single(record => record.Key == "list").SchemaId, Is.EqualTo(holderId)); Assert.That(after.SchemaCatalog.Single(entry => entry.SchemaId == holderId).Members.Single(member => member.Slot == 1).IsReference, Is.True); foreach (var kept in new[] { "alpha", "beta" }) { Assert.That(after.Records.Single(record => record.Key == kept).SchemaId, Is.EqualTo(before.Records.Single(record => record.Key == kept).SchemaId), kept + " keeps its persisted id"); Assert.That(after.Records.Single(record => record.Key == kept).Payload, Is.EqualTo(before.Records.Single(record => record.Key == kept).Payload), kept + " keeps its bytes"); } using var reopened = new CultCache(registry); reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); await reopened.PullAllBackingStoresAsync(); Assert.That(reopened.GetAll().Single().Items.Select(item => item.Key.Value).ToArray(), Is.EqualTo(new[] { "beta", "alpha" })); } private static CultPersistedStoreSnapshot Snapshot(string path) => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); } }