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:
parent
cdb8ee5dcc
commit
c66d79e248
6 changed files with 304 additions and 108 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue