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>
402 lines
16 KiB
C#
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 }));
|
|
});
|
|
}
|
|
}
|
|
}
|