C#: HoldsIds is read off the stored form each time a store writes

A record's HoldsIds was decided by its admission and trusted afterwards, so a
document changed after it was committed, or loaded and changed in place,
flushed under the header its admission-time content needed (Soul P7, P10).

HoldsIds now has one owner, the record's stored form, read when asked:
- a plain record: CultElementIds.Holds over its document, a scan that reads
  and refuses nothing;
- a variant: the same scan over each override value, decoded with the codec
  the record was built with (the cache's, or the attached cache's at load);
- a variant built with no codec (a store that loaded it with no cache) cannot
  see its values and counts as holding an id, so a store never writes an
  unmarked header over ids it could not see.

Cut: the admission path that set HoldsIds on plain records, on variants from
their resolved copies, and on flattened records; the internal setter and its
backing flag; IdPlan.HeldIds and IdPlan.HoldsIds. The Mesh typed write reads
Holds off the document it writes. Admission keeps its own id counting
(IdPlan.Count) for IdsInMemoryOnly.

Tests: an id added to a committed or loaded deck after admission marks a
PushAll v3, and taking it away unmarks it; a variant read without a codec
counts as holding ids while the same record read with one does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 23:46:50 +02:00
commit 619e089fa6
4 changed files with 121 additions and 35 deletions

View file

@ -353,17 +353,22 @@ namespace GameCult.Caching
string storedAt,
CultDocumentDescriptor descriptor,
CultVariantDelta variant,
object? resolved)
object? resolved,
CultCodec? codec)
{
Key = key;
StoredAt = storedAt;
Descriptor = descriptor;
Variant = variant;
_document = resolved;
_codec = codec;
}
private readonly object? _document;
// What reads a variant's override values; null for a plain record.
private readonly CultCodec? _codec;
public CultRecordKey Key { get; }
public string StoredAt { get; }
public CultDocumentDescriptor Descriptor { get; }
@ -382,19 +387,26 @@ namespace GameCult.Caching
internal bool IdsInMemoryOnly { get; set; }
// True when some element this record persists holds a non-empty id, and the only thing a store reads to mark its header.
// An admission decides it (it also counts the ids the admission fills; a variant holds ids when an override value does,
// its base's ids being the base's). A record that never passed an admission, one a caller built and handed straight to a
// store, reads it off the document it stores: the flag means what the stored form persists, wherever the record came from.
// It is read off the stored form each time it is asked, so a store asks it as it writes: a document changed after it was
// admitted, or loaded and changed in place, writes the header its content needs. A plain record persists its document; a
// variant persists its overrides, so it holds an id when an override value does (its base's ids are the base's). A
// variant built without a codec (a store loaded it with no cache attached) cannot see into its values and counts as holding
// one, so a store never writes an unmarked header over ids it could not see.
public bool HoldsIds
{
get => _holdsIds ?? (Variant == null && CultElementIds.Plan(_document, Key.Value, deterministic: false).HeldIds);
internal set => _holdsIds = value;
get
{
if (Variant == null)
return CultElementIds.Holds(_document);
if (_codec == null)
return true;
return Variant.Overrides.Any(entry => CultElementIds.Holds(_codec.Deserialize(
Descriptor.RichMembers.First(member => member.Slot == entry.Path[0].Slot).MemberType, Descriptor.DocumentType, entry.Value)));
}
}
private bool? _holdsIds;
internal CultStoredDocument Resolved(object document, CultVariantDelta delta, bool idsInMemoryOnly, bool holdsIds) =>
new(Key, StoredAt, Descriptor, delta, document) { IdsInMemoryOnly = idsInMemoryOnly, HoldsIds = holdsIds };
internal CultStoredDocument Resolved(object document, CultVariantDelta delta, bool idsInMemoryOnly, CultCodec codec) =>
new(Key, StoredAt, Descriptor, delta, document, codec) { IdsInMemoryOnly = idsInMemoryOnly };
}
public sealed class CultDocumentRegistry
@ -2330,7 +2342,7 @@ namespace GameCult.Caching
// An unresolved variant: the admitting hold resolves it against its base before anything lands.
private CultStoredDocument StampVariant(CultRecordKey key, CultDocumentDescriptor descriptor, CultVariantDelta delta) =>
new(key, MintStoredAt(PreviousStoredAt(key)), descriptor, delta, null);
new(key, MintStoredAt(PreviousStoredAt(key)), descriptor, delta, null, _codec);
// The plain record a Flatten stages: the variant's resolved document, under the variant's own key and schema.
private CultStoredDocument StampFlatten(CultRecordKey key)
@ -2476,8 +2488,6 @@ namespace GameCult.Caching
.Where(stored => stored.Variant == null)
.Select(stored => (Stored: stored, Plan: CultElementIds.Plan(stored.Document, stored.Key.Value, deterministic: source != null)))
.ToArray();
foreach (var (stored, idPlan) in idPlans)
stored.HoldsIds = idPlan.HoldsIds;
VariantPlan plan;
CultCommitOutcome outcome;
try
@ -2487,10 +2497,6 @@ namespace GameCult.Caching
// The one resolution: a variant admitted, a base admitted or evicted, all resolved here against the post-batch
// set, before land and before memory. land receives the admitted records with variants resolved.
plan = Resolve(admitted, evicted, source);
// A variant's own record is resolved into a copy; the record a loading store keeps is the original.
var resolvedByKey = plan.Admitted.ToDictionary(resolved => resolved.Key.Value, StringComparer.Ordinal);
foreach (var stored in admitted.Where(stored => stored.Variant != null))
stored.HoldsIds = resolvedByKey[stored.Key.Value].HoldsIds;
outcome = land(home, plan.Admitted);
}
catch
@ -2889,7 +2895,6 @@ namespace GameCult.Caching
var kept = new List<KeyValuePair<int, byte[]>>();
var overrides = new List<CultVariantOverride>(delta.Overrides.Count);
var minted = false;
var holdsIds = false;
var seen = new HashSet<int>();
foreach (var entry in delta.Overrides)
{
@ -2922,9 +2927,6 @@ namespace GameCult.Caching
// decoded copy, and the delta this hold lands carries them.
var bytes = entry.Value;
var idPlan = CultElementIds.Plan(value, key + "." + slot.ToString(CultureInfo.InvariantCulture), deterministic: source != null);
// What the record persists: a write persists the ids it mints, a load keeps the delta as the store held it, so
// the ids a load mints exist only in memory.
holdsIds |= source == null ? idPlan.HoldsIds : idPlan.HeldIds;
if (idPlan.Count > 0)
{
idPlan.Apply();
@ -2942,13 +2944,10 @@ namespace GameCult.Caching
stack.RemoveAt(stack.Count - 1);
return done[key] = isVariant
? variant.Resolved(document, minted ? new CultVariantDelta(delta.BaseKey, overrides) : delta, variant.IdsInMemoryOnly || (minted && source != null), holdsIds)
? variant.Resolved(document, minted ? new CultVariantDelta(delta.BaseKey, overrides) : delta, variant.IdsInMemoryOnly || (minted && source != null), _codec)
: new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document)
{
Flattens = true,
// The flattened record is the document it stores, resolved against the post-batch base: the flag is read
// off that document, not carried from the variant or from the document the flatten was planned against.
HoldsIds = CultElementIds.Plan(document, key, deterministic: source != null).HoldsIds
Flattens = true
};
}
}
@ -3384,7 +3383,8 @@ namespace GameCult.Caching
record.StoredAt,
descriptor,
new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides.Where(Has).ToArray()),
null);
null,
Cache?.Codec);
}
var document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload);

