CultLib/tests/GameCult.Caching.Tests/CultCacheJournalTests.cs
Meta Vens 592a2d49d4 Journal registration counts as disposed only once removal succeeds; a throwing hold body surfaces journal failures
AddJournal's Disposable.Create counted a refused dispose (from inside a journal
or another cache's hold) as done, leaving the journal registered forever. A
private registration now flips only after the removal succeeded. Held's catch
path dropped the hold's journal failures; it now throws an AggregateException
with the body's exception first. The store-composition contract states exactly
when the second getter read runs on a conditional commit. Adds the direct test
that a load is journaled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:35:50 +02:00

402 lines
16 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
using R3;
namespace GameCult.Caching.Tests
{
// Cut 3 of docs/cultcache-publication-cut.md: a journal is called under the cache gate, right after an admission is
// applied, once per admission, with that admission's changes in Sequence order. It is the one place cache order is a fact.
[NonParallelizable]
public class CultCacheJournalTests
{
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(JournalNote) });
private string _directory = string.Empty;
[SetUp]
public void Prepare()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-journal-{Guid.NewGuid():N}");
Directory.CreateDirectory(_directory);
JournalNote.Armed = false;
}
[TearDown]
public void Cleanup()
{
JournalNote.Armed = false;
if (Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
[CultDocument("tests.journal_note", "tests.journal_note.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class JournalNote
{
public static volatile bool Armed;
[IgnoreMember] private string _name = string.Empty;
[Key(0)]
[CultName]
public string Name
{
get
{
if (Armed && _name == "boom")
throw new InvalidOperationException("name getter boom");
return _name;
}
set => _name = value;
}
[Key(1)]
public int Power;
}
private static CultRecordKey KeyOf(string text) => new($"journal:{text}");
private static CultCache Memory() => new(Registry, CultCacheMessagePack.CreateCodec(Registry));
private static void Put(CultCache cache, string name, int power = 1) =>
cache.Commit(batch => batch.Upsert(typeof(JournalNote), new JournalNote { Name = name, Power = power }, KeyOf(name)));
[Test]
public void EveryAdmissionIsJournaledInSequenceOrderAndItsChangesArriveTogether()
{
using var cache = Memory();
var calls = new List<long[]>();
using var journal = cache.AddJournal(changes => calls.Add(changes.Select(change => change.Sequence).ToArray()));
const int threads = 8;
const int writes = 1000;
Parallel.For(0, threads, new ParallelOptions { MaxDegreeOfParallelism = threads }, thread =>
{
for (var i = 0; i < writes; i++)
cache.Commit(batch =>
{
batch.Upsert(typeof(JournalNote), new JournalNote { Name = $"{thread}-{i}-a" }, KeyOf($"{thread}-{i}-a"));
batch.Upsert(typeof(JournalNote), new JournalNote { Name = $"{thread}-{i}-b" }, KeyOf($"{thread}-{i}-b"));
});
});
Assert.That(calls, Has.Count.EqualTo(threads * writes), "one call per admission");
var expected = 1L;
foreach (var call in calls)
{
Assert.That(call, Is.EqualTo(new[] { expected, expected + 1 }), "contiguous, and one call's changes together");
expected += 2;
}
}
[Test]
public void AJournalRunsAfterTheAdmissionIsAppliedAndBeforeAnyObserver()
{
using var cache = Memory();
var order = new List<string>();
using var observer = cache.Watch<JournalNote>().Subscribe(change => order.Add($"observer:{change.Sequence}"));
var seenInside = new List<string?>();
using var journal = cache.AddJournal(changes =>
{
order.Add($"journal:{changes[0].Sequence}");
seenInside.Add(cache.Get<JournalNote>(KeyOf("one"))?.Name);
Assert.That(changes[0].Kind, Is.EqualTo(order.Count == 1 ? CultCacheDocumentChangeKind.Added : CultCacheDocumentChangeKind.Updated));
Assert.That(((JournalNote?)changes[0].Document)?.Power, Is.EqualTo(order.Count == 1 ? 1 : 2));
Assert.That(((JournalNote?)changes[0].PreviousDocument)?.Power, Is.EqualTo(order.Count == 1 ? null : 1));
});
Put(cache, "one", 1);
Put(cache, "one", 2);
Assert.That(order, Is.EqualTo(new[] { "journal:1", "observer:1", "journal:2", "observer:2" }));
Assert.That(seenInside, Is.EqualTo(new[] { "one", "one" }), "the journal reads the new state");
}
[Test]
public void AnAdmissionThatChangesNothingIsNotJournaled()
{
using var cache = Memory();
var calls = 0;
using var journal = cache.AddJournal(_ => calls++);
// Removing a record the cache does not hold is a commit that admits nothing.
cache.Commit(batch => batch.Remove(KeyOf("absent")));
Assert.That(calls, Is.Zero);
}
[Test]
public void JournalsRunInRegistrationOrderAndADisposedJournalStopsRunning()
{
using var cache = Memory();
var order = new List<string>();
using var first = cache.AddJournal(_ => order.Add("first"));
var second = cache.AddJournal(_ => order.Add("second"));
using var third = cache.AddJournal(_ => order.Add("third"));
Put(cache, "one");
second.Dispose();
order.Add("|");
Put(cache, "two");
Assert.That(order, Is.EqualTo(new[] { "first", "second", "third", "|", "first", "third" }));
}
[Test]
public void AJournalThatWritesTheCacheThrowsAndTheAdmissionStands()
{
using var cache = Memory();
var seen = new List<long>();
using var observer = cache.Watch<JournalNote>().Subscribe(change => seen.Add(change.Sequence));
using var journal = cache.AddJournal(_ => Put(cache, "nested"));
var thrown = Assert.Throws<InvalidOperationException>(() => Put(cache, "outer"))!;
Assert.Multiple(() =>
{
Assert.That(thrown.Message, Does.Contain("journal"));
Assert.That(cache.Get<JournalNote>(KeyOf("outer")), Is.Not.Null, "the admission stands");
Assert.That(cache.Get<JournalNote>(KeyOf("nested")), Is.Null, "the journal's own write did not land");
Assert.That(seen, Is.EqualTo(new[] { 1L }), "and it was published");
});
}
[Test]
public void AJournalThatRegistersAnotherJournalThrows()
{
using var cache = Memory();
using var journal = cache.AddJournal(_ => cache.AddJournal(_ => { }));
var thrown = Assert.Throws<InvalidOperationException>(() => Put(cache, "outer"))!;
Assert.That(thrown.Message, Does.Contain("journal"));
Assert.That(cache.Get<JournalNote>(KeyOf("outer")), Is.Not.Null);
}
[Test]
public void TheCacheIsWritableAgainOnceAJournalHasReturned()
{
using var cache = Memory();
using var journal = cache.AddJournal(_ => { });
Put(cache, "one");
cache.Commit(batch => batch.Upsert(typeof(JournalNote), new JournalNote { Name = "two" }, KeyOf("two")));
using var late = cache.AddJournal(_ => { });
Assert.That(cache.Get<JournalNote>(KeyOf("two")), Is.Not.Null);
}
[Test]
public void ARefusedAdmissionIsNotJournaled()
{
using var cache = Memory();
var calls = 0;
using var journal = cache.AddJournal(_ => calls++);
JournalNote.Armed = true;
Assert.Throws<InvalidOperationException>(() => Put(cache, "boom"));
JournalNote.Armed = false;
Assert.That(calls, Is.Zero);
}
[Test]
public void AnAdmissionTheStoreDeclinesIsNotJournaled()
{
using var cache = Memory();
Put(cache, "seed");
var calls = 0;
using var journal = cache.AddJournal(_ => calls++);
var outcome = cache.TryCommit(batch =>
{
batch.Expect(KeyOf("seed"), null);
batch.Upsert(typeof(JournalNote), new JournalNote { Name = "two" }, KeyOf("two"));
});
Assert.That(outcome, Is.EqualTo(CultCommitOutcome.Mismatch));
Assert.That(calls, Is.Zero);
Assert.That(cache.Get<JournalNote>(KeyOf("two")), Is.Null);
}
[Test]
public void AVariantDependentIsJournaledInTheSameCallAsItsBaseEdit()
{
using var cache = Memory();
Put(cache, "base", 10);
cache.Commit(batch => batch.UpsertVariant(KeyOf("big"), KeyOf("base"), new[]
{
cache.Override<JournalNote>(nameof(JournalNote.Name), "big"),
}));
var calls = new List<string[]>();
using var journal = cache.AddJournal(changes => calls.Add(changes.Select(change => change.Key.Value).ToArray()));
Put(cache, "base", 20);
Assert.That(calls, Has.Count.EqualTo(1));
Assert.That(calls[0], Is.EquivalentTo(new[] { KeyOf("base").Value, KeyOf("big").Value }));
}
[Test]
public void AThrowingJournalDoesNotStopTheOthersOrTheAdmissionAndTheWriterHearsOfItAfterPublication()
{
using var cache = Memory();
var order = new List<string>();
using var observer = cache.Watch<JournalNote>().Subscribe(_ => order.Add("observer"));
using var a = cache.AddJournal(_ =>
{
order.Add("a");
throw new InvalidOperationException("a");
});
using var b = cache.AddJournal(_ => order.Add("b"));
using var c = cache.AddJournal(_ =>
{
order.Add("c");
throw new InvalidOperationException("c");
});
var thrown = Assert.Throws<AggregateException>(() => Put(cache, "one"))!;
Assert.Multiple(() =>
{
Assert.That(thrown.InnerExceptions.Select(exception => exception.Message), Is.EqualTo(new[] { "a", "c" }));
Assert.That(order, Is.EqualTo(new[] { "a", "b", "c", "observer" }), "every journal ran, then the change published");
Assert.That(cache.Get<JournalNote>(KeyOf("one")), Is.Not.Null, "the admission stands");
});
using var quiet = cache.Watch<JournalNote>().Subscribe(_ => { });
Assert.Throws<AggregateException>(() => Put(cache, "two"), "a throwing journal stays registered");
}
[Test]
public void ADisposeTheCacheRefusesLeavesTheJournalRegisteredAndALaterDisposeRemovesIt()
{
using var cache = Memory();
var calls = 0;
IDisposable? registration = null;
registration = cache.AddJournal(_ =>
{
calls++;
registration!.Dispose();
});
var thrown = Assert.Throws<InvalidOperationException>(() => Put(cache, "one"))!;
Assert.That(thrown.Message, Does.Contain("journal"), "disposing from inside a journal is refused");
registration.Dispose();
Put(cache, "two");
Assert.That(calls, Is.EqualTo(1), "the outside dispose removed it, though the first attempt threw");
registration.Dispose();
}
[Test]
public void ALoadIsJournaledWithItsChanges()
{
var path = Path.Combine(_directory, "notes.cc");
using (var writer = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }))
{
Put(writer, "a");
Put(writer, "b");
}
using var cache = Memory();
var journaled = new List<IReadOnlyList<CultCacheDocumentChange<object>>>();
using var journal = cache.AddJournal(changes => journaled.Add(changes));
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path));
Assert.Multiple(() =>
{
Assert.That(journaled, Has.Count.EqualTo(1), "one admission, one call");
Assert.That(journaled[0].Select(change => change.Key).OrderBy(key => key.Value),
Is.EqualTo(new[] { KeyOf("a"), KeyOf("b") }));
});
}
// A store that admits a load and then fails: the hold's body throws after the journal has already failed.
private sealed class LoadThenFailStore : CacheBackingStore
{
public bool Armed;
public override void PullAll() => Held(() =>
{
if (!Armed)
return;
var record = new CultStoredDocument(
KeyOf("loaded"), "2026-01-01T00:00:00.0000000+00:00", Registry.GetRequired(typeof(JournalNote)), new JournalNote { Name = "loaded" });
Loaded!(new[] { record }, Array.Empty<CultStoredDocument>());
throw new InvalidOperationException("store failed after the load");
});
public override void Push(CultStoredDocument entry) { }
public override void Delete(CultStoredDocument entry) { }
public override void PushAll() { }
public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait) => CultCommitOutcome.Committed;
}
[Test]
public void AHoldWhoseBodyThrowsSurfacesTheBodyFirstThenTheJournalFailures()
{
using var cache = Memory();
var store = new LoadThenFailStore();
cache.AddBackingStore(store);
using var a = cache.AddJournal(_ => throw new InvalidOperationException("journal a"));
using var b = cache.AddJournal(_ => throw new InvalidOperationException("journal b"));
store.Armed = true;
var thrown = Assert.Throws<AggregateException>(() => store.PullAll())!;
Assert.Multiple(() =>
{
Assert.That(thrown.InnerExceptions.Select(exception => exception.Message),
Is.EqualTo(new[] { "store failed after the load", "journal a", "journal b" }));
Assert.That(cache.Get<JournalNote>(KeyOf("loaded")), Is.Not.Null, "the load landed");
});
}
[Test]
public void AHoldWhoseBodyThrowsAloneRethrowsItAsItself()
{
using var cache = Memory();
var store = new LoadThenFailStore();
cache.AddBackingStore(store);
using var journal = cache.AddJournal(_ => { });
store.Armed = true;
var thrown = Assert.Throws<InvalidOperationException>(() => store.PullAll())!;
Assert.That(thrown.Message, Is.EqualTo("store failed after the load"));
}
[Test]
public void AJournalThatThrowsDuringALoadLeavesTheStoreAttachedAndTheLoadAdopted()
{
var path = Path.Combine(_directory, "notes.cc");
using (var writer = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry }))
Put(writer, "stored");
var cache = Memory();
var seen = new List<long>();
using var observer = cache.Watch<JournalNote>().Subscribe(change => seen.Add(change.Sequence));
using var journal = cache.AddJournal(_ => throw new InvalidOperationException("journal boom"));
var thrown = Assert.Throws<InvalidOperationException>(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(path)))!;
Assert.Multiple(() =>
{
Assert.That(thrown.Message, Is.EqualTo("journal boom"));
Assert.That(cache.BackingStores, Has.Count.EqualTo(1), "the store stays attached: it and the cache agree");
Assert.That(cache.Get<JournalNote>(KeyOf("stored")), Is.Not.Null);
Assert.That(seen, Is.EqualTo(new[] { 1L }));
});
}
}
}