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;