View file

@ -64,12 +64,6 @@ namespace GameCult.Caching
public int Count => _fills.Count;
// True when some element under the root already holds a non-empty id: one a store persisted, or one the caller set.
public bool HeldIds { get; internal set; }
// True when some element holds a non-empty id once the plan is applied: one already there, or one this plan fills.
public bool HoldsIds => HeldIds || _fills.Count > 0;
internal void Add(object item, Shape shape, string? before, string id) => _fills.Add((item, shape, before, id));
public void Apply()
@ -206,6 +200,45 @@ namespace GameCult.Caching
return null;
}
// ---- write: what the stored form holds ----
// True when some element under root holds a non-empty id: the one fact a store marks its header by. It reads the value
// as it is now, refuses nothing and changes nothing, so a writer asks it of exactly what it is about to write.
internal static bool Holds(object? root)
{
if (root == null)
return false;
var type = root.GetType();
if (IsLeaf(type))
return false;
if (root is IDictionary dictionary)
{
foreach (DictionaryEntry entry in dictionary)
{
if (Holds(entry.Value))
return true;
}
return false;
}
if (root is IEnumerable list)
{
foreach (var item in list)
{
if (item != null && IsObjectType(item.GetType()) && ShapeOf(item.GetType()).GetId is { } getId &&
!string.IsNullOrEmpty(getId(item)))
return true;
if (Holds(item))
return true;
}
return false;
}
return IsObjectType(type) && ShapeOf(type).Walk.Any(member => Holds(member.Get(root)));
}
// ---- write and load: mint and check ----
// Decides every unset element id under root, and refuses what the rule refuses, without changing anything: a random id
@ -255,7 +288,6 @@ namespace GameCult.Caching
var id = getId(item);
if (string.IsNullOrEmpty(id))
continue;
plan.HeldIds = true;
if (shape.Derive == null && !IsRandomId(id!))
throw new CultElementIdException(
$"Record {rootPath}: element id '{id}' in the list at {path} is not 12 lowercase hex characters.",

View file

@ -110,7 +110,7 @@ namespace GameCult.Mesh
descriptor.ToCatalogEntry(),
storedAt,
CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)),
ids.HoldsIds,
CultElementIds.Holds(document),
contentKnown: true);
}
catch

