From 282e337c2cf1f3ad0c5ef115df1c73009a5e5843 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 09:20:47 +0200 Subject: [PATCH] Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 --- .../DirectoryMessagePackBackingStore.cs | 4 +- src/GameCult.Caching/CultCache.cs | 272 +++++++++--------- tests/GameCult.Caching.Tests/VariantTests.cs | 197 +++++++++++++ 3 files changed, 329 insertions(+), 144 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs index 3ef36be2..8244d76a 100644 --- a/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs +++ b/src/GameCult.Caching.MessagePack/DirectoryMessagePackBackingStore.cs @@ -339,11 +339,11 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore var started = tracePages ? Stopwatch.GetTimestamp() : 0L; var metadata = records[index]; var record = ReadPersistedRecordPage(metadata, out var pagePayload); - recordReports[index] = Registry.ResolvePersistedSchemaReport(record.SchemaId, catalogEntries); storedRecords[index] = ToStoredDocument( record, catalogEntries, - (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry)); + (type, payload) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, payload, Registry), + out recordReports[index]); if (tracePages) { pageBytes[index] = pagePayload.LongLength; diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 2d4e7137..65c75b93 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1799,7 +1799,8 @@ namespace GameCult.Caching private readonly List<(CacheBackingStore Store, Type[] Homes)> _stores = new(); private readonly Dictionary _entries = new(StringComparer.Ordinal); private readonly Dictionary> _names = new(); - private readonly Dictionary<(Type Type, string Alias), Dictionary> _indexes = new(); + // Every key holding a value, oldest first: the index is truthful about duplicates, so removing one holder never hides another. + private readonly Dictionary<(Type Type, string Alias), Dictionary>> _indexes = new(); private readonly Dictionary _globals = new(); private readonly CultCodec? _codec; // Keys holding a variant: derived from the entries by Apply, never decided anywhere else. @@ -1934,6 +1935,7 @@ namespace GameCult.Caching store.AttachRegistry(_registry); store.Cache = this; store.Loaded = (loaded, dropped) => Admit(loaded, dropped, store, (_, _) => CultCommitOutcome.Committed); + store.Judging = (arriving, departing) => { Resolve(arriving, departing, store); }; _stores.Add(candidate); try { @@ -1943,6 +1945,7 @@ namespace GameCult.Caching { _stores.Remove(candidate); store.Loaded = null; + store.Judging = null; store.Cache = null; throw; } @@ -2121,11 +2124,13 @@ namespace GameCult.Caching .SelectMany(pair => pair.Value.TryGetValue(name, out var key) ? new[] { key } : Array.Empty())); } + // A value held by one record finds it. Records of one type sharing a value (plain duplicates) find the most recently + // indexed holder, and removing that holder finds the one before it: a held value never finds nothing. public T? GetByIndex(string alias, string value) where T : class { return Single($"index {alias}='{value}'", () => _indexes .Where(pair => pair.Key.Alias == alias && typeof(T).IsAssignableFrom(pair.Key.Type)) - .SelectMany(pair => pair.Value.TryGetValue(value, out var key) ? new[] { key } : Array.Empty())); + .SelectMany(pair => pair.Value.TryGetValue(value, out var keys) ? new[] { keys[keys.Count - 1] } : Array.Empty())); } public void Dispose() @@ -2522,21 +2527,20 @@ namespace GameCult.Caching if (toResolve.Count == 0) { var unchanged = new VariantPlan(admitted); - RefusePlainSharingWithVariant(unchanged, incoming, gone); + RefuseVariantIndexSharing(unchanged, incoming, gone); return unchanged; } var pending = new HashSet(toResolve, StringComparer.Ordinal); var done = new Dictionary(StringComparer.Ordinal); var stack = new List(); - Dictionary<(Type Type, string Alias), Dictionary>? claimed = null; foreach (var key in toResolve) ResolveOne(key); var plan = new VariantPlan(admitted.Select(stored => done.TryGetValue(stored.Key.Value, out var resolved) ? resolved : stored).ToList()); foreach (var key in toResolve.Where(key => !incoming.ContainsKey(key))) plan.Dependents.Add(done[key]); - RefusePlainSharingWithVariant(plan, incoming, gone); + RefuseVariantIndexSharing(plan, incoming, gone); return plan; CultStoredDocument ResolveOne(string key) @@ -2567,9 +2571,6 @@ namespace GameCult.Caching var type = descriptor.DocumentType; var kept = new List>(); var seen = new HashSet(); - // Bad writes are refused; drift is for type changes after the fact. A load, a re-resolution and a flatten - // carry what was already stored, so they follow the plain-record outcome instead. - var writing = source == null && isVariant && incoming.ContainsKey(key); foreach (var entry in delta.Overrides) { if (entry.Op != CultOverrideOp.Set || entry.Path.Count != 1 || entry.Path[0].ElementId.Length != 0 || entry.Id.Length != 0) @@ -2579,14 +2580,10 @@ namespace GameCult.Caching if (!seen.Add(slot)) throw new InvalidOperationException($"Variant {key} overrides slot {slot} more than once; a variant has one override per slot."); var member = descriptor.RichMembers.FirstOrDefault(candidate => candidate.Slot == slot); + // Drift is the load's business (ToStoredDocument sheds an override of a slot the type lacks), so a delta + // reaching here names only slots the type has, unless someone is writing one it never had. if (member == null) - { - if (writing) - throw new InvalidOperationException($"Variant {key} overrides slot {slot}, which {type.Name} does not have."); - // A slot the type dropped soft-drifts exactly as the same member does on a plain record: ignored, - // reported by the store's schema resolution (LastSchemaMigrationReports), the variant inherits the base. - continue; - } + throw new InvalidOperationException($"Variant {key} overrides slot {slot}, which {type.Name} does not have."); try { @@ -2620,37 +2617,6 @@ namespace GameCult.Caching $"Variant {key} and its base {ancestor.Key.Value} are both named '{variantName}'; a variant must override its name."); } } - - // The same rule for an indexed value: a unique index maps one value to one key, so a variant that - // inherited its base's value (or any other record's) would be found in place of it or lose it. - foreach (var pair in descriptor.IndexAccessors) - { - var value = pair.Value(document); - if (string.IsNullOrWhiteSpace(value)) - continue; - claimed ??= ClaimIncomingIndexValues(); - var claim = (type, pair.Key); - string? other = null; - if (claimed.TryGetValue(claim, out var claims) && claims.TryGetValue(value, out var claimant) && claimant != key) - other = claimant; - else if (_indexes.TryGetValue(claim, out var held) && held.TryGetValue(value, out var holder) && holder != key && - !incoming.ContainsKey(holder) && !gone.Contains(holder) && !pending.Contains(holder)) - other = holder; - if (other != null) - throw new InvalidOperationException( - $"Variant {key} and {other} both hold index {pair.Key} '{value}'; a variant must override its indexed member."); - } - } - - if (descriptor.IndexAccessors.Count > 0) - { - claimed ??= ClaimIncomingIndexValues(); - foreach (var pair in descriptor.IndexAccessors) - { - var value = pair.Value(document); - if (!string.IsNullOrWhiteSpace(value)) - MapOf(claimed, (type, pair.Key))[value] = key; - } } stack.RemoveAt(stack.Count - 1); @@ -2658,80 +2624,56 @@ namespace GameCult.Caching ? variant.Resolved(document) : new CultStoredDocument(variant.Key, variant.StoredAt, variant.Descriptor, document) { Flattens = true }; } - - // The indexed values the plain records of this admission will hold, by (type, index) then value. - Dictionary<(Type Type, string Alias), Dictionary> ClaimIncomingIndexValues() - { - var claims = new Dictionary<(Type Type, string Alias), Dictionary>(); - foreach (var stored in incoming.Values.Where(stored => stored.Variant == null && !stored.Flattens)) - { - foreach (var pair in stored.Descriptor.IndexAccessors) - { - var value = pair.Value(stored.Document); - if (!string.IsNullOrWhiteSpace(value)) - MapOf(claims, (stored.Descriptor.DocumentType, pair.Key))[value] = stored.Key.Value; - } - } - - return claims; - } } - // The index rule is about the commit's result, so it holds for every commit that changes an indexed value, in either - // order: a variant resolving onto another record's value is refused in ResolveOne; a plain record landing on a - // variant's value is refused here, against the variants of the post-commit set (never the held index, which the plain - // write would already have overwritten). Plain-vs-plain sharing is not this rule's business. - private void RefusePlainSharingWithVariant( + // The one index rule, judged on the commit's result whatever the write order: a variant may not share an indexed + // value with another record. It reads the truthful held index (every holder of a value), minus the records this + // commit replaces or removes, plus the records it lands. Plain-vs-plain sharing is not this rule's business. + // A unique index maps a value to one key at lookup; a variant sharing it would be found in place of its + // holder or lose the value, and the store would refuse to reopen. + private void RefuseVariantIndexSharing( VariantPlan plan, IReadOnlyDictionary incoming, HashSet gone) { - Dictionary<(Type Type, string Alias), Dictionary>? variantValues = null; - foreach (var stored in plan.Admitted) + var landing = plan.Admitted.Concat(plan.Dependents).ToList(); + if (landing.All(stored => stored.Descriptor.IndexAccessors.Count == 0)) + return; + var replaced = new HashSet(incoming.Keys.Concat(gone).Concat(plan.Dependents.Select(stored => stored.Key.Value)), StringComparer.Ordinal); + var landingByKey = landing.ToDictionary(stored => stored.Key.Value, StringComparer.Ordinal); + + bool IsVariant(string key) => + landingByKey.TryGetValue(key, out var landed) ? landed.Variant != null : _variantKeys.Contains(key); + + foreach (var stored in landing) { - if (stored.Variant != null || stored.Descriptor.IndexAccessors.Count == 0) - continue; foreach (var pair in stored.Descriptor.IndexAccessors) { var value = pair.Value(stored.Document); if (string.IsNullOrWhiteSpace(value)) continue; - variantValues ??= HeldByVariants(plan, incoming, gone); - if (variantValues.TryGetValue((stored.Descriptor.DocumentType, pair.Key), out var values) && - values.TryGetValue(value, out var variantKey) && variantKey != stored.Key.Value) + var type = stored.Descriptor.DocumentType; + var holders = (_indexes.TryGetValue((type, pair.Key), out var held) && held.TryGetValue(value, out var heldKeys) + ? heldKeys.Where(heldKey => !replaced.Contains(heldKey)) + : Enumerable.Empty()) + .Concat(landing + .Where(other => other.Descriptor.DocumentType == type && pair.Value(other.Document) == value) + .Select(other => other.Key.Value)) + .Where(holder => holder != stored.Key.Value) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (stored.Variant != null && holders.Length > 0) throw new InvalidOperationException( - $"Record {stored.Key.Value} and variant {variantKey} both hold index {pair.Key} '{value}'; " + + $"Variant {stored.Key.Value} and {holders[0]} both hold index {pair.Key} '{value}'; a variant must override its indexed member."); + var variantHolder = holders.FirstOrDefault(IsVariant); + if (stored.Variant == null && variantHolder != null) + throw new InvalidOperationException( + $"Record {stored.Key.Value} and variant {variantHolder} both hold index {pair.Key} '{value}'; " + "a variant may not share an indexed value with another record."); } } } - // The indexed values the variants of the post-commit set hold: those this commit resolved, and those it left alone. - private Dictionary<(Type Type, string Alias), Dictionary> HeldByVariants( - VariantPlan plan, - IReadOnlyDictionary incoming, - HashSet gone) - { - var variants = new Dictionary(StringComparer.Ordinal); - foreach (var variantKey in _variantKeys.Where(variantKey => !incoming.ContainsKey(variantKey) && !gone.Contains(variantKey))) - variants[variantKey] = _entries[variantKey]; - foreach (var stored in plan.Admitted.Concat(plan.Dependents).Where(stored => stored.Variant != null)) - variants[stored.Key.Value] = stored; - - var held = new Dictionary<(Type Type, string Alias), Dictionary>(); - foreach (var stored in variants.Values) - { - foreach (var pair in stored.Descriptor.IndexAccessors) - { - var value = pair.Value(stored.Document); - if (!string.IsNullOrWhiteSpace(value)) - MapOf(held, (stored.Descriptor.DocumentType, pair.Key))[value] = stored.Key.Value; - } - } - - return held; - } - private static InvalidOperationException CycleRefusal(IEnumerable keys) => new($"Variant bases form a cycle: {string.Join(" -> ", keys)}."); @@ -2837,7 +2779,7 @@ namespace GameCult.Caching { var value = pair.Value(stored.Document); if (!string.IsNullOrWhiteSpace(value)) - MapOf(_indexes, (type, pair.Key))[value] = stored.Key.Value; + AddHolder(_indexes, (type, pair.Key), value, stored.Key.Value); } } @@ -2850,7 +2792,27 @@ namespace GameCult.Caching if (_names.TryGetValue(type, out var names)) RemoveKey(names, stored.Key.Value); foreach (var pair in _indexes.Where(pair => pair.Key.Type == type)) - RemoveKey(pair.Value, stored.Key.Value); + RemoveHolder(pair.Value, stored.Key.Value); + } + + // Re-indexing a key makes it the most recent holder of its value. + private static void AddHolder(Dictionary<(Type Type, string Alias), Dictionary>> indexes, (Type, string) index, string value, string key) + { + if (!indexes.TryGetValue(index, out var map)) + indexes[index] = map = new Dictionary>(StringComparer.Ordinal); + if (!map.TryGetValue(value, out var keys)) + map[value] = keys = new List(); + keys.Remove(key); + keys.Add(key); + } + + private static void RemoveHolder(Dictionary> map, string key) + { + foreach (var pair in map.ToArray()) + { + if (pair.Value.Remove(key) && pair.Value.Count == 0) + map.Remove(pair.Key); + } } private static Dictionary MapOf(Dictionary> maps, TKey key) @@ -2924,6 +2886,11 @@ namespace GameCult.Caching // any of it; if the cache refuses a record the call throws and the store keeps its previous view. protected internal Action, IReadOnlyList>? Loaded; + // Set by the cache at attach: the cache's own resolution (variants, their indexed values, their bases) run over a + // set of records a store is about to hold, mutating nothing. It throws the refusal the cache would give the same + // set arriving as a load. + protected internal Action, IReadOnlyList>? Judging; + private readonly object _detachedGate = new(); internal CultCache? Cache; @@ -2977,55 +2944,51 @@ namespace GameCult.Caching SchemaId = entry.Descriptor.SchemaId, StoredAt = entry.StoredAt, Payload = entry.Variant != null ? Array.Empty() : serializePayload(entry.Document), - Variant = entry.Variant == null ? null : new CultVariantDelta(entry.Variant.BaseKey, OverridesTheTypeHas(entry).ToArray()) + Variant = entry.Variant }; } - // A plain record sheds a member its type dropped when it is flushed; a variant sheds the override of that slot, so - // a later type reusing the slot number cannot resurrect it. The load's report names the shed slots. - private static IEnumerable OverridesTheTypeHas(CultStoredDocument entry) => - entry.Variant!.Overrides.Where(entryOverride => - entryOverride.Path.Count != 1 || entry.Descriptor.RichMembers.Any(member => member.Slot == entryOverride.Path[0].Slot)); - - // A variant's override of a slot its type lacks is ignored like an extra slot on a plain record. Schema resolution - // reports slots the persisted schema had and the type dropped; an override the schema never had is added here. - protected static CultSchemaMigrationReport ReportVariantSheds(CultSchemaMigrationReport report, CultStoredDocument stored) - { - if (stored.Variant == null) - return report; - var shed = stored.Variant.Overrides - .Where(entryOverride => entryOverride.Path.Count == 1 && - stored.Descriptor.RichMembers.All(member => member.Slot != entryOverride.Path[0].Slot)) - .Select(entryOverride => entryOverride.Path[0].Slot) - .Except(report.IgnoredExtraSlots) - .Distinct() - .OrderBy(slot => slot) - .ToArray(); - if (shed.Length == 0) - return report; - report.IgnoredExtraSlots = report.IgnoredExtraSlots.Concat(shed).OrderBy(slot => slot).ToArray(); - report.Warnings = report.Warnings.Concat(shed.Select(slot => new CultSchemaMigrationWarning - { - Code = "ignored_extra_slot", - Message = $"Variant {stored.Key.Value} overrides slot {slot}, which {stored.Descriptor.DocumentType.Name} does not have; the override is ignored and shed on flush." - })).ToArray(); - return report; - } - + // The one load surface: a persisted record resolved against the current type, with the report of what that + // resolution shed. A plain record sheds a member its type dropped by never carrying it into the document; a variant + // sheds the override of such a slot by never carrying it into the delta, so a later type reusing the slot number + // cannot resurrect it and a flush needs no rule of its own. The report is the schema diff's ignored slots plus the + // overrides whose slot the type does not have, including one the persisted schema never listed. protected CultStoredDocument ToStoredDocument( CultPersistedRecord record, IReadOnlyCollection catalog, - Func deserializePayload) + Func deserializePayload, + out CultSchemaMigrationReport report) { var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog); + report = resolution.Report; if (record.Variant != null) { + var descriptor = resolution.Descriptor; + bool Has(CultVariantOverride entryOverride) => + entryOverride.Path.Count != 1 || descriptor.RichMembers.Any(member => member.Slot == entryOverride.Path[0].Slot); + var shed = record.Variant.Overrides + .Where(entryOverride => !Has(entryOverride)) + .Select(entryOverride => entryOverride.Path[0].Slot) + .Distinct() + .OrderBy(slot => slot) + .ToArray(); + if (shed.Length > 0) + { + var alreadyReported = report.IgnoredExtraSlots; + report.IgnoredExtraSlots = alreadyReported.Concat(shed.Except(alreadyReported)).OrderBy(slot => slot).ToArray(); + report.Warnings = report.Warnings.Concat(shed.Select(slot => new CultSchemaMigrationWarning + { + Code = "ignored_extra_slot", + Message = $"Variant {record.Key} overrides slot {slot}, which {descriptor.DocumentType.Name} does not have; the override is ignored and shed on flush." + })).ToArray(); + } + // The cache resolves it against its base when it admits the load. return new CultStoredDocument( new CultRecordKey(record.Key), record.StoredAt, - resolution.Descriptor, - new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides), + descriptor, + new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides.Where(Has).ToArray()), null); } @@ -3084,9 +3047,8 @@ namespace GameCult.Caching var persisted = new Dictionary(StringComparer.Ordinal); foreach (var record in snapshot.Records) { - var report = Registry.ResolvePersistedSchemaReport(record.SchemaId, snapshot.SchemaCatalog); - var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload); - reports.Add(ReportVariantSheds(report, stored)); + var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload, out var report); + reports.Add(report); persisted[stored.Key.Value] = stored; } @@ -3169,6 +3131,11 @@ namespace GameCult.Caching var catalog = ontoDisk ? disk.SchemaCatalog : Entries.Values.Select(entry => entry.Descriptor.ToCatalogEntry()); + // The committer judged the batch against its own view; the file may have moved since. A merge is judged on the + // set the file will hold, exactly as that set would be judged loading: what other processes wrote (arriving), + // what they removed (departing), and this batch. + if (ontoDisk && Judging != null) + JudgeMerge(request, disk); foreach (var entry in request.Deletes) records.Remove(entry.Key.Value); foreach (var entry in request.Upserts) @@ -3183,6 +3150,27 @@ namespace GameCult.Caching return CultCommitOutcome.Committed; } + private void JudgeMerge(CultCommitRequest request, CultPersistedStoreSnapshot disk) + { + var landing = request.Upserts.Select(entry => entry.Key.Value).ToHashSet(StringComparer.Ordinal); + var removing = request.Deletes.Select(entry => entry.Key.Value).ToHashSet(StringComparer.Ordinal); + var onDisk = disk.Records.Select(record => record.Key).ToHashSet(StringComparer.Ordinal); + var arriving = new List(request.Upserts); + foreach (var record in disk.Records) + { + if (landing.Contains(record.Key) || removing.Contains(record.Key)) + continue; + if (Entries.TryGetValue(record.Key, out var known) && known.StoredAt == record.StoredAt && known.Descriptor.SchemaId == record.SchemaId) + continue; + arriving.Add(ToStoredDocument(record, disk.SchemaCatalog, DeserializePayload, out _)); + } + + var departing = new List(request.Deletes); + departing.AddRange(Entries.Values.Where(known => + !onDisk.Contains(known.Key.Value) && !landing.Contains(known.Key.Value) && !removing.Contains(known.Key.Value))); + Judging!(arriving, departing); + } + private CultPersistedStoreSnapshot? ReadSnapshot() { FileInfo.Refresh(); diff --git a/tests/GameCult.Caching.Tests/VariantTests.cs b/tests/GameCult.Caching.Tests/VariantTests.cs index c81e892b..6f6654e2 100644 --- a/tests/GameCult.Caching.Tests/VariantTests.cs +++ b/tests/GameCult.Caching.Tests/VariantTests.cs @@ -749,6 +749,203 @@ namespace GameCult.Caching.Tests Assert.That(reloaded.Get(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" })); } + // ---- the held index is truthful: every holder of a value, so no rule judges a variant against a lossy view ---- + + private static VariantGear Named(string name, string code) => new() { Name = name, Power = 1, Code = code }; + + private static CultRecordKey[] SharedCodePair(CultCache cache) + { + var p1 = new CultRecordKey("p1"); + var p2 = new CultRecordKey("p2"); + cache.Commit(batch => + { + batch.Upsert(typeof(VariantGear), Named("p1", "x"), p1); + batch.Upsert(typeof(VariantGear), Named("p2", "x"), p2); + }); + return new[] { p1, p2 }; + } + + private static void VariantOnCode(CultCache cache, string code) => + cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] + { + cache.Override(nameof(VariantGear.Name), "laser big"), + cache.Override(nameof(VariantGear.Code), code) + })); + + [Test] + public void PlainDuplicatesFindTheMostRecentHolderAndRemovingEitherLeavesTheOtherFindable() + { + var path = PathOf("gear.cc"); + using (var cache = Open(path)) + { + var pair = SharedCodePair(cache); + Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p2"), "the last writer"); + + cache.Remove(pair[1]); + Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p1"), "removing the winner finds the other holder"); + + cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p2", "x"), pair[1])); + Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p2")); + cache.Remove(pair[0]); + Assert.That(cache.GetByIndex("code", "x")!.Name, Is.EqualTo("p2"), "removing the loser changes nothing"); + cache.Remove(pair[1]); + Assert.That(cache.GetByIndex("code", "x"), Is.Null); + cache.BackingStores[0].PushAll(); + } + + using var reopened = Open(path); + Assert.That(reopened.GetByIndex("code", "x"), Is.Null); + } + + [Test] + public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinWasRemovedIsRefusedAndTheStoreReopens() + { + var path = PathOf("gear.cc"); + using (var cache = Open(path)) + { + SeedBase(cache); + var pair = SharedCodePair(cache); + cache.Remove(pair[1]); + + var refused = Refused(() => VariantOnCode(cache, "x")); + Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("code").And.Contain("'x'")); + Assert.That(cache.Get(BigKey), Is.Null, "nothing landed"); + cache.BackingStores[0].PushAll(); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(BigKey), Is.Null); + Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); + } + + [Test] + public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinChangedItsValueIsRefusedAndTheStoreReopens() + { + var path = PathOf("gear.cc"); + using (var cache = Open(path)) + { + SeedBase(cache); + var pair = SharedCodePair(cache); + cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p2", "y"), pair[1])); + + var refused = Refused(() => VariantOnCode(cache, "x")); + Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("'x'")); + Assert.DoesNotThrow(() => VariantOnCode(cache, "z"), "an unshared value is fine"); + cache.BackingStores[0].PushAll(); + } + + using var reopened = Open(path); + Assert.That(reopened.GetByIndex("code", "z")!.Name, Is.EqualTo("laser big")); + Assert.That(reopened.GetByIndex("code", "x")!.Name, Is.EqualTo("p1")); + } + + // ---- a merge onto the file is judged on the set the file will hold ---- + + [Test] + public void AConditionalCommitThatWouldShareAnIndexedValueWithAnotherWritersVariantIsRefusedAndTheStoreReopens() + { + var path = PathOf("gear.cc"); + var other = new CultRecordKey("other"); + using (var seed = Open(path)) + SeedBase(seed); + + using (var a = Open(path)) + using (var b = Open(path)) + { + a.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[] + { + a.Override(nameof(VariantGear.Name), "laser big"), + a.Override(nameof(VariantGear.Code), "shared") + })); + + var refused = Refused(() => b.Commit(batch => + { + batch.Expect(other, null); + batch.Upsert(typeof(VariantGear), Named("other", "shared"), other); + })); + Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared")); + Assert.That(b.Get(other), Is.Null, "nothing landed in memory"); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(other), Is.Null, "nothing landed on the file"); + Assert.That(reopened.GetByIndex("code", "shared")!.Name, Is.EqualTo("laser big")); + } + + [Test] + public void AConditionalCommitThatRemovesAnotherWritersVariantsBaseIsRefusedAndTheStoreReopens() + { + var path = PathOf("gear.cc"); + using (var seed = Open(path)) + SeedBase(seed); + + using (var a = Open(path)) + using (var b = Open(path)) + { + var seen = b.Get(BaseKey)!; + SeedBig(a); + + var refused = Refused(() => b.Commit(batch => + { + batch.Expect(BaseKey, seen); + batch.Remove(BaseKey); + })); + Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value)); + Assert.That(b.Get(BaseKey), Is.Not.Null, "nothing landed in memory"); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(BaseKey), Is.Not.Null); + Assert.That(reopened.Get(BigKey)!.Name, Is.EqualTo("laser big")); + } + + [Test] + public void AConditionalCommitThatIsSoundOnTheMergedSetStillLands() + { + var path = PathOf("gear.cc"); + var other = new CultRecordKey("other"); + using (var seed = Open(path)) + SeedBase(seed); + + using (var a = Open(path)) + using (var b = Open(path)) + { + SeedBig(a); + Assert.That(b.Commit(batch => + { + batch.Expect(other, null); + batch.Upsert(typeof(VariantGear), Named("other", "fine"), other); + }), Is.True); + } + + using var reopened = Open(path); + Assert.That(reopened.Get(other), Is.Not.Null); + Assert.That(reopened.Get(BigKey), Is.Not.Null, "the other writer's variant survives the merge"); + } + + [Test] + public void AVariantLoadedUnderANarrowerTypeHoldsOnlyTheOverridesTheTypeHasAndReportsTheRest() + { + var path = PathOf("drift.cc"); + using (var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }))) + { + writer.Commit(batch => + { + batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base")); + batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[] + { + writer.Override(nameof(DriftWide.Name), "drift variant"), + writer.Override(nameof(DriftWide.Wing), "stale wing") + }); + }); + } + + using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) })); + Assert.That(narrow.GetStored(new CultRecordKey("drift-variant"))!.Variant!.Overrides.Select(entry => entry.Path[0].Slot), Is.EqualTo(new[] { 0 }), + "the dropped slot's override is shed when the record is loaded, not decided again at flush"); + Assert.That(narrow.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Does.Contain(2)); + } + // ---- the directory store refuses a variant page on load as it does on write ---- [Test]