CultCache: every backing store marks the records it rewrites, through a protected CacheBackingStore.MarkWritten

The directory store's write of a generation marks each page it wrote once the
manifest names it, so a Push+PushAll of a loaded drifted entry no longer leaves
its persisted schema id stale. The single-file store marks through the same
member, and a store outside GameCult.Caching now has a supported way to mark
its own rewrite. The getter's doc names how a store records a rewrite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-21 21:41:53 +02:00
commit 73d438dbcf
3 changed files with 24 additions and 5 deletions

View file

@ -263,6 +263,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
var keysToWrite = _dirtyKeys.Keys
.OrderBy(value => value, StringComparer.Ordinal)
.ToArray();
var written = new List<CultStoredDocument>(keysToWrite.Length);
foreach (var key in keysToWrite)
{
if (!Entries.TryGetValue(key, out var stored))
@ -281,11 +282,15 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
WriteFileAtomically(
ContentAddressedRecordPath(indexRecord),
pagePayload);
written.Add(stored);
}
WriteManifest(targetCatalog, currentIndex.Values
.OrderBy(record => record.Key, StringComparer.Ordinal)
.ToArray());
// The manifest names the new pages: each was written under its own descriptor.
foreach (var stored in written)
MarkWritten(stored);
DeleteUnreferencedRecordPages(currentIndex.Values);

View file

@ -231,7 +231,10 @@ the id the record is persisted under (`CultStoredDocument.PersistedSchemaId`),
not the one this cache decodes it as. A value change mints a `storedAt`
strictly later than the record it replaces; writers bump a minted timestamp by
one tick when it is not later. An entry loaded under compatible schema drift
keeps its persisted id until its store rewrites it. A whole-file flush or
keeps its persisted id until its store rewrites it; a store records each
record its completed write rewrote with the protected
`CacheBackingStore.MarkWritten`, and one that does not leaves the entry's
`Expect` failing closed until the next pull. A whole-file flush or
unconditional commit rewrites every record under the writer's current schema
and keeps its `storedAt`, so another cache's `Expect` on a drifted record it
read before that rewrite fails until it re-reads the store.

View file

@ -393,7 +393,8 @@ namespace GameCult.Caching
/// <summary>
/// The schema id this record is durably stored under, as its backing store last read or wrote it. It differs from
/// <see cref="CultDocumentDescriptor.SchemaId"/> only for a record loaded under compatible schema drift that its
/// store has not rewritten since. Conditional commit compares this id and <see cref="StoredAt"/> with the disk.
/// store has not rewritten since; a store records its rewrite with <see cref="CacheBackingStore.MarkWritten"/>.
/// Conditional commit compares this id and <see cref="StoredAt"/> with the disk.
/// </summary>
public string PersistedSchemaId => _persisted.SchemaId;
@ -411,7 +412,7 @@ namespace GameCult.Caching
internal CultStoredDocument Resolved(object document) => new(Key, StoredAt, Descriptor, Variant!, document, _persisted);
// Only a store calls this, under the hold, after its completed durable write rewrote the record under its descriptor.
// Reached only through CacheBackingStore.MarkWritten, the one way a store records a rewrite.
internal void MarkWritten() => _persisted.SchemaId = Descriptor.SchemaId;
private sealed class PersistedIdentity
@ -3322,6 +3323,16 @@ namespace GameCult.Caching
{
IsDirty = false;
}
/// <summary>
/// Records that this store's completed durable write stored <paramref name="entry"/> under its descriptor, so its
/// <see cref="CultStoredDocument.PersistedSchemaId"/> is <see cref="CultDocumentDescriptor.SchemaId"/> again. Call it
/// under <c>Held</c>, once the write is durable, for each record the write rewrote; a record whose bytes the write
/// kept is not marked. A rewrite left unmarked makes conditional commit compare the schema id the record was read
/// under, which fails closed until the next pull.
/// </summary>
protected static void MarkWritten(CultStoredDocument entry) =>
(entry ?? throw new ArgumentNullException(nameof(entry))).MarkWritten();
}
public abstract class SingleFileBackingStore : CacheBackingStore
@ -3416,7 +3427,7 @@ namespace GameCult.Caching
}
foreach (var entry in Entries.Values)
entry.MarkWritten();
MarkWritten(entry);
MarkFlushSucceeded();
});
}
@ -3465,7 +3476,7 @@ namespace GameCult.Caching
if (!ontoDisk)
{
foreach (var entry in Entries.Values)
entry.MarkWritten();
MarkWritten(entry);
}
MarkFlushSucceeded();