Directory store: a refused foreign overwrite leaves the pending set with its failed write

WriteGeneration left the refused key in the dirty and deleted sets, so every later generation
refused it. The refusal is now one check before any staging, shared by flush and commit, that
removes the refused keys from the pending sets. Tests cover both store kinds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-02 16:51:54 +02:00
commit b0f7d59c96
2 changed files with 66 additions and 9 deletions

View file

@ -186,6 +186,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
if (!request.ConditionsHold(manifest.Records, Entries.Values))
return CultCommitOutcome.Mismatch;
RefuseForeign(manifest, request.Upserts.Concat(request.Deletes).Select(entry => entry.Key.Value));
var previousEntries = Entries.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
var previousDirtyKeys = _dirtyKeys.Keys.ToArray();
var previousDeletedKeys = _deletedKeys.Keys.ToArray();
@ -235,23 +236,41 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
Directory.CreateDirectory(_manifestFile.DirectoryName!);
using var commitLease = AcquireCommitLease(wait: true, create: true);
WriteGeneration(ReadManifest());
var manifest = ReadManifest();
RefuseForeign(manifest, Array.Empty<string>());
WriteGeneration(manifest);
});
}
// The manifest's other records stay as they are, foreign ones included; a write that would replace or remove a foreign
// record is refused before any page is written. The refusal takes the refused keys with it: a staged change to a record that
// is foreign now, and this store's copy of one, are not what the file holds, and kept they would refuse every later write.
private void RefuseForeign(CultPersistedStoreSnapshot currentManifest, IEnumerable<string> written)
{
var foreign = currentManifest.Records
.Where(record => Foreign(record, currentManifest.SchemaCatalog) != null)
.Select(record => record.Key)
.ToHashSet(StringComparer.Ordinal);
var refused = written.Concat(_deletedKeys.Keys).Concat(_dirtyKeys.Keys).Where(foreign.Contains).Distinct().ToArray();
if (refused.Length == 0)
return;
foreach (var key in refused)
{
Entries.TryRemove(key, out _);
_dirtyKeys.TryRemove(key, out _);
_deletedKeys.TryRemove(key, out _);
}
IsDirty = !_dirtyKeys.IsEmpty || !_deletedKeys.IsEmpty;
var held = currentManifest.Records.First(record => record.Key == refused[0]);
throw Overwrites(Foreign(held, currentManifest.SchemaCatalog)!);
}
// Runs under the commit lease: pages first, then the manifest that names them.
private void WriteGeneration(CultPersistedStoreSnapshot currentManifest)
{
Directory.CreateDirectory(_recordDirectory.FullName);
var currentIndex = currentManifest.Records.ToDictionary(record => record.Key, record => record, StringComparer.Ordinal);
// The manifest's other records stay as they are, foreign ones included; a write that would replace or remove a foreign
// record is refused before any page is written.
foreach (var key in _deletedKeys.Keys.Concat(_dirtyKeys.Keys))
{
if (currentIndex.TryGetValue(key, out var held) && Foreign(held, currentManifest.SchemaCatalog) is { } carried)
throw Overwrites(carried);
}
foreach (var key in _deletedKeys.Keys)
currentIndex.Remove(key);
foreach (var key in _dirtyKeys.Keys)