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

View file

@ -393,7 +393,8 @@ namespace GameCult.Caching
/// <summary> /// <summary>
/// The schema id this record is durably stored under, as its backing store last read or wrote it. It differs from /// 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 /// <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> /// </summary>
public string PersistedSchemaId => _persisted.SchemaId; public string PersistedSchemaId => _persisted.SchemaId;
@ -411,7 +412,7 @@ namespace GameCult.Caching
internal CultStoredDocument Resolved(object document) => new(Key, StoredAt, Descriptor, Variant!, document, _persisted); 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; internal void MarkWritten() => _persisted.SchemaId = Descriptor.SchemaId;
private sealed class PersistedIdentity private sealed class PersistedIdentity
@ -3322,6 +3323,16 @@ namespace GameCult.Caching
{ {
IsDirty = false; 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 public abstract class SingleFileBackingStore : CacheBackingStore
@ -3416,7 +3427,7 @@ namespace GameCult.Caching
} }
foreach (var entry in Entries.Values) foreach (var entry in Entries.Values)
entry.MarkWritten(); MarkWritten(entry);
MarkFlushSucceeded(); MarkFlushSucceeded();
}); });
} }
@ -3465,7 +3476,7 @@ namespace GameCult.Caching
if (!ontoDisk) if (!ontoDisk)
{ {
foreach (var entry in Entries.Values) foreach (var entry in Entries.Values)
entry.MarkWritten(); MarkWritten(entry);
} }
MarkFlushSucceeded(); MarkFlushSucceeded();