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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 07:23:59 +01:00
commit d8a2385080
2 changed files with 93 additions and 6 deletions

View file

@ -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)
{

View file

@ -262,6 +262,80 @@ namespace GameCult.Caching.Tests
Assert.Throws<InvalidOperationException>(() => 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<EvA>(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<EvA>(nameof(EvA.Name), "v1") });
batch.Flatten(K1);
}), Is.True);
else
{
Assert.That(cache.Remove(K1), Is.True);
cache.UpsertVariantAsync(K1, baseA, new[] { cache.Override<EvA>(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<EvA>(nameof(EvA.Name), "v3b") });
batch.Flatten(K3);
}), Is.True);
else
{
cache.UpsertVariantAsync(K3, baseA, new[] { cache.Override<EvA>(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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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.