From d8a238508064e0614a15e6bf478622e865fa0a0d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 28 Oct 2026 07:23:59 +0100 Subject: [PATCH] Flatten acts on what the batch stages at the key: a staged variant is flattened as it lands, a removal or plain record there leaves nothing to flatten; pinned Co-Authored-By: Claude Opus 5.5 --- src/GameCult.Caching/CultCache.cs | 25 +++++-- .../RetypeEvictionTests.cs | 74 +++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index f5513807..8c80116c 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2141,8 +2141,20 @@ namespace GameCult.Caching public void Flatten(CultRecordKey key) { ThrowIfSealed(); - if (Removals.Contains(key.Value)) - throw new InvalidOperationException($"Flatten({key.Value}): the record is not a variant."); + // 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. + 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) + throw new InvalidOperationException($"Flatten({key.Value}): the record is not a variant."); + } + Operations[key.Value] = new StagedOperation(key, null, null, true); } @@ -2728,8 +2740,9 @@ namespace GameCult.Caching new(key, MintStoredAt(PreviousStoredAt(key), Clock()), descriptor, document); // 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), Clock()), descriptor, delta, null, _codec); + // A staged variant that a Flatten followed lands as the plain record of its resolution (flattens). + private CultStoredDocument StampVariant(CultRecordKey key, CultDocumentDescriptor descriptor, CultVariantDelta delta, bool flattens = false) => + new(key, MintStoredAt(PreviousStoredAt(key), Clock()), descriptor, delta, null, _codec) { Flattens = flattens }; // The plain record a Flatten stages: the variant's resolved document, under the variant's own key and schema. private CultStoredDocument StampFlatten(CultRecordKey key) @@ -2830,7 +2843,7 @@ namespace GameCult.Caching var upserts = batch.Operations.Values .Where(op => !op.IsRemoval) .Select(op => op.Stored != null ? Stamp(op.Key, op.Stored.Descriptor, op.Stored.Document) - : op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant) + : op.Variant != null ? StampVariant(op.Key, DescriptorOfBase(op.Key, op.Variant.BaseKey, batch), op.Variant, op.Flatten) : StampFlatten(op.Key)) .ToArray(); // A removal evicts the record the cache holds at the key, and a record staged at the key lands after the eviction, as @@ -3411,7 +3424,7 @@ namespace GameCult.Caching ?? throw new InvalidOperationException($"Variant {key} resolved to nothing."); stack.RemoveAt(stack.Count - 1); - return done[key] = isVariant + return done[key] = isVariant && !variant.Flattens ? 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) { diff --git a/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs b/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs index 02d93c80..9f1cdd9f 100644 --- a/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs +++ b/tests/GameCult.Caching.Tests/RetypeEvictionTests.cs @@ -262,6 +262,80 @@ namespace GameCult.Caching.Tests Assert.Throws(() => cache.FlattenAsync(K3).GetAwaiter().GetResult()); } + // finding flatten-guard-reads-removals: a Flatten acts on what the key will hold, as staged apart. A variant staged at the key + // (after a removal or not) is flattened as it lands; a plain record staged there leaves nothing to flatten. + [TestCaseSource(nameof(VariantKinds))] + public void AFlattenOfAVariantStagedInTheSameBatchFlattensItAsStagedApart(string kind) + { + foreach (var oneBatch in new[] { true, false }) + { + using var cache = Open(NewPath(kind), kind, AB); + var baseA = new CultRecordKey("base-a"); + Assert.That(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") }); + batch.Upsert(typeof(EvB), new EvB { Name = "k1", Value = 2 }, K1); + }), Is.True); + + // Remove, UpsertVariant, Flatten: the plain record of the variant's resolution, retyped. + if (oneBatch) + Assert.That(cache.Commit(batch => + { + batch.Remove(K1); + batch.UpsertVariant(K1, baseA, new[] { cache.Override(nameof(EvA.Name), "v1") }); + batch.Flatten(K1); + }), Is.True); + else + { + Assert.That(cache.Remove(K1), Is.True); + cache.UpsertVariantAsync(K1, baseA, new[] { cache.Override(nameof(EvA.Name), "v1") }).GetAwaiter().GetResult(); + cache.FlattenAsync(K1).GetAwaiter().GetResult(); + } + + var flattened = cache.GetStored(K1)!; + Assert.That(flattened.Variant, Is.Null, $"one batch {oneBatch}"); + Assert.That(flattened.Descriptor.DocumentType, Is.EqualTo(typeof(EvA))); + Assert.That(((EvA)flattened.Document).Name, Is.EqualTo("v1")); + + // A held variant rebased and flattened in one batch flattens the rebase, not the held record. + if (oneBatch) + Assert.That(cache.Commit(batch => + { + batch.UpsertVariant(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3b") }); + batch.Flatten(K3); + }), Is.True); + else + { + cache.UpsertVariantAsync(K3, baseA, new[] { cache.Override(nameof(EvA.Name), "v3b") }).GetAwaiter().GetResult(); + cache.FlattenAsync(K3).GetAwaiter().GetResult(); + } + + Assert.That(cache.GetStored(K3)!.Variant, Is.Null, $"one batch {oneBatch}"); + Assert.That(((EvA)cache.GetStored(K3)!.Document).Name, Is.EqualTo("v3b")); + } + } + + [TestCaseSource(nameof(VariantKinds))] + public void AFlattenOfAPlainRecordStagedInTheSameBatchIsRefusedAsStagedApart(string kind) + { + using var cache = Open(NewPath(kind), kind, AB); + PutB(cache, K2); + + var refused = Assert.Throws(() => cache.Commit(batch => + { + batch.Remove(K2); + batch.Upsert(typeof(EvA), new EvA { Name = "k2" }, K2); + batch.Flatten(K2); + }))!; + Assert.That(refused.Message, Does.Contain("Flatten(k2)").And.Contain("not a variant")); + Assert.That(cache.GetStored(K2)!.Descriptor.DocumentType, Is.EqualTo(typeof(EvB)), "the refused batch changed nothing"); + + Assert.That(cache.Remove(K2), Is.True); + cache.UpsertAsync(typeof(EvA), new EvA { Name = "k2" }, K2).GetAwaiter().GetResult(); + Assert.Throws(() => cache.FlattenAsync(K2).GetAwaiter().GetResult()); + } + // 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.