Keep the StoredPublishedBy staleness guard and pin it: a record minting relabels in place is owned by a newer version that reads its current publisher whole

The probe (a throw where a loaded record is relabelled in place) tripped 32 existing tests, so
the guard is reachable, not dead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 05:14:10 +01:00
commit 3e283c81d4
2 changed files with 52 additions and 8 deletions

View file

@ -524,11 +524,12 @@ namespace GameCult.Caching
// asked.
internal bool StoredSchemaReadWhole { get; set; }
// The catalog entry that publishes StoredSchemaId in the store this record was loaded from, so staging can ask WrittenOwns the
// question the store's judge asks, over the catalog the record came with. Null for a record this cache constructed or wrote,
// which is stored under Descriptor's own id and published by Descriptor's entry. Only a loaded record carries it, and a loaded
// record is replaced by a new one when written, never relabelled in place, so it always names the id StoredSchemaId holds.
internal CultSchemaCatalogEntry? StoredPublishedBy { get; set; }
// The catalog entry that publishes StoredSchemaId in the store this record was loaded from, with the id it was published for,
// so staging can ask WrittenOwns the question the store's judge asks, over the catalog the record came with. Null for a record
// this cache constructed or wrote, which is stored under Descriptor's own id and published by Descriptor's entry. A write that
// relabels a loaded record in place (minting its element ids, then a flush) moves StoredSchemaId and leaves this behind, so a
// reader uses it only while the id still matches StoredSchemaId.
internal (string Id, CultSchemaCatalogEntry Entry)? StoredPublishedBy { get; set; }
// Names the bytes this record was read from or written as: the SHA-256 of its persisted encoding, set by the store that
// read or wrote them. A record no store has read or written has a version of its own that equals nothing else.
@ -3986,8 +3987,8 @@ namespace GameCult.Caching
};
}
private static CultSchemaCatalogEntry? PublishedByIn(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog) =>
CultDocumentRegistry.PublishedBy(schemaId, catalog);
private static (string Id, CultSchemaCatalogEntry Entry)? PublishedByIn(string schemaId, IReadOnlyCollection<CultSchemaCatalogEntry> catalog) =>
CultDocumentRegistry.PublishedBy(schemaId, catalog) is { } entry ? (schemaId, entry) : null;
protected void SetLastSchemaMigrationReports(IEnumerable<CultSchemaMigrationReport> reports)
{
@ -4035,7 +4036,10 @@ namespace GameCult.Caching
// record's id when it was loaded (a record this cache wrote is published by its own type). The one answer for a store-backed
// cache's staging and a store-less cache's.
internal static bool UpsertOwned(CultStoredDocument held, CultDocumentDescriptor written) =>
CultDocumentRegistry.WrittenOwns(written.ToCatalogEntry(), held.StoredSchemaId, new[] { held.StoredPublishedBy ?? held.Descriptor.ToCatalogEntry() });
CultDocumentRegistry.WrittenOwns(
written.ToCatalogEntry(),
held.StoredSchemaId,
new[] { held.StoredPublishedBy is { } loaded && string.Equals(loaded.Id, held.StoredSchemaId, StringComparison.Ordinal) ? loaded.Entry : held.Descriptor.ToCatalogEntry() });
// The refusal of a store-less cache that would retype a key.
internal static CultSchemaConflictException Retyped(CultStoredDocument held, string key, CultDocumentDescriptor written) => new(

View file

@ -274,6 +274,36 @@ namespace GameCult.Caching.Tests
Assert.That(DiskRecord(path, "a").SchemaId, Is.Not.EqualTo(otherRuntimeId));
}
// Minting rewrites a loaded record in place under this type's id, and the cache keeps that entry. A newer version of the type,
// registered afterwards, reads the entry the record is now published by whole, so it owns the record: staging asks the entry
// that publishes the id the record carries now, not the one that published the id it was loaded under.
[Test]
public void ANewerVersionReadsWholeTheRecordMintingRelabelledInPlace()
{
const string otherRuntimeId = "other.runtime.id_deck";
var path = PathOf("mint-then-newer.cc");
WritePreIdStore(path, null, ("a", "a", 1));
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
var entry = snapshot.SchemaCatalog.Single();
entry.SchemaId = otherRuntimeId;
entry.ContentHash = otherRuntimeId;
entry.CompatibleSchemaIds = new[] { otherRuntimeId };
snapshot.Records.Single().SchemaId = otherRuntimeId;
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(IdDeck) });
var key = new CultRecordKey("a");
using (var cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }))
{
Assert.That(cache.MintElementIds(), Is.EqualTo(1));
Assert.That(cache.GetStored(key)!.StoredSchemaId, Is.Not.EqualTo(otherRuntimeId), "minting relabelled the loaded entry in place");
registry.GetRequired(typeof(IdDeckNext));
Assert.That(cache.Commit(batch => batch.Upsert(typeof(IdDeckNext), new IdDeckNext { Name = "newer" }, key)), Is.True);
}
Assert.That(DiskRecord(path, "a").SchemaId, Is.EqualTo(registry.GetRequired(typeof(IdDeckNext)).SchemaId));
}
// A pre-id record this cache reads only by dropping a persisted slot is read-only, so the rewrite that would persist its
// minted ids is refused typed, naming it, and the store is left byte for byte.
[Test]
@ -1401,6 +1431,16 @@ namespace GameCult.Caching.Tests
[Key(2)] public List<IdMove> Moves { get; set; } = new();
}
// The next version of IdDeck: the same slots under a new schema version.
[CultDocument("tests.element_id_deck", "tests.element_id_deck.v2")]
[MessagePackObject]
public sealed class IdDeckNext
{
[Key(0)] [CultName] public string Name { get; set; } = "";
[Key(1)] public List<IdReel> Reels { get; set; } = new();
[Key(2)] public List<IdMove> Moves { get; set; } = new();
}
// The shapes before ids: same slots, no id member. Not documents, so never registered.
[MessagePackObject]
public sealed class OldMark