Variant overrides follow the plain-record outcome; bad writes and shared indexes are refused

F3: an override drifts exactly as the same member would on a plain record. A
slot the type dropped is ignored and reported by the store's schema resolution
(LastSchemaMigrationReports), the one surface a plain record uses; a value that
no longer decodes refuses the load, naming the variant and member. The second
report surface (VariantMigrationReports, the per-variant report map, the
catalog slot-name plumbing) is deleted.

F4: an override for a slot the type does not have, or a value that does not
decode as the member, is refused at write. Drift is for type changes after the
fact, not for bad writes. F5: two overrides of one slot are refused.

F2: a variant that shares an indexed value with another record is refused,
naming both keys and the index (the R6 rule extended to [CultIndex]); plain
record duplicate handling is unchanged.

F7: a flatten now resolves against the same batch, so a base edited in that
batch is in the flattened record; and the directory store refuses to load a
variant page as it refuses to write one.

The C1 vector's big variant now overrides its indexed code, as the unique-index
rule requires.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 06:58:51 +02:00
commit c66d79e248
6 changed files with 304 additions and 108 deletions

View file

@ -502,6 +502,10 @@ public sealed class DirectoryMessagePackBackingStore : CacheBackingStore
var record = CultDocumentMessagePackSerialization.DeserializePersistedRecord(pagePayload);
if (!string.Equals(record.Key, metadata.Key, StringComparison.Ordinal))
throw new InvalidDataException($"Record page '{path}' contains key '{record.Key}', expected '{metadata.Key}'.");
// The shared decoder reads a variant page; this store does not hold variants, so a load refuses it as a write does.
if (record.Variant != null)
throw new NotSupportedException(
$"Record '{record.Key}' is a variant of '{record.Variant.BaseKey}'; the directory store does not hold document variants yet.");
return record;
}

View file

@ -108,7 +108,17 @@ This is not negotiable. Better a loud refusal than quiet bit-rot.
## Variant overrides
A variant's override drifts exactly as the same member would on a plain record: an
override naming a slot the type no longer has, or holding a value that no longer decodes
into the member's type, is ignored with a warning naming the variant and the member, and
the variant inherits the base value. There is no separate rule for variants.
A variant's override follows the same outcome as the same member on a plain record; there
is no separate rule for variants, and no separate report.
- An override naming a slot the type no longer has is ignored, exactly as a plain record
ignores a dropped member. The drift is reported once, by the store's schema resolution
(`LastSchemaMigrationReports`), for the variant's record as for any other record of the
schema. The variant inherits the base value.
- An override holding a value that no longer decodes into the member's type refuses the
load, exactly as a plain record holding such a value does; the refusal names the variant
and the member.
Drift is for type changes after the fact. A write is validated: an override for a slot the
type does not have, a value that does not decode as the member, or two overrides of one
slot is refused when written.

View file

