CultLib/tests/GameCult.Caching.Tests/DirectoryStoreDurabilityTests.cs
Meta Vens a5d1be650d Mark every Cult document [MessagePackObject]
PlayerData, CultMeshSimulationFact, the Networking interop peer's
CultNetInteropNote, the cultnet-py benchmark item and the Caching test
documents lacked [MessagePackObject]. Only the generated codec could
serialize them; MessagePackSerializer refuses such types. Non-public test
documents take [MessagePackObject(AllowPrivate = true)], as consumer
internal documents already do, and ConditionalCommitTests.Slow marks its
private backing fields [IgnoreMember].

Same [Key]s, so payloads are unchanged: a pre/post probe over the
Networking assembly and both interop peers showed identical schema id,
content hash and payload bytes for all 9 documents, PlayerData equal once
its Guid.NewGuid() default id is masked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 23:38:08 +02:00

242 lines
9.9 KiB
C#

#nullable enable
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// Faults come from real file locks, not from probes in the store.
public class DirectoryStoreDurabilityTests
{
private const string FixedStoredAt = "2026-09-13T00:00:00.0000000+00:00";
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(Page) });
private string _directory = string.Empty;
private string _manifest = string.Empty;
private string _records = string.Empty;
[SetUp]
public void CreateDirectory()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-durability-{Guid.NewGuid():N}");
Directory.CreateDirectory(_directory);
_manifest = Path.Combine(_directory, "store.cc");
_records = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(_manifest);
}
[TearDown]
public void DeleteDirectory()
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
[Test]
public async Task FailedManifestReplaceKeepsPreviousGeneration()
{
using var cache = Open(out _);
var key = await cache.UpsertAsync(typeof(Page), new Page { Name = "a", Text = "one" });
cache.FlushAllBackingStores();
await cache.UpsertAsync(typeof(Page), new Page { Name = "a", Text = "two" }, key);
using (new FileStream(_manifest, FileMode.Open, FileAccess.Read, FileShare.Read))
{
Assert.That(() => cache.FlushAllBackingStores(),
Throws.InstanceOf<IOException>().Or.InstanceOf<UnauthorizedAccessException>());
}
Assert.That(cache.IsDirty, Is.True);
using (var reader = Open(out _))
Assert.That(reader.Get<Page>(key)?.Text, Is.EqualTo("one"));
cache.FlushAllBackingStores();
using var reopened = Open(out _);
Assert.That(reopened.Get<Page>(key)?.Text, Is.EqualTo("two"));
}
[Test]
public void SameStoredAtDifferentPayloadWritesADistinctPage()
{
using var cache = Open(out var store);
var key = new CultRecordKey("page");
var descriptor = Registry.GetRequired<Page>();
store.Push(new CultStoredDocument(key, FixedStoredAt, descriptor, new Page { Name = "p", Text = "first" }));
store.PushAll();
var firstIdentity = ManifestRecord(key).Payload;
store.Push(new CultStoredDocument(key, FixedStoredAt, descriptor, new Page { Name = "p", Text = "second" }));
store.PushAll();
var secondIdentity = ManifestRecord(key).Payload;
Assert.That(secondIdentity, Is.Not.EqualTo(firstIdentity));
Assert.That(File.Exists(PagePath(secondIdentity)), Is.True);
using var reader = Open(out _);
Assert.That(reader.Get<Page>(key)?.Text, Is.EqualTo("second"));
}
[Test]
public void FlushWritesPagesBeforeManifest()
{
using var cache = Open(out var store);
var key = new CultRecordKey("page");
var descriptor = Registry.GetRequired<Page>();
store.Push(new CultStoredDocument(key, FixedStoredAt, descriptor, new Page { Name = "p", Text = "first" }));
store.PushAll();
var before = File.ReadAllBytes(_manifest);
const string nextStoredAt = "2026-09-13T00:00:01.0000000+00:00";
var next = new Page { Name = "p", Text = "second" };
var page = CultDocumentMessagePackSerialization.SerializePersistedRecord(new CultPersistedRecord
{
Key = key.Value,
SchemaId = descriptor.SchemaId,
StoredAt = nextStoredAt,
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(next, typeof(Page), Registry)
});
using (var sha = SHA256.Create())
Directory.CreateDirectory(PagePath(sha.ComputeHash(page)));
store.Push(new CultStoredDocument(key, nextStoredAt, descriptor, next));
Assert.That(() => store.PushAll(), Throws.InstanceOf<IOException>().Or.InstanceOf<UnauthorizedAccessException>());
Assert.That(File.ReadAllBytes(_manifest), Is.EqualTo(before), "the manifest was written although its page was not");
using var reader = Open(out _);
Assert.That(reader.Get<Page>(key)?.Text, Is.EqualTo("first"));
}
[Test]
public void PullAllWaitsForHeldCommitLease()
{
using var cache = Open(out var store);
Task pull;
using (HoldLease())
{
pull = Task.Run(() => store.PullAll());
Assert.That(pull.Wait(100), Is.False, "PullAll did not wait for the commit lease");
}
Assert.That(pull.Wait(TimeSpan.FromSeconds(10)), Is.True);
}
[Test]
public async Task MutationBlockedBehindFlushStaysDirty()
{
using var cache = Open(out var store);
var first = await cache.UpsertAsync(typeof(Page), new Page { Name = "a" });
Task flush;
Task<CultRecordKey> mutation;
using (HoldLease())
{
flush = Task.Run(() => cache.FlushAllBackingStores());
Assert.That(flush.Wait(100), Is.False);
mutation = Task.Run(() => cache.UpsertAsync(typeof(Page), new Page { Name = "b" }));
Assert.That(mutation.Wait(100), Is.False, "a mutation ran while the flush held the store");
}
Assert.That(Task.WaitAll(new Task[] { flush, mutation }, TimeSpan.FromSeconds(10)), Is.True);
Assert.That(store.IsDirty, Is.True, "the flush pardoned a mutation it did not write");
using (var reader = Open(out _))
{
Assert.That(reader.Get(first), Is.Not.Null);
Assert.That(reader.Get(mutation.Result), Is.Null);
}
cache.FlushAllBackingStores();
using var reopened = Open(out _);
Assert.That(reopened.Get(mutation.Result), Is.Not.Null);
}
[Test]
public async Task FlushNeverOpensUnchangedPages()
{
using var cache = Open(out var store);
var first = await cache.UpsertAsync(typeof(Page), new Page { Name = "a" });
cache.FlushAllBackingStores();
var page = Directory.GetFiles(_records, "*.msgpack").Single();
using (new FileStream(page, FileMode.Open, FileAccess.Read, FileShare.None))
{
await cache.UpsertAsync(typeof(Page), new Page { Name = "b" });
Assert.That(() => cache.FlushAllBackingStores(), Throws.Nothing);
}
Assert.That(store.IsDirty, Is.False);
using var reader = Open(out _);
Assert.That(reader.GetAll<Page>().Count(), Is.EqualTo(2));
Assert.That(reader.Get(first), Is.Not.Null);
}
[Test]
public async Task MissingManifestIgnoresOrphanPagesAndFlushDeletesThem()
{
Directory.CreateDirectory(_records);
var orphan = Path.Combine(_records, new string('a', 64) + ".msgpack");
File.WriteAllBytes(orphan, new byte[] { 0x91, 0xA1, 0x78 });
using var cache = Open(out _);
Assert.That(cache.AllEntries, Is.Empty);
await cache.UpsertAsync(typeof(Page), new Page { Name = "a" });
cache.FlushAllBackingStores();
Assert.That(File.Exists(orphan), Is.False);
Assert.That(Directory.GetFiles(_records, "*.msgpack"), Has.Length.EqualTo(1));
}
[Test]
public async Task TamperedPageFailsLoadNamingPage()
{
using (var cache = Open(out _))
{
await cache.UpsertAsync(typeof(Page), new Page { Name = "a", Text = "honest" });
cache.FlushAllBackingStores();
}
var page = Directory.GetFiles(_records, "*.msgpack").Single();
var bytes = File.ReadAllBytes(page);
bytes[bytes.Length - 1] ^= 0xFF;
File.WriteAllBytes(page, bytes);
using var reader = new CultCache(Registry);
Assert.That(() => reader.AddBackingStore(new DirectoryMessagePackBackingStore(_manifest)),
Throws.Exception.With.Message.Contains(page));
Assert.That(reader.AllEntries, Is.Empty);
}
private CultCache Open(out DirectoryMessagePackBackingStore store)
{
var cache = new CultCache(Registry);
store = new DirectoryMessagePackBackingStore(_manifest);
cache.AddBackingStore(store);
return cache;
}
private FileStream HoldLease()
{
Directory.CreateDirectory(_records);
return new FileStream(Path.Combine(_records, ".commit.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
}
private CultPersistedRecord ManifestRecord(CultRecordKey key) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(_manifest)).Records.Single(record => record.Key == key.Value);
private string PagePath(byte[] identity) =>
Path.Combine(_records, BitConverter.ToString(identity).Replace("-", string.Empty).ToLowerInvariant() + ".msgpack");
[CultDocument("tests.durability_page", "tests.durability_page.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class Page
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public string Text = string.Empty;
}
}
}