diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 8c80116c..5a0626a6 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -547,6 +547,9 @@ namespace GameCult.Caching // A plain record staged by Flatten: the one way a plain record may replace a variant at its key. internal bool Flattens { get; set; } + // A plain record a batch staged after a Flatten at its key: it replaces the plain record that flatten left, so it may land on the variant the cache holds. + internal bool ReplacesFlattened { get; set; } + // True while this record holds element ids minted when it loaded, that no write has persisted (see MintElementIds). internal bool IdsInMemoryOnly { get; set; } @@ -2076,8 +2079,9 @@ namespace GameCult.Caching // One staged write: a plain record (Stored), a variant (Variant), a flatten, or, with none of those, a removal. internal sealed class StagedOperation { - public StagedOperation(CultRecordKey key, CultStoredDocument? stored, CultVariantDelta? variant, bool flatten) + public StagedOperation(CultRecordKey key, CultStoredDocument? stored, CultVariantDelta? variant, bool flatten, bool afterFlatten = false) { + AfterFlatten = afterFlatten; Key = key; Stored = stored; Variant = variant; @@ -2088,6 +2092,8 @@ namespace GameCult.Caching public CultStoredDocument? Stored { get; } public CultVariantDelta? Variant { get; } public bool Flatten { get; } + // A plain record staged after a Flatten at its key: the key holds a plain record by then, so it replaces that, not a variant. + public bool AfterFlatten { get; } public bool IsRemoval => Stored == null && Variant == null && !Flatten; } @@ -2132,6 +2138,10 @@ namespace GameCult.Caching public CultRecordKey UpsertVariant(CultRecordKey key, CultRecordKey baseKey, IEnumerable? overrides = null) { ThrowIfSealed(); + // A flatten staged here leaves a plain record at the key, which cannot become a variant in place, as when staged apart. + if (Operations.TryGetValue(key.Value, out var flattened) && flattened.Flatten) + throw new InvalidOperationException( + $"Record {key.Value} is a plain record; it cannot become a variant of {baseKey.Value} in place. Use a new key."); Operations[key.Value] = new StagedOperation( key, null, new CultVariantDelta(baseKey.Value, (overrides ?? Array.Empty()).ToArray()), false); return key; @@ -2142,17 +2152,14 @@ namespace GameCult.Caching { ThrowIfSealed(); // A flatten acts on what the key will hold, as staged apart: a variant staged here is flattened as it lands; a removal or a - // plain record staged here leaves nothing to flatten; with nothing staged it flattens the variant the cache holds. + // plain record staged here, or an earlier flatten, leaves nothing to flatten; with nothing staged it flattens the variant + // the cache holds. if (Operations.TryGetValue(key.Value, out var staged)) { - if (staged.Variant != null) - { - Operations[key.Value] = new StagedOperation(key, null, staged.Variant, true); - return; - } - - if (!staged.Flatten) + if (staged.Variant == null || staged.Flatten) throw new InvalidOperationException($"Flatten({key.Value}): the record is not a variant."); + Operations[key.Value] = new StagedOperation(key, null, staged.Variant, true); + return; } Operations[key.Value] = new StagedOperation(key, null, null, true); @@ -2175,7 +2182,8 @@ namespace GameCult.Caching { ThrowIfSealed(); var stored = _cache.CreateStoredDocument(document, key); - Operations[stored.Key.Value] = new StagedOperation(stored.Key, stored, null, false); + Operations[stored.Key.Value] = new StagedOperation( + stored.Key, stored, null, false, afterFlatten: Operations.TryGetValue(stored.Key.Value, out var prior) && prior.Flatten); return stored.Key; } @@ -2736,8 +2744,8 @@ namespace GameCult.Caching } // Call under the gate that admits the result, so no load can land a later storedAt in between. - private CultStoredDocument Stamp(CultRecordKey key, CultDocumentDescriptor descriptor, object document) => - new(key, MintStoredAt(PreviousStoredAt(key), Clock()), descriptor, document); + private CultStoredDocument Stamp(CultRecordKey key, CultDocumentDescriptor descriptor, object document, bool replacesFlattened = false) => + new(key, MintStoredAt(PreviousStoredAt(key), Clock()), descriptor, document) { ReplacesFlattened = replacesFlattened }; // An unresolved variant: the admitting hold resolves it against its base before anything lands. // A staged variant that a Flatten followed lands as the plain record of its resolution (flattens). @@ -2842,7 +2850,7 @@ namespace GameCult.Caching // Staging stamped outside this gate; a pull since then may hold a later storedAt, so stamp again. var upserts = batch.Operations.Values .Where(op => !op.IsRemoval) - .Select(op => op.Stored != null ? Stamp(op.Key, op.Stored.Descriptor, op.Stored.Document) + .Select(op => op.Stored != null ? Stamp(op.Key, op.Stored.Descriptor, op.Stored.Document, op.AfterFlatten) : op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant, op.Flatten) : StampFlatten(op.Key)) .ToArray(); @@ -3299,7 +3307,7 @@ namespace GameCult.Caching _entries.TryGetValue(stored.Key.Value, out var present); if (gone.Contains(stored.Key.Value)) present = null; - if (stored.Variant == null && !stored.Flattens && present?.Variant != null) + if (stored.Variant == null && !stored.Flattens && !stored.ReplacesFlattened && present?.Variant != null) throw new InvalidOperationException( $"Record {stored.Key.Value} is a variant of {present.Variant.BaseKey}; a plain write cannot replace it. " + "Change it through the variant API, or Flatten it."); diff --git a/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs b/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs index 5c6bcf0a..4e6b2e15 100644 --- a/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs +++ b/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs @@ -365,6 +365,85 @@ namespace GameCult.Caching.Tests Assert.That(((EvA)cache.GetStored(K3)!.Document).Name, Is.EqualTo("plain")); } + // finding op-after-staged-flatten-diverges: an operation after a Flatten at the same key acts on the plain record that flatten + // leaves, so one intent has one answer in a batch and staged apart, in both stores kinds and after a reload from disk. + [TestCaseSource(nameof(VariantKinds))] + public void AnOperationAfterAStagedFlattenAnswersAsStagedApart(string kind) + { + var scenarios = new[] { "F,F", "F,P", "F,V", "V,F,V", "F,P,F", "V,F,P" }; + foreach (var scenario in scenarios) + { + var steps = scenario.Split(','); + var results = new List(); + foreach (var oneBatch in new[] { true, false }) + { + var path = NewPath(kind); + string outcome; + using (var cache = Open(path, kind, AB)) + { + var baseA = new CultRecordKey("base-a"); + cache.Commit(batch => + { + batch.Upsert(typeof(EvA), new EvA { Name = "base-a", Value = 1 }, baseA); + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3") }); + }); + var variant = new[] { cache.Override(nameof(EvA.Name), "vv") }; + + try + { + if (oneBatch) + cache.Commit(batch => + { + foreach (var step in steps) + { + if (step == "F") batch.Flatten(K3); + else if (step == "P") batch.Upsert(typeof(EvA), new EvA { Name = "plain" }, K3); + else batch.UpsertVariant(K3, baseA, variant); + } + }); + else + foreach (var step in steps) + { + if (step == "F") cache.FlattenAsync(K3).GetAwaiter().GetResult(); + else if (step == "P") cache.UpsertAsync(typeof(EvA), new EvA { Name = "plain" }, K3).GetAwaiter().GetResult(); + else cache.UpsertVariantAsync(K3, baseA, variant).GetAwaiter().GetResult(); + } + outcome = "committed"; + } + catch (InvalidOperationException refused) + { + outcome = "refused: " + refused.Message; + } + + outcome += " | " + Describe(cache); + cache.FlushAsync().GetAwaiter().GetResult(); + } + + if (kind != "storeless" && outcome.StartsWith("committed", StringComparison.Ordinal)) + { + using var reloaded = Open(path, kind, AB); + outcome += " | reloaded " + Describe(reloaded); + } + + results.Add(outcome); + } + + // A refused batch changes nothing, while staged apart the steps before the refused one have landed: the answer compared + // is the verdict and its text, and the end state only where both committed. + var batchAnswer = results[0].Split(" | ")[0]; + var apartAnswer = results[1].Split(" | ")[0]; + Assert.That(batchAnswer, Is.EqualTo(apartAnswer), $"{kind} {scenario}"); + if (batchAnswer == "committed") + Assert.That(results[0], Is.EqualTo(results[1]), $"{kind} {scenario}"); + } + + static string Describe(CultCache cache) + { + var held = cache.GetStored(K3)!; + return $"{held.Descriptor.DocumentType.Name} variant={held.Variant?.BaseKey} name={((EvA)held.Document).Name}"; + } + } + // finding cross-store-retype-decided-by-key-home: a one-batch retype between two home stores is refused by the rule that a commit // lands in one home store, with that rule's text (an evicted key is absent to the one-key-one-store check, as when staged // apart); staged apart, as two commits, it leaves the key in exactly one store.