@ -1802,10 +1802,8 @@ namespace GameCult.Caching
private readonly Dictionary<(Type Type, string Alias), Dictionary<string, string>> _indexes = new();
private readonly Dictionary<Type, string> _globals = new();
private readonly CultCodec? _codec;
// Keys holding a variant, and the drift report of each variant whose overrides were partly ignored: both derived
// from the entries by Apply, never decided anywhere else.
// Keys holding a variant: derived from the entries by Apply, never decided anywhere else.
private readonly HashSet<string> _variantKeys = new(StringComparer.Ordinal);
private readonly Dictionary<string, CultSchemaMigrationReport> _variantReports = new(StringComparer.Ordinal);
private readonly ConditionalWeakTable<object, KeyBox> _handles = new();
private readonly Subject<Change> _changes = new();
private readonly object _gate = new();
@ -1826,17 +1824,6 @@ namespace GameCult.Caching
public CultCodec? Codec => _codec;
// Overrides a variant carries that no longer resolve (a slot the type dropped, a value that no longer decodes),
// reported as compatible drift: the override is ignored and the variant inherits the base value.
public IReadOnlyList<CultSchemaMigrationReport> VariantMigrationReports
{
get
{
lock (_gate)
return _variantReports.Values.ToArray();
}
}
// The whole override of one member, encoded with this cache's codec, ready for UpsertVariant.
public CultVariantOverride Override(Type documentType, string member, object? value)
{
@ -2467,9 +2454,6 @@ namespace GameCult.Caching
// Variants this admission did not write whose base chain it changed: re-resolved, same delta, same storedAt.
public List<CultStoredDocument> Dependents { get; } = new();
// The drift report of every variant resolved here; null when nothing was ignored.
public Dictionary<string, CultSchemaMigrationReport?> Reports { get; } = new(StringComparer.Ordinal);
}
// Resolves every admitted variant and every variant whose base chain this admission changes, base first, against
@ -2512,7 +2496,9 @@ namespace GameCult.Caching
}
var changed = new HashSet<string>(incoming.Keys.Concat(gone), StringComparer.Ordinal);
var toResolve = new List<string>(admitted.Where(stored => stored.Variant != null).Select(stored => stored.Key.Value));
// A flatten resolves like the variant it replaces, against the post-batch set, so a base edited in the same
// batch is in it; it lands as the plain record of that resolution.
var toResolve = new List<string>(admitted.Where(stored => stored.Variant != null || stored.Flattens).Select(stored => stored.Key.Value));
foreach (var variantKey in _variantKeys)
{
if (incoming.ContainsKey(variantKey) || gone.Contains(variantKey))
@ -2538,16 +2524,14 @@ namespace GameCult.Caching
var pending = new HashSet<string>(toResolve, StringComparer.Ordinal);
var done = new Dictionary<string, CultStoredDocument>(StringComparer.Ordinal);
var reports = new Dictionary<string, CultSchemaMigrationReport?>(StringComparer.Ordinal);
var stack = new List<string>();
Dictionary<(Type Type, string Alias), Dictionary<string, string>>? claimed = null;
foreach (var key in toResolve)
ResolveOne(key);
var plan = new VariantPlan(admitted.Select(stored => stored.Variant != null ? done[stored.Key.Value] : stored).ToList());
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]);
foreach (var pair in reports)
plan.Reports[pair.Key] = pair.Value;
return plan;
CultStoredDocument ResolveOne(string key)
@ -2559,9 +2543,11 @@ namespace GameCult.Caching
throw CycleRefusal(stack.SkipWhile(entry => entry != key).Append(key));
stack.Add(key);
var delta = variant.Variant!;
// A flatten carries no delta of its own; the variant it replaces does.
var isVariant = variant.Variant != null;
var delta = variant.Variant ?? _entries[key].Variant!;
var baseStored = Lookup(delta.BaseKey) ?? throw MissingBaseRefusal(key, delta.BaseKey, gone.Contains(delta.BaseKey));
if (baseStored.Variant != null && pending.Contains(delta.BaseKey))
if (pending.Contains(delta.BaseKey))
baseStored = ResolveOne(delta.BaseKey);
var descriptor = variant.Descriptor;
@ -2575,25 +2561,25 @@ namespace GameCult.Caching
var type = descriptor.DocumentType;
var kept = new List<KeyValuePair<int, byte[]>>();
var ignored = new List<int>();
var warnings = new List<CultSchemaMigrationWarning>();
var seen = new HashSet<int>();
// 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)
throw new InvalidOperationException(
$"Variant {key} carries {entry.Op} at {entry.PathText}; this runtime resolves top-level Set overrides only.");
var slot = entry.Path[0].Slot;
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);
if (member == null)
{
var name = delta.SlotNames != null && delta.SlotNames.TryGetValue(slot, out var persistedName)
? $"'{persistedName}'" : "unnamed";
ignored.Add(slot);
warnings.Add(new CultSchemaMigrationWarning
{
Code = "ignored_extra_slot",
Message = $"Variant {key} overrides slot {slot} ({name}), which {type.Name} no longer has; the override is ignored and the variant inherits the base value."
});
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;
}
@ -2603,14 +2589,10 @@ namespace GameCult.Caching
}
catch (Exception exception)
{
ignored.Add(slot);
warnings.Add(new CultSchemaMigrationWarning
{
Code = "ignored_undecodable_override",
Message = $"Variant {key} overrides slot {slot} ('{member.Member.Name}') with a value that no longer decodes as {member.MemberType.Name} " +
$"({exception.GetBaseException().Message}); the override is ignored and the variant inherits the base value."
});
continue;
// A value that no longer decodes refuses exactly as a plain record holding it does.
throw new InvalidOperationException(
$"Variant {key} overrides '{member.Member.Name}' (slot {slot}) with a value that does not decode as {member.MemberType.Name}: " +
exception.GetBaseException().Message, exception);
}
kept.Add(new KeyValuePair<int, byte[]>(slot, entry.Value));
@ -2620,32 +2602,73 @@ namespace GameCult.Caching
var document = _codec.Deserialize(type, type, kept.Count == 0 ? payload : _codec.Overlay(payload, kept))
?? throw new InvalidOperationException($"Variant {key} resolved to nothing.");
// R6: the name index maps one name to one key, so a variant that kept its base's name would shadow it.
if (descriptor.NameAccessor?.Invoke(document) is { Length: > 0 } variantName)
if (isVariant)
{
for (var ancestor = baseStored; ancestor != null;
ancestor = ancestor.Variant == null ? null : done.TryGetValue(ancestor.Variant.BaseKey, out var above) ? above : Lookup(ancestor.Variant.BaseKey))
// R6: the name index maps one name to one key, so a variant that kept its base's name would shadow it.
if (descriptor.NameAccessor?.Invoke(document) is { Length: > 0 } variantName)
{
if (descriptor.NameAccessor(ancestor.Document) == variantName)
for (var ancestor = baseStored; ancestor != null;
ancestor = ancestor.Variant == null ? null : done.TryGetValue(ancestor.Variant.BaseKey, out var above) ? above : Lookup(ancestor.Variant.BaseKey))
{
if (descriptor.NameAccessor(ancestor.Document) == variantName)
throw new InvalidOperationException(
$"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 its base {ancestor.Key.Value} are both named '{variantName}'; a variant must override its name.");
$"Variant {key} and {other} both hold index {pair.Key} '{value}'; a variant must override its indexed member.");
}
}
reports[key] = ignored.Count == 0
? null
: new CultSchemaMigrationReport
if (descriptor.IndexAccessors.Count > 0)
{
claimed ??= ClaimIncomingIndexValues();
foreach (var pair in descriptor.IndexAccessors)
{
PersistedSchemaId = descriptor.SchemaId,
LocalSchemaId = descriptor.SchemaId,
PersistedSchemaName = descriptor.SchemaName,
LocalSchemaName = descriptor.SchemaName,
Kind = CultSchemaMigrationKind.CompatibleDrift,
IgnoredExtraSlots = ignored.OrderBy(slot => slot).ToArray(),
Warnings = warnings.ToArray()
};
var value = pair.Value(document);
if (!string.IsNullOrWhiteSpace(value))
MapOf(claimed, (type, pair.Key))[value] = key;
}
}
stack.RemoveAt(stack.Count - 1);
return done[key] = variant.Resolved(document);
return done[key] = isVariant
? 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<string, string>> ClaimIncomingIndexValues()
{
var claims = new Dictionary<(Type Type, string Alias), Dictionary<string, string>>();
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;
}
}
@ -2672,7 +2695,6 @@ namespace GameCult.Caching
_entries.Remove(stored.Key.Value);
_handles.Remove(existing.Document);
_variantKeys.Remove(stored.Key.Value);
_variantReports.Remove(stored.Key.Value);
changes.Add(new Change(CultCacheDocumentChangeKind.Removed, existing, null, existing.Document, ++_sequence));
}
@ -2692,10 +2714,6 @@ namespace GameCult.Caching
_variantKeys.Remove(stored.Key.Value);
else
_variantKeys.Add(stored.Key.Value);
if (plan.Reports.TryGetValue(stored.Key.Value, out var report) && report != null)
_variantReports[stored.Key.Value] = report;
else
_variantReports.Remove(stored.Key.Value);
changes.Add(new Change(
previous == null ? CultCacheDocumentChangeKind.Added : CultCacheDocumentChangeKind.Updated,
stored,
@ -2911,16 +2929,12 @@ namespace GameCult.Caching
var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog);
if (record.Variant != null)
{
// The cache resolves it against its base when it admits the load; the catalog names the slots the type may have dropped.
var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, record.SchemaId, StringComparison.Ordinal));
// 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)
{
SlotNames = persisted?.Members.GroupBy(member => member.Slot).ToDictionary(group => group.Key, group => group.First().MemberName)
},
new CultVariantDelta(record.Variant.BaseKey, record.Variant.Overrides),
null);
}

