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:
parent
cf16cb7c1f
commit
619e089fa6
4 changed files with 121 additions and 35 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ namespace GameCult.Mesh
|
|||
descriptor.ToCatalogEntry(),
|
||||
storedAt,
|
||||
CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)),
|
||||
ids.HoldsIds,
|
||||
CultElementIds.Holds(document),
|
||||
contentKnown: true);
|
||||
}
|
||||
catch
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue