#nullable enable using System; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; using R3; namespace GameCult.Caching.Tests { public class BackingStoreTests { [Test] public async Task SingleFileMessagePackBackingStore_RoundTrips_DiscoveredEntryType() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); try { var writeStore = new SingleFileMessagePackBackingStore(filePath); var writeCache = new CultCache(); writeCache.AddBackingStore(writeStore); var entry = new NamedTestEntry { Name = "SingleFileMsgpack", Value = "payload" }; var handle = await writeCache.AddAsync(entry); writeStore.PushAll(); var readStore = new SingleFileMessagePackBackingStore(filePath); var readCache = new CultCache(); readCache.AddBackingStore(readStore); await readCache.PullAllBackingStoresAsync(); var loaded = readCache.Get(handle.Key); Assert.That(loaded, Is.Not.Null); Assert.That(loaded!.Value, Is.EqualTo("payload")); Assert.That(readStore.LastSchemaMigrationReports, Has.Count.EqualTo(1)); Assert.That(readStore.LastSchemaMigrationReports[0].Kind, Is.EqualTo(CultSchemaMigrationKind.Exact)); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } } } [Test] public async Task SingleFileMessagePackBackingStore_PullAll_SeesFileCreatedAfterReaderOpened() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); try { var readCache = new CultCache(); readCache.AddBackingStore(new SingleFileMessagePackBackingStore(filePath)); await readCache.PullAllBackingStoresAsync(); var observedChanges = 0; using var subscription = readCache.Watch() .Subscribe(_ => observedChanges++); var writeStore = new SingleFileMessagePackBackingStore(filePath); var writeCache = new CultCache(); writeCache.AddBackingStore(writeStore); var handle = await writeCache.AddAsync(new NamedTestEntry { Name = "external-writer", Value = "visible-after-refresh" }); writeStore.PushAll(); await readCache.PullAllBackingStoresAsync(); Assert.That(readCache.Get(handle.Key)?.Value, Is.EqualTo("visible-after-refresh")); Assert.That(observedChanges, Is.EqualTo(1)); await readCache.PullAllBackingStoresAsync(); Assert.That(observedChanges, Is.EqualTo(1), "an unchanged snapshot must not replay its documents"); await writeCache.UpsertAsync(new NamedTestEntry { Name = "external-writer", Value = "updated-once" }, handle); writeStore.PushAll(); await readCache.PullAllBackingStoresAsync(); Assert.That(readCache.Get(handle.Key)?.Value, Is.EqualTo("updated-once")); Assert.That(observedChanges, Is.EqualTo(2)); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } } } [Test] public async Task SingleFileMessagePackBackingStore_PullAll_DoesNotEraseUnflushedLocalMutations() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); try { using (var seed = await CultCacheMessagePack.OpenAsync(filePath)) { await seed.UpsertAsync(new NamedTestEntry { Name = "persisted", Value = "before-local-write" }); await seed.FlushAsync(); } CultRecordHandle localHandle; using (var cache = await CultCacheMessagePack.OpenAsync(filePath)) { localHandle = await cache.UpsertAsync(new NamedTestEntry { Name = "local", Value = "must-survive-pull" }); Assert.That(cache.IsDirty, Is.True); await cache.PullAllBackingStoresAsync(); Assert.That(cache.Get(localHandle.Key)?.Value, Is.EqualTo("must-survive-pull")); Assert.That(cache.IsDirty, Is.True, "pulling a clean disk snapshot must not pardon staged local mutations"); await cache.FlushAsync(); } using var reopened = await CultCacheMessagePack.OpenAsync(filePath); Assert.That(reopened.Get(localHandle.Key)?.Value, Is.EqualTo("must-survive-pull")); } finally { if (File.Exists(filePath)) File.Delete(filePath); } } [Test] public async Task CultCache_DirtyState_Tracks_Mutations_And_ExplicitFlush() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); try { var store = new SingleFileMessagePackBackingStore(filePath); var cache = new CultCache(); cache.AddBackingStore(store); Assert.That(cache.IsDirty, Is.False); Assert.That(store.IsDirty, Is.False); await cache.AddAsync(new NamedTestEntry { Name = "dirty", Value = "pending" }); Assert.That(cache.IsDirty, Is.True); Assert.That(store.IsDirty, Is.True); Assert.That(cache.LastSuccessfulFlushAtUtc, Is.Null); cache.FlushAllBackingStores(); Assert.That(cache.IsDirty, Is.False); Assert.That(store.IsDirty, Is.False); Assert.That(cache.LastSuccessfulFlushAtUtc, Is.Not.Null); Assert.That(store.LastSuccessfulFlushAtUtc, Is.Not.Null); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } } } [Test] public async Task CultCacheMessagePack_OpenAsync_Creates_Usable_Durable_Cache() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); try { var cache = await CultCacheMessagePack.OpenAsync(filePath); var handle = await cache.UpsertAsync(new NamedTestEntry { Name = "open", Value = "magic" }); await cache.FlushAsync(); cache.Dispose(); var reopened = await CultCacheMessagePack.OpenAsync(filePath); Assert.That(reopened.TryGet(handle.Key, out NamedTestEntry? loaded), Is.True); Assert.That(loaded!.Value, Is.EqualTo("magic")); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } } } [Test] public async Task CultCacheManagedDocument_Keeps_Poco_Surface_And_Updates_Soa_Storage() { var cache = new CultCache(); var aliceKey = new CultRecordKey("entity:alice"); var managed = cache.Document(aliceKey); TransformEntry? observed = null; using var subscription = managed.Watch().Subscribe(value => observed = value); await managed.ReplaceAsync(new TransformEntry { Name = "alice", PositionX = 1.5f, PositionY = 2.5f, Health = 90 }); await cache.UpsertAsync(new TransformEntry { Name = "bob", PositionX = 3.5f, PositionY = 4.5f, Health = 80 }, new CultRecordHandle(new CultRecordKey("entity:bob"))); managed.Value!.Health = 70; await managed.CommitAsync(); var table = cache.Soa(); Assert.That(table.Count, Is.EqualTo(2)); Assert.That(table.Keys.Select(key => key.Value).ToArray(), Is.EqualTo(new[] { "entity:alice", "entity:bob" })); Assert.That(table.Column(nameof(TransformEntry.PositionX)).Span.ToArray(), Is.EqualTo(new[] { 1.5f, 3.5f })); Assert.That(table.Column(nameof(TransformEntry.PositionY)).Span.ToArray(), Is.EqualTo(new[] { 2.5f, 4.5f })); Assert.That(table.Column(nameof(TransformEntry.Health)).Span.ToArray(), Is.EqualTo(new[] { 70, 80 })); Assert.That(observed, Is.Not.Null); Assert.That(observed!.Health, Is.EqualTo(70)); } [Test] public async Task DirectoryMessagePackBackingStore_Writes_Record_Pages_Without_Rewriting_Cold_Records() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { var cache = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); var first = await cache.UpsertAsync(new NamedTestEntry { Name = "cold", Value = new string('a', 4096) }); await cache.FlushAsync(); cache.Dispose(); var firstRecord = Directory.GetFiles(recordsPath, "*.msgpack").Single(); var firstWrite = File.GetLastWriteTimeUtc(firstRecord); await Task.Delay(1100); var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); var second = await reopened.UpsertAsync(new NamedTestEntry { Name = "hot", Value = "new" }); await reopened.FlushAsync(); reopened.Dispose(); var recordFiles = Directory.GetFiles(recordsPath, "*.msgpack"); Assert.That(recordFiles, Has.Length.EqualTo(2)); Assert.That(File.GetLastWriteTimeUtc(firstRecord), Is.EqualTo(firstWrite)); Assert.That(new FileInfo(filePath).Length, Is.LessThan(new FileInfo(firstRecord).Length)); var read = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(read.Get(first.Key)!.Value, Is.EqualTo(new string('a', 4096))); Assert.That(read.Get(second.Key)!.Value, Is.EqualTo("new")); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } if (Directory.Exists(recordsPath)) { Directory.Delete(recordsPath, recursive: true); } } } [Test] public async Task CultCacheMessagePack_OpenAsync_HydratesPersistedGlobalBeforeDefault() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); var registry = CultDocumentRegistry.ForTypes(new[] { typeof(GlobalTestEntry) }); typeof(CultDocumentDescriptor) .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! .SetValue(registry.GetRequired(), true); try { using (var seed = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = true })) { var global = seed.GetGlobal(); Assert.That(global, Is.Not.Null); global!.Value = "durable authority"; await seed.UpsertAsync(global); await seed.FlushAsync(); } using var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { Registry = registry, UseDirectoryStore = true, DirectoryStoreHydrationFilter = _ => false }); Assert.That(reopened.GetGlobal(), Is.Null, "the hydration filter owns whether the durable global becomes hot"); Assert.That(reopened.IsDirty, Is.False, "a cold durable global must not stage its schema default as a write"); var globalKey = "global:" + registry.GetRequired().SchemaId; await reopened.PullBackingStoreRecordsAsync(metadata => metadata.Key == globalKey); Assert.That(reopened.GetGlobal()?.Value, Is.EqualTo("durable authority")); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_CleanFlush_DoesNotRewriteManifest() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { using (var seed = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true })) { await seed.UpsertAsync(new NamedTestEntry { Name = "clean-flush", Value = "must-remain-durable" }); await seed.FlushAsync(); } using var cache = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(cache.IsDirty, Is.False); using (new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) { Assert.That(async () => await cache.FlushAsync(), Throws.Nothing, "a clean indexed store must not touch its durable manifest"); } Assert.That(cache.IsDirty, Is.False); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_IndexedFilter_DoesNotOpenOrDeleteColdPayloads() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { CultRecordHandle hot; CultRecordHandle cold; using (var seed = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true })) { hot = await seed.UpsertAsync(new NamedTestEntry { Name = "hot", Value = "hydrate-me" }); cold = await seed.UpsertAsync(new NamedTestEntry { Name = "cold", Value = new string('c', 1024 * 1024) }); await seed.FlushAsync(); } var manifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath)); Assert.That(manifest.FormatVersion, Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages")); Assert.That(manifest.Records, Has.Length.EqualTo(2)); Assert.That(manifest.Records.All(record => record.Payload.Length == 32), Is.True, "the hot index carries only the SHA-256 page identity, never the document body"); var coldPath = Directory.GetFiles(recordsPath, "*.msgpack") .Single(path => string.Equals( CultDocumentMessagePackSerialization.DeserializePersistedRecord(File.ReadAllBytes(path)).Key, cold.Key.Value, StringComparison.Ordinal)); using (new FileStream(coldPath, FileMode.Open, FileAccess.Read, FileShare.None)) using (var selected = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true, DirectoryStoreHydrationFilter = metadata => string.Equals(metadata.Key, hot.Key.Value, StringComparison.Ordinal) })) { Assert.That(selected.Get(hot.Key)?.Value, Is.EqualTo("hydrate-me")); Assert.That(selected.Get(cold.Key), Is.Null); await selected.UpsertAsync(new NamedTestEntry { Name = "new", Value = "persist-with-cold-page-locked" }); await selected.FlushAsync(); } using var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(reopened.Get(cold.Key)?.Value.Length, Is.EqualTo(1024 * 1024)); Assert.That(reopened.GetAll().Count(), Is.EqualTo(3)); var finalManifest = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath)); Assert.That(finalManifest.Records, Has.Length.EqualTo(3)); Assert.That(finalManifest.Records.Any(record => record.Key == cold.Key.Value), Is.True); Assert.That(finalManifest.Records.All(record => record.Payload.Length == 32), Is.True); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_PullSelected_HydratesMatchingColdPagesOnly() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { CultRecordHandle hot; CultRecordHandle requested; CultRecordHandle unrelated; using (var seed = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true })) { hot = await seed.UpsertAsync(new NamedTestEntry { Name = "hot", Value = "already-loaded" }); requested = await seed.UpsertAsync(new NamedTestEntry { Name = "requested", Value = "load-later" }); unrelated = await seed.UpsertAsync(new NamedTestEntry { Name = "unrelated", Value = new string('u', 1024 * 1024) }); await seed.FlushAsync(); } string PageFor(CultRecordHandle handle) => Directory.GetFiles(recordsPath, "*.msgpack").Single(path => string.Equals( CultDocumentMessagePackSerialization.DeserializePersistedRecord(File.ReadAllBytes(path)).Key, handle.Key.Value, StringComparison.Ordinal)); using var selected = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true, DirectoryStoreHydrationFilter = metadata => metadata.Key == hot.Key.Value }); Assert.That(selected.Get(requested.Key), Is.Null); using (new FileStream(PageFor(unrelated), FileMode.Open, FileAccess.Read, FileShare.None)) { await selected.PullBackingStoreRecordsAsync(metadata => metadata.Key == requested.Key.Value); Assert.That(selected.Get(requested.Key)?.Value, Is.EqualTo("load-later")); Assert.That(selected.Get(hot.Key)?.Value, Is.EqualTo("already-loaded")); Assert.That(selected.Get(unrelated.Key), Is.Null); } } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_Loads_Record_When_Manifest_Misses_Catalog_Entry() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); var recordPath = Path.Combine(recordsPath, "stale-schema-record.msgpack"); try { Directory.CreateDirectory(recordsPath); var entry = new SchemaStampedTestEntry { SchemaVersion = "tests.schema_stamped_entry.v1", Name = "schema-stamped", Value = "still readable" }; var record = new CultPersistedRecord { Key = "record:stale", SchemaId = "sha256:stale-schema-id-from-cold-record", StoredAt = "2026-06-25T12:00:00Z", Payload = CultDocumentMessagePackSerialization.SerializeUntyped(entry, typeof(SchemaStampedTestEntry)) }; var manifest = new CultPersistedStoreSnapshot { FormatVersion = "cultcache.store.v1.directory", SchemaCatalog = Array.Empty(), Records = Array.Empty() }; File.WriteAllBytes(filePath, CultDocumentMessagePackSerialization.SerializeSnapshot(manifest)); File.WriteAllBytes(recordPath, CultDocumentMessagePackSerialization.SerializePersistedRecord(record)); var readStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); var rawGeneration = readStore.ReadPersistedGeneration(); Assert.That(rawGeneration.Records.Select(value => value.Key), Is.EqualTo(new[] { "record:stale" }), "v1 split record pages remain part of the selected legacy generation"); var readCache = new CultCache(); readCache.AddBackingStore(readStore); await readCache.PullAllBackingStoresAsync(); var loaded = readCache.Get(new CultRecordKey("record:stale")); Assert.That(loaded, Is.Not.Null); Assert.That(loaded!.Value, Is.EqualTo("still readable")); Assert.That(readStore.LastSchemaMigrationReports, Has.Count.EqualTo(1)); Assert.That(readStore.LastSchemaMigrationReports[0].PersistedSchemaName, Is.EqualTo("tests.schema_stamped_entry")); Assert.That(readStore.LastSchemaMigrationReports[0].Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } if (Directory.Exists(recordsPath)) { Directory.Delete(recordsPath, recursive: true); } } } [Test] public async Task DirectoryMessagePackBackingStore_Does_Not_Expose_New_Pages_Before_Manifest_Commit() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { var store = new DirectoryMessagePackBackingStore(filePath, recordsPath); var cache = new CultCache(); cache.AddBackingStore(store); var key = new CultRecordKey("record:catalog-precommit"); await cache.UpsertAsync(new NamedTestEntry { Name = "old", Value = "durable" }, new CultRecordHandle(key)); store.PushAll(); var recordPath = Directory.GetFiles(recordsPath, "*.msgpack").Single(); var durableSchema = CultDocumentMessagePackSerialization .DeserializePersistedRecord(File.ReadAllBytes(recordPath)) .SchemaId; var alternateDescriptor = CultDocumentRegistry.Shared.GetRequired(); store.Push(new CultStoredDocument( key, DateTimeOffset.UtcNow.ToString("O"), alternateDescriptor, new AlternateNamedTestEntry { Name = "new" })); store.FlushStageProbe = (stage, _) => { if (string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal)) throw new IOException("injected crash before manifest commit"); }; Assert.That(() => store.PushAll(), Throws.TypeOf()); var precommit = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath)); var alternateSchema = alternateDescriptor.SchemaId; Assert.That(precommit.SchemaCatalog.Select(entry => entry.SchemaId), Does.Contain(durableSchema)); Assert.That(precommit.SchemaCatalog.Select(entry => entry.SchemaId), Does.Not.Contain(alternateSchema)); Assert.That(store.IsDirty, Is.True); Assert.That(CultDocumentMessagePackSerialization.DeserializePersistedRecord(File.ReadAllBytes(recordPath)).SchemaId, Is.EqualTo(durableSchema)); store.FlushStageProbe = null; store.PushAll(); var compacted = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath)); Assert.That(compacted.SchemaCatalog.Select(entry => entry.SchemaId), Is.EqualTo(new[] { alternateSchema })); Assert.That(store.IsDirty, Is.False); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [TestCase(0)] [TestCase(1)] [TestCase(2)] [TestCase(3)] public async Task DirectoryMessagePackBackingStore_Fault_Before_Manifest_Leaves_Entire_Previous_Generation(int writtenPageCount) { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { var store = new DirectoryMessagePackBackingStore(filePath, recordsPath); var cache = new CultCache(); cache.AddBackingStore(store); var handles = new[] { new CultRecordHandle(new CultRecordKey("record:atomic:a")), new CultRecordHandle(new CultRecordKey("record:atomic:b")), new CultRecordHandle(new CultRecordKey("record:atomic:c")) }; foreach (var handle in handles) await cache.UpsertAsync(new NamedTestEntry { Name = handle.Key.Value, Value = "old" }, handle); store.PushAll(); foreach (var handle in handles) await cache.UpsertAsync(new NamedTestEntry { Name = handle.Key.Value, Value = "new" }, handle); store.FlushStageProbe = (stage, index) => { if ((writtenPageCount < handles.Length && string.Equals(stage, DirectoryMessagePackBackingStore.BeforeRecordPageWriteStage, StringComparison.Ordinal) && index == writtenPageCount) || (writtenPageCount == handles.Length && string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal))) throw new IOException($"injected crash after {writtenPageCount} pages"); }; Assert.That(() => store.PushAll(), Throws.TypeOf()); using (var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true })) { foreach (var handle in handles) Assert.That(reopened.Get(handle.Key)?.Value, Is.EqualTo("old")); } store.FlushStageProbe = null; store.PushAll(); using (var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true })) { foreach (var handle in handles) Assert.That(reopened.Get(handle.Key)?.Value, Is.EqualTo("new")); } } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_SameTimestampPayload_CannotOverwriteCommittedPageBeforeManifest() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { const string storedAt = "2026-08-19T00:00:00.0000000+00:00"; var store = new DirectoryMessagePackBackingStore(filePath, recordsPath); var cache = new CultCache(); cache.AddBackingStore(store); var key = new CultRecordKey("record:atomic:same-timestamp"); var descriptor = CultDocumentRegistry.Shared.GetRequired(); store.Push(new CultStoredDocument( key, storedAt, descriptor, new NamedTestEntry { Name = "same", Value = "old" })); store.PushAll(); store.Push(new CultStoredDocument( key, storedAt, descriptor, new NamedTestEntry { Name = "same", Value = "new" })); store.FlushStageProbe = (stage, _) => { if (string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal)) throw new IOException("injected crash before same-timestamp manifest commit"); }; Assert.That(() => store.PushAll(), Throws.TypeOf()); using var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(reopened.Get(key)?.Value, Is.EqualTo("old")); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_MutationDuringFlush_RemainsDirtyAndCommitsNextGeneration() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { var store = new DirectoryMessagePackBackingStore(filePath, recordsPath); var cache = new CultCache(); cache.AddBackingStore(store); var first = await cache.UpsertAsync(new NamedTestEntry { Name = "first", Value = "one" }); using var flushEntered = new ManualResetEventSlim(); using var writerAttempting = new ManualResetEventSlim(); store.FlushStageProbe = (stage, _) => { if (!string.Equals(stage, DirectoryMessagePackBackingStore.BeforeManifestCommitStage, StringComparison.Ordinal)) return; flushEntered.Set(); Assert.That(writerAttempting.Wait(TimeSpan.FromSeconds(5)), Is.True); }; var flush = Task.Run(() => store.PushAll()); var writer = Task.Run(async () => { Assert.That(flushEntered.Wait(TimeSpan.FromSeconds(5)), Is.True); writerAttempting.Set(); return await cache.UpsertAsync(new NamedTestEntry { Name = "second", Value = "two" }); }); await flush; var second = await writer; Assert.That(store.IsDirty, Is.True, "the mutation blocked behind flush must remain pending"); store.FlushStageProbe = null; store.PushAll(); using var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(reopened.Get(first.Key)?.Value, Is.EqualTo("one")); Assert.That(reopened.Get(second.Key)?.Value, Is.EqualTo("two")); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_ConcurrentInstances_MergeUnderOneCommitLease() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { var firstStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); var firstCache = new CultCache(); firstCache.AddBackingStore(firstStore); var secondStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); var secondCache = new CultCache(); secondCache.AddBackingStore(secondStore); var first = await firstCache.UpsertAsync(new NamedTestEntry { Name = "first-process", Value = "one" }); var second = await secondCache.UpsertAsync(new NamedTestEntry { Name = "second-process", Value = "two" }); await Task.WhenAll( Task.Run(() => firstStore.PushAll()), Task.Run(() => secondStore.PushAll())); using var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(reopened.Get(first.Key)?.Value, Is.EqualTo("one")); Assert.That(reopened.Get(second.Key)?.Value, Is.EqualTo("two")); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_ReaderLeasesManifestUntilEveryReferencedPageIsHydrated() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { var key = new CultRecordKey("record:reader-generation"); var writerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); var writerCache = new CultCache(); writerCache.AddBackingStore(writerStore); await writerCache.UpsertAsync( new NamedTestEntry { Name = "generation", Value = "old" }, new CultRecordHandle(key)); writerStore.PushAll(); var readerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); var readerCache = new CultCache(); readerCache.AddBackingStore(readerStore); using var manifestRead = new ManualResetEventSlim(); using var releaseReader = new ManualResetEventSlim(); readerStore.ReadStageProbe = stage => { if (!string.Equals(stage, DirectoryMessagePackBackingStore.AfterManifestReadStage, StringComparison.Ordinal)) return; manifestRead.Set(); Assert.That(releaseReader.Wait(TimeSpan.FromSeconds(5)), Is.True); }; var read = Task.Run(() => readerStore.PullAll()); Assert.That(manifestRead.Wait(TimeSpan.FromSeconds(5)), Is.True); await writerCache.UpsertAsync( new NamedTestEntry { Name = "generation", Value = "new" }, new CultRecordHandle(key)); var write = Task.Run(() => writerStore.PushAll()); Assert.That(write.Wait(TimeSpan.FromMilliseconds(100)), Is.False, "writer must wait while a reader hydrates its selected manifest generation"); releaseReader.Set(); await Task.WhenAll(read, write); Assert.That(readerCache.Get(key)?.Value, Is.EqualTo("old")); using var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(reopened.Get(key)?.Value, Is.EqualTo("new")); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_ReleasesGenerationLeaseBeforePublishingObservers() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { var writerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); var writerCache = new CultCache(); writerCache.AddBackingStore(writerStore); await writerCache.UpsertAsync(new NamedTestEntry { Name = "source", Value = "one" }); writerStore.PushAll(); var readerStore = new DirectoryMessagePackBackingStore(filePath, recordsPath); var readerCache = new CultCache(); readerCache.AddBackingStore(readerStore); var observerRan = false; using var subscription = readerStore.EntryAdded.Subscribe(_ => { if (observerRan) return; observerRan = true; readerCache.UpsertAsync(new NamedTestEntry { Name = "observer", Value = "two" }) .GetAwaiter() .GetResult(); readerStore.PushAll(); }); Assert.That(() => readerStore.PullAll(), Throws.Nothing); Assert.That(observerRan, Is.True); using var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(reopened.GetAll().Select(value => value.Name), Is.EquivalentTo(new[] { "source", "observer" })); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_PullsOnlyExternalRecordDeltas() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { using var reader = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); var observedChanges = 0; using var subscription = reader.Watch().Subscribe(_ => observedChanges++); using var writer = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); var handle = await writer.UpsertAsync(new NamedTestEntry { Name = "external-command", Value = "once" }); await writer.FlushAsync(); await reader.PullAllBackingStoresAsync(); await reader.PullAllBackingStoresAsync(); Assert.That(reader.Get(handle.Key)?.Value, Is.EqualTo("once")); Assert.That(observedChanges, Is.EqualTo(1), "unchanged paged records must not replay"); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_PullAll_PreservesUnflushedLocalKeys() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { using (var seed = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true })) { await seed.UpsertAsync(new NamedTestEntry { Name = "persisted", Value = "before-local-write" }); await seed.FlushAsync(); } CultRecordHandle localHandle; using (var cache = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true })) { localHandle = await cache.UpsertAsync(new NamedTestEntry { Name = "local", Value = "must-survive-pull" }); await cache.PullAllBackingStoresAsync(); Assert.That(cache.Get(localHandle.Key)?.Value, Is.EqualTo("must-survive-pull")); Assert.That(cache.IsDirty, Is.True); await cache.FlushAsync(); } using var reopened = await CultCacheMessagePack.OpenAsync( filePath, new CultCacheOpenOptions { UseDirectoryStore = true }); Assert.That(reopened.Get(localHandle.Key)?.Value, Is.EqualTo("must-survive-pull")); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task DirectoryMessagePackBackingStore_Deletion_Commits_While_Locked_Old_Page_Remains_An_Orphan() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(filePath); try { var store = new DirectoryMessagePackBackingStore(filePath, recordsPath); var cache = new CultCache(); cache.AddBackingStore(store); var handle = await cache.AddAsync(new NamedTestEntry { Name = "delete", Value = "durable" }); store.PushAll(); var recordPath = Directory.GetFiles(recordsPath, "*.msgpack").Single(); Assert.That(cache.Remove(handle.Key), Is.True); using (new FileStream(recordPath, FileMode.Open, FileAccess.Read, FileShare.Read)) { Assert.That(() => store.PushAll(), Throws.Nothing); Assert.That(File.Exists(recordPath), Is.True); } Assert.That(store.IsDirty, Is.False); var compacted = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(filePath)); Assert.That(compacted.SchemaCatalog, Is.Empty); Assert.That(compacted.Records, Is.Empty); var selectedGeneration = store.ReadPersistedGeneration(); Assert.That(selectedGeneration.Records, Is.Empty, "a raw generation reader must not resurrect an orphaned page left by another process"); } finally { if (File.Exists(filePath)) File.Delete(filePath); if (Directory.Exists(recordsPath)) Directory.Delete(recordsPath, recursive: true); } } [Test] public async Task CultCache_RuntimeType_Upsert_Snapshot_And_Remove_Work_For_Editor_Tooling() { var cache = new CultCache(); var entry = new NamedTestEntry { Name = "editor", Value = "first" }; var key = await cache.UpsertAsync(typeof(NamedTestEntry), entry); var stored = cache.AllStoredDocuments.Single(document => document.Key.Equals(key)); Assert.That(stored.Document, Is.SameAs(entry)); Assert.That(stored.Descriptor.DocumentType, Is.EqualTo(typeof(NamedTestEntry))); entry.Value = "second"; var sameKey = await cache.UpsertAsync(typeof(NamedTestEntry), entry, key); Assert.That(sameKey, Is.EqualTo(key)); Assert.That(cache.Get(key)!.Value, Is.EqualTo("second")); Assert.That(cache.Remove(key), Is.True); Assert.That(cache.Get(key), Is.Null); Assert.That(cache.Remove(key), Is.False); } [Test] public async Task CultCache_TypedStoredDocumentIndex_Tracks_Upsert_Replacement_And_Removal() { var cache = new CultCache(); var key = new CultRecordKey("typed-index:named"); await cache.UpsertAsync(typeof(NamedTestEntry), new NamedTestEntry { Name = "indexed", Value = "first" }, key); await cache.UpsertAsync(typeof(AlternateNamedTestEntry), new AlternateNamedTestEntry { Name = "unrelated" }, new CultRecordKey("typed-index:unrelated")); var initial = cache.GetStoredDocuments().Single(); Assert.That(initial.Key, Is.EqualTo(key)); Assert.That(((NamedTestEntry)initial.Document).Value, Is.EqualTo("first")); await cache.UpsertAsync(typeof(NamedTestEntry), new NamedTestEntry { Name = "indexed", Value = "second" }, key); var replaced = cache.GetStoredDocuments().Single(); Assert.That(((NamedTestEntry)replaced.Document).Value, Is.EqualTo("second")); Assert.That(cache.Remove(key), Is.True); Assert.That(cache.GetStoredDocuments(), Is.Empty); } [Test] public async Task CultCache_FlushOnDispose_Persists_When_Enabled() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); try { var store = new SingleFileMessagePackBackingStore(filePath); var cache = new CultCache { FlushAttachedStoresOnDispose = true }; cache.AddBackingStore(store); var handle = await cache.AddAsync(new NamedTestEntry { Name = "dispose", Value = "flush" }); cache.Dispose(); var readStore = new SingleFileMessagePackBackingStore(filePath); var readCache = new CultCache(); readCache.AddBackingStore(readStore); await readCache.PullAllBackingStoresAsync(); var loaded = readCache.Get(handle.Key); Assert.That(loaded, Is.Not.Null); Assert.That(loaded!.Value, Is.EqualTo("flush")); } finally { if (File.Exists(filePath)) { File.Delete(filePath); } } } [Test] public void MessagePackSerialization_RoundTrips_CultRecordRef() { var reference = new CultRecordRef(new CultRecordKey("record-1")); var payload = CultDocumentMessagePackSerialization.Serialize(reference); var roundTrip = CultDocumentMessagePackSerialization.Deserialize>(payload); Assert.That(roundTrip.Key.Value, Is.EqualTo("record-1")); } [Test] public void MessagePackSerialization_Rejects_InvalidPayload() { Assert.That( () => CultDocumentMessagePackSerialization.Deserialize>(new byte[] { 0xC1 }), Throws.TypeOf()); } [Test] public void GeneratedMetadataProvider_Emits_AttributedDocuments_And_References() { var providers = typeof(NamedTestEntry).Assembly .GetCustomAttributes(typeof(CultGeneratedDocumentMetadataProviderAttribute), false) .Cast() .ToArray(); Assert.That(providers, Is.Not.Empty); var definitions = providers .SelectMany(provider => ((ICultGeneratedDocumentMetadataProvider)Activator.CreateInstance(provider.ProviderType)!) .GetDocumentDefinitions()) .ToArray(); var named = definitions.Single(definition => definition.DocumentType == typeof(NamedTestEntry)); Assert.That(named.SchemaName, Is.EqualTo("tests.named_entry")); Assert.That(named.NameMember, Is.EqualTo(nameof(NamedTestEntry.Name))); var referenceHolder = definitions.Single(definition => definition.DocumentType == typeof(ReferenceHolderEntry)); var parentMember = referenceHolder.Members.Single(member => member.MemberName == nameof(ReferenceHolderEntry.Parent)); Assert.That(parentMember.IsReference, Is.True); Assert.That(parentMember.TargetSchemaName, Is.EqualTo("tests.named_entry")); Assert.That(parentMember.TypeName, Does.Contain("CultRecordRef")); } [Test] public void GeneratedMetadataProvider_Emits_Payload_Codecs_For_Plain_CultDocuments() { var descriptor = CultDocumentRegistry.Shared.GetRequired(); var original = new NamedTestEntry { Name = "Teeth", Value = "slot-array" }; Assert.That(descriptor.GeneratedPayloadSerializer, Is.Not.Null); Assert.That(descriptor.GeneratedPayloadDeserializer, Is.Not.Null); var payload = descriptor.GeneratedPayloadSerializer!(original); var roundTrip = (NamedTestEntry)descriptor.GeneratedPayloadDeserializer!(payload); Assert.That(roundTrip.Name, Is.EqualTo("Teeth")); Assert.That(roundTrip.Value, Is.EqualTo("slot-array")); } [Test] public void MessagePackStoreSerialization_RoundTrips_Snapshot_Record_And_Catalog() { var record = new CultPersistedRecord { Key = "record-1", SchemaId = "schema-1", StoredAt = "2026-05-08T12:00:00Z", Payload = new byte[] { 0x91, 0xA3, 0x66, 0x6F, 0x6F } }; var catalog = new[] { new CultSchemaCatalogEntry { SchemaId = "schema-1", SchemaName = "tests.named_entry", SchemaVersion = "tests.named_entry.v1", ContentHash = "hash-1", CanonicalSchemaJson = "{\"fields\":2}", CompatibleSchemaIds = new[] { "schema-1", "schema-0" } } }; var snapshot = new CultPersistedStoreSnapshot { FormatVersion = "cultcache.store.v1", SchemaCatalog = catalog, Records = new[] { record } }; var roundTripRecord = CultDocumentMessagePackSerialization.DeserializePersistedRecord( CultDocumentMessagePackSerialization.SerializePersistedRecord(record)); var roundTripCatalog = CultDocumentMessagePackSerialization.DeserializeSchemaCatalog( CultDocumentMessagePackSerialization.SerializeSchemaCatalog(catalog)); var roundTripSnapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot( CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); Assert.That(roundTripRecord.Key, Is.EqualTo("record-1")); Assert.That(roundTripRecord.SchemaId, Is.EqualTo("schema-1")); Assert.That(roundTripRecord.StoredAt, Is.EqualTo("2026-05-08T12:00:00Z")); Assert.That(roundTripRecord.Payload, Is.EqualTo(record.Payload)); Assert.That(roundTripCatalog.Single().CompatibleSchemaIds, Is.EqualTo(catalog.Single().CompatibleSchemaIds)); Assert.That(roundTripSnapshot.FormatVersion, Is.EqualTo("cultcache.store.v1")); Assert.That(roundTripSnapshot.SchemaCatalog.Single().SchemaName, Is.EqualTo("tests.named_entry")); Assert.That(roundTripSnapshot.Records.Single().Key, Is.EqualTo("record-1")); } [Test] public void Registry_CanonicalSchemaJson_Tracks_Reference_Metadata() { var descriptor = CultDocumentRegistry.Shared.GetRequired(); Assert.That(descriptor.CanonicalSchemaJson, Does.Contain("\"targetSchemaName\":\"tests.named_entry\"")); Assert.That(descriptor.CanonicalSchemaJson, Does.Contain("\"isReference\":true")); } [Test] public void Registry_CanonicalSchema_Fixtures_Are_Stable() { var named = CultDocumentRegistry.Shared.GetRequired(); var referenceHolder = CultDocumentRegistry.Shared.GetRequired(); Assert.That(named.CanonicalSchemaJson, Is.EqualTo(NamedFixtureCanonicalSchemaJson)); Assert.That(named.SchemaId, Is.EqualTo(NamedFixtureSchemaId)); Assert.That(named.ContentHash, Is.EqualTo(NamedFixtureContentHash)); Assert.That(referenceHolder.SchemaId, Is.EqualTo(ReferenceFixtureSchemaId)); } [Test] public void ResolvePersistedSchemaReport_Classifies_Compatible_And_Incompatible_Drift() { var registry = CultDocumentRegistry.Shared; var namedV1 = registry.GetRequired(); var namedV2 = registry.GetRequired(); var namedV3 = registry.GetRequired(); var namedMismatch = registry.GetRequired(); var referenceV1 = registry.GetRequired(); var referenceRetargeted = registry.GetRequired(); var additiveCatalog = namedV1.ToCatalogEntry(); additiveCatalog.SchemaId = "persisted.tests.named_entry.v1"; additiveCatalog.CompatibleSchemaIds = new[] { namedV2.SchemaId }; var additiveReport = registry.ResolvePersistedSchemaReport(additiveCatalog.SchemaId, new[] { additiveCatalog }); Assert.That(additiveReport.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); Assert.That(additiveReport.DefaultedMissingSlots, Is.EqualTo(new[] { 2 })); Assert.That(additiveReport.IgnoredExtraSlots, Is.Empty); var removedCatalog = namedV2.ToCatalogEntry(); removedCatalog.SchemaId = "persisted.tests.named_entry.v2"; removedCatalog.CompatibleSchemaIds = new[] { namedV3.SchemaId }; var removedReport = registry.ResolvePersistedSchemaReport(removedCatalog.SchemaId, new[] { removedCatalog }); Assert.That(removedReport.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); Assert.That(removedReport.DefaultedMissingSlots, Is.Empty); Assert.That(removedReport.IgnoredExtraSlots, Is.EqualTo(new[] { 1, 2 })); var mismatchCatalog = namedV1.ToCatalogEntry(); mismatchCatalog.SchemaId = "persisted.tests.named_entry.type_mismatch"; mismatchCatalog.CompatibleSchemaIds = new[] { namedMismatch.SchemaId }; Assert.That( () => registry.ResolvePersistedSchemaReport(mismatchCatalog.SchemaId, new[] { mismatchCatalog }), Throws.TypeOf().With.Message.Contains("changed type")); var retargetedCatalog = referenceV1.ToCatalogEntry(); retargetedCatalog.SchemaId = "persisted.tests.reference_holder.v1"; retargetedCatalog.CompatibleSchemaIds = new[] { referenceRetargeted.SchemaId }; Assert.That( () => registry.ResolvePersistedSchemaReport(retargetedCatalog.SchemaId, new[] { retargetedCatalog }), Throws.TypeOf().With.Message.Contains("changed target schema")); } [Test] public async Task CultCache_Transaction_Hides_Staged_Records_Until_Whole_Batch_Is_Durable() { var root = Path.Combine(Path.GetTempPath(), $"cultcache-transaction-{Guid.NewGuid():N}"); var manifest = Path.Combine(root, "state.cc"); Directory.CreateDirectory(root); try { using var cache = new CultCache(); cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); var firstKey = new CultRecordKey("transaction:first"); var secondKey = new CultRecordKey("transaction:second"); var staged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var observed = 0; using var subscription = cache.Watch().Subscribe(_ => observed++); var transaction = cache.ExecuteTransactionAsync(async () => { await cache.UpsertAsync( new NamedTestEntry { Name = "first", Value = "one" }, new CultRecordHandle(firstKey)); staged.SetResult(true); await release.Task; await cache.UpsertAsync( new NamedTestEntry { Name = "second", Value = "two" }, new CultRecordHandle(secondKey)); }); await staged.Task; Assert.That(cache.Get(firstKey), Is.Null); Assert.That(observed, Is.Zero); release.SetResult(true); await transaction; Assert.That(cache.Get(firstKey)?.Value, Is.EqualTo("one")); Assert.That(cache.Get(secondKey)?.Value, Is.EqualTo("two")); Assert.That(observed, Is.EqualTo(2)); using var reopened = new CultCache(); reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); await reopened.PullAllBackingStoresAsync(); Assert.That(reopened.Get(firstKey)?.Value, Is.EqualTo("one")); Assert.That(reopened.Get(secondKey)?.Value, Is.EqualTo("two")); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Test] public async Task CultCache_Transaction_Abort_Cannot_Leak_Into_Later_Commit() { var root = Path.Combine(Path.GetTempPath(), $"cultcache-abort-{Guid.NewGuid():N}"); var manifest = Path.Combine(root, "state.cc"); Directory.CreateDirectory(root); try { using var cache = new CultCache(); cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); var abortedKey = new CultRecordKey("transaction:aborted"); var committedKey = new CultRecordKey("transaction:committed"); var observed = 0; using var subscription = cache.Watch().Subscribe(_ => observed++); Assert.ThrowsAsync(async () => await cache.ExecuteTransactionAsync(async () => { await cache.UpsertAsync( new NamedTestEntry { Name = "aborted", Value = "must-not-leak" }, new CultRecordHandle(abortedKey)); throw new InvalidOperationException("abort probe"); })); Assert.That(cache.Get(abortedKey), Is.Null); Assert.That(observed, Is.Zero); await cache.ExecuteTransactionAsync(async () => { await cache.UpsertAsync( new NamedTestEntry { Name = "committed", Value = "survives" }, new CultRecordHandle(committedKey)); }); Assert.That(observed, Is.EqualTo(1)); using var reopened = new CultCache(); reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); await reopened.PullAllBackingStoresAsync(); Assert.That(reopened.Get(abortedKey), Is.Null); Assert.That(reopened.Get(committedKey)?.Value, Is.EqualTo("survives")); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Test] public async Task CultCache_Transaction_Rejects_Escaped_Async_Writes_After_Commit() { var root = Path.Combine(Path.GetTempPath(), $"cultcache-escaped-transaction-{Guid.NewGuid():N}"); var manifest = Path.Combine(root, "state.cc"); Directory.CreateDirectory(root); try { using var cache = new CultCache(); cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); var committedKey = new CultRecordKey("transaction:committed-parent"); var escapedKey = new CultRecordKey("transaction:escaped-child"); var releaseChild = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Task? escapedWrite = null; await cache.ExecuteTransactionAsync(async () => { await cache.UpsertAsync( new NamedTestEntry { Name = "parent", Value = "committed" }, new CultRecordHandle(committedKey)); escapedWrite = Task.Run(async () => { await releaseChild.Task; await cache.UpsertAsync( new NamedTestEntry { Name = "child", Value = "must-fail" }, new CultRecordHandle(escapedKey)); }); }); releaseChild.SetResult(true); Assert.That(escapedWrite, Is.Not.Null); Assert.That( async () => await escapedWrite!, Throws.TypeOf().With.Message.Contains("already completed")); Assert.That(cache.Get(committedKey)?.Value, Is.EqualTo("committed")); Assert.That(cache.Get(escapedKey), Is.Null); using var reopened = new CultCache(); reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); await reopened.PullAllBackingStoresAsync(); Assert.That(reopened.Get(committedKey)?.Value, Is.EqualTo("committed")); Assert.That(reopened.Get(escapedKey), Is.Null); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Test] public async Task CultCache_Transaction_Rejects_Hydration_Inside_Its_Mutation_Scope() { var root = Path.Combine(Path.GetTempPath(), $"cultcache-transaction-hydration-{Guid.NewGuid():N}"); var manifest = Path.Combine(root, "state.cc"); Directory.CreateDirectory(root); try { using (var writer = new CultCache()) { writer.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); await writer.UpsertAsync( new NamedTestEntry { Name = "external", Value = "durable" }, new CultRecordHandle(new CultRecordKey("transaction:external"))); writer.FlushAllBackingStores(); } using var reader = new CultCache(); reader.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); var observed = 0; using var subscription = reader.Watch().Subscribe(_ => observed++); Assert.That( async () => await reader.ExecuteTransactionAsync( () => reader.PullAllBackingStoresAsync()), Throws.TypeOf().With.Message.Contains("hydrate before")); Assert.That(reader.Get(new CultRecordKey("transaction:external")), Is.Null); Assert.That(observed, Is.Zero); await reader.PullAllBackingStoresAsync(); Assert.That(reader.Get(new CultRecordKey("transaction:external"))?.Value, Is.EqualTo("durable")); Assert.That(observed, Is.EqualTo(1)); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Test] public async Task CultCache_Hydration_Waits_For_The_Active_Mutation_Transaction() { var root = Path.Combine(Path.GetTempPath(), $"cultcache-hydration-gate-{Guid.NewGuid():N}"); var manifest = Path.Combine(root, "state.cc"); Directory.CreateDirectory(root); try { using var cache = new CultCache(); cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); var staged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var transaction = cache.ExecuteTransactionAsync(async () => { await cache.UpsertAsync( new NamedTestEntry { Name = "staged", Value = "committed" }, new CultRecordHandle(new CultRecordKey("transaction:hydration-gate"))); staged.SetResult(true); await release.Task; }); await staged.Task; var hydration = cache.PullAllBackingStoresAsync(); Assert.That(hydration.IsCompleted, Is.False, "hydration must wait until the active mutation transaction has committed"); release.SetResult(true); await transaction; await hydration; Assert.That(cache.Get(new CultRecordKey("transaction:hydration-gate"))?.Value, Is.EqualTo("committed")); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } [Test] public async Task CultCache_Transaction_Observer_Writes_In_A_New_Commit() { var root = Path.Combine(Path.GetTempPath(), $"cultcache-observer-transaction-{Guid.NewGuid():N}"); var manifest = Path.Combine(root, "state.cc"); Directory.CreateDirectory(root); try { using var cache = new CultCache(); cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); var firstKey = new CultRecordKey("transaction:observer-source"); var secondKey = new CultRecordKey("transaction:observer-write"); using var subscription = cache.Watch().Subscribe(change => { if (!change.Key.Equals(firstKey)) return; cache.ExecuteTransactionAsync(async () => { await cache.UpsertAsync( new NamedTestEntry { Name = "observer", Value = "separate-commit" }, new CultRecordHandle(secondKey)); }).GetAwaiter().GetResult(); }); await cache.ExecuteTransactionAsync(async () => { await cache.UpsertAsync( new NamedTestEntry { Name = "source", Value = "committed-first" }, new CultRecordHandle(firstKey)); }); Assert.That(cache.Get(secondKey)?.Value, Is.EqualTo("separate-commit")); using var reopened = new CultCache(); reopened.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)); await reopened.PullAllBackingStoresAsync(); Assert.That(reopened.Get(firstKey)?.Value, Is.EqualTo("committed-first")); Assert.That(reopened.Get(secondKey)?.Value, Is.EqualTo("separate-commit")); } finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } } private const string NamedFixtureCanonicalSchemaJson = "{\"schemaName\":\"tests.named_entry\",\"schemaVersion\":\"tests.named_entry.v1\",\"members\":[{\"slot\":0,\"name\":\"Name\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"Value\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}"; private const string NamedFixtureSchemaId = "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8"; private const string NamedFixtureContentHash = "sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e"; private const string ReferenceFixtureSchemaId = "sha256:bd85064961cc74565fb73e3ccbc4217cfba4dc4869e365a08bea4f704739bd8f"; [CultDocument("tests.named_entry", "tests.named_entry.v1")] internal sealed class NamedTestEntry { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public string Value = string.Empty; } [CultDocument("tests.global_entry", "tests.global_entry.v1")] internal sealed class GlobalTestEntry { [Key(0)] public string Value = "schema default"; } [CultDocument("tests.reference_holder", "tests.reference_holder.v1")] internal sealed class ReferenceHolderEntry { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public CultRecordRef Parent = new(new CultRecordKey("parent")); } [CultDocument("tests.transform_entry", "tests.transform_entry.v1")] internal sealed class TransformEntry { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public float PositionX; [Key(2)] public float PositionY; [Key(3)] public int Health; } [CultDocument("tests.schema_stamped_entry", "tests.schema_stamped_entry.v1")] internal sealed class SchemaStampedTestEntry { [Key(0)] public string SchemaVersion = string.Empty; [Key(1)] [CultName] public string Name = string.Empty; [Key(2)] public string Value = string.Empty; } [CultDocument("tests.named_entry", "tests.named_entry.v2")] internal sealed class NamedTestEntryAdditive { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public string Value = string.Empty; [Key(2)] public string Notes = string.Empty; } [CultDocument("tests.named_entry", "tests.named_entry.v3")] internal sealed class NamedTestEntryRemoved { [Key(0)] [CultName] public string Name = string.Empty; } [CultDocument("tests.named_entry", "tests.named_entry.v4")] internal sealed class NamedTestEntryTypeMismatch { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public int Value; } [CultDocument("tests.alt_named_entry", "tests.alt_named_entry.v1")] internal sealed class AlternateNamedTestEntry { [Key(0)] [CultName] public string Name = string.Empty; } [CultDocument("tests.reference_holder", "tests.reference_holder.v2")] internal sealed class ReferenceHolderRetargetedEntry { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public CultRecordRef Parent = new(new CultRecordKey("parent")); } } }