View file

@ -87,9 +87,5 @@ namespace GameCult.Caching
public string BaseKey { get; }
public IReadOnlyList<CultVariantOverride> Overrides { get; }
// Member names by slot from the catalog the delta was loaded under, for warnings about slots the type dropped.
// Not persisted.
internal IReadOnlyDictionary<int, string>? SlotNames { get; set; }
}
}

View file

@ -50,12 +50,13 @@ namespace GameCult.Caching.Tests
private static void SeedBase(CultCache cache) =>
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Laser(), BaseKey));
// laser-big: a variant of laser that overrides its name and power and inherits the rest.
// laser-big: a variant of laser that overrides its name, power and code (an indexed value must be its own) and inherits the rest.
private static void SeedBig(CultCache cache, CultRecordKey? key = null, CultRecordKey? baseKey = null, string name = "laser big", int power = 25) =>
cache.Commit(batch => batch.UpsertVariant(key ?? BigKey, baseKey ?? BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Name), name),
cache.Override<VariantGear>(nameof(VariantGear.Power), power)
cache.Override<VariantGear>(nameof(VariantGear.Power), power),
cache.Override<VariantGear>(nameof(VariantGear.Code), name)
}));
private static void EditBase(CultCache cache, Action<VariantGear> edit) =>
@ -85,7 +86,7 @@ namespace GameCult.Caching.Tests
var big = cache.Get<VariantGear>(BigKey)!;
Assert.That(big.Name, Is.EqualTo("laser big"));
Assert.That(big.Power, Is.EqualTo(25));
Assert.That(big.Code, Is.EqualTo("l1"), "an inherited member");
Assert.That(big.Code, Is.EqualTo("laser big"), "the override the unique index requires");
Assert.That(big.Tags, Is.EqualTo(new[] { "beam" }), "an inherited list");
Assert.That(cache.Get<VariantGear>(BaseKey)!.Power, Is.EqualTo(10));
Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(BaseKey.Value));
@ -133,9 +134,10 @@ namespace GameCult.Caching.Tests
Assert.That(cache.GetByName<VariantGear>("laser big")!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
Assert.That(cache.GetByName<VariantGear>("laser big")!.Power, Is.EqualTo(25), "the override survives the base edit");
Assert.That(cache.GetByIndex<VariantGear>("code", "l9")!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
// big inherited the old code; its stale index entry must be gone, not shadowing.
// the base's old code entry must be gone, not shadowing, and its new one must find the base itself.
Assert.That(cache.GetByIndex<VariantGear>("code", "l1"), Is.Null);
Assert.That(cache.GetByIndex<VariantGear>("code", "l2")!.Name, Is.Not.Null);
Assert.That(cache.GetByIndex<VariantGear>("code", "l2")!.Name, Is.EqualTo("laser"));
Assert.That(cache.GetByIndex<VariantGear>("code", "laser big")!.Name, Is.EqualTo("laser big"));
Assert.That(seen.Select(entry => entry.Key), Is.EquivalentTo(new[] { BaseKey.Value, BigKey.Value, "laser-coded" }));
Assert.That(seen.Select(entry => entry.Sequence), Is.EqualTo(Enumerable.Range((int)seen[0].Sequence, seen.Count).Select(n => (long)n)),
@ -297,7 +299,7 @@ namespace GameCult.Caching.Tests
batch.UpsertVariant(BigKey, spare, cache.GetStored(BigKey)!.Variant!.Overrides);
batch.Remove(BaseKey);
}), Is.True, "rebased in the same batch");
Assert.That(cache.Get<VariantGear>(BigKey)!.Code, Is.EqualTo(string.Empty), "now resolved from spare");
Assert.That(cache.Get<VariantGear>(BigKey)!.Tags, Is.Empty, "now resolved from spare");
Assert.That(cache.Commit(batch =>
{
@ -435,36 +437,204 @@ namespace GameCult.Caching.Tests
Assert.That(variant.Power, Is.EqualTo(7), "the override the type still has applies");
Assert.That(variant.Name, Is.EqualTo("drift variant"));
var report = cache.VariantMigrationReports.Single();
Assert.That(report.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
Assert.That(report.IgnoredExtraSlots, Is.EqualTo(new[] { 2 }));
var warning = report.Warnings.Single();
Assert.That(warning.Code, Is.EqualTo("ignored_extra_slot"), "the code a dropped member gets on a plain record");
Assert.That(warning.Message, Does.Contain("drift-variant").And.Contain("Wing"));
// The same decision, on the same surface, that the plain base record gets from the store's schema resolution.
var reports = cache.BackingStores[0].LastSchemaMigrationReports;
Assert.That(reports, Has.Count.EqualTo(2), "one per record: the base and the variant");
Assert.That(reports.Select(report => report.Kind), Is.All.EqualTo(CultSchemaMigrationKind.CompatibleDrift));
Assert.That(reports.Select(report => string.Join(",", report.IgnoredExtraSlots)), Is.All.EqualTo("2"));
Assert.That(reports.SelectMany(report => report.Warnings).Select(warning => warning.Code), Is.All.EqualTo("ignored_extra_slot"));
}
private static readonly byte[] NotAnInt = { 0xa3, (byte)'a', (byte)'b', (byte)'c' };
private static CultVariantOverride TextOverride(int slot, string value)
{
var bytes = new byte[value.Length + 1];
bytes[0] = (byte)(0xa0 | value.Length);
for (var index = 0; index < value.Length; index++)
bytes[index + 1] = (byte)value[index];
return CultVariantOverride.Set(slot, bytes);
}
private static CultPersistedRecord OverridingRecord(string key, string baseKey, params CultVariantOverride[] overrides) => new()
{
Key = key,
SchemaId = Registry.GetRequired<VariantGear>().SchemaId,
StoredAt = "2026-01-01T00:00:00.0000000Z",
Payload = Array.Empty<byte>(),
Variant = new CultVariantDelta(baseKey, overrides)
};
[Test]
public void AnOverrideThatNoLongerDecodesRefusesTheLoadAsAPlainRecordHoldingItDoes()
{
// VariantGear.Power is [Key(1)] int; a string there is the nested-type-change case with the schema id unchanged.
var plainRecord = new CultPersistedRecord
{
Key = "hand-plain",
SchemaId = Registry.GetRequired<VariantGear>().SchemaId,
StoredAt = "2026-01-01T00:00:00.0000000Z",
Payload = new byte[] { 0x94, 0xa1, (byte)'x', 0xa3, (byte)'a', (byte)'b', (byte)'c', 0xa0, 0x90 }
};
Assert.That(Assert.Catch(() => Open(WriteStore(plainRecord)).Dispose()), Is.Not.Null, "the plain-record outcome is a refusal");
var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base", CultVariantOverride.Set(1, NotAnInt)));
var refused = Assert.Catch(() => Open(path).Dispose())!;
Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("Power"));
}
[Test]
public void AnOverrideThatNoLongerDecodesIsIgnoredWithAWarningAndTheBaseValueApplies()
public void AnOverrideOfASlotTheTypeNeverHadIsIgnoredOnLoadAsAPlainRecordIgnoresAnExtraSlot()
{
var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base",
CultVariantOverride.Set(99, new byte[] { 0x01 }),
TextOverride(0, "hand variant"),
TextOverride(2, "hand-code")));
using var cache = Open(path);
Assert.That(cache.Get<VariantGear>(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant"));
Assert.That(cache.Get<VariantGear>(new CultRecordKey("hand-variant"))!.Power, Is.EqualTo(10), "inherits the base");
}
// ---- a bad write is refused; drift is for type changes after the fact ----
[Test]
public void AnInvalidOverrideIsRefusedAtWriteNamingVariantSlotAndReasonAndNothingLands()
{
var path = PathOf("gear.cc");
using var cache = Open(path);
SeedBase(cache);
var unknown = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
{
CultVariantOverride.Set(99, new byte[] { 0x01 })
})));
Assert.That(unknown.Message, Does.Contain(BigKey.Value).And.Contain("99").And.Contain("does not have"));
var wrongType = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[]
{
CultVariantOverride.Set(1, NotAnInt)
}).GetAwaiter().GetResult());
Assert.That(wrongType.Message, Does.Contain(BigKey.Value).And.Contain("Power").And.Contain("slot 1"));
Assert.That(cache.Get(BigKey), Is.Null);
cache.FlushAsync().GetAwaiter().GetResult();
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "no variant was persisted");
}
[Test]
public void TwoOverridesOfOneSlotAreRefusedAtWriteAndAnUnknownMemberNameIsAnArgumentException()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
var twice = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Power), 1),
cache.Override<VariantGear>(nameof(VariantGear.Power), 2)
})));
Assert.That(twice.Message, Does.Contain(BigKey.Value).And.Contain("slot 1").And.Contain("more than once"));
Assert.That(cache.Get(BigKey), Is.Null);
var unknownName = Assert.Throws<ArgumentException>(() => cache.Override<VariantGear>("Nope", 1))!;
Assert.That(unknownName.Message, Does.Contain("Nope"));
}
// ---- a variant may not share an indexed value with another record ----
[Test]
public void AVariantThatInheritsAnIndexedValueIsRefusedNamingBothKeysAndTheIndex()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
var inherited = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big")
})));
Assert.That(inherited.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code").And.Contain("l1"));
var explicitly = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big"),
cache.Override<VariantGear>(nameof(VariantGear.Code), "l1")
})));
Assert.That(explicitly.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value));
Assert.That(cache.Get(BigKey), Is.Null, "nothing landed");
Assert.That(cache.GetByIndex<VariantGear>("code", "l1")!.Name, Is.EqualTo("laser"));
SeedBig(cache);
Assert.That(cache.GetByIndex<VariantGear>("code", "l1")!.Name, Is.EqualTo("laser"));
Assert.That(cache.GetByIndex<VariantGear>("code", "laser big")!.Name, Is.EqualTo("laser big"));
}
[Test]
public void ABaseEditThatWouldGiveItsVariantsIndexedValueToTheBaseIsRefused()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
SeedBig(cache);
var refused = Refused(() => EditBase(cache, gear => gear.Code = "laser big"));
Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("code"));
Assert.That(cache.Get<VariantGear>(BaseKey)!.Code, Is.EqualTo("l1"), "nothing landed");
}
[Test]
public void ALoadedVariantThatInheritsAnIndexedValueRefusesTheLoadNamingBothKeys()
{
var path = WriteStore(PlainGear("hand-base"), OverridingRecord("hand-variant", "hand-base"));
var refused = Assert.Throws<InvalidOperationException>(() => Open(path).Dispose())!;
Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("hand-base").And.Contain("code"));
}
// ---- flatten resolves against the same batch ----
[Test]
public void AFlattenAndABaseEditInOneBatchLandTheResolutionOfTheWholeBatch()
{
var path = PathOf("gear.cc");
const int notAnInt = 1; // VariantGear.Power, [Key(1)]
using (var cache = Open(path))
{
SeedBase(cache);
cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
SeedBig(cache);
cache.Commit(batch =>
{
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big"),
CultVariantOverride.Set(notAnInt, new byte[] { 0xa3, (byte)'a', (byte)'b', (byte)'c' })
}));
var warning = cache.VariantMigrationReports.Single().Warnings.Single();
Assert.That(warning.Message, Does.Contain(BigKey.Value).And.Contain("Power"));
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(10), "inherits the base value");
Assert.That(cache.Get<VariantGear>(BigKey)!.Name, Is.EqualTo("laser big"));
var edited = Laser();
edited.Tags.Add("pierce");
batch.Upsert(typeof(VariantGear), edited, BaseKey);
batch.Flatten(BigKey);
});
Assert.That(cache.GetStored(BigKey)!.Variant, Is.Null);
Assert.That(cache.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }), "the edit made in the same batch");
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
}
using var reloaded = Open(path);
Assert.That(reloaded.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(10));
Assert.That(reloaded.VariantMigrationReports.Single().Warnings.Single().Message, Does.Contain("Power"));
Assert.That(reloaded.GetStored(BigKey)!.Variant, Is.Null);
Assert.That(reloaded.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
}
// ---- the directory store refuses a variant page on load as it does on write ----
[Test]
public void TheDirectoryStoreRefusesToLoadAVariantPage()
{
var manifest = PathOf("dir-page.cc");
var records = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(manifest);
Directory.CreateDirectory(records);
var page = CultDocumentMessagePackSerialization.SerializePersistedRecord(OverridingRecord("hand-variant", "hand-base", TextOverride(0, "x")));
var hash = System.Security.Cryptography.SHA256.HashData(page);
File.WriteAllBytes(Path.Combine(records, Convert.ToHexString(hash).ToLowerInvariant() + ".msgpack"), page);
using var store = new DirectoryMessagePackBackingStore(manifest, records);
var read = typeof(DirectoryMessagePackBackingStore).GetMethod(
"ReadPersistedRecordPage", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var refused = Assert.Throws<System.Reflection.TargetInvocationException>(
() => read.Invoke(store, new object?[] { new CultPersistedRecord { Key = "hand-variant", Payload = hash }, null }))!;
Assert.That(refused.InnerException, Is.TypeOf<NotSupportedException>());
Assert.That(refused.InnerException!.Message, Does.Contain("hand-variant").And.Contain("directory store"));
}
// ---- persisted format ----
@ -541,7 +711,7 @@ namespace GameCult.Caching.Tests
using var readOnly = Open(path, readOnly: true);
Assert.That(readOnly.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
Assert.That(readOnly.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("l1"));
Assert.That(readOnly.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("laser big"));
// A base edit another writer commits reaches the read-only cache's variant when it pulls: a re-resolution is no write.
using var writer = Open(path);
@ -705,7 +875,7 @@ namespace GameCult.Caching.Tests
using (var cache = Open(path))
{
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
Assert.That(cache.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("l1"));
Assert.That(cache.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("laser big"));
cache.BackingStores[0].PushAll();
}

View file

@ -11,9 +11,11 @@ at test time. Run: python generate.py (needs msgpack).
v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib
69a21bb (SingleFileMessagePackBackingStore.push of alpha and
beta, type vectors.item, before any C0 code existed)
variant-v2.msgpack what C1 writes for a variant: header v2, record slot 4 =
[baseKey, overrides[]], empty payload (override op Set = 0,
path steps are [slot, elementId])
variant-v2.msgpack the wire shape of a variant record, hand-built: header v2, record slot 4 =
[baseKey, overrides[]], empty payload (override op Set = 0, path steps
are [slot, elementId]). C1 does not write this store and refuses to load
it: the variant resolves to its base's name (R6). It exercises readers
that refuse a variant by version or record.
"""
import pathlib