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>
This commit is contained in:
Meta Vens 2026-09-29 20:35:50 +02:00
commit 592a2d49d4
3 changed files with 135 additions and 7 deletions

View file

@ -101,10 +101,12 @@ which route wrote it.
already agree when publication starts.
- An admission runs a record's `[CultName]` and `[CultIndex]` getters once per
landing record, before the store is written and before any `Sequence` is
minted. A conditional commit on a store that holds variants is judged a second
time against the file it merges onto, which runs the getters again for the
records it lands and for any variant it re-resolves; that judgement is also
before the store is written. A getter that throws refuses the whole change
minted. A conditional commit on a clean file store is judged a second time
against the file it merges onto whenever the cache holds variants in any store,
the batch lands a variant, or the file holds one. That second read runs the
getters again for the records the batch lands, for any variant it re-resolves,
and for the records other writers put in the file (the arriving set); that
judgement is also before the store is written. A getter that throws refuses the whole change
set, loads included: nothing is written, minted or left in memory.
- Pulling all stores pulls every attached store even if a handler throws during
one store's load, then rethrows.

View file

@ -1903,6 +1903,29 @@ namespace GameCult.Caching
change.Sequence));
}
// Disposing removes the journal. A dispose the cache refuses (from inside a journal, or inside another cache's hold)
// throws and leaves the registration live and disposable; it counts as disposed only once the removal succeeded.
private sealed class JournalRegistration : IDisposable
{
private readonly CultCache _cache;
private readonly Action<IReadOnlyList<CultCacheDocumentChange<object>>> _journal;
private bool _removed;
public JournalRegistration(CultCache cache, Action<IReadOnlyList<CultCacheDocumentChange<object>>> journal)
{
_cache = cache;
_journal = journal;
}
public void Dispose()
{
if (_removed)
return;
_cache.Held(() => _cache._journals.Remove(_journal));
_removed = true;
}
}
private IDisposable Register(Observer<Change> observer)
{
lock (_observerGate)
@ -1941,7 +1964,7 @@ namespace GameCult.Caching
_journals.Add(journal);
return true;
});
return Disposable.Create(() => Held(() => _journals.Remove(journal)));
return new JournalRegistration(this, journal);
}
// Attaching reads the store; there is no interval in which it is attached but unread.
@ -2425,11 +2448,13 @@ namespace GameCult.Caching
lock (_gate)
result = body();
}
catch
catch (Exception exception)
{
_held = null;
Publish(mine);
throw;
if (mine.Failures.Count == 0)
throw;
throw new AggregateException(mine.Failures.Prepend(exception));
}
_held = null;

View file

@ -275,6 +275,107 @@ namespace GameCult.Caching.Tests
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()
{