Share the cache gate with its stores and make unconditional commit last-writer-wins

An attached store takes its cache's gate as its own lock (CacheBackingStore.Gate),
so a pull's load callback into the cache can no longer take the gate after the
store lock. The directory store's private mutation gate is deleted; the
single-file pull now runs under the gate; PullAllBackingStoresAsync takes the
gate around each store's pull. Fixes the pull/upsert deadlock on directory
stores and the re-pull that erased a staged single-file write.

An unconditional single-file Commit writes the store's whole view plus the batch,
exactly as a flush would, instead of merging onto the file; it persists earlier
staged writes and leaves the store clean. Conditional commits still land onto
the file under the lock. Directory-store commit and flush already share one
generation writer (staged keys onto the current manifest) and are unchanged.

Contracts state both rules, the lock order, that OnUpdate fires for loads only,
that a missing single file keeps staged writes, and that a store failing to
hydrate on open throws and is left byte-identical.

Tests: PullAndUpsertOnDirectoryStoreDoNotDeadlock, RepullNeverErasesAStagedWrite,
UnconditionalCommitIsLastWriterWinsLikeFlush, UnconditionalCommitPersistsStagedWrites,
OpeningACorruptStoreThrowsAndLeavesItUntouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-13 18:45:27 +02:00
commit 5aad137e61
5 changed files with 236 additions and 25 deletions

View file

@ -17,7 +17,6 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
private const string IndexedFormatVersion = "cultcache.store.v4.directory-content-addressed-pages";
private readonly FileInfo _manifestFile;
private readonly DirectoryInfo _recordDirectory;
private readonly object _mutationGate = new();
private readonly ConcurrentDictionary<string, bool> _dirtyKeys = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, bool> _deletedKeys = new(StringComparer.Ordinal);
private readonly HashSet<string> _hydratedKeys = new(StringComparer.Ordinal);
@ -42,7 +41,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public override void PullAll()
{
lock (_mutationGate)
lock (Gate)
PullAllCore();
}
@ -112,7 +111,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public override void Push(CultStoredDocument entry)
{
ThrowIfReadOnly();
lock (_mutationGate)
lock (Gate)
{
Entries[entry.Key.Value] = entry;
_dirtyKeys[entry.Key.Value] = true;
@ -124,7 +123,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public override void Delete(CultStoredDocument entry)
{
ThrowIfReadOnly();
lock (_mutationGate)
lock (Gate)
{
Entries.TryRemove(entry.Key.Value, out _);
_dirtyKeys.TryRemove(entry.Key.Value, out _);
@ -136,7 +135,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public override CultCommitOutcome CommitBatch(CultCommitRequest request, bool wait)
{
ThrowIfReadOnly();
lock (_mutationGate)
lock (Gate)
{
Directory.CreateDirectory(_manifestFile.DirectoryName!);
using var commitLease = AcquireCommitLease(wait);
@ -188,7 +187,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
public override void PushAll()
{
ThrowIfReadOnly();
lock (_mutationGate)
lock (Gate)
{
if (!IsDirty)
return;