From 73d438dbcf2a4350472bee696fad19eeb07cd693 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 21 Oct 2026 21:41:53 +0200 Subject: [PATCH] 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 --- .../DirectoryMessagePackBackingStore.cs | 5 +++++ .../Contracts/cultcache-store-composition.md | 5 ++++- src/GameCult.Caching/CultCache.cs | 19 +++++++++++++++---- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index a7384e8c..61e65208 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -263,6 +263,7 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var keysToWrite = _dirtyKeys.Keys .OrderBy(value => value, StringComparer.Ordinal) .ToArray(); + var written = new List(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); diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index a1a7e949..b64dde2d 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -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. diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 719298bd..ea79e8ea 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -393,7 +393,8 @@ namespace GameCult.Caching /// /// The schema id this record is durably stored under, as its backing store last read or wrote it. It differs from /// only for a record loaded under compatible schema drift that its - /// store has not rewritten since. Conditional commit compares this id and with the disk. + /// store has not rewritten since; a store records its rewrite with . + /// Conditional commit compares this id and with the disk. /// 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; } + + /// + /// Records that this store's completed durable write stored under its descriptor, so its + /// is again. Call it + /// under Held, 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. + /// + 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();