diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 37cab529..0563d2a4 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -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>(); var overrides = new List(delta.Overrides.Count); var minted = false; - var holdsIds = false; var seen = new HashSet(); 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); diff --git a/src/GameCult.Caching/CultElementIds.cs b/src/GameCult.Caching/CultElementIds.cs index 29e574b9..81fada0a 100644 --- a/src/GameCult.Caching/CultElementIds.cs +++ b/src/GameCult.Caching/CultElementIds.cs @@ -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.", diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 22aaf951..ae033e54 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -110,7 +110,7 @@ namespace GameCult.Mesh descriptor.ToCatalogEntry(), storedAt, CultDocumentMessagePackSerialization.SerializeUntyped(document, typeof(TDocument)), - ids.HoldsIds, + CultElementIds.Holds(document), contentKnown: true); } catch diff --git a/tests/GameCult.Caching.Tests/ElementIdTests.cs b/tests/GameCult.Caching.Tests/ElementIdTests.cs index 7b885817..37d938e3 100644 --- a/tests/GameCult.Caching.Tests/ElementIdTests.cs +++ b/tests/GameCult.Caching.Tests/ElementIdTests.cs @@ -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(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(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() {