CultLib/tests/GameCult.Caching.Tests/CachelessSingleFileTests.cs
Meta Vens 6353055b72 Caching tests: the nested cacheless write propagates its refusal; the variant header pin overrides the name beside the reels
Fixes two tests of the previous commit that failed on their own fixtures (the inner call's
exception was swallowed in the hook; a variant that shares its base's name is refused). Builds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 20:14:38 +01:00

1315 lines
73 KiB
C#

#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
using static GameCult.Caching.Tests.ElementIdTests;
namespace GameCult.Caching.Tests
{
// A single-file store file is read and written without a cache through ReadStoreSnapshot, WriteRecord and WriteDocument: the same reader, lock,
// atomic replace and header rule a cache's store uses. CultMesh's single-file document helpers are built on these members.
public class CachelessSingleFileTests
{
private static readonly CultSchemaCatalogEntry One = new() { SchemaId = "tests.cacheless.one", SchemaName = "tests.cacheless.one" };
private static readonly CultSchemaCatalogEntry Two = new() { SchemaId = "tests.cacheless.two", SchemaName = "tests.cacheless.two" };
private string _directory = "";
private string _path = "";
[SetUp]
public void SetUp()
{
_directory = Path.Combine(Path.GetTempPath(), "cultlib-cacheless-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_directory);
_path = Path.Combine(_directory, "store.cc");
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, true);
}
private static CultCodec Codec(CultDocumentRegistry? registry = null) =>
CultCacheMessagePack.CreateCodec(registry ?? CultDocumentRegistry.Shared);
[CultDocument("tests.cacheless.base", "tests.cacheless.base.v1")]
[MessagePackObject]
public class DeclaredBase
{
[Key(0)] [CultName] public string Name { get; set; } = "";
}
[CultDocument("tests.cacheless.id_base", "tests.cacheless.id_base.v1")]
[MessagePackObject]
public class IdBase
{
[Key(0)] [CultName] public string Name { get; set; } = "";
}
[MessagePackObject]
public sealed class IdDerived : IdBase
{
[Key(1)] public System.Collections.Generic.List<IdReel> Reels { get; set; } = new();
}
[MessagePackObject]
public sealed class UndeclaredDerived : DeclaredBase
{
[Key(1)] public string Extra { get; set; } = "";
}
// Reads the store file as a SingleFileMessagePackBackingStore does, and holds the first reader inside ReadStore so a test can
// see whether a second one gets in beside it.
private sealed class GatedStore : SingleFileBackingStore
{
private int _held;
private int _inside;
private int _mostInside;
public readonly ManualResetEventSlim Entered = new();
public readonly ManualResetEventSlim Release = new();
public CultDocumentRegistry? Attached => AttachedRegistry;
public CultDocumentRegistry? RegistryBeforeWait;
public CultDocumentRegistry? RegistryAfterWait;
public GatedStore(string path) : base(path) { }
public int MostInside => Volatile.Read(ref _mostInside);
protected override byte[] SerializeStore(string formatVersion, System.Collections.Generic.IReadOnlyList<byte[]> catalogEntries, System.Collections.Generic.IReadOnlyList<byte[]> records) =>
CultDocumentMessagePackSerialization.SerializeStore(formatVersion, catalogEntries, records);
protected override byte[] SerializeRecord(CultPersistedRecord record) =>
CultDocumentMessagePackSerialization.SerializePersistedRecord(record);
protected override byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry) =>
CultDocumentMessagePackSerialization.SerializeCatalogEntry(entry);
protected override (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data)
{
var inside = Interlocked.Increment(ref _inside);
int seen;
while ((seen = MostInside) < inside && Interlocked.CompareExchange(ref _mostInside, inside, seen) != seen) { }
Entered.Set();
try
{
if (Interlocked.Exchange(ref _held, 1) == 0)
{
RegistryBeforeWait = AttachedRegistry;
Release.Wait(TimeSpan.FromSeconds(30));
RegistryAfterWait = AttachedRegistry;
}
var store = CultDocumentMessagePackSerialization.ReadStore(data);
CultDocumentMessagePackSerialization.RequireSingleFileFormat(store.Snapshot);
return store;
}
finally
{
Interlocked.Decrement(ref _inside);
}
}
protected override byte[] SerializePayload(object document, Type documentType) =>
CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType, Registry);
protected override object DeserializePayload(Type documentType, byte[] payload) =>
CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
}
// ReadStoreSnapshot takes the store's gate: while one read is inside the reader, a second does not enter beside it.
[Test]
public async Task ReadStoreSnapshotTakesTheStoresGate()
{
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
var store = new GatedStore(_path);
var first = Task.Run(() => store.ReadStoreSnapshot());
Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the first read reached the reader");
var second = Task.Run(() => store.ReadStoreSnapshot());
await Task.Delay(500);
var mostInside = store.MostInside;
store.Release.Set();
await Task.WhenAll(first, second);
Assert.That(mostInside, Is.EqualTo(1), "a second read waits at the gate while the first is inside the reader");
Assert.That(first.Result!.Records, Has.Length.EqualTo(1));
Assert.That(second.Result!.Records, Has.Length.EqualTo(1));
}
// A call's registry is attached under the store's gate: a second call with another registry waits for the first, so the
// first sees its own registry from its start to its end.
[Test]
public async Task ACallKeepsItsRegistryWhileAnotherCallWaits()
{
var mine = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var other = CultDocumentRegistry.ForTypes(new[] { typeof(DeclaredBase) });
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
var store = new GatedStore(_path);
var first = Task.Run(() => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: mine));
Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the first call reached the reader");
var second = Task.Run(() => store.WriteDocument(new CultRecordKey("second"), new DeclaredBase { Name = "second" }, registry: other));
await Task.Delay(500);
store.Release.Set();
await Task.WhenAll(first, second);
Assert.That(store.RegistryBeforeWait, Is.SameAs(mine));
Assert.That(store.RegistryAfterWait, Is.SameAs(mine), "the second call's registry did not replace the first's mid-call");
Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.Select(record => record.Key), Is.EquivalentTo(new[] { "a", "first", "second" }));
}
// An attach during a cacheless call waits for it: the call finishes on the registry it was given, the store then gains the
// cache's registry, and the cache's next flush works.
[Test]
public async Task AnAttachDuringACachelessCallWaitsForIt()
{
var mine = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: mine);
var store = new GatedStore(_path);
var write = Task.Run(() => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: mine));
Assert.That(store.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True, "the call reached the reader");
using var cache = new CultCache(cacheRegistry);
var attach = Task.Run(() => cache.AddBackingStore(store));
Assert.That(attach.Wait(TimeSpan.FromMilliseconds(500)), Is.False, "the attach waits while a cacheless call is inside the store");
store.Release.Set();
await write;
await attach;
Assert.That(store.RegistryAfterWait, Is.SameAs(mine), "the call kept its own registry to its end");
Assert.That(store.Attached, Is.SameAs(cacheRegistry), "the call's end did not detach the cache's registry");
await cache.AddAsync(new IdDeck { Name = "b" });
Assert.DoesNotThrow(() => cache.FlushAllBackingStores());
}
// A cacheless call that finds the store attached by the time it holds the gate is refused, and leaves the cache's registry.
[Test]
public void ACachelessCallOnAnAttachedStoreIsRefusedAndLeavesTheRegistry()
{
var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var store = new GatedStore(_path);
store.Release.Set();
using var cache = new CultCache(cacheRegistry);
cache.AddBackingStore(store);
Assert.Throws<InvalidOperationException>(() => store.WriteDocument(new CultRecordKey("x"), new IdDeck { Name = "x" }));
Assert.Throws<InvalidOperationException>(() => store.WriteRecord(Record("a", One, 1), One));
Assert.That(store.Attached, Is.SameAs(cacheRegistry));
}
// A store whose hooks fire by the role of the thread inside it, so a test can hold an attach and a cacheless call at chosen
// points: the attach inside its pull (and make that pull fail), a call inside its read or inside its payload encoding.
private sealed class RoleStore : SingleFileBackingStore
{
[ThreadStatic] public static string? Role;
public bool FailAttachPull;
public readonly ManualResetEventSlim AttachInside = new();
public readonly ManualResetEventSlim ReleaseAttach = new();
public readonly ManualResetEventSlim CallInside = new();
public readonly ManualResetEventSlim ReleaseCall = new();
public readonly ManualResetEventSlim ReaderInside = new();
public readonly ManualResetEventSlim ReleaseReader = new();
// How many threads are inside ReadStore at once, and the most there ever were.
private int _inside, _mostInside;
public int MostInside => Volatile.Read(ref _mostInside);
// Run by the thread whose role is "hook", inside ReadStore: user code that runs under the store's gate.
[ThreadStatic] public static Action? RoleHook;
public CultDocumentRegistry? Attached => AttachedRegistry;
public bool IsAttachedToACache => Cache != null;
public RoleStore(string path) : base(path) { }
protected override byte[] SerializeStore(string formatVersion, System.Collections.Generic.IReadOnlyList<byte[]> catalogEntries, System.Collections.Generic.IReadOnlyList<byte[]> records) =>
CultDocumentMessagePackSerialization.SerializeStore(formatVersion, catalogEntries, records);
protected override byte[] SerializeRecord(CultPersistedRecord record) =>
CultDocumentMessagePackSerialization.SerializePersistedRecord(record);
protected override byte[] SerializeCatalogEntry(CultSchemaCatalogEntry entry) =>
CultDocumentMessagePackSerialization.SerializeCatalogEntry(entry);
protected override (CultPersistedStoreSnapshot Snapshot, byte[][] CatalogBytes, byte[][] RecordBytes) ReadStore(byte[] data)
{
var now = Interlocked.Increment(ref _inside);
int seen;
while ((seen = Volatile.Read(ref _mostInside)) < now && Interlocked.CompareExchange(ref _mostInside, now, seen) != seen) { }
try
{
if (Role == "attach")
{
AttachInside.Set();
ReleaseAttach.Wait(TimeSpan.FromSeconds(30));
if (FailAttachPull)
throw new InvalidOperationException("the attach's pull fails");
}
else if (Role == "call-in-read")
{
CallInside.Set();
ReleaseCall.Wait(TimeSpan.FromSeconds(30));
}
else if (Role == "reader")
{
ReaderInside.Set();
ReleaseReader.Wait(TimeSpan.FromSeconds(30));
}
else if (Role == "hook")
{
RoleHook?.Invoke();
}
var store = CultDocumentMessagePackSerialization.ReadStore(data);
CultDocumentMessagePackSerialization.RequireSingleFileFormat(store.Snapshot);
return store;
}
finally
{
Interlocked.Decrement(ref _inside);
}
}
protected override byte[] SerializePayload(object document, Type documentType)
{
if (Role == "call-in-payload")
{
CallInside.Set();
ReleaseCall.Wait(TimeSpan.FromSeconds(30));
}
return CultDocumentMessagePackSerialization.SerializeUntyped(document, documentType, Registry);
}
protected override object DeserializePayload(Type documentType, byte[] payload) =>
CultDocumentMessagePackSerialization.DeserializeUntyped(documentType, payload, Registry);
}
// Runs body on its own thread under a role, and returns the thread with whatever it threw.
private static (Thread Thread, Func<Exception?> Error) OnThread(string role, Action body)
{
Exception? error = null;
var thread = new Thread(() =>
{
RoleStore.Role = role;
try { body(); }
catch (Exception e) { error = e; }
finally { RoleStore.Role = null; }
}) { IsBackground = true };
thread.Start();
return (thread, () => error);
}
private static void WaitUntilBlocked(Thread thread) =>
Assert.That(SpinWait.SpinUntil(() => thread.ThreadState.HasFlag(ThreadState.WaitSleepJoin), TimeSpan.FromSeconds(10)), Is.True, "the thread is blocked at a gate");
// A cacheless call that arrives during an attach waits for the attach to end, whatever the attach does. The call's thread blocks
// on the store's gate (it does not read Cache first and queue on the cache's gate), so when the pull fails the call runs, and the
// next attach waits for it; when the pull succeeds the call finds the cache and is refused, leaving the cache's registry.
[TestCase(true)]
[TestCase(false)]
public async Task ACallArrivingDuringAnAttachWaitsForTheAttachToEnd(bool theAttachFails)
{
var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var registry1 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var registry2 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry);
var store = new RoleStore(_path) { FailAttachPull = theAttachFails };
using var cache1 = new CultCache(registry1);
var attach = OnThread("attach", () => cache1.AddBackingStore(store));
Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the attach reached its pull");
var call = OnThread("call-in-read", () => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: callRegistry));
WaitUntilBlocked(call.Thread);
Assert.That(store.CallInside.IsSet, Is.False, "the call waits while the attach is inside the store");
store.ReleaseAttach.Set();
attach.Thread.Join();
Assert.That(attach.Error(), theAttachFails ? Is.InstanceOf<InvalidOperationException>() : Is.Null);
if (!theAttachFails)
{
call.Thread.Join();
Assert.That(call.Error(), Is.InstanceOf<InvalidOperationException>(), "the attach succeeded, so the call is refused");
Assert.That(store.CallInside.IsSet, Is.False);
Assert.That(store.Attached, Is.SameAs(registry1), "the refused call left the cache's registry");
await cache1.AddAsync(new IdDeck { Name = "b" });
Assert.DoesNotThrow(() => cache1.FlushAllBackingStores());
return;
}
Assert.That(store.CallInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the attach failed, so the call runs");
using var cache2 = new CultCache(registry2);
var secondAttach = Task.Run(() => cache2.AddBackingStore(store));
Assert.That(secondAttach.Wait(TimeSpan.FromMilliseconds(500)), Is.False, "the next attach waits while the call is inside the store");
store.ReleaseCall.Set();
call.Thread.Join();
await secondAttach;
Assert.That(call.Error(), Is.Null);
Assert.That(store.Attached, Is.SameAs(registry2), "the call's end did not detach the second cache's registry");
await cache2.AddAsync(new IdDeck { Name = "c" });
Assert.DoesNotThrow(() => cache2.FlushAllBackingStores());
var keys = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.Select(record => record.Key).ToArray();
Assert.That(keys, Has.Length.EqualTo(3), "the seed, the call's record and the cache's record");
Assert.That(keys, Does.Contain("a").And.Contain("first"));
}
// The attach waits for a call that is already inside the store, wherever in the call it is: here inside the payload encoding,
// before the file is read. The call ends on its own registry, then the cache owns the store and its registry.
[Test]
public async Task AnAttachDuringACallEncodingItsPayloadWaitsForIt()
{
var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var store = new RoleStore(_path);
var call = OnThread("call-in-payload", () => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: callRegistry));
Assert.That(store.CallInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the call reached its payload encoding");
using var cache = new CultCache(cacheRegistry);
var attach = Task.Run(() => cache.AddBackingStore(store));
Assert.That(attach.Wait(TimeSpan.FromMilliseconds(500)), Is.False, "the attach waits while a call is inside the store");
store.ReleaseCall.Set();
call.Thread.Join();
await attach;
Assert.That(call.Error(), Is.Null);
Assert.That(store.Attached, Is.SameAs(cacheRegistry), "the call's end did not detach the cache's registry");
await cache.AddAsync(new IdDeck { Name = "b" });
Assert.DoesNotThrow(() => cache.FlushAllBackingStores());
}
// An attach the cache refuses before it pulls leaves the store as it found it: no cache, no registry, and a cacheless call
// still runs.
[Test]
public void ARefusedAttachLeavesTheStoreCacheless()
{
var cacheRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
using var cache = new CultCache(cacheRegistry);
cache.AddBackingStore(new RoleStore(Path.Combine(_directory, "first.cc")));
var second = new RoleStore(_path);
Assert.Throws<InvalidOperationException>(() => cache.AddBackingStore(second), "a second untyped store is refused");
Assert.DoesNotThrow(() => second.WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: cacheRegistry));
Assert.That(second.Attached, Is.Null);
Assert.That(second.ReadStoreSnapshot()!.Records, Has.Length.EqualTo(1));
}
// WriteDocument<T> stores the document as T: the schema id, the id plan, the header and the bytes all come from the one
// declared type, as they did when CultMesh encoded typeof(T).
[Test]
public void WriteDocumentEncodesTheDeclaredTypeNotTheRuntimeType()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(DeclaredBase) });
new SingleFileMessagePackBackingStore(_path).WriteDocument<DeclaredBase>(new CultRecordKey("d"), new UndeclaredDerived { Name = "n", Extra = "x" }, registry: registry);
var record = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.Single();
var asBase = CultDocumentMessagePackSerialization.SerializeUntyped(new DeclaredBase { Name = "n" }, typeof(DeclaredBase), registry);
Assert.That(record.SchemaId, Is.EqualTo(registry.GetRequired<DeclaredBase>().SchemaId));
Assert.That(record.Payload, Is.EqualTo(asBase), "base bytes under the base schema: one slot, not the derived type's two");
}
// The header, like the bytes and the id plan, comes from the declared type: ids that only the derived type holds are not the
// stored document's.
[Test]
public void WriteDocumentMarksTheStoreByTheDeclaredTypeNotTheRuntimeType()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdBase) });
var derived = new IdDerived { Name = "n", Reels = { new IdReel { Id = "0123456789ab", Label = "r" } } };
new SingleFileMessagePackBackingStore(_path).WriteDocument<IdBase>(new CultRecordKey("d"), derived, registry: registry);
var snapshot = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!;
Assert.That(snapshot.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV1), "the stored IdBase holds no element id");
Assert.That(snapshot.Records.Single().Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(new IdBase { Name = "n" }, typeof(IdBase), registry)));
}
// The id plan comes from the declared type too: an unset id that only the derived type holds is not the stored document's, so
// it is not minted into the caller's object.
[Test]
public void WriteDocumentMintsIdsOfTheDeclaredTypeNotTheRuntimeType()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdBase) });
var derived = new IdDerived { Name = "n", Reels = { new IdReel { Label = "r" } } };
new SingleFileMessagePackBackingStore(_path).WriteDocument<IdBase>(new CultRecordKey("d"), derived, registry: registry);
Assert.That(derived.Reels.Single().Id, Is.Null.Or.Empty, "an id outside the declared type is left unset");
}
// No call leaves its registry on the store: after a write, a refused write and WriteRecord, the store holds none.
[Test]
public void NoCacheCallLeavesARegistryOnTheStore()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
var store = new GatedStore(_path);
store.Release.Set();
store.WriteDocument(new CultRecordKey("d"), new IdDeck { Name = "d" }, registry: registry);
Assert.That(store.Attached, Is.Null, "after WriteDocument");
store.WriteRecord(Record("b", One, 2), One, holdsIds: false, registry: registry);
Assert.That(store.Attached, Is.Null, "after WriteRecord");
File.WriteAllBytes(_path, new byte[] { 0xC1 });
Assert.Throws<CultStoreUnreadableException>(() => store.WriteDocument(new CultRecordKey("e"), new IdDeck { Name = "e" }, registry: registry));
Assert.That(store.Attached, Is.Null, "after a refused write");
}
// The registry is the call's argument. A typed call on a store instance leaves nothing behind, so a later WriteRecord that
// gives none decides the header as a fresh instance does.
[Test]
public async Task ATypedCallLeavesNoRegistryOnTheStore()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
using (var cache = new CultCache(registry))
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(_path));
await cache.AddAsync(new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } });
cache.FlushAllBackingStores();
}
void Unmark()
{
var written = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path));
written.FormatVersion = CultPersistedStoreSnapshot.FormatV1;
File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(written));
}
Unmark();
var store = new SingleFileMessagePackBackingStore(_path);
store.WriteDocument(new CultRecordKey("again"), new IdDeck { Name = "again" }, registry: registry);
Assert.That(store.ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3), "the typed call, given a registry, read the copied ids");
Unmark();
store.WriteRecord(Record("other", One, 1), One, holdsIds: false, registry: null);
Assert.That(store.ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV1), "a write given no registry decides nothing for copied records");
}
private static CultPersistedRecord Record(string key, CultSchemaCatalogEntry entry, params byte[] payload) =>
new() { Key = key, SchemaId = entry.SchemaId, StoredAt = "2026-10-22T00:00:00.0000000+00:00", Payload = payload };
[Test]
public void WriteRecordKeepsAnotherWritersRecord()
{
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("b", Two, 2), Two);
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 3), One);
var snapshot = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!;
Assert.That(snapshot.Records, Has.Length.EqualTo(2));
Assert.That(Array.Find(snapshot.Records, record => record.Key == "a")!.Payload, Is.EqualTo(new byte[] { 3 }));
Assert.That(Array.Find(snapshot.Records, record => record.Key == "b")!.Payload, Is.EqualTo(new byte[] { 2 }));
Assert.That(snapshot.SchemaCatalog, Has.Length.EqualTo(2));
}
[Test]
public void WriteRecordRefusesOnAStoreAttachedToACache()
{
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
var before = File.ReadAllBytes(_path);
var store = new SingleFileMessagePackBackingStore(_path);
using var cache = new CultCache();
cache.AddBackingStore(store);
var error = Assert.Throws<InvalidOperationException>(() => store.WriteRecord(Record("b", Two, 2), Two))!;
Assert.That(error.Message, Does.Contain("write through the cache"));
Assert.That(File.ReadAllBytes(_path), Is.EqualTo(before));
}
[Test]
public void ReadStoreSnapshotOfNothingIsNull()
{
Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot(), Is.Null);
}
[Test]
public void ReadStoreSnapshotRefusesAV4Stamp()
{
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path));
snapshot.FormatVersion = "cultcache.store.v4";
File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var error = Assert.Throws<CultStoreUnreadableException>(() => new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot())!;
Assert.That(error.Message, Does.Contain("cultcache.store.v4"));
}
[Test]
public void WriteRecordWithIdsMarksV3()
{
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One);
Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.Not.EqualTo(CultPersistedStoreSnapshot.FormatV3));
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("b", Two, 2), Two, holdsIds: true);
Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3));
}
[Test]
public void WriteRecordRefusesNullArguments()
{
var store = new SingleFileMessagePackBackingStore(_path);
Assert.Throws<ArgumentNullException>(() => store.WriteRecord(null!, One));
Assert.Throws<ArgumentNullException>(() => store.WriteRecord(Record("a", One, 1), null!));
Assert.That(File.Exists(_path), Is.False);
}
[Test]
public void ARefusedWriteDocumentOnAnAttachedStoreNeverReachesTheRegistryItWasGiven()
{
var store = new SingleFileMessagePackBackingStore(_path);
using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }));
cache.AddBackingStore(store);
var elsewhere = CultDocumentRegistry.ForTypes(new[] { typeof(StoreRoutingTests.RoutingNote) });
var error = Assert.Throws<InvalidOperationException>(() => store.WriteDocument(new CultRecordKey("deck"), new IdDeck { Name = "d" }, registry: elsewhere))!;
Assert.That(error.Message, Does.Contain("write through the cache"));
}
[Test]
public void WriteDocumentRefusesANullDocumentAndDecodeDocumentANullPayload()
{
var store = new SingleFileMessagePackBackingStore(_path);
Assert.Throws<ArgumentNullException>(() => store.WriteDocument<IdDeck>(new CultRecordKey("deck"), null!));
Assert.Throws<ArgumentNullException>(() => Codec().DecodeDocument<IdDeck>(new CultRecordKey("deck"), null!));
}
[Test]
public void WriteDocumentStoresTheGivenStoredAtAndOtherwiseNow()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var store = new SingleFileMessagePackBackingStore(_path);
store.WriteDocument(new CultRecordKey("fixed"), new IdDeck { Name = "a" }, "2026-01-02T03:04:05.0000000+00:00", registry);
store.WriteDocument(new CultRecordKey("now"), new IdDeck { Name = "b" }, null, registry);
store.WriteDocument(new CultRecordKey("blank"), new IdDeck { Name = "c" }, " ", registry);
var records = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records.ToDictionary(record => record.Key);
Assert.That(records["fixed"].StoredAt, Is.EqualTo("2026-01-02T03:04:05.0000000+00:00"));
Assert.That(records["now"].StoredAt, Does.Match(@"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{7}\+00:00$"), "round-trip format");
Assert.That(DateTimeOffset.Parse(records["now"].StoredAt), Is.EqualTo(DateTimeOffset.UtcNow).Within(TimeSpan.FromMinutes(5)));
Assert.That(DateTimeOffset.Parse(records["blank"].StoredAt), Is.EqualTo(DateTimeOffset.UtcNow).Within(TimeSpan.FromMinutes(5)));
}
[Test]
public void WriteDocumentMintsRandomIdsAndDecodeDocumentMintsTheSameIdsEveryTime()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
string Written(string name)
{
var path = Path.Combine(_directory, name);
var deck = new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } };
new SingleFileMessagePackBackingStore(path).WriteDocument(new CultRecordKey("deck"), deck, registry: registry);
return deck.Reels[0].Id;
}
Assert.That(Written("one.cc"), Is.Not.EqualTo(Written("two.cc")), "a write mints random ids");
var unset = CultDocumentMessagePackSerialization.SerializeUntyped(new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } }, typeof(IdDeck), registry);
var first = Codec(registry).DecodeDocument<IdDeck>(new CultRecordKey("deck"), unset).Reels[0].Id;
var second = Codec(registry).DecodeDocument<IdDeck>(new CultRecordKey("deck"), unset).Reels[0].Id;
Assert.That(first, Does.Match("^[0-9a-f]{12}$"));
Assert.That(second, Is.EqualTo(first), "a load mints from the key and path, so every reader mints the same id");
}
// The write is under the store's lock and waits for it: a write that finds the lock held lands after it is released, and
// never without it.
[Test]
public async Task WriteRecordWaitsForTheStoreLock()
{
var write = default(Task);
using (new FileStream(_path + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None))
{
write = Task.Run(() => new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("a", One, 1), One));
await Task.Delay(500);
Assert.That(write.IsCompleted, Is.False, "the write waits while another holder has the lock");
Assert.That(File.Exists(_path), Is.False);
}
await write;
Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records, Has.Length.EqualTo(1));
}
// The registry the payload was encoded with lets the write decide the header for the records it copies: another runtime may
// have written ids under an unmarked header, and a write that can read them marks the store. Without a registry it decides
// nothing for them.
[TestCase(true, CultPersistedStoreSnapshot.FormatV3)]
[TestCase(false, CultPersistedStoreSnapshot.FormatV1)]
public async Task WriteRecordGivenARegistryMarksAStoreWhoseCopiedRecordHoldsIds(bool withRegistry, string expected)
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
using (var cache = new CultCache(registry))
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(_path));
await cache.AddAsync(new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } });
cache.FlushAllBackingStores();
}
var written = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_path));
Assert.That(written.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3), "the cache marked the store it wrote ids into");
written.FormatVersion = CultPersistedStoreSnapshot.FormatV1;
File.WriteAllBytes(_path, CultDocumentMessagePackSerialization.SerializeSnapshot(written));
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("other", One, 1), One, holdsIds: false, registry: withRegistry ? registry : null);
Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.FormatVersion, Is.EqualTo(expected));
}
[Test]
public void WriteDocumentMintsIdsMarksV3AndKeepsTheOtherRecord()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteRecord(Record("other", One, 1), One);
var deck = new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } };
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("deck"), deck, registry: registry);
var snapshot = new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!;
Assert.That(snapshot.FormatVersion, Is.EqualTo(CultPersistedStoreSnapshot.FormatV3));
Assert.That(snapshot.Records.Select(record => record.Key), Is.EquivalentTo(new[] { "other", "deck" }));
Assert.That(deck.Reels[0].Id, Does.Match("^[0-9a-f]{12}$"), "the id the file holds was minted on the document");
var read = Codec(registry).DecodeDocument<IdDeck>(new CultRecordKey("deck"), snapshot.Records.Single(record => record.Key == "deck").Payload);
Assert.That(read.Reels[0].Id, Is.EqualTo(deck.Reels[0].Id));
}
[Test]
public void WriteDocumentGivesMintedIdsBackWhenTheWriteIsRefused()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
File.WriteAllBytes(_path, new byte[] { 0xC1 });
var deck = new IdDeck { Name = "deck", Reels = { new IdReel { Label = "reel" } } };
Assert.Throws<CultStoreUnreadableException>(() =>
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("deck"), deck, registry: registry));
Assert.That(deck.Reels[0].Id, Is.Null.Or.Empty, "a refused write leaves the document as it was");
}
[Test]
public void WriteDocumentRefusesOnAStoreAttachedToACache()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var store = new SingleFileMessagePackBackingStore(_path);
using var cache = new CultCache(registry);
cache.AddBackingStore(store);
Assert.That(Assert.Throws<InvalidOperationException>(() => store.WriteDocument(new CultRecordKey("deck"), new IdDeck { Name = "d" }))!.Message, Does.Contain("write through the cache"));
Assert.That(File.Exists(_path), Is.False);
}
// A document whose getter runs user code while MessagePack encodes it, under the store's gate.
[CultDocument("tests.cacheless.tap", "tests.cacheless.tap.v1")]
[MessagePackObject]
public class TapDoc
{
[ThreadStatic] public static Action? Hook;
[Key(0)] [CultName] public string Name { get; set; } = "";
[Key(1)] public string Tap { get { var hook = Hook; Hook = null; hook?.Invoke(); return "t"; } set { } }
[Key(2)] public System.Collections.Generic.List<IdReel> Reels { get; set; } = new();
}
private static void Finishes(Thread thread, string what) =>
Assert.That(thread.Join(TimeSpan.FromSeconds(10)), Is.True, what + " finished (a deadlock fails here, it does not hang)");
private static bool Refused(Exception? error)
{
for (var inner = error; inner != null; inner = inner.InnerException)
if (inner is InvalidOperationException)
return true;
return false;
}
// While a thread holds a store's gate (a cacheless call, an attach's pull) it enters no cache it does not already hold, reads
// included, and no second store. The call is refused where the getter ran, the file is not written, and minted ids are given back.
[Test]
public void AGetterThatEntersACacheDuringACachelessWriteIsRefused()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) });
using var unrelated = new CultCache(registry);
var doc = new TapDoc { Name = "d", Reels = { new IdReel() } };
TapDoc.Hook = () => unrelated.Get(new CultRecordKey("x"));
var error = Assert.Catch(() => new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), doc, registry: registry));
Assert.That(Refused(error), Is.True, "the getter's read of a cache was refused");
Assert.That(File.Exists(_path), Is.False);
Assert.That(doc.Reels[0].Id, Is.Null.Or.Empty, "the minted id was given back");
}
[Test]
public void AGetterThatWritesACacheDuringACachelessWriteIsRefused()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) });
using var unrelated = new CultCache(registry);
TapDoc.Hook = () => _ = unrelated.AddAsync(new TapDoc { Name = "other" });
var error = Assert.Catch(() => new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry));
Assert.That(Refused(error), Is.True, "the getter's write to a cache was refused");
Assert.That(File.Exists(_path), Is.False);
Assert.That(unrelated.AllEntries, Is.Empty, "the refused write reached nothing");
}
// Soul P4: the writer holds the store's gate and its getter reads the cache that another thread is attaching the same store to.
// The attach holds the cache's gate and waits for the store's. The getter is refused instead of waiting on the cache's gate.
[Test]
public async Task AGetterThatEntersTheAttachingCacheIsRefusedNotDeadlocked()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) });
var store = new RoleStore(_path);
var cache = new CultCache(registry);
var inside = new ManualResetEventSlim();
var attachWaits = new ManualResetEventSlim();
var write = OnThread("writer", () =>
{
TapDoc.Hook = () =>
{
inside.Set();
attachWaits.Wait(TimeSpan.FromSeconds(30));
_ = cache.Get(new CultRecordKey("anything"));
};
store.WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry);
});
Assert.That(inside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the getter ran under the store's gate");
var attach = OnThread("attach-after", () => cache.AddBackingStore(store));
WaitUntilBlocked(attach.Thread);
attachWaits.Set();
Finishes(write.Thread, "the writer");
Finishes(attach.Thread, "the attach");
Assert.That(Refused(write.Error()), Is.True, "the write was refused");
Assert.That(attach.Error(), Is.Null);
await cache.AddAsync(new TapDoc { Name = "after" });
Assert.DoesNotThrow(() => cache.FlushAllBackingStores());
// Disposed only after every join passed: a deadlocked gate would make Dispose wait for it.
cache.Dispose();
}
// The attach holds the store's gate while it pulls; user code in the pull may not enter a second store.
[Test]
public void AnAttachPullThatEntersAnotherStoreIsRefused()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry);
var store = new RoleStore(_path);
var second = new SingleFileMessagePackBackingStore(Path.Combine(_directory, "second.cc"));
var cache = new CultCache(registry);
var attach = OnThread("hook", () =>
{
RoleStore.RoleHook = () => second.WriteDocument(new CultRecordKey("b"), new IdDeck { Name = "b" }, registry: registry);
try { cache.AddBackingStore(store); }
finally { RoleStore.RoleHook = null; }
});
Finishes(attach.Thread, "the attach");
Assert.That(Refused(attach.Error()), Is.True, "the pull's write to a second store was refused");
Assert.That(File.Exists(Path.Combine(_directory, "second.cc")), Is.False);
Assert.That(cache.BackingStores, Is.Empty, "the failed attach left nothing behind");
Assert.DoesNotThrow(() => store.WriteDocument(new CultRecordKey("c"), new IdDeck { Name = "c" }, registry: registry), "the store is cacheless after");
// Disposed only after every join passed: a deadlocked gate would make Dispose wait for it.
cache.Dispose();
}
// Soul P4's window: a reader queued on a failed attach's cache gate must retry under the store's gate. Whichever of the reader and
// the cacheless writer queued behind the attach gets the store first is alone inside it, and a second cache's attach waits for it.
[TestCase("snapshot")]
[TestCase("pullall")]
public async Task AReaderQueuedOnAFailedAttachRunsUnderTheStoresGate(string how)
{
var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var registry1 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var registry2 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry);
var store = new RoleStore(_path) { FailAttachPull = true };
var cache1 = new CultCache(registry1);
var cache2 = new CultCache(registry2);
var attach1 = OnThread("attach", () => cache1.AddBackingStore(store));
Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True);
CultPersistedStoreSnapshot? snapshot = null;
var reader = OnThread("reader", () => { if (how == "snapshot") snapshot = store.ReadStoreSnapshot(); else store.PullAll(); });
WaitUntilBlocked(reader.Thread);
var writer = OnThread("call-in-read", () => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: callRegistry));
WaitUntilBlocked(writer.Thread);
store.ReleaseAttach.Set();
Finishes(attach1.Thread, "the failed attach");
Assert.That(SpinWait.SpinUntil(() => store.ReaderInside.IsSet || store.CallInside.IsSet, TimeSpan.FromSeconds(10)), Is.True, "one of them is inside the store");
var attach2 = OnThread("attach2", () => cache2.AddBackingStore(store));
WaitUntilBlocked(attach2.Thread);
Assert.That(attach2.Thread.IsAlive, Is.True, "the second cache's attach waits while a reader or a call is inside the store");
store.ReleaseReader.Set();
store.ReleaseCall.Set();
Finishes(reader.Thread, "the reader");
Finishes(writer.Thread, "the writer");
Finishes(attach2.Thread, "the second attach");
Assert.That(store.MostInside, Is.EqualTo(1), "never two threads inside the store's read at once");
// A PullAll on a cacheless store with no registry (the cacheless writer that ran before it detached its own) is a usage error;
// being refused for entering a cache's hold is not.
Assert.That(reader.Error()?.Message, Is.Null.Or.Contain("not attached to a CultDocumentRegistry"), "the reader was not refused");
if (how == "snapshot")
Assert.That(snapshot?.Records.Select(record => record.Key), Does.Contain("a"), "the retried read ran and returned the file's snapshot");
Assert.That(attach2.Error(), Is.Null);
await cache2.AddAsync(new IdDeck { Name = "b" });
Assert.DoesNotThrow(() => cache2.FlushAllBackingStores());
// Disposed only after every join passed: a deadlocked gate would make Dispose wait for it.
cache1.Dispose();
cache2.Dispose();
}
// After a failed attach left the store cacheless, the next cache attaches it, and a PullAll that queues on that attach (it guesses
// the second cache from Cache) runs under the second cache's gate once the attach ends: no refusal, and the cache holds the records.
[Test]
public void AFailedAttachThenPullAllLoadsIntoTheSecondCache()
{
var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry);
var store = new RoleStore(_path) { FailAttachPull = true };
var cache1 = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }));
var cache2 = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }));
var attach1 = OnThread("attach", () => cache1.AddBackingStore(store));
Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True);
store.ReleaseAttach.Set();
Finishes(attach1.Thread, "the failed attach");
Assert.That(Refused(attach1.Error()), Is.True);
store.AttachInside.Reset();
store.ReleaseAttach.Reset();
store.FailAttachPull = false;
var attach2 = OnThread("attach", () => cache2.AddBackingStore(store));
Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the second attach is inside its pull");
var pull = OnThread("pull", () => store.PullAll());
WaitUntilBlocked(pull.Thread);
store.ReleaseAttach.Set();
Finishes(attach2.Thread, "the second attach");
Finishes(pull.Thread, "the queued PullAll");
Assert.That(attach2.Error(), Is.Null);
Assert.That(pull.Error(), Is.Null, "the queued PullAll was not refused");
Assert.That(cache2.Get<IdDeck>(new CultRecordKey("a")), Is.Not.Null, "the second cache holds the file's records");
// Disposed only after every join passed: a deadlocked gate would make Dispose wait for it.
cache1.Dispose();
cache2.Dispose();
}
// A reader that guessed "no cache" and queued on the store's gate behind an attach finds the cache when it gets the gate, and runs
// under the cache's gate: while it is inside the store, the cache's own readers wait.
[Test]
public void AReaderThatQueuedBeforeAnAttachRunsUnderTheCacheGateAfterIt()
{
var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry);
var store = new RoleStore(_path);
var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }));
var call = OnThread("call-in-read", () => store.WriteDocument(new CultRecordKey("first"), new IdDeck { Name = "first" }, registry: callRegistry));
Assert.That(store.CallInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the call holds the store's gate");
var attach = OnThread("attach", () => cache.AddBackingStore(store));
WaitUntilBlocked(attach.Thread);
var reader = OnThread("reader", () => store.ReadStoreSnapshot());
WaitUntilBlocked(reader.Thread);
store.ReleaseCall.Set();
Assert.That(store.AttachInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the attach queued first and got the store's gate first");
store.ReleaseAttach.Set();
Assert.That(store.ReaderInside.Wait(TimeSpan.FromSeconds(10)), Is.True, "the reader retried and is inside the store");
var probe = OnThread("probe", () => cache.Get(new CultRecordKey("a")));
Assert.That(SpinWait.SpinUntil(() => !probe.Thread.IsAlive || probe.Thread.ThreadState.HasFlag(ThreadState.WaitSleepJoin), TimeSpan.FromSeconds(10)), Is.True);
Assert.That(probe.Thread.IsAlive, Is.True, "the cache's own reader waits while the retried reader is inside the store: it holds the cache's gate");
store.ReleaseReader.Set();
Finishes(call.Thread, "the call");
Finishes(attach.Thread, "the attach");
Finishes(reader.Thread, "the reader");
Finishes(probe.Thread, "the cache's reader");
Assert.That(attach.Error(), Is.Null);
Assert.That(reader.Error(), Is.Null);
cache.Dispose();
}
// The gate refuses a cache the thread does not hold; it does not refuse the cache whose gate the thread already holds.
[Test]
public void AnAttachPullMayReadTheCacheItIsAttachingTo()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry);
var store = new RoleStore(_path);
var cache = new CultCache(registry);
var attach = OnThread("hook", () =>
{
RoleStore.RoleHook = () => cache.Get(new CultRecordKey("a"));
try { cache.AddBackingStore(store); }
finally { RoleStore.RoleHook = null; }
});
Finishes(attach.Thread, "the attach");
Assert.That(attach.Error(), Is.Null);
Assert.That(cache.BackingStores, Has.Count.EqualTo(1));
cache.Dispose();
}
// A store call made from user code the same store's call is running is the same thread's own hold, not a second store.
[Test]
public void UserCodeInACachelessCallMayCallItsOwnStore()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) });
var store = new SingleFileMessagePackBackingStore(_path);
var descriptor = registry.GetRequired<TapDoc>();
TapDoc.Hook = () => store.Delete(new CultStoredDocument(new CultRecordKey("x"), DateTimeOffset.UtcNow.ToString("O"), descriptor, new TapDoc { Name = "x" }));
Assert.DoesNotThrow(() => store.WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry));
}
// A failed attach leaves the store as it found it: no cache and no registry. A cacheless PullAll after it is then a usage error
// that loads nothing, so the next cache to attach admits the file's records, and creating what it cannot find does not duplicate.
[Test]
public void AFailedAttachLeavesNoRegistryAndTheNextCacheSeesTheFilesRecords()
{
var callRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: callRegistry);
var store = new RoleStore(_path) { FailAttachPull = true };
store.ReleaseAttach.Set();
var cache1 = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }));
var attach1 = OnThread("attach", () => cache1.AddBackingStore(store));
Finishes(attach1.Thread, "the failed attach");
Assert.That(Refused(attach1.Error()), Is.True);
Assert.That(store.IsAttachedToACache, Is.False);
Assert.That(store.Attached, Is.Null, "the failed attach took its registry back");
Assert.That(Assert.Throws<InvalidOperationException>(() => store.PullAll())!.Message, Does.Contain("not attached to a CultDocumentRegistry"));
store.FailAttachPull = false;
var cache2 = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) }));
var attach2 = OnThread("attach", () => cache2.AddBackingStore(store));
Finishes(attach2.Thread, "the second attach");
Assert.That(attach2.Error(), Is.Null);
Assert.That(cache2.Get<IdDeck>(new CultRecordKey("a")), Is.Not.Null, "the second cache holds the file's record");
Assert.That(cache2.GetAll<IdDeck>().Count(), Is.EqualTo(1));
cache2.FlushAllBackingStores();
Assert.That(new SingleFileMessagePackBackingStore(_path).ReadStoreSnapshot()!.Records, Has.Length.EqualTo(1));
cache1.Dispose();
cache2.Dispose();
}
// Soul P4's load: cacheless writers and snapshot readers race an attach that fails (odd rounds) or succeeds. Whoever ends up
// owning the store owns its registry, and its cache writes and flushes. A call split into a refusal hold and a body hold fails here:
// a body that runs after the attach took the store leaves the call's registry (or none) on the cache's store.
[Test]
public async Task CachelessCallsRacingAnAttachNeverLeaveTheCachesStoreWithoutItsRegistry()
{
var bad = new System.Collections.Generic.List<string>();
for (var round = 0; round < 60; round++)
{
var path = Path.Combine(_directory, $"load{round}.cc");
var mine = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var reg1 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var reg2 = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var attachFails = round % 2 == 1;
var store = new RoleStore(path) { FailAttachPull = attachFails };
store.ReleaseAttach.Set();
store.WriteDocument(new CultRecordKey("seed"), new IdDeck { Name = "seed" }, registry: mine);
using var cache1 = new CultCache(reg1);
using var cache2 = new CultCache(reg2);
var go = new ManualResetEventSlim();
var tasks = new System.Collections.Generic.List<Task>();
for (var writer = 0; writer < 4; writer++)
{
var n = writer;
tasks.Add(Task.Run(() =>
{
go.Wait();
for (var k = 0; k < 8; k++)
{
try { store.WriteDocument(new CultRecordKey($"w{n}-{k}"), new IdDeck { Name = $"w{n}-{k}" }, registry: mine); }
catch (InvalidOperationException) { }
}
}));
}
for (var reader = 0; reader < 2; reader++)
tasks.Add(Task.Run(() =>
{
go.Wait();
for (var k = 0; k < 12; k++)
store.ReadStoreSnapshot();
}));
tasks.Add(Task.Run(() =>
{
go.Wait();
Thread.SpinWait(1500 * round);
RoleStore.Role = "attach";
try { cache1.AddBackingStore(store); }
catch (InvalidOperationException) { }
finally { RoleStore.Role = null; }
if (!store.IsAttachedToACache)
cache2.AddBackingStore(store);
}));
go.Set();
Assert.That(Task.WhenAll(tasks).Wait(TimeSpan.FromSeconds(60)), Is.True, "the racing calls and the attach finished (a deadlock fails here, it does not hang)");
var owner = attachFails ? cache2 : cache1;
var ownerRegistry = attachFails ? reg2 : reg1;
if (!ReferenceEquals(store.Attached, ownerRegistry))
bad.Add($"round {round}: the owning cache's registry is not on its store");
else
{
await owner.AddAsync(new IdDeck { Name = "after" });
owner.FlushAllBackingStores();
}
}
Assert.That(bad, Is.Empty);
}
// Every way into a cache's gate refuses a thread that holds a store's gate and not that cache's.
[TestCase("Get")]
[TestCase("GetWithSequence")]
[TestCase("GetStored")]
[TestCase("GetAll")]
[TestCase("IsDirty")]
[TestCase("BackingStores")]
[TestCase("AllEntries")]
[TestCase("AllStoredDocuments")]
[TestCase("TryGetHandle")]
public void EveryCacheGateEntryRefusesAGetterRunningUnderAStoresGate(string entry)
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) });
using var unrelated = new CultCache(registry);
var key = new CultRecordKey("x");
TapDoc.Hook = () =>
{
switch (entry)
{
case "Get": unrelated.Get(key); break;
case "GetWithSequence": unrelated.GetWithSequence(key); break;
case "GetStored": unrelated.GetStored(key); break;
case "GetAll": unrelated.GetAll<TapDoc>(); break;
case "IsDirty": _ = unrelated.IsDirty; break;
case "BackingStores": _ = unrelated.BackingStores; break;
case "AllEntries": _ = unrelated.AllEntries; break;
case "AllStoredDocuments": _ = unrelated.AllStoredDocuments; break;
default: unrelated.TryGetHandle(new TapDoc()); break;
}
};
var error = Assert.Catch(() => new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry));
Assert.That(Refused(error), Is.True, entry + " was refused under the store's gate");
Assert.That(File.Exists(_path), Is.False);
}
// Calling its own store from user code does not end the store's hold for the rest of the call: a cache entered afterwards is
// still refused.
[Test]
public void UserCodeThatCalledItsOwnStoreStillCannotEnterACacheAfterwards()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) });
var store = new SingleFileMessagePackBackingStore(_path);
var descriptor = registry.GetRequired<TapDoc>();
using var unrelated = new CultCache(registry);
TapDoc.Hook = () =>
{
store.Delete(new CultStoredDocument(new CultRecordKey("x"), DateTimeOffset.UtcNow.ToString("O"), descriptor, new TapDoc { Name = "x" }));
unrelated.Get(new CultRecordKey("x"));
};
var error = Assert.Catch(() => store.WriteDocument(new CultRecordKey("d"), new TapDoc { Name = "d" }, registry: registry));
Assert.That(Refused(error), Is.True, "the read after the own-store call was refused");
Assert.That(File.Exists(_path), Is.False);
}
// The attach lends the pull its registry, and publishes the cache only after the pull: while the pull runs the store holds the
// loan and no cache; once it returns the cache is there.
[Test]
public void AnAttachLendsTheRegistryAndPublishesTheCacheAfterThePull()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry);
var codec = Codec(registry);
var store = new RoleStore(_path);
using var cache = new CultCache(registry, codec);
bool cacheDuringPull = true;
CultDocumentRegistry? registryDuringPull = null;
var attach = OnThread("hook", () =>
{
RoleStore.RoleHook = () =>
{
cacheDuringPull = store.IsAttachedToACache;
registryDuringPull = store.Attached;
};
try { cache.AddBackingStore(store); }
finally { RoleStore.RoleHook = null; }
});
Finishes(attach.Thread, "the attach");
Assert.That(attach.Error(), Is.Null);
Assert.That(cacheDuringPull, Is.False, "the cache is not visible to the pull");
Assert.That(registryDuringPull, Is.SameAs(registry));
Assert.That(store.IsAttachedToACache, Is.True, "published once the pull succeeded");
}
// A failed attach takes back the registry through the one call, and never shows the cache.
[Test]
public void AFailedAttachTakesBackTheRegistryAndNeverShowsTheCache()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry);
var store = new RoleStore(_path) { FailAttachPull = true };
store.ReleaseAttach.Set();
using var cache = new CultCache(registry, Codec(registry));
var attach = OnThread("attach", () => cache.AddBackingStore(store));
Finishes(attach.Thread, "the failed attach");
Assert.That(attach.Error(), Is.Not.Null);
Assert.That(store.IsAttachedToACache, Is.False);
Assert.That(store.Attached, Is.Null, "the registry was taken back");
}
private const string ReentryRefusal = "A cacheless write was made inside a call that already holds this store";
// Until the attach publishes the cache the store's hold is the attach's, so a journal the pull's Admit runs on the attaching
// thread cannot write the store cacheless: it would write the file behind the cache and take back the registry the attach lent.
[Test]
public void AJournalOnTheAttachingThreadCannotWriteItsStoreCacheless()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry);
var store = new RoleStore(_path);
using var cache = new CultCache(registry, Codec(registry));
var journalRuns = 0;
Exception? journalError = null;
using var journal = cache.AddJournal(_ =>
{
journalRuns++;
try { store.WriteDocument(new CultRecordKey("b"), new IdDeck { Name = "b" }, registry: registry); }
catch (Exception error) { journalError = error; }
});
cache.AddBackingStore(store);
Assert.That(journalRuns, Is.EqualTo(1), "the pull's admission ran the journal");
Assert.That(journalError, Is.InstanceOf<InvalidOperationException>());
Assert.That(journalError!.Message, Does.StartWith(ReentryRefusal));
Assert.That(store.Attached, Is.SameAs(registry), "the refused call did not take back the attach's registry");
Assert.That(store.ReadStoreSnapshot()!.Records.Select(record => record.Key), Is.EquivalentTo(new[] { "a" }));
Assert.DoesNotThrow(() => cache.AddAsync(new IdDeck { Name = "later" }).GetAwaiter().GetResult());
Assert.DoesNotThrow(() => cache.FlushAllBackingStores());
Assert.DoesNotThrow(() => store.PullAll());
var keys = store.ReadStoreSnapshot()!.Records.Select(record => record.Key).ToArray();
Assert.That(keys, Has.Length.EqualTo(2));
Assert.That(keys, Does.Contain("a"));
Assert.That(keys, Does.Not.Contain("b"));
}
// A cacheless write owns its store outright: user code another cacheless write of the same store runs may not make a second.
[Test]
public void ACachelessWriteInsideACachelessWriteOfTheSameStoreIsRefused()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(TapDoc) });
var store = new RoleStore(_path);
TapDoc.Hook = () => store.WriteDocument(new CultRecordKey("inner"), new TapDoc { Name = "inner" }, registry: registry);
var outer = Assert.Catch<Exception>(() => store.WriteDocument(new CultRecordKey("outer"), new TapDoc { Name = "outer" }, registry: registry));
Assert.That(Refused(outer), Is.True, "the outer write failed with the inner call's refusal");
var refusal = outer!;
while (refusal is not InvalidOperationException && refusal.InnerException != null)
refusal = refusal.InnerException;
Assert.That(refusal.Message, Does.StartWith(ReentryRefusal));
Assert.That(File.Exists(_path), Is.False);
Assert.That(store.Attached, Is.Null);
}
// The bound on a call's passes is structural: Cache goes from null to a cache once. This test writes it any other way (the
// cache, then null again, under the cache's gate and the store's, in the order they are taken); the reader, which guessed null and
// then the cache, is stale twice and throws instead of looping.
[Test]
public void ACallWhoseGateChangesTwiceUnderItThrowsInsteadOfLooping()
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry);
var store = new RoleStore(_path);
using var cache = new CultCache(registry);
var holding = new ManualResetEventSlim();
var publish = new ManualResetEventSlim();
var retract = new ManualResetEventSlim();
var tamper = OnThread("tamper", () => cache.Held(() =>
{
store.UnderStoreGate(() =>
{
holding.Set();
publish.Wait(TimeSpan.FromSeconds(30));
store.Cache = cache;
return true;
});
retract.Wait(TimeSpan.FromSeconds(30));
store.UnderStoreGate(() => { store.Cache = null; return true; });
return true;
}));
Assert.That(holding.Wait(TimeSpan.FromSeconds(10)), Is.True);
Exception? readerError = null;
var reader = OnThread("reader", () =>
{
try { store.ReadStoreSnapshot(); }
catch (Exception e) { readerError = e; throw; }
});
WaitUntilBlocked(reader.Thread);
publish.Set();
Thread.Sleep(500);
retract.Set();
Finishes(tamper.Thread, "the tampering thread");
Finishes(reader.Thread, "the reader");
Assert.That(tamper.Error(), Is.Null);
Assert.That(readerError, Is.InstanceOf<InvalidOperationException>().With.Message.Contain("changed twice"));
}
// Failing attaches over and over, from one, two and four caches, against readers: a reader's gate changes under it at most once
// (the bound above throws on a second), so no reader fails and every thread finishes.
[TestCase(1)]
[TestCase(2)]
[TestCase(4)]
public void ReadersAcrossFailingAttachChurnNeverExceedTheBound(int attachers)
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
new SingleFileMessagePackBackingStore(_path).WriteDocument(new CultRecordKey("a"), new IdDeck { Name = "a" }, registry: registry);
var store = new RoleStore(_path) { FailAttachPull = true };
store.ReleaseAttach.Set();
var caches = Enumerable.Range(0, attachers).Select(_ => new CultCache(registry)).ToArray();
var stop = 0;
var readerErrors = new System.Collections.Concurrent.ConcurrentBag<string>();
var attachErrors = new System.Collections.Concurrent.ConcurrentBag<string>();
var threads = caches.Select(c => OnThread("attach", () =>
{
while (Volatile.Read(ref stop) == 0)
{
try { c.AddBackingStore(store); }
catch (InvalidOperationException e) when (e.Message.Contains("pull fails")) { }
catch (Exception e) { attachErrors.Add(e.Message); }
}
})).Concat(Enumerable.Range(0, 4).Select(_ => OnThread("churn-reader", () =>
{
while (Volatile.Read(ref stop) == 0)
{
try { store.ReadStoreSnapshot(); }
catch (Exception e) { readerErrors.Add(e.Message); }
}
}))).ToArray();
Thread.Sleep(TimeSpan.FromSeconds(2));
Volatile.Write(ref stop, 1);
foreach (var t in threads)
Finishes(t.Thread, "a churn thread");
Assert.That(readerErrors, Is.Empty);
Assert.That(attachErrors, Is.Empty);
foreach (var c in caches)
c.Dispose();
}
}
}