#nullable enable using System; using System.Buffers; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; namespace GameCult.Caching.Tests { // Rule 1 of variants:ruling:write-set-content-version-declared-ownership: a flush, a commit and a single-document write apply // only what the writer staged to what the store durably holds now, under its lock. Every other record and catalog entry is // copied forward as stored. Every input goes through the cache's own write API and the real stores. public class WriteSetTests { [CultDocument("tests.write_set_item", "tests.write_set_item.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class WsItem { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public string Note = string.Empty; } [CultDocument("tests.write_set_other", "tests.write_set_other.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class WsOther { [Key(0)] [CultName] public string Name = string.Empty; } [CultDocument("tests.write_set_nested", "tests.write_set_nested.v1")] [MessagePackObject(AllowPrivate = true)] internal sealed class WsNested { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public List Tags = new(); [Key(2)] public WsInner Inner = new(); } [MessagePackObject(AllowPrivate = true)] internal sealed class WsInner { [Key(0)] public int X; } private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(WsItem), typeof(WsOther), typeof(WsNested) }); private static readonly string ItemId = Registry.GetRequired(typeof(WsItem)).SchemaId; private static readonly CultRecordKey A = new("a"); private static readonly CultRecordKey X = new("x"); private string _directory = string.Empty; [SetUp] public void SetUp() { _directory = Path.Combine(Path.GetTempPath(), "cultlib-write-set-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_directory); } [TearDown] public void TearDown() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } private string PathOf(string name) => Path.Combine(_directory, name); private static CultCache Open(string path, bool directory) => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory }); private static CultPersistedStoreSnapshot Read(string path) => CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)); private static void Write(string path, CultPersistedStoreSnapshot snapshot) => File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); // What a record is on disk: its id, its storedAt and, in a single file, its payload (a directory manifest indexes pages). private static string Stored(string path, string key, bool directory) { var record = Read(path).Records.Single(candidate => candidate.Key == key); return $"{record.SchemaId}|{record.StoredAt}|{(directory ? string.Empty : BitConverter.ToString(record.Payload))}"; } private static byte[] EntryBytes(string path, string schemaId) => StoreSlices.CatalogEntry(path, schemaId); private static string[] Keys(string path) => Read(path).Records.Select(record => record.Key).OrderBy(key => key, StringComparer.Ordinal).ToArray(); private static void Seed(string path, bool directory, params string[] keys) { using var cache = Open(path, directory); cache.Commit(batch => { foreach (var key in keys) batch.Upsert(typeof(WsItem), new WsItem { Name = "name-" + key, Note = "seeded" }, new CultRecordKey(key)); }); } // One staged write landed by a plain flush or by an unconditional commit. private static void Land(CultCache cache, bool commit, Type type, object document, CultRecordKey key) { if (commit) { cache.Commit(batch => batch.Upsert(type, document, key)); return; } cache.UpsertAsync(type, document, key).GetAwaiter().GetResult(); cache.FlushAllBackingStores(); } // A loads, B writes x, A stages y and flushes or commits: x keeps B's bytes, id and storedAt. A record B removed stays removed. [Test] public void ALoadThenAWriteAfterAnotherWritersWriteKeepsThatWrite([Values] bool directory, [Values] bool commit) { var path = PathOf("keeps.cc"); Seed(path, directory, "a"); using var a = Open(path, directory); using (var b = Open(path, directory)) b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x", Note = "by-b" }, X)); var written = Stored(path, "x", directory); Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y", Note = "by-a" }, new CultRecordKey("y")); Assert.That(Keys(path), Is.EqualTo(new[] { "a", "x", "y" })); Assert.That(Stored(path, "x", directory), Is.EqualTo(written)); } [Test] public void ARecordAnotherWriterRemovedStaysRemovedAfterALaterWrite([Values] bool directory, [Values] bool commit) { var path = PathOf("removed.cc"); Seed(path, directory, "a", "x"); using var a = Open(path, directory); Assert.That(a.Get(X), Is.Not.Null, "A loaded x"); using (var b = Open(path, directory)) b.Commit(batch => batch.Remove(X)); Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); Assert.That(Keys(path), Is.EqualTo(new[] { "a", "y" }), "A's copy of x is not written back"); } // A path with nothing at it when the cache opened, and a store there by the time it writes: the write lands on that store. [Test] public void AWriteOverAStoreThatAppearedAfterTheOpenKeepsItsRecords([Values] bool commit) { var path = PathOf("appeared.cc"); using var a = Open(path, false); Assert.That(File.Exists(path), Is.False); using (var b = Open(path, false)) b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)); Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); Assert.That(Keys(path), Is.EqualTo(new[] { "x", "y" })); } // An in-place change to a loaded document is not a write: it is written when the document is staged. [Test] public void AnInPlaceChangeThatWasNeverStagedIsNotWrittenAndAStagedOneIs([Values] bool directory, [Values] bool commit) { var path = PathOf("in-place.cc"); Seed(path, directory, "a"); using var cache = Open(path, directory); var item = cache.Get(A)!; item.Note = "edited"; cache.FlushAllBackingStores(); using (var check = Open(path, directory)) Assert.That(check.Get(A)!.Note, Is.EqualTo("seeded"), "an unstaged change is not written"); Land(cache, commit, typeof(WsItem), item, A); using (var check = Open(path, directory)) Assert.That(check.Get(A)!.Note, Is.EqualTo("edited")); } // B changes the durable entry for the id of a record A carries. A writes a record under another id: the entry for the id it // did not write is B's, byte for byte, not the one A registered or loaded. [Test] public void ARecordCopiedForwardKeepsTheCatalogEntryTheStoreHoldsNow([Values] bool directory, [Values] bool commit) { var path = PathOf("entry.cc"); Seed(path, directory, "a"); using var cache = Open(path, directory); var snapshot = Read(path); snapshot.SchemaCatalog.Single(entry => entry.SchemaId == ItemId).ContentHash = "changed-by-b"; Write(path, snapshot); var theirs = EntryBytes(path, ItemId); Land(cache, commit, typeof(WsOther), new WsOther { Name = "name-z" }, new CultRecordKey("z")); Assert.That(EntryBytes(path, ItemId), Is.EqualTo(theirs)); Assert.That(Read(path).SchemaCatalog.Single(entry => entry.SchemaId == ItemId).ContentHash, Is.EqualTo("changed-by-b")); } // The runtime decodes seven fields of an entry. Fields after them, and the width a value was written in, are not its to drop. [Test] public void ACarriedCatalogEntryKeepsItsExactBytesIncludingWhatThisRuntimeDoesNotDecode([Values] bool directory, [Values] bool commit) { var path = PathOf("raw.cc"); Seed(path, directory, "a"); var original = EntryBytes(path, ItemId); Assert.That(original[0], Is.EqualTo(0x97), "a fixarray of the seven fields the runtime knows"); var crafted = new byte[] { 0x99 } .Concat(original.Skip(1)) .Concat(new byte[] { 0xd3, 0, 0, 0, 0, 0, 0, 0, 1 }) .Concat(new byte[] { 0xc0 }) .ToArray(); var bytes = File.ReadAllBytes(path); var at = IndexOf(bytes, original); Assert.That(at, Is.GreaterThanOrEqualTo(0)); File.WriteAllBytes(path, bytes.Take(at).Concat(crafted).Concat(bytes.Skip(at + original.Length)).ToArray()); using var cache = Open(path, directory); Assert.That(cache.Get(A), Is.Not.Null, "the entry with two unknown trailing fields reads"); Land(cache, commit, typeof(WsOther), new WsOther { Name = "name-z" }, new CultRecordKey("z")); Assert.That(IndexOf(File.ReadAllBytes(path), crafted), Is.GreaterThanOrEqualTo(0), "the entry is in the written store byte for byte"); } private static int IndexOf(byte[] haystack, byte[] needle) { for (var start = 0; start + needle.Length <= haystack.Length; start++) { var same = true; for (var index = 0; same && index < needle.Length; index++) same = haystack[start + index] == needle[index]; if (same) return start; } return -1; } // The condition is the only difference between a conditional and an unconditional commit: one batch, one apply. [Test] public void AConditionalAndAnUnconditionalCommitOfOneBatchOntoOneStoreWriteTheSameBytes() { var first = PathOf("unconditional.cc"); var second = PathOf("conditional.cc"); Seed(first, false, "a", "x"); File.Copy(first, second); foreach (var (path, conditional) in new[] { (first, false), (second, true) }) { using var cache = Open(path, false); var current = cache.Get(A)!; Assert.That(cache.Commit(batch => { if (conditional) batch.Expect(A, current); batch.Upsert(typeof(WsOther), new WsOther { Name = "name-z" }, new CultRecordKey("z")); batch.Remove(X); }), Is.True); } byte[] Normalized(string path) { var snapshot = Read(path); foreach (var record in snapshot.Records.Where(record => record.Key == "z")) record.StoredAt = string.Empty; return CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot); } Assert.That(Keys(first), Is.EqualTo(new[] { "a", "z" })); Assert.That(Normalized(second), Is.EqualTo(Normalized(first))); } // An unconditional commit also lands what was staged before it, on the store as it is now. [Test] public void AnUnconditionalCommitLandsEarlierStagedWritesWithItsBatchOntoWhatAnotherWriterWrote([Values] bool directory) { var path = PathOf("staged.cc"); Seed(path, directory, "a"); using var cache = Open(path, directory); using (var other = Open(path, directory)) other.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)); cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-s" }, new CultRecordKey("s")).GetAwaiter().GetResult(); Assert.That(cache.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y"))), Is.True); Assert.That(Keys(path), Is.EqualTo(new[] { "a", "s", "x", "y" })); Assert.That(cache.IsDirty, Is.False); } // The operator's check on a real store: CULTLIB_REAL_STORE names a copy of one. A cache that registers none of its types // stages one record and flushes or commits: every other record and catalog entry, and the header, are byte for byte as they were. [Test] public void ARealStoreSurvivesOneStagedWriteByteForByte([Values] bool commit) { var source = Environment.GetEnvironmentVariable("CULTLIB_REAL_STORE"); if (string.IsNullOrEmpty(source) || !File.Exists(source)) Assert.Ignore("CULTLIB_REAL_STORE does not name a copy of a real store."); var path = PathOf("real.cc"); File.Copy(source!, path); var beforePath = PathOf("real-before.cc"); File.Copy(source!, beforePath); var before = Read(path); using (var cache = Open(path, false)) Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-real-write" }, new CultRecordKey("real-write")); var after = Read(path); TestContext.Out.WriteLine($"real store: {before.Records.Length} records, {before.SchemaCatalog.Length} catalog entries, header {before.FormatVersion}"); Assert.That(after.Records.Length, Is.EqualTo(before.Records.Length + 1)); Assert.That(after.FormatVersion, Is.EqualTo(before.FormatVersion)); var changed = before.Records.Where(record => !StoreSlices.Record(beforePath, record.Key).SequenceEqual(StoreSlices.Record(path, record.Key))).Select(record => record.Key).ToArray(); Assert.That(changed, Is.Empty, "records that are not byte-identical"); var afterEntries = StoreSlices.CatalogEntries(path).ToList(); var missing = StoreSlices.CatalogEntries(beforePath).Where(raw => !afterEntries.Any(other => other.SequenceEqual(raw))).Select(raw => BitConverter.ToString(raw)).ToArray(); Assert.That(missing, Is.Empty, "catalog entries that are not byte-identical"); } private static void MarkAs(string path, string header) { var snapshot = Read(path); snapshot.FormatVersion = header; Write(path, snapshot); } // A marked header says the store holds an element id, and a record's bytes cannot say whether it does. A write that copies // any record forward therefore keeps the mark, whatever this cache wrote or read of that record. [Test] public void AMarkedHeaderIsKeptWhileTheWriteCopiesAnyRecord([Values] bool anotherWriterAddedARecord) { var path = PathOf("mark.cc"); Seed(path, false, "a"); MarkAs(path, CultPersistedStoreSnapshot.FormatV3); using var cache = Open(path, false); if (anotherWriterAddedARecord) { using (var other = Open(path, false)) other.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)); MarkAs(path, CultPersistedStoreSnapshot.FormatV3); } Land(cache, commit: false, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); } // Only a write that encodes every record the store will hold can say none holds an id. [Test] public void AMarkedHeaderIsDroppedOnlyByAWriteThatEncodesEveryRecord([Values] bool commit) { var path = PathOf("mark-all.cc"); Seed(path, false, "a"); MarkAs(path, CultPersistedStoreSnapshot.FormatV3); using var cache = Open(path, false); Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-a", Note = "again" }, A); Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV1)); } // Another writer changed a copied record after this cache read it, and the mark stays: a copied record is not read to decide it. [Test] public void AMarkedHeaderIsKeptWhenAnotherWriterChangedACopiedRecordAfterTheLoad() { var path = PathOf("mark-changed.cc"); Seed(path, false, "a"); MarkAs(path, CultPersistedStoreSnapshot.FormatV3); using var cache = Open(path, false); using (var other = Open(path, false)) other.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-a", Note = "changed" }, A)); MarkAs(path, CultPersistedStoreSnapshot.FormatV3); Land(cache, commit: false, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); } // A copied record stays copied whoever wrote it: what this cache wrote earlier does not make the next write one that decides by content. [Test] public void AMarkedHeaderIsKeptWhenEveryCopiedRecordWasWrittenByThisCache() { var path = PathOf("mark-wrote.cc"); Seed(path, false, "a"); using var cache = Open(path, false); Land(cache, commit: false, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); MarkAs(path, CultPersistedStoreSnapshot.FormatV3); Land(cache, commit: false, typeof(WsItem), new WsItem { Name = "name-z" }, new CultRecordKey("z")); Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3)); } // A batch is the write that lands last: it replaces a staged write of the same key. [Test] public void ABatchWinsOverAStagedWriteOfTheSameKey() { var path = PathOf("batch-wins.cc"); Seed(path, false, "a"); using (var cache = Open(path, false)) { cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-a", Note = "staged" }, A).GetAwaiter().GetResult(); Assert.That(cache.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-a", Note = "batch" }, A)), Is.True); } using var check = Open(path, false); Assert.That(check.Get(A)!.Note, Is.EqualTo("batch")); } // v2 exactly when the written store holds a variant: a copied variant keeps it, and the last one removed drops it. [Test] public void TheV2HeaderIsWrittenExactlyWhileTheStoreHoldsAVariant() { var path = PathOf("variant.cc"); Seed(path, false, "a"); var variant = new CultRecordKey("v"); using (var cache = Open(path, false)) { cache.Commit(batch => batch.UpsertVariant(variant, A, new[] { cache.Override(nameof(WsItem.Name), "name-v"), cache.Override(nameof(WsItem.Note), "variant") })); } Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV2)); using (var cache = Open(path, false)) { Land(cache, commit: true, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV2), "the variant was copied"); cache.Commit(batch => batch.Remove(variant)); } Assert.That(Read(path).FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV1)); } // Another writer adds a record under this cache's schema id whose payload this cache's type cannot decode. private static void AddUndecodableRecord(string path, string key) { var snapshot = Read(path); snapshot.Records = snapshot.Records.Concat(new[] { new CultPersistedRecord { Key = key, SchemaId = ItemId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0xC1 } } }).OrderBy(record => record.Key, StringComparer.Ordinal).ToArray(); Write(path, snapshot); } // A flush decodes nothing it did not stage: a copied record this cache's type cannot decode is carried byte for byte. [Test] public void AFlushCopiesARecordThisCacheCannotDecode([Values] bool commit) { var path = PathOf("undecodable.cc"); Seed(path, false, "a"); using var a = Open(path, false); AddUndecodableRecord(path, "r"); var carried = Stored(path, "r", false); Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); Assert.That(Keys(path), Is.EqualTo(new[] { "a", "r", "y" })); Assert.That(Stored(path, "r", false), Is.EqualTo(carried)); } // The same record in a store holding a variant cannot be judged without decoding it: the write is refused with the decode // failure as its cause, and the disk, the cache and its staged change are as they were. [Test] public void AWriteIntoAVariantStoreWithARecordThisCacheCannotLoadChangesNothing([Values] bool commit) { var path = PathOf("undecodable-variant.cc"); Seed(path, false, "a"); using (var seed = Open(path, false)) seed.Commit(batch => batch.UpsertVariant(new CultRecordKey("v"), A, new[] { seed.Override(nameof(WsItem.Name), "name-v"), seed.Override(nameof(WsItem.Note), "variant") })); var a = Open(path, false); AddUndecodableRecord(path, "r"); var before = File.ReadAllBytes(path); var y = new CultRecordKey("y"); var conflict = Assert.Throws(() => { if (commit) a.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-y" }, y)); else Land(a, false, typeof(WsItem), new WsItem { Name = "name-y" }, y); })!; Assert.That(conflict.InnerException, Is.Not.Null, "the decode failure is the cause"); Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { "r" })); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); Assert.That(a.Get(new CultRecordKey("r")), Is.Null); if (!commit) { Assert.That(a.IsDirty, Is.True, "a reload that fails changes nothing, the staged write included"); Assert.That(a.Get(y), Is.Not.Null); } } // A stages k, B writes k later, A flushes: A's bytes land at a storedAt later than B's in either store kind, so a writer holding B's k is told it moved. [Test] public void AWriteOverAnotherWritersLaterRecordStoresALaterStoredAt([Values] bool directory) { var path = PathOf("later.cc"); Seed(path, directory, "a"); var k = new CultRecordKey("k"); using var a = Open(path, directory); a.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-a" }, k).GetAwaiter().GetResult(); System.Threading.Thread.Sleep(30); using var b = Open(path, directory); b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-b" }, k)); var seenByB = b.Get(k)!; var bAt = Read(path).Records.Single(record => record.Key == "k").StoredAt; a.FlushAllBackingStores(); var onDisk = Read(path).Records.Single(record => record.Key == "k"); Assert.That(DateTimeOffset.Parse(onDisk.StoredAt, null, System.Globalization.DateTimeStyles.RoundtripKind), Is.GreaterThan(DateTimeOffset.Parse(bAt, null, System.Globalization.DateTimeStyles.RoundtripKind))); 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"); Assert.That(b.Commit(batch => { batch.Expect(k, seenByB); batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "stale" }, k); }), 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 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)); var wide = Read(path).Records.Single(record => record.Key == "k"); Assert.That(slices["k"], Is.Not.EqualTo(CultDocumentMessagePackSerialization.SerializePersistedRecord(new CultPersistedRecord { Key = wide.Key, SchemaId = wide.SchemaId, StoredAt = wide.StoredAt, Payload = wide.Payload })), "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) => IndexOf(haystack, 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()?.ToArray() ?? Array.Empty(); 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"); } // A commit with no condition decides nothing about the other records, so it hashes none of them: only the record it writes // is hashed 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 SingleFileMessagePackBackingStore(path); using var cache = new CultCache(Registry, CultCacheMessagePack.CreateCodec(Registry)); cache.AddBackingStore(store); cache.PullAllBackingStoresAsync().GetAwaiter().GetResult(); store.RecordsHashed = 0; Assert.That(cache.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-x" }, X)), Is.True); Assert.That(store.RecordsHashed, Is.EqualTo(1)); store.RecordsHashed = 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.RecordsHashed, 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() { var path = PathOf("staged-removed.cc"); Seed(path, false, "a"); var k = new CultRecordKey("k"); using (var cache = Open(path, false)) { cache.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-k" }, k).GetAwaiter().GetResult(); Assert.That(cache.Commit(batch => batch.Remove(k)), Is.True); Assert.That(Keys(path), Is.EqualTo(new[] { "a" })); Assert.That(cache.Get(k), Is.Null); } using var reopened = Open(path, false); Assert.That(reopened.Get(k), Is.Null); } private static DateTimeOffset At(string storedAt) => DateTimeOffset.Parse(storedAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); private static string StoredAtOf(string path, string key) => Read(path).Records.Single(record => record.Key == key).StoredAt; // The stored bytes belong to the store that read them, never to a record object: a record read through the public snapshot // API is only a decoded copy, so an edit made to its payload in place is what a write of it stores. [Test] public void ARecordReadThroughTheSnapshotApiAndEditedInPlaceIsWrittenAsEdited() { var path = PathOf("edit-payload.cc"); Seed(path, false, "k"); var snapshot = Read(path); OverwriteInPlace(snapshot.Records.Single().Payload, "seeded", "SEEDED"); File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); Assert.That(NoteOnDisk(path, false), Is.EqualTo("SEEDED")); var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(StoreSlices.Record(path, "k")); OverwriteInPlace(record.Payload, "SEEDED", "seeded"); Assert.That( CultDocumentMessagePackSerialization.DeserializePersistedRecord(CultDocumentMessagePackSerialization.SerializePersistedRecord(record)).Payload, Is.EqualTo(record.Payload), "a single record is re-encoded from its fields too"); } [Test] public void AnOverrideReadThroughTheSnapshotApiAndEditedInPlaceIsWrittenAsEdited() { var path = PathOf("edit-override.cc"); Seed(path, false, "a"); using (var seed = Open(path, false)) AddVariant(seed, "v", A); var snapshot = Read(path); var variant = snapshot.Records.Single(record => record.Key == "v").Variant!; OverwriteInPlace(variant.Overrides.Single(entry => entry.Path[0].Slot == 1).Value, "variant-v", "VARIANT-v"); File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); using var fresh = Open(path, false); Assert.That(fresh.Get(new CultRecordKey("v"))!.Note, Is.EqualTo("VARIANT-v")); } // A held document edited in place (a list, a nested object) and put back as the same instance is written as edited, and a // later write of a neighbour copies it as the file then holds it. [Test] public void AHeldDocumentEditedInPlaceAndPutBackIsWrittenAsEdited([Values] bool commit) { var path = PathOf("edit-held.cc"); var n = new CultRecordKey("n"); using (var seed = Open(path, false)) seed.Commit(batch => batch.Upsert(typeof(WsNested), new WsNested { Name = "name-n", Tags = { "t1" }, Inner = { X = 1 } }, n)); using (var cache = Open(path, false)) { var held = cache.Get(n)!; held.Tags.Add("t2"); held.Inner.X = 9; Land(cache, commit, typeof(WsNested), held, n); } using (var fresh = Open(path, false)) { var read = fresh.Get(n)!; Assert.That(read.Tags, Is.EqualTo(new[] { "t1", "t2" })); Assert.That(read.Inner.X, Is.EqualTo(9)); } var written = StoreSlices.Record(path, "n"); using (var other = Open(path, false)) Land(other, commit, typeof(WsItem), new WsItem { Name = "name-x" }, X); Assert.That(StoreSlices.Record(path, "n"), Is.EqualTo(written), "the edit is copied as the file holds it"); } // A record the writer did not stage keeps the bytes and the version its file holds, whatever encoding its writer chose, // across any number of writes of its neighbours. [Test] public void AnUntouchedNonCanonicalRecordKeepsItsBytesAndVersionAcrossAWriteOfANeighbour([Values] bool commit) { var path = PathOf("untouched-wide.cc"); Seed(path, false, "k"); File.WriteAllBytes(path, WidenEveryRecord(File.ReadAllBytes(path), out var slices)); using var cache = Open(path, false); var version = cache.GetStored(K)!.StoredVersion; Assert.That(version, Is.EqualTo(HexOfSha256(slices["k"]))); Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-x" }, X); Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")); Assert.That(StoreSlices.Record(path, "k"), Is.EqualTo(slices["k"])); Assert.That(cache.GetStored(K)!.StoredVersion, Is.EqualTo(version)); using var fresh = Open(path, false); Assert.That(fresh.GetStored(K)!.StoredVersion, Is.EqualTo(version)); } // A record the writer stages is encoded from its object, so a non-canonical record put back unchanged is written canonically // and, being other bytes, has another version. [Test] public void AStagedNonCanonicalRecordPutBackUnchangedIsReEncodedAndGetsANewVersion([Values] bool commit) { var path = PathOf("staged-wide.cc"); Seed(path, false, "k"); File.WriteAllBytes(path, WidenEveryRecord(File.ReadAllBytes(path), out var slices)); using var cache = Open(path, false); var held = cache.Get(K)!; Land(cache, commit, typeof(WsItem), held, K); var written = StoreSlices.Record(path, "k"); Assert.That(written, Is.Not.EqualTo(slices["k"])); Assert.That(written, Is.EqualTo(CultDocumentMessagePackSerialization.SerializePersistedRecord(Read(path).Records.Single())), "the record is in the canonical encoding"); Assert.That(cache.GetStored(K)!.StoredVersion, Is.EqualTo(HexOfSha256(written))); Assert.That(cache.GetStored(K)!.StoredVersion, Is.Not.EqualTo(HexOfSha256(slices["k"]))); } // A catalog entry the writer did not choose to replace is carried as the bytes the file holds, a slot this runtime does not // read included, in either store kind. The ItemId entry is carried: a WsItem stays in the store and only a WsOther is staged. [Test] public void ACatalogEntryWithASlotThisRuntimeDoesNotReadSurvivesAWriteOfANeighbour([Values] bool directory, [Values] bool commit) { var path = PathOf("catalog-unknown-slot.cc"); Seed(path, directory, "a"); File.WriteAllBytes(path, AddAnUnreadSlotToEveryCatalogEntry(File.ReadAllBytes(path))); var entry = StoreSlices.CatalogEntry(path, ItemId); using var cache = Open(path, directory); Land(cache, commit, typeof(WsOther), new WsOther { Name = "name-x" }, X); Assert.That(StoreSlices.CatalogEntry(path, ItemId), Is.EqualTo(entry)); } // The catalog entry for a staged record's id is the writer's, encoded from its object, even where the file publishes another // entry under that id: a type that adds a compatible id keeps its id, and only the writer's entry carries the change. Here the // file's ItemId entry differs from the writer's by a slot this runtime does not read, and a WsItem is staged. [Test] public void ACatalogEntryForAStagedRecordsIdIsTheWritersEncodingNotTheFilesSlice([Values] bool directory, [Values] bool commit) { var path = PathOf("catalog-writer-entry.cc"); Seed(path, directory, "a"); var writers = StoreSlices.CatalogEntry(path, ItemId); File.WriteAllBytes(path, AddAnUnreadSlotToEveryCatalogEntry(File.ReadAllBytes(path))); Assert.That(StoreSlices.CatalogEntry(path, ItemId), Is.Not.EqualTo(writers)); using var cache = Open(path, directory); Land(cache, commit, typeof(WsItem), new WsItem { Name = "name-x" }, X); Assert.That(StoreSlices.CatalogEntry(path, ItemId), Is.EqualTo(writers)); } // The same store with every catalog entry one slot longer than this runtime reads, as a newer writer's would be. private static byte[] AddAnUnreadSlotToEveryCatalogEntry(byte[] bytes) { var output = new List(); var reader = new MessagePackReader(bytes); var top = reader.ReadArrayHeader(); output.Add((byte)(0x90 | top)); for (var slot = 0; slot < top; slot++) { if (slot != 1) { 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 from = (int)reader.Consumed; reader.Skip(); var entry = bytes.Skip(from).Take((int)reader.Consumed - from).ToArray(); Assert.That(entry[0] & 0xf0, Is.EqualTo(0x90), "a catalog entry is a fixarray"); output.Add((byte)(entry[0] + 1)); output.AddRange(entry.Skip(1)); output.AddRange(new byte[] { 0xa3, (byte)'x', (byte)'y', (byte)'z' }); } } return output.ToArray(); } private static void OverwriteInPlace(byte[] bytes, string from, string to) { var was = System.Text.Encoding.UTF8.GetBytes(from); var now = System.Text.Encoding.UTF8.GetBytes(to); Assert.That(now.Length, Is.EqualTo(was.Length)); var at = IndexOf(bytes, was); Assert.That(at, Is.GreaterThanOrEqualTo(0), $"'{from}' is in the bytes"); Array.Copy(now, 0, bytes, at, now.Length); } private static void AddVariant(CultCache cache, string key, CultRecordKey baseKey) => cache.Commit(batch => batch.UpsertVariant(new CultRecordKey(key), baseKey, new[] { cache.Override(nameof(WsItem.Name), "name-" + key), cache.Override(nameof(WsItem.Note), "variant-" + key) })); // The record under a key now belongs to a schema no registered type claims, as if a build that knows more types wrote it. private static void MakeForeign(string path, string key) { const string foreignId = "sha256:00000000000000000000000000000000000000000000000000000000000050de"; var snapshot = Read(path); snapshot.Records.Single(record => record.Key == key).SchemaId = foreignId; snapshot.SchemaCatalog = snapshot.SchemaCatalog.Append(new CultSchemaCatalogEntry { SchemaId = foreignId, SchemaName = "tests.write_set_foreign", SchemaVersion = "tests.write_set_foreign.v1", ContentHash = "foreign", CanonicalSchemaJson = "{}" }).ToArray(); Write(path, snapshot); } // Two writers whose clocks put a record's storedAt ahead of both mint the same storedAt over it. The second to write // replaces the first's different bytes, so it is stored later than the first's, and the first's copy is told it moved. // The equal storedAt is the case, and the mint is from the stored one, not from the writer's clock (which is behind it). Either store kind. [Test] public void AWriteOverARecordAtItsOwnStoredAtIsStoredLaterWhateverTheClockSays([Values] bool directory, [Values] bool commit) { var path = PathOf("skew.cc"); Seed(path, directory, "k"); var k = new CultRecordKey("k"); var ahead = DateTimeOffset.UtcNow.AddHours(1).ToString("O", CultureInfo.InvariantCulture); // A single file holds the record at that storedAt. A directory store loads a record from its page, which the manifest // does not own, so there both writers are made to hold it ahead: a stage then mints from it, as a skewed clock does. if (!directory) { var snapshot = Read(path); snapshot.Records.Single(record => record.Key == "k").StoredAt = ahead; Write(path, snapshot); } using var a = Open(path, directory); using var b = Open(path, directory); if (directory) { a.GetStored(k)!.StoredAt = ahead; b.GetStored(k)!.StoredAt = ahead; } if (commit) b.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-b" }, k)); else { a.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-a" }, k).GetAwaiter().GetResult(); b.UpsertAsync(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-b" }, k).GetAwaiter().GetResult(); b.FlushAllBackingStores(); } var bAt = StoredAtOf(path, "k"); var seenByB = b.Get(k)!; if (!commit) Assert.That(a.GetStored(k)!.StoredAt, Is.EqualTo(bAt), "both writers minted the same storedAt over the record"); if (commit) a.Commit(batch => batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "by-a" }, k)); else a.FlushAllBackingStores(); Assert.That(At(StoredAtOf(path, "k")), Is.GreaterThan(At(bAt)), "A's different bytes are stored later than B's"); Assert.That(b.Commit(batch => { batch.Expect(k, seenByB); batch.Upsert(typeof(WsItem), new WsItem { Name = "name-k", Note = "stale" }, k); }), Is.False, "B's copy of k is not what the store holds"); 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: the cache took the storedAt the write gave it"); } // Another writer removes a base and its variant together; this cache, still holding both, adds a variant of that base. // The base and the variant disappeared from the store, which moved it: the write is refused and the cache reloads. [Test] public void AVariantWrittenOverABaseAnotherWriterRemovedIsRefusedAndReloaded() { var path = PathOf("gone-base.cc"); Seed(path, false, "x"); using var a = Open(path, false); AddVariant(a, "v", X); using (var b = Open(path, false)) Assert.That(b.Commit(batch => { batch.Remove(new CultRecordKey("v")); batch.Remove(X); }), Is.True); var conflict = Assert.Throws(() => AddVariant(a, "w", X))!; Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { "v", "x" })); Assert.That(Keys(path), Is.Empty, "nothing was written"); Assert.That(a.Get(X), Is.Null, "the cache reloaded what the other writer removed"); using var fresh = Open(path, false); Assert.That(fresh.Get(X), Is.Null); } // A foreign refusal whose own reload is refused (dropping x, which a held variant is based on) carries that refusal as its cause. [Test] public void AForeignRefusalWhoseReloadFailsCarriesThatCause([Values] bool commit) { var path = PathOf("foreign-reload.cc"); Seed(path, false, "x"); var a = Open(path, false); AddVariant(a, "v", X); MakeForeign(path, "x"); var before = File.ReadAllBytes(path); var refusal = Assert.Throws(() => Land(a, commit, typeof(WsItem), new WsItem { Name = "name-x", Note = "edited" }, X))!; Assert.That(refusal.RecordKey, Is.EqualTo("x")); Assert.That(refusal.InnerException, Is.TypeOf().And.Message.Contains("variant v is based on it"), "the reload that failed is the cause"); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); Assert.That(a.Get(X), Is.Not.Null, "the reload failed, so the cache is unchanged"); } // A write that finds a record it holds gone foreign is refused as a conflict, and when the reload that refusal owes fails // (x cannot go while variant v is based on it) the conflict carries that failure, and nothing changes. [Test] public void AWriteConflictWhoseReloadFailsCarriesThatCause([Values] bool commit) { var path = PathOf("conflict-reload.cc"); Seed(path, false, "x"); var a = Open(path, false); AddVariant(a, "v", X); MakeForeign(path, "x"); var before = File.ReadAllBytes(path); var conflict = Assert.Throws(() => Land(a, commit, typeof(WsItem), new WsItem { Name = "name-y" }, new CultRecordKey("y")))!; Assert.That(conflict.InnerException, Is.TypeOf().And.Message.Contains("variant v is based on it"), "the reload that failed is the cause"); Assert.That(File.ReadAllBytes(path), Is.EqualTo(before)); Assert.That(a.Get(X), Is.Not.Null, "the reload failed, so the cache is unchanged"); } // A refused foreign record is what the store holds, so the next write into a variant store is not told that it moved. [Test] public void AForeignRecordARefusalReloadedIsNotSeenAsMovedByTheNextWrite([Values] bool commit) { var path = PathOf("foreign-read.cc"); Seed(path, false, "a", "x"); using var a = Open(path, false); AddVariant(a, "v", A); MakeForeign(path, "x"); Assert.Throws(() => Land(a, commit, typeof(WsItem), new WsItem { Name = "name-x", Note = "edited" }, X)); Assert.DoesNotThrow(() => Land(a, commit, typeof(WsItem), new WsItem { Name = "name-n" }, new CultRecordKey("n"))); Assert.That(Keys(path), Is.EqualTo(new[] { "a", "n", "v", "x" })); } // A writer that finds the store file gone has read nothing there: a write into the store it makes anew is not refused as // moved because of what the writer read from the file that was. [Test] public void AWriterThatFindsTheStoreFileGoneWritesItAnewWithoutBeingToldItMoved() { var path = PathOf("gone-store.cc"); Seed(path, false, "a"); using var a = Open(path, false); AddVariant(a, "v", A); File.Delete(path); a.PullAllBackingStoresAsync().GetAwaiter().GetResult(); Assert.That(a.Commit(batch => { batch.Upsert(typeof(WsItem), new WsItem { Name = "name-a", Note = "again" }, A); batch.UpsertVariant(new CultRecordKey("v"), A, new[] { a.Override(nameof(WsItem.Name), "name-v"), a.Override(nameof(WsItem.Note), "variant-again") }); }), Is.True); Assert.That(Keys(path), Is.EqualTo(new[] { "a", "v" })); } } }