View file

@ -731,6 +731,60 @@ namespace GameCult.Caching.Tests
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "a whole-store flush decides by what the store now holds");
}
// Soul's P7 and P10: a store reads the header off what it writes, when it writes it. A document changed after its admission,
// or loaded and changed in place, is marked by what it holds at the flush, in both directions.
[TestCase(false)]
[TestCase(true)]
public void AFlushIsMarkedByWhatADocumentHoldsNowNotWhatItHeldWhenAdmitted(bool loaded)
{
var path = PathOf(loaded ? "mutated-loaded.cc" : "mutated-admitted.cc");
var key = new CultRecordKey("d");
using (var seed = Open(path))
seed.Commit(batch => batch.Upsert(typeof(IdDeck), EmptyDeck("d"), key));
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"));
using var cache = Open(path);
var deck = EmptyDeck("d");
if (loaded)
deck = cache.Get<IdDeck>(key)!;
else
cache.Commit(batch => batch.Upsert(typeof(IdDeck), deck, key));
deck.Reels.Add(new IdReel { Id = HexA });
cache.BackingStores[0].PushAll();
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "an id added after admission marks the flush");
deck.Reels.Clear();
cache.BackingStores[0].PushAll();
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the last id taken away after admission unmarks it");
}
// A store with no cache has no codec to read a variant's override values with, so it cannot see whether they hold an id: the
// variant counts as holding one. The same record read through a cache is marked by what its values hold.
[Test]
public void AVariantAStoreCannotReadCountsAsHoldingIds()
{
var path = PathOf("detached-variant.cc");
using (var cache = Open(path))
{
cache.Commit(batch =>
{
batch.Upsert(typeof(IdDeck), EmptyDeck("base"), new CultRecordKey("base"));
batch.UpsertVariant(new CultRecordKey("v"), new CultRecordKey("base"), new[] { cache.Override<IdDeck>(nameof(IdDeck.Name), "v") });
});
}
using (var cache = Open(path))
cache.BackingStores[0].PushAll();
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"), "read with a codec, a name override holds no id");
var blind = new SingleFileMessagePackBackingStore(path);
blind.AttachRegistry(Registry);
blind.PullAll();
blind.PushAll();
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v3"), "read without one, it cannot be seen, so it is marked");
}
[Test]
public void ADirectoryStoreIsMarkedByTheIdsItWritesAndAnEmptyDeckDoesNotMarkIt()
{