A batch stages an operation after a Flatten against the plain record it leaves: a second Flatten and an UpsertVariant are refused, a plain Upsert replaces it; one answer in a batch and staged apart; pinned

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-28 08:01:15 +01:00
commit 6f31d90a9a
2 changed files with 101 additions and 14 deletions

View file

@ -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<CultVariantOverride>? 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<CultVariantOverride>()).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.");

View file

@ -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<string>();
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<EvA>(nameof(EvA.Name), "v3") });
});
var variant = new[] { cache.Override<EvA>(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.