2026-09-13 17:17:14 +02:00
|
|
|
#nullable enable
|
|
|
|
|
using System;
|
|
|
|
|
using System.IO;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using GameCult.Caching.MessagePack;
|
|
|
|
|
using NUnit.Framework;
|
|
|
|
|
|
|
|
|
|
namespace GameCult.Caching.Tests
|
|
|
|
|
{
|
|
|
|
|
public class PreCut2StoreFormatTests
|
|
|
|
|
{
|
|
|
|
|
private static string FixtureRoot =>
|
|
|
|
|
Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "pre-cut2");
|
|
|
|
|
|
|
|
|
|
private static string CopyFixture(string name)
|
|
|
|
|
{
|
|
|
|
|
var target = Path.Combine(Path.GetTempPath(), $"cultlib-precut2-{Guid.NewGuid():N}");
|
|
|
|
|
var source = Path.Combine(FixtureRoot, name);
|
|
|
|
|
foreach (var file in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
|
|
|
|
|
{
|
|
|
|
|
var destination = Path.Combine(target, Path.GetRelativePath(source, file));
|
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
|
|
|
|
File.Copy(file, destination);
|
|
|
|
|
}
|
|
|
|
|
return target;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void AssertFixtureRecords(CultCache cache)
|
|
|
|
|
{
|
|
|
|
|
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:anvil")), Is.Not.Null);
|
|
|
|
|
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:anvil"))!.Name, Is.EqualTo("anvil"));
|
|
|
|
|
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:anvil"))!.Count, Is.EqualTo(3));
|
|
|
|
|
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:bellows"))?.Name, Is.EqualTo("bellows"));
|
|
|
|
|
Assert.That(cache.Get<PreCut2FixtureItem>(new CultRecordKey("item:bellows"))?.Count, Is.EqualTo(42));
|
|
|
|
|
Assert.That(cache.Get<PreCut2FixtureNote>(new CultRecordKey("note:origin"))?.Text,
|
|
|
|
|
Is.EqualTo("written before cut 2"));
|
|
|
|
|
Assert.That(cache.GetAll<PreCut2FixtureItem>(), Has.Length.EqualTo(2));
|
|
|
|
|
Assert.That(cache.GetAll<PreCut2FixtureNote>(), Has.Length.EqualTo(1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task DirectoryStoreOpensV4ManifestWrittenBeforeCut2()
|
|
|
|
|
{
|
|
|
|
|
var root = CopyFixture("directory-v4");
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var manifest = Path.Combine(root, "store.cc");
|
|
|
|
|
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(manifest)).FormatVersion,
|
|
|
|
|
Is.EqualTo("cultcache.store.v4.directory-content-addressed-pages"));
|
|
|
|
|
|
|
|
|
|
using var cache = new CultCache();
|
|
|
|
|
cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest));
|
|
|
|
|
await cache.PullAllBackingStoresAsync();
|
|
|
|
|
|
|
|
|
|
AssertFixtureRecords(cache);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public async Task SingleFileOpensV1SnapshotWrittenBeforeCut2()
|
|
|
|
|
{
|
|
|
|
|
var root = CopyFixture("single-file-v1");
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var file = Path.Combine(root, "store.msgpack");
|
|
|
|
|
Assert.That(CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(file)).FormatVersion,
|
|
|
|
|
Is.EqualTo("cultcache.store.v1"));
|
|
|
|
|
|
|
|
|
|
using var cache = new CultCache();
|
|
|
|
|
cache.AddBackingStore(new SingleFileMessagePackBackingStore(file));
|
|
|
|
|
await cache.PullAllBackingStoresAsync();
|
|
|
|
|
|
|
|
|
|
AssertFixtureRecords(cache);
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Format strings taken from DirectoryMessagePackBackingStore.cs at 0db1fe5.
|
|
|
|
|
[TestCase("cultcache.store.v1")]
|
|
|
|
|
[TestCase("cultcache.store.v1.directory")]
|
|
|
|
|
[TestCase("cultcache.store.v2.directory-indexed")]
|
|
|
|
|
[TestCase("cultcache.store.v3.directory-immutable-pages")]
|
|
|
|
|
public void DirectoryStoreRefusesNonV4Manifest(string formatVersion)
|
|
|
|
|
{
|
|
|
|
|
var root = Path.Combine(Path.GetTempPath(), $"cultlib-refuse-{Guid.NewGuid():N}");
|
|
|
|
|
var manifest = Path.Combine(root, "store.cc");
|
|
|
|
|
Directory.CreateDirectory(root);
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var bytes = CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
|
|
|
|
|
{
|
|
|
|
|
FormatVersion = formatVersion,
|
|
|
|
|
SchemaCatalog = Array.Empty<CultSchemaCatalogEntry>(),
|
|
|
|
|
Records = Array.Empty<CultPersistedRecord>()
|
|
|
|
|
});
|
|
|
|
|
File.WriteAllBytes(manifest, bytes);
|
|
|
|
|
|
|
|
|
|
using var cache = new CultCache();
|
|
|
|
|
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Assert.That(() => cache.AddBackingStore(new DirectoryMessagePackBackingStore(manifest)),
|
2026-09-13 17:17:14 +02:00
|
|
|
Throws.TypeOf<InvalidOperationException>().With.Message.Contains(formatVersion));
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
Assert.That(cache.BackingStores, Is.Empty);
|
2026-09-13 17:17:14 +02:00
|
|
|
Assert.That(File.ReadAllBytes(manifest), Is.EqualTo(bytes));
|
|
|
|
|
}
|
|
|
|
|
finally
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|