CultLib/tests/GameCult.Caching.Tests/VariantTests.cs

1839 lines
88 KiB
C#
Raw Normal View History

#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
using R3;
namespace GameCult.Caching.Tests
{
// The contract is src/GameCult.Caching/Contracts/cultcache-persistence-format.md (variant records) and
// docs/document-variants-cut.md C1. Every input goes through the cache's own write API and the real single-file store.
public class VariantTests
{
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[]
{
typeof(VariantGear), typeof(VariantOther), typeof(VariantGlobal)
});
private static readonly CultRecordKey BaseKey = new("laser");
private static readonly CultRecordKey BigKey = new("laser-big");
private string _directory = string.Empty;
[SetUp]
public void CreateDirectory()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-variants-{Guid.NewGuid():N}");
Directory.CreateDirectory(_directory);
}
[TearDown]
public void DeleteDirectory()
{
if (Directory.Exists(_directory))
Directory.Delete(_directory, recursive: true);
}
private string PathOf(string name) => Path.Combine(_directory, name);
private static CultCache Open(string path, bool readOnly = false, CultDocumentRegistry? registry = null) =>
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry ?? Registry, ReadOnly = readOnly });
private static VariantGear Laser() => new() { Name = "laser", Power = 10, Code = "l1", Tags = { "beam" } };
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, 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.Code), name)
}));
private static void EditBase(CultCache cache, Action<VariantGear> edit) =>
cache.Commit(batch =>
{
var edited = Laser();
edit(edited);
batch.Upsert(typeof(VariantGear), edited, BaseKey);
});
private static InvalidOperationException Refused(TestDelegate write) =>
Assert.Throws<InvalidOperationException>(write)!;
// ---- resolution ----
[Test]
public void ALoadedVariantResolvesAgainstItsBase()
{
var path = PathOf("gear.cc");
using (var writer = Open(path))
{
SeedBase(writer);
SeedBig(writer);
}
using var cache = Open(path);
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("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));
Assert.That(cache.GetStored(BigKey)!.Variant!.Overrides, Has.Count.EqualTo(3));
}
[Test]
public void AVariantOfAVariantResolvesBaseFirstAndFollowsTheRoot()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
SeedBig(cache);
var hugeKey = new CultRecordKey("laser-huge");
SeedBig(cache, hugeKey, BigKey, name: "laser huge", power: 40);
Assert.That(cache.Get<VariantGear>(hugeKey)!.Tags, Is.EqualTo(new[] { "beam" }));
EditBase(cache, gear => gear.Tags.Add("pierce"));
Assert.That(cache.Get<VariantGear>(hugeKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
Assert.That(cache.Get<VariantGear>(hugeKey)!.Power, Is.EqualTo(40));
}
[Test]
public void ABaseEditReachesItsVariantsThroughGetNameAndIndexAndOneWatchPublication()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
SeedBig(cache);
cache.Commit(batch => batch.UpsertVariant(new CultRecordKey("laser-coded"), BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser coded"),
cache.Override<VariantGear>(nameof(VariantGear.Code), "l9")
}));
var seen = new List<(string Key, long Sequence, string BigTagsAtThatMoment, string BigNameLookupTags)>();
using var subscription = cache.Watch<VariantGear>().Subscribe(change => seen.Add((
change.Key.Value,
change.Sequence,
string.Join(",", cache.Get<VariantGear>(BigKey)!.Tags),
string.Join(",", cache.GetByName<VariantGear>("laser big")!.Tags))));
EditBase(cache, gear => { gear.Tags.Add("pierce"); gear.Code = "l2"; });
Assert.That(cache.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
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" }));
// 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.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)),
"one commit, one run of sequences with no gap");
Assert.That(seen.Select(entry => entry.BigTagsAtThatMoment), Is.All.EqualTo("beam,pierce"),
"a subscriber observing the base change already reads the new variant");
Assert.That(seen.Select(entry => entry.BigNameLookupTags), Is.All.EqualTo("beam,pierce"));
}
[Test]
public void AReResolvedDependentKeepsItsStoredAtAndIsAnUpdatedChange()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
SeedBig(cache);
var before = cache.GetStored(BigKey)!;
var changes = new List<CultCacheDocumentChange<VariantGear>>();
using var subscription = cache.WatchRecord<VariantGear>(BigKey).Subscribe(changes.Add);
EditBase(cache, gear => gear.Tags.Add("pierce"));
Assert.That(cache.GetStored(BigKey)!.StoredAt, Is.EqualTo(before.StoredAt), "a base change stamps no dependent");
Assert.That(changes, Has.Count.EqualTo(1));
Assert.That(changes[0].Kind, Is.EqualTo(CultCacheDocumentChangeKind.Updated));
Assert.That(changes[0].PreviousDocument!.Tags, Is.EqualTo(new[] { "beam" }));
Assert.That(changes[0].Document!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
}
[Test]
public void ExpectOnAVariantSurvivesABaseChangeButNotAChangeToItsDelta()
{
var path = PathOf("gear.cc");
using (var seed = Open(path))
{
SeedBase(seed);
SeedBig(seed);
}
using var a = Open(path);
using var b = Open(path);
var seen = a.Get<VariantGear>(BigKey)!;
EditBase(b, gear => gear.Tags.Add("pierce"));
// Expect tests the variant's stored delta only, and it did not change, so the condition holds and the write goes on: the
// store holds a variant and moved, so it is refused and reloaded as a write conflict. It is not a mismatch (false).
Assert.Throws<CultWriteConflictException>(() => a.Commit(batch =>
{
batch.Expect(BigKey, seen);
batch.Upsert(typeof(VariantOther), new VariantOther { Name = "marker" }, new CultRecordKey("marker"));
}));
seen = a.Get<VariantGear>(BigKey)!;
Assert.That(a.Commit(batch =>
{
batch.Expect(BigKey, seen);
batch.Upsert(typeof(VariantOther), new VariantOther { Name = "marker" }, new CultRecordKey("marker"));
}), Is.True, "the base changed; the variant's stored delta did not");
a.PullAllBackingStoresAsync();
seen = a.Get<VariantGear>(BigKey)!;
b.PullAllBackingStoresAsync();
SeedBig(b, power: 26);
Assert.That(a.Commit(batch =>
{
batch.Expect(BigKey, seen);
batch.Upsert(typeof(VariantOther), new VariantOther { Name = "second" }, new CultRecordKey("second"));
}), Is.False, "another writer changed the variant's own delta");
}
// ---- refusals, each naming the keys ----
[Test]
public void ACycleOfBasesIsRefusedNamingEveryKey()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
var a = new CultRecordKey("cycle-a");
var b = new CultRecordKey("cycle-b");
var staged = Refused(() => cache.Commit(batch =>
{
batch.UpsertVariant(a, b);
batch.UpsertVariant(b, a);
}));
Assert.That(staged.Message, Does.Contain("cycle").And.Contain(a.Value).And.Contain(b.Value));
SeedBig(cache);
var huge = new CultRecordKey("laser-huge");
SeedBig(cache, huge, BigKey, name: "laser huge");
var rebased = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, huge)));
Assert.That(rebased.Message, Does.Contain("cycle").And.Contain(BigKey.Value).And.Contain(huge.Value));
Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(BaseKey.Value), "nothing landed");
}
[Test]
public void AMissingBaseIsRefusedNamingBoth()
{
using var cache = Open(PathOf("gear.cc"));
var error = Refused(() => cache.UpsertVariantAsync(BigKey, new CultRecordKey("no-such-base")).GetAwaiter().GetResult());
Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("no-such-base"));
Assert.That(cache.Get(BigKey), Is.Null);
}
[Test]
public void ABaseInAnotherTypeIsRefusedOnRebaseAndOnLoad()
{
var path = PathOf("gear.cc");
using (var cache = Open(path))
{
SeedBase(cache);
SeedBig(cache);
cache.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "other" }, new CultRecordKey("other")));
var rebase = Refused(() => cache.Commit(batch => batch.UpsertVariant(BigKey, new CultRecordKey("other"))));
Assert.That(rebase.Message, Does.Contain(BigKey.Value).And.Contain("VariantOther"));
}
// A store whose variant record names another type than its base's: the loader refuses it naming both keys.
var bad = PathOf("bad.cc");
var gear = Registry.GetRequired<VariantGear>();
var other = Registry.GetRequired<VariantOther>();
File.WriteAllBytes(bad, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
{
FormatVersion = CultPersistedStoreSnapshot.FormatV2,
SchemaCatalog = new[] { gear.ToCatalogEntry(), other.ToCatalogEntry() },
Records = new[]
{
new CultPersistedRecord
{
Key = "gear-base", SchemaId = gear.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z",
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(Laser(), typeof(VariantGear), Registry)
},
new CultPersistedRecord
{
Key = "other-variant", SchemaId = other.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z",
Variant = new CultVariantDelta("gear-base", Array.Empty<CultVariantOverride>())
}
}
}));
var load = Refused(() => Open(bad).Dispose());
Assert.That(load.Message, Does.Contain("other-variant").And.Contain("gear-base"));
}
[Test]
public void AVariantOfAGlobalIsRefused()
{
using var cache = Open(PathOf("gear.cc"));
var key = cache.UpsertAsync(new VariantGlobal { Value = "one" }).GetAwaiter().GetResult().Key;
var error = Refused(() => cache.UpsertVariantAsync(BigKey, key).GetAwaiter().GetResult());
Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("global"));
}
[Test]
public void RemovingABaseThatKeepsVariantsIsRefusedUnlessTheSameBatchRemovesOrRebasesThem()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
var spare = new CultRecordKey("spare");
cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare", Power = 3 }, spare));
SeedBig(cache);
var refused = Refused(() => cache.Commit(batch => batch.Remove(BaseKey)));
Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value));
Assert.That(cache.Get(BaseKey), Is.Not.Null);
Assert.That(cache.Get(BigKey), Is.Not.Null);
Assert.That(cache.Commit(batch =>
{
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)!.Tags, Is.Empty, "now resolved from spare");
Assert.That(cache.Commit(batch =>
{
batch.Remove(spare);
batch.Remove(BigKey);
}), Is.True, "removed in the same batch");
SeedBase(cache);
var removedInTheBatch = Refused(() => cache.Commit(batch =>
{
batch.Remove(BaseKey);
batch.UpsertVariant(BigKey, BaseKey);
}));
Assert.That(removedInTheBatch.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value));
}
[Test]
public void AVariantThatKeepsItsBasesNameIsRefused()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey).GetAwaiter().GetResult());
Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value).And.Contain("laser"));
var sameName = Refused(() => SeedBig(cache, name: "laser"));
Assert.That(sameName.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value));
SeedBig(cache);
var grand = new CultRecordKey("laser-grand");
var grandOfBase = Refused(() => SeedBig(cache, grand, BigKey, name: "laser"));
Assert.That(grandOfBase.Message, Does.Contain(grand.Value).And.Contain(BaseKey.Value), "the whole chain, not only the parent");
}
[Test]
public void ABaseEditThatWouldGiveAVariantItsBasesNameIsRefused()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
SeedBig(cache);
var error = Refused(() => EditBase(cache, gear => gear.Name = "laser big"));
Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain(BaseKey.Value));
Assert.That(cache.Get<VariantGear>(BaseKey)!.Name, Is.EqualTo("laser"));
}
[Test]
public void APlainWriteAtAVariantsKeyIsRefusedByEveryPathAndFlattenIsTheExplicitWay()
{
var path = PathOf("gear.cc");
using var cache = Open(path);
SeedBase(cache);
SeedBig(cache);
var plain = new VariantGear { Name = "laser big", Power = 99 };
Assert.That(Refused(() => cache.UpsertAsync(typeof(VariantGear), plain, BigKey).GetAwaiter().GetResult()).Message,
Does.Contain(BigKey.Value));
Assert.That(Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), plain, BigKey))).Message,
Does.Contain(BigKey.Value));
Assert.That(cache.GetStored(BigKey)!.Variant, Is.Not.Null);
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
cache.FlattenAsync(BigKey).GetAwaiter().GetResult();
var flat = cache.GetStored(BigKey)!;
Assert.That(flat.Variant, Is.Null);
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25), "flatten keeps the resolved values");
EditBase(cache, gear => gear.Tags.Add("pierce"));
Assert.That(cache.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam" }), "a flattened record no longer follows its base");
cache.UpsertAsync(typeof(VariantGear), plain, BigKey).GetAwaiter().GetResult();
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(99), "and is an ordinary record again");
cache.FlushAsync().GetAwaiter().GetResult();
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "no variant left in the store");
}
[Test]
public void FlattenRefusesARecordThatIsNotAVariantAndAPlainRecordCannotBecomeOne()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
Assert.That(Refused(() => cache.FlattenAsync(BaseKey).GetAwaiter().GetResult()).Message, Does.Contain(BaseKey.Value));
var spare = new CultRecordKey("spare");
cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare" }, spare));
var named = new[] { cache.Override<VariantGear>(nameof(VariantGear.Name), "spare variant") };
Assert.That(Refused(() => cache.UpsertVariantAsync(spare, BaseKey, named).GetAwaiter().GetResult()).Message, Does.Contain(spare.Value));
Assert.That(cache.GetStored(spare)!.Variant, Is.Null, "the plain record is still plain");
}
[Test]
public void ACacheWithoutACodecHoldsNoVariants()
{
using var cache = new CultCache(Registry);
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Laser(), BaseKey));
var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey).GetAwaiter().GetResult());
Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("codec"));
}
[Test]
public void OnlyTopLevelSetOverridesResolveInThisCut()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
var element = new CultVariantOverride(
CultOverrideOp.Set, new[] { new CultPathStep(3, "el-1"), new CultPathStep(0) }, string.Empty, new byte[] { 0x01 });
var error = Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[] { element }).GetAwaiter().GetResult());
Assert.That(error.Message, Does.Contain(BigKey.Value).And.Contain("top-level"));
var insert = new CultVariantOverride(CultOverrideOp.Insert, new[] { new CultPathStep(3) }, "new", new byte[] { 0xa1, 0x78 });
Assert.That(Refused(() => cache.UpsertVariantAsync(BigKey, BaseKey, new[] { insert }).GetAwaiter().GetResult()).Message,
Does.Contain(BigKey.Value).And.Contain("top-level"));
}
// ---- soft drift: an override drifts exactly as the same member would on a plain record ----
[Test]
public void AnOverrideOfASlotTheTypeNoLongerHasIsIgnoredWithAWarningNamingVariantAndMember()
{
var path = PathOf("drift.cc");
var wide = CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) });
using (var writer = Open(path, registry: wide))
{
writer.Commit(batch =>
{
batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base"));
batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[]
{
writer.Override<DriftWide>(nameof(DriftWide.Name), "drift variant"),
writer.Override<DriftWide>(nameof(DriftWide.Power), 7),
writer.Override<DriftWide>(nameof(DriftWide.Wing), "variant wing")
});
});
}
var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) });
using var cache = Open(path, registry: narrow);
var variant = cache.Get<DriftNarrow>(new CultRecordKey("drift-variant"))!;
Assert.That(variant.Power, Is.EqualTo(7), "the override the type still has applies");
Assert.That(variant.Name, Is.EqualTo("drift variant"));
// 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.Select(report => report.Warnings.Select(warning => warning.Code)), Has.All.Contain("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 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", TextOverride(0, "hand variant")));
var refused = Assert.Throws<InvalidOperationException>(() => Open(path).Dispose())!;
Assert.That(refused.Message, Does.Contain("hand-variant").And.Contain("hand-base").And.Contain("code"));
}
// ---- the index rule holds for the commit's result, in either write order, and the store always reopens ----
private static VariantGear Other(string code) => new() { Name = "other", Power = 1, Code = code };
[Test]
public async Task APlainWriteThatWouldShareAVariantsIndexedValueIsRefusedNamingBothKeysAndTheStoreReopens()
{
var path = PathOf("gear.cc");
var other = new CultRecordKey("other");
using (var cache = Open(path))
{
SeedBase(cache);
SeedBig(cache);
var refused = Refused(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other)));
Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("laser big"));
var single = Assert.ThrowsAsync<InvalidOperationException>(
() => cache.UpsertAsync(Other("laser big"), new CultRecordHandle<VariantGear>(other)))!;
Assert.That(single.Message, Does.Contain(other.Value).And.Contain(BigKey.Value));
Assert.That(cache.Get(other), Is.Null, "nothing landed");
Assert.That(cache.GetByIndex<VariantGear>("code", "laser big")!.Name, Is.EqualTo("laser big"), "the variant is still found by its value");
EditBase(cache, gear => gear.Power = 11);
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25), "the variant is still editable through its base");
await Task.CompletedTask;
}
using var reopened = Open(path);
Assert.That(reopened.GetByIndex<VariantGear>("code", "laser big")!.Name, Is.EqualTo("laser big"));
Assert.That(reopened.Get(other), Is.Null);
}
[Test]
public void ABaseEditOrANewPlainRecordFirstThenAVariantOnTheSameValueIsRefusedAndTheStoreReopens()
{
var path = PathOf("gear.cc");
var other = new CultRecordKey("other");
using (var cache = Open(path))
{
SeedBase(cache);
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("laser big"), other));
var refused = Refused(() => SeedBig(cache));
Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain(other.Value).And.Contain("code").And.Contain("laser big"));
Assert.That(cache.Get(BigKey), Is.Null, "nothing landed");
// The same batch, either staging order: the result is what is judged.
var sameBatchPlainFirst = Refused(() => cache.Commit(batch =>
{
batch.Upsert(typeof(VariantGear), Other("taken"), new CultRecordKey("taker"));
batch.UpsertVariant(BigKey, BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big"),
cache.Override<VariantGear>(nameof(VariantGear.Code), "taken")
});
}));
Assert.That(sameBatchPlainFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker"));
var sameBatchVariantFirst = Refused(() => cache.Commit(batch =>
{
batch.UpsertVariant(BigKey, BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big"),
cache.Override<VariantGear>(nameof(VariantGear.Code), "taken")
});
batch.Upsert(typeof(VariantGear), Other("taken"), new CultRecordKey("taker"));
}));
Assert.That(sameBatchVariantFirst.Message, Does.Contain(BigKey.Value).And.Contain("taker"));
// Plain-vs-plain sharing is not this rule's business.
Assert.DoesNotThrow(() => cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("l1"), new CultRecordKey("twin"))));
}
using var reopened = Open(path);
Assert.That(reopened.Get(BigKey), Is.Null);
Assert.That(reopened.Get(other), Is.Not.Null);
}
// A store holding a variant is written only from what its writer last read. Another writer adds a variant whose indexed value
// this cache's staged edit of its base now takes: the write is refused whole, the cache reloads what it refused and what moved,
// and the retry is judged by the cache's own rules.
[Test]
public void AWriteIntoAVariantStoreAnotherWriterChangedIsRefusedReloadedAndJudgedOnRetry([Values] bool viaCommit)
{
var path = PathOf(viaCommit ? "judged-commit.cc" : "judged-flush.cc");
using var cache = Open(path);
SeedBase(cache);
using (var other = Open(path))
SeedBig(other);
var edited = Laser();
edited.Code = "laser big";
cache.UpsertAsync(typeof(VariantGear), edited, BaseKey).GetAwaiter().GetResult();
var before = File.ReadAllBytes(path);
var conflict = Assert.Throws<CultWriteConflictException>(() =>
{
if (viaCommit)
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Other("n"), new CultRecordKey("n")));
else
cache.FlushAllBackingStores();
})!;
Assert.That(conflict.RecordKeys, Does.Contain(BaseKey.Value));
Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value));
Assert.That(cache.Get<VariantGear>(BaseKey)!.Code, Is.EqualTo("l1"), "the refused edit is not served");
Assert.That(cache.Get(BigKey), Is.Not.Null, "the moved record is loaded");
Assert.That(cache.IsDirty, Is.False);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "nothing was written");
Assert.That(Refused(() => cache.UpsertAsync(typeof(VariantGear), edited, BaseKey).GetAwaiter().GetResult()).Message,
Does.Contain(BigKey.Value), "the retry is judged by the cache's own index rule");
}
// A refused base removal leaves the cache serving the store, writable, and judging the next attempt itself.
[Test]
public void ARefusedBaseRemovalReloadsAndLeavesTheCacheWritable()
{
var path = PathOf("gear.cc");
var n = new CultRecordKey("n");
using var a = Open(path);
SeedBase(a);
using (var b = Open(path))
SeedBig(b);
a.Remove(BaseKey);
var conflict = Assert.Throws<CultWriteConflictException>(() => a.FlushAllBackingStores())!;
Assert.That(conflict.RecordKeys, Is.EqualTo(new[] { BaseKey.Value }));
Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { BigKey.Value }));
Assert.That(a.Get(BaseKey), Is.Not.Null);
Assert.That(a.Get(BigKey), Is.Not.Null);
Assert.That(a.IsDirty, Is.False);
Assert.DoesNotThrow(() => a.FlushAllBackingStores());
Assert.DoesNotThrow(() => a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("n", "nn"), n)));
Assert.That(Refused(() => a.Remove(BaseKey)).Message, Does.Contain(BigKey.Value), "a base with variants is refused by this cache's own rule");
using var reopened = Open(path);
Assert.That(reopened.Get(n), Is.Not.Null);
Assert.That(reopened.Get(BigKey), Is.Not.Null);
Assert.That(reopened.Get(BaseKey), Is.Not.Null);
}
// A held record another writer changed is not trusted: the write is refused although this cache never staged it.
[Test]
public void AWriteIsRefusedWhenAnotherWriterChangedAVariantThisCacheHolds()
{
var path = PathOf("gear.cc");
using var a = Open(path);
SeedBase(a);
SeedBig(a);
using (var b = Open(path))
b.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
{
b.Override<VariantGear>(nameof(VariantGear.Name), "laser big"),
b.Override<VariantGear>(nameof(VariantGear.Power), 99),
b.Override<VariantGear>(nameof(VariantGear.Code), "laser big")
}));
Assert.That(a.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
var conflict = Assert.Throws<CultWriteConflictException>(() =>
a.Commit(batch => batch.Upsert(typeof(VariantGear), Named("y", "yy"), new CultRecordKey("y"))))!;
Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { BigKey.Value }));
Assert.That(a.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(99), "the cache serves the other writer's override after the refusal");
}
// A write that would add a variant is judged like one into a variant store: a plain store another writer changed refuses it.
[Test]
public void AVariantWrittenIntoAPlainStoreAnotherWriterChangedIsRefusedThenLandsOnRetry()
{
var path = PathOf("plain-then-variant.cc");
var z = new CultRecordKey("z");
using (var seed = Open(path))
SeedBase(seed);
using (var a = Open(path))
{
using (var b = Open(path))
b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z));
var conflict = Assert.Throws<CultWriteConflictException>(() => SeedBig(a))!;
Assert.That(conflict.RecordKeys, Does.Contain(BigKey.Value));
Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { z.Value }));
Assert.That(a.Get(z), Is.Not.Null, "the refusal reloaded the record that moved");
Assert.DoesNotThrow(() => SeedBig(a));
}
using var reopened = Open(path);
Assert.That(reopened.Get(BigKey), Is.Not.Null);
Assert.That(reopened.Get(z), Is.Not.Null);
}
// A plain write copies another writer's record forward without reading it: this cache has still not read it, so a variant
// written next is refused.
[Test]
public void ARecordACachePlainWriteCopiedForwardIsStillUnreadWhenAVariantIsWrittenNext()
{
var path = PathOf("copied-unread.cc");
var z = new CultRecordKey("z");
using (var seed = Open(path))
SeedBase(seed);
using var a = Open(path);
using (var b = Open(path))
b.Commit(batch => batch.Upsert(typeof(VariantGear), Other("zc"), z));
a.Commit(batch => batch.Upsert(typeof(VariantGear), Other("yc"), new CultRecordKey("y")));
var conflict = Assert.Throws<CultWriteConflictException>(() => SeedBig(a))!;
Assert.That(conflict.ChangedKeys, Is.EqualTo(new[] { z.Value }));
}
// ---- a write copies what it did not stage, and re-encodes what it stages under the type's own id ----
private static readonly string DriftWideId = CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }).GetRequired(typeof(DriftWide)).SchemaId;
// The same schema name as Wide, a later version that lists Wide's id as compatible and has no Wing.
private static readonly Type DriftDeclared = EmittedDocumentTypes.Emit(
"DriftDeclared",
"tests.variant_drift",
"tests.variant_drift.v4",
new[] { new EmittedDocumentTypes.Field("Name", typeof(string), 0, IsName: true), new EmittedDocumentTypes.Field("Power", typeof(int), 1) },
new[] { DriftWideId });
private static readonly CultRecordKey DriftBase = new("drift-base");
private static readonly CultRecordKey DriftVariant = new("drift-variant");
private static CultPersistedStoreSnapshot ReadStore(string path) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
private void SeedDrift(string path)
{
using var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }));
writer.Commit(batch =>
{
batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, DriftBase);
batch.UpsertVariant(DriftVariant, DriftBase, new[]
{
writer.Override<DriftWide>(nameof(DriftWide.Name), "drift variant"),
writer.Override<DriftWide>(nameof(DriftWide.Wing), "stale wing")
});
});
}
// Narrow reads Wide's records through the schema name and neither owns nor declares Wide's id. A write of another record
// copies them as Wide stored them: bytes, id, storedAt, the override of the slot Narrow lacks, and the entry that publishes
// Wide's id.
[Test]
public void AWriteOfAnotherRecordLeavesAVariantItsTypeHasNotDeclaredAsStored([Values] bool viaFlush)
{
var path = PathOf(viaFlush ? "drift-untouched-flush.cc" : "drift-untouched-commit.cc");
SeedDrift(path);
var before = ReadStore(path);
CultCache: the store owns a file's bytes, not the record object Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every drop-on-assign hook and the catalog's content guard. A decoded record or snapshot carries no stored bytes, so an in-place edit of its payload or overrides can never be shadowed by cached bytes, and the public snapshot and record serializers encode every record from its fields. SingleFileBackingStore now reads through ReadStore, which returns the snapshot and, parallel to it, the exact slice the file holds for each catalog entry and record. Its durable index keeps, by record key, that slice and its SHA-256 (hashed when a comparison asks), which is the record's version. A write re-encodes every record the writer staged and copies every other record, and every catalog entry it keeps, as the slice the file holds. The directory store keeps its manifest's catalog slices the same way. SerializeSnapshot/DeserializeSnapshot hooks become SerializeStore/ReadStore plus SerializeCatalogEntry. A staged but unchanged non-canonical record is now re-encoded canonically and gets a new version; an untouched one keeps its bytes and version. Tests: in-place edits through the snapshot API (payload, override) and through a held document (collection, nested object) are written as edited; an untouched non-canonical record survives a neighbour's write byte for byte; a staged one is re-encoded. The cost test counts record hashes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
var wideEntryBefore = StoreSlices.CatalogEntry(path, DriftWideId);
using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) })))
{
Assert.That(narrow.Get<DriftNarrow>(DriftVariant)!.Name, Is.EqualTo("drift variant"), "Narrow reads the variant");
var other = new CultRecordKey("other");
if (viaFlush)
{
narrow.UpsertAsync(typeof(DriftNarrow), new DriftNarrow { Name = "other" }, other).GetAwaiter().GetResult();
narrow.FlushAllBackingStores();
}
else
narrow.Commit(batch => batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "other" }, other));
}
var after = ReadStore(path);
foreach (var key in new[] { DriftBase.Value, DriftVariant.Value })
{
var was = before.Records.Single(record => record.Key == key);
var is_ = after.Records.Single(record => record.Key == key);
Assert.That((is_.SchemaId, is_.StoredAt, is_.Payload), Is.EqualTo((was.SchemaId, was.StoredAt, was.Payload)), key);
}
Assert.That(OverriddenSlotsOn(path, DriftVariant.Value).OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }), "the dropped slot's override is still stored");
CultCache: the store owns a file's bytes, not the record object Delete RawBytes from CultPersistedRecord and CultSchemaCatalogEntry, with every drop-on-assign hook and the catalog's content guard. A decoded record or snapshot carries no stored bytes, so an in-place edit of its payload or overrides can never be shadowed by cached bytes, and the public snapshot and record serializers encode every record from its fields. SingleFileBackingStore now reads through ReadStore, which returns the snapshot and, parallel to it, the exact slice the file holds for each catalog entry and record. Its durable index keeps, by record key, that slice and its SHA-256 (hashed when a comparison asks), which is the record's version. A write re-encodes every record the writer staged and copies every other record, and every catalog entry it keeps, as the slice the file holds. The directory store keeps its manifest's catalog slices the same way. SerializeSnapshot/DeserializeSnapshot hooks become SerializeStore/ReadStore plus SerializeCatalogEntry. A staged but unchanged non-canonical record is now re-encoded canonically and gets a new version; an untouched one keeps its bytes and version. Tests: in-place edits through the snapshot API (payload, override) and through a held document (collection, nested object) are written as edited; an untouched non-canonical record survives a neighbour's write byte for byte; a staged one is re-encoded. The cost test counts record hashes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 10:47:30 +02:00
Assert.That(StoreSlices.CatalogEntry(path, DriftWideId),
Is.EqualTo(wideEntryBefore));
}
// A type that lists Wide's id as compatible owns Wide's records: staging one writes it under the type's own id, at a later
// storedAt, without the member the type dropped, so a later type reusing that slot does not resurrect it.
[Test]
public void AStagedWriteOfADeclaredCompatibleRecordIsReEncodedUnderTheTypesOwnIdAndShedsTheDroppedSlot()
{
var path = PathOf("drift-declared.cc");
SeedDrift(path);
var wideBase = ReadStore(path).Records.Single(record => record.Key == DriftBase.Value);
var ownId = CultDocumentRegistry.ForTypes(new[] { DriftDeclared }).GetRequired(DriftDeclared).SchemaId;
using (var declared = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { DriftDeclared })))
{
declared.Commit(batch =>
{
batch.Upsert(DriftDeclared, declared.Get(DriftBase)!, DriftBase);
batch.UpsertVariant(DriftVariant, DriftBase, new[] { declared.Override(DriftDeclared, "Name", "drift variant") });
});
}
var written = ReadStore(path);
Assert.That(written.Records.Select(record => record.SchemaId), Is.All.EqualTo(ownId));
Assert.That(string.CompareOrdinal(written.Records.Single(record => record.Key == DriftBase.Value).StoredAt, wideBase.StoredAt), Is.GreaterThan(0));
Assert.That(OverriddenSlotsOn(path, DriftVariant.Value), Is.EqualTo(new[] { 0 }), "the dropped slot's override is gone from the written record");
using var reused = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftReused) }));
Assert.That(reused.Get<DriftReused>(DriftBase)!.Colour, Is.Empty);
Assert.That(reused.Get<DriftReused>(DriftVariant)!.Colour, Is.Empty, "the variant inherits its base's colour, not the old wing");
}
public enum UndeclaredWrite
{
UpsertBase,
UpsertVariant,
FlattenVariant,
RemoveBase,
RemoveVariant
}
// Narrow reads Wide's records through the schema name and does not list Wide's id, so they are read-only to it: a staged write,
// flatten or removal of either is refused typed, and the store keeps them as Wide stored them, the dropped slot's override too.
[Test]
public void AnUndeclaredDroppedSlotRecordThatIsWrittenIsRefused([Values] UndeclaredWrite write)
{
var path = PathOf("drift-undeclared-" + write + ".cc");
SeedDrift(path);
var before = File.ReadAllBytes(path);
using (var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) })))
{
var key = write is UndeclaredWrite.UpsertBase or UndeclaredWrite.RemoveBase ? DriftBase : DriftVariant;
var refusal = Assert.Throws<CultSchemaConflictException>(() =>
{
switch (write)
{
case UndeclaredWrite.UpsertBase:
narrow.Commit(batch => batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "changed" }, DriftBase));
break;
case UndeclaredWrite.UpsertVariant:
narrow.Commit(batch => batch.UpsertVariant(DriftVariant, DriftBase, new[] { narrow.Override<DriftNarrow>(nameof(DriftNarrow.Name), "changed") }));
break;
case UndeclaredWrite.FlattenVariant:
narrow.FlattenAsync(DriftVariant).GetAwaiter().GetResult();
break;
case UndeclaredWrite.RemoveBase:
narrow.Commit(batch => batch.Remove(DriftBase));
break;
case UndeclaredWrite.RemoveVariant:
narrow.Commit(batch => batch.Remove(DriftVariant));
break;
}
})!;
Assert.That((refusal.RecordKey, refusal.SchemaId), Is.EqualTo((key.Value, DriftWideId)));
narrow.FlushAllBackingStores();
}
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
Assert.That(OverriddenSlotsOn(path, DriftVariant.Value).OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }));
}
// Only the read-only key is read-only: a variant of a read-only base, written at a new key, is a write of the variant alone and lands.
[Test]
public void AVariantOfAReadOnlyBaseLands()
{
var path = PathOf("drift-read-only-base.cc");
SeedDrift(path);
var fresh = new CultRecordKey("fresh-variant");
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) });
using (var narrow = Open(path, registry: narrowRegistry))
narrow.Commit(batch => batch.UpsertVariant(fresh, DriftBase, new[] { narrow.Override<DriftNarrow>(nameof(DriftNarrow.Name), "fresh") }));
using var reopened = Open(path, registry: narrowRegistry);
Assert.That(reopened.Get<DriftNarrow>(fresh)!.Name, Is.EqualTo("fresh"));
Assert.That(ReadStore(path).Records.Single(record => record.Key == DriftBase.Value).SchemaId, Is.EqualTo(DriftWideId));
}
[Test]
public void ADriftedVariantSatisfiesExpectUnchanged()
{
var path = PathOf("drift.cc");
WriteWideVariant(path);
using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }));
Assert.That(ExpectVariantCommit(narrow), Is.True, "an unchanged drifted variant failed Expect and ExpectUnchanged");
}
[Test]
public void AnOwnFlushKeepsADriftedVariantsExpectHolding()
{
var path = PathOf("drift.cc");
WriteWideVariant(path);
using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }));
narrow.BackingStores[0].PushAll();
Assert.That(ExpectVariantCommit(narrow), Is.True, "Expect on a drifted variant failed after this cache's own flush rewrote it");
}
private static void WriteWideVariant(string path)
{
using var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) }));
writer.Commit(batch =>
{
batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base"));
batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[]
{
writer.Override<DriftWide>(nameof(DriftWide.Name), "drift variant"),
writer.Override<DriftWide>(nameof(DriftWide.Wing), "stale wing")
});
});
}
private static bool ExpectVariantCommit(CultCache cache)
{
var variant = new CultRecordKey("drift-variant");
return cache.Commit(batch =>
{
batch.Expect(variant, cache.Get(variant));
batch.ExpectUnchanged();
batch.Upsert(typeof(DriftNarrow), new DriftNarrow { Name = "other", Power = 2 }, new CultRecordKey("other"));
});
}
[Test]
public void AnOverrideOfASlotTheTypeNeverHadIsReportedOnEveryLoadAndStaysStoredUntilTheVariantIsWritten()
{
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))
{
var variantReport = cache.BackingStores[0].LastSchemaMigrationReports.Single(report => report.IgnoredExtraSlots.Length > 0);
Assert.That(variantReport.IgnoredExtraSlots, Is.EqualTo(new[] { 99 }));
Assert.That(variantReport.Warnings.Select(warning => warning.Code), Has.Some.EqualTo("ignored_extra_slot"));
Assert.That(variantReport.Warnings.Select(warning => warning.Message), Has.Some.Contain("hand-variant"));
cache.BackingStores[0].PushAll();
}
Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2, 99 }), "a flush that staged nothing copies the variant as stored");
using (var reopened = Open(path))
{
Assert.That(reopened.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Is.EqualTo(new[] { 99 }));
Assert.That(reopened.Get<VariantGear>(new CultRecordKey("hand-variant"))!.Name, Is.EqualTo("hand variant"));
reopened.Commit(batch => batch.UpsertVariant(new CultRecordKey("hand-variant"), new CultRecordKey("hand-base"), new[]
{
reopened.Override<VariantGear>(nameof(VariantGear.Name), "hand variant"),
reopened.Override<VariantGear>(nameof(VariantGear.Code), "hand-code")
}));
}
Assert.That(OverriddenSlotsOn(path, "hand-variant").OrderBy(slot => slot), Is.EqualTo(new[] { 0, 2 }), "a write of the variant stores only what it was given");
}
private static int[] OverriddenSlotsOn(string path, string key) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).Records
.Single(record => record.Key == key).Variant!.Overrides.Select(entry => entry.Path[0].Slot).ToArray();
// ---- flatten resolves against the same batch ----
[Test]
public void AFlattenAndABaseEditInOneBatchLandTheResolutionOfTheWholeBatch()
{
var path = PathOf("gear.cc");
using (var cache = Open(path))
{
SeedBase(cache);
SeedBig(cache);
cache.Commit(batch =>
{
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.GetStored(BigKey)!.Variant, Is.Null);
Assert.That(reloaded.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
}
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
// ---- the held index is truthful: every holder of a value, so no rule judges a variant against a lossy view ----
private static VariantGear Named(string name, string code) => new() { Name = name, Power = 1, Code = code };
private static CultRecordKey[] SharedCodePair(CultCache cache)
{
var p1 = new CultRecordKey("p1");
var p2 = new CultRecordKey("p2");
cache.Commit(batch =>
{
batch.Upsert(typeof(VariantGear), Named("p1", "x"), p1);
batch.Upsert(typeof(VariantGear), Named("p2", "x"), p2);
});
return new[] { p1, p2 };
}
private static void VariantOnCode(CultCache cache, string code) =>
cache.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
{
cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big"),
cache.Override<VariantGear>(nameof(VariantGear.Code), code)
}));
[Test]
public void RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable()
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
{
var path = PathOf("gear.cc");
using (var cache = Open(path))
{
var pair = SharedCodePair(cache);
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"), "the smallest key");
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
cache.Remove(pair[0]);
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p2"), "removing the holder finds the other one");
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p1", "x"), pair[0]));
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"));
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
cache.Remove(pair[1]);
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"), "removing the other changes nothing");
cache.Remove(pair[0]);
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
Assert.That(cache.GetByIndex<VariantGear>("code", "x"), Is.Null);
cache.BackingStores[0].PushAll();
}
using var reopened = Open(path);
Assert.That(reopened.GetByIndex<VariantGear>("code", "x"), Is.Null);
}
[Test]
public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinWasRemovedIsRefusedAndTheStoreReopens()
{
var path = PathOf("gear.cc");
using (var cache = Open(path))
{
SeedBase(cache);
var pair = SharedCodePair(cache);
cache.Remove(pair[1]);
var refused = Refused(() => VariantOnCode(cache, "x"));
Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("code").And.Contain("'x'"));
Assert.That(cache.Get(BigKey), Is.Null, "nothing landed");
cache.BackingStores[0].PushAll();
}
using var reopened = Open(path);
Assert.That(reopened.Get(BigKey), Is.Null);
Assert.That(reopened.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"));
}
[Test]
public void AVariantOnAValueAPlainRecordStillHoldsAfterItsTwinChangedItsValueIsRefusedAndTheStoreReopens()
{
var path = PathOf("gear.cc");
using (var cache = Open(path))
{
SeedBase(cache);
var pair = SharedCodePair(cache);
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("p2", "y"), pair[1]));
var refused = Refused(() => VariantOnCode(cache, "x"));
Assert.That(refused.Message, Does.Contain(BigKey.Value).And.Contain("p1").And.Contain("'x'"));
Assert.DoesNotThrow(() => VariantOnCode(cache, "z"), "an unshared value is fine");
cache.BackingStores[0].PushAll();
}
using var reopened = Open(path);
Assert.That(reopened.GetByIndex<VariantGear>("code", "z")!.Name, Is.EqualTo("laser big"));
Assert.That(reopened.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("p1"));
}
// ---- a merge onto the file is judged on the set the file will hold ----
[Test]
public void AConditionalCommitThatWouldShareAnIndexedValueWithAnotherWritersVariantIsRefusedAndTheStoreReopens()
{
var path = PathOf("gear.cc");
var other = new CultRecordKey("other");
using (var seed = Open(path))
SeedBase(seed);
using (var a = Open(path))
using (var b = Open(path))
{
a.Commit(batch => batch.UpsertVariant(BigKey, BaseKey, new[]
{
a.Override<VariantGear>(nameof(VariantGear.Name), "laser big"),
a.Override<VariantGear>(nameof(VariantGear.Code), "shared")
}));
void Write() => b.Commit(batch =>
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
{
batch.Expect(other, null);
batch.Upsert(typeof(VariantGear), Named("other", "shared"), other);
});
var conflict = Assert.Throws<CultWriteConflictException>(Write)!;
Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value));
var refused = Refused(Write);
Assert.That(refused.Message, Does.Contain(other.Value).And.Contain(BigKey.Value).And.Contain("code").And.Contain("shared"), "the retry is judged by Resolve");
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
Assert.That(b.Get(other), Is.Null, "nothing landed in memory");
}
using var reopened = Open(path);
Assert.That(reopened.Get(other), Is.Null, "nothing landed on the file");
Assert.That(reopened.GetByIndex<VariantGear>("code", "shared")!.Name, Is.EqualTo("laser big"));
}
[Test]
public void AConditionalCommitThatRemovesAnotherWritersVariantsBaseIsRefusedAndTheStoreReopens()
{
var path = PathOf("gear.cc");
using (var seed = Open(path))
SeedBase(seed);
using (var a = Open(path))
using (var b = Open(path))
{
var seen = b.Get<VariantGear>(BaseKey)!;
SeedBig(a);
void Write() => b.Commit(batch =>
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
{
batch.Expect(BaseKey, seen);
batch.Remove(BaseKey);
});
var conflict = Assert.Throws<CultWriteConflictException>(Write)!;
Assert.That(conflict.ChangedKeys, Does.Contain(BigKey.Value));
var refused = Refused(Write);
Assert.That(refused.Message, Does.Contain(BaseKey.Value).And.Contain(BigKey.Value), "the retry is judged by Resolve");
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
Assert.That(b.Get(BaseKey), Is.Not.Null, "nothing landed in memory");
}
using var reopened = Open(path);
Assert.That(reopened.Get<VariantGear>(BaseKey), Is.Not.Null);
Assert.That(reopened.Get<VariantGear>(BigKey)!.Name, Is.EqualTo("laser big"));
}
// The cache holds a copy of the base another writer has since renamed; the merge is judged on the renamed base.
[Test]
public void AConditionalCommitIsJudgedOnAnotherWritersChangeToARecordThisCacheHolds()
{
var path = PathOf("gear.cc");
var other = new CultRecordKey("other");
using (var seed = Open(path))
Assert.That(seed.Commit(batch =>
{
batch.Upsert(typeof(VariantGear), Laser(), BaseKey);
batch.Upsert(typeof(VariantGear), Named("other", "o1"), other);
}), Is.True);
using (var a = Open(path))
{
using (var w = Open(path))
Assert.That(w.Commit(batch => batch.Upsert(typeof(VariantGear), Named("x", "l1"), BaseKey)), Is.True);
var refused = Assert.Throws<CultWriteConflictException>(() => a.Commit(batch =>
{
batch.Expect(other, a.Get(other));
batch.UpsertVariant(BigKey, BaseKey, new[]
{
a.Override<VariantGear>(nameof(VariantGear.Name), "x"),
a.Override<VariantGear>(nameof(VariantGear.Code), "big")
});
}))!;
Assert.That(refused.Message, Does.Contain(BigKey.Value));
Assert.That(refused.ChangedKeys, Is.EqualTo(new[] { BaseKey.Value }), "the refusal names the base another writer renamed");
}
using var reopened = Open(path, readOnly: true);
Assert.That(reopened.GetStored(BigKey), Is.Null, "nothing landed on the file");
}
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
[Test]
public void AConditionalCommitThatIsSoundOntoAVariantStoreAnotherWriterChangedIsRefusedThenLandsOnRetry()
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
{
var path = PathOf("gear.cc");
var other = new CultRecordKey("other");
using (var seed = Open(path))
SeedBase(seed);
using (var a = Open(path))
using (var b = Open(path))
{
SeedBig(a);
bool Write() => b.Commit(batch =>
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
{
batch.Expect(other, null);
batch.Upsert(typeof(VariantGear), Named("other", "fine"), other);
});
Assert.Throws<CultWriteConflictException>(() => Write());
Assert.That(b.Get(BigKey), Is.Not.Null, "the refusal reloaded the other writer's variant");
Assert.That(Write(), Is.True);
Variants C1: the held index tells the truth; a merge is judged on what the file will hold The held index kept one key per value, last writer winning, and RemoveKey dropped a value outright. Two consequences: removing or changing one of two plain holders left the other unfindable, and the variant index rule judged a variant against that lossy view, so a variant could land on a value a plain record still held and the store then refused to reopen. - _indexes maps a value to the keys holding it, oldest first. Index, Unindex and the removal maintain the set. GetByIndex finds the most recently indexed holder of a value, so removing the winner finds the other (behaviour change for plain duplicates: it used to find nothing). - One rule, RefuseVariantIndexSharing, serves both write orders. It reads the truthful index minus what the commit replaces or removes, plus what it lands. ResolveOne no longer judges indexes; ClaimIncomingIndexValues, HeldByVariants and RefusePlainSharingWithVariant are gone. - A conditional commit merged onto the file is judged by the cache's own resolution (CacheBackingStore.Judging) on the merged set: records other writers added, records they removed, and the batch. Another writer's variant sharing a value with the batch, or a base removal that strands another writer's variant, is refused with the named refusal instead of writing an unopenable store. - Shedding is one decision at load. ToStoredDocument resolves the schema once and returns the report with it; a variant's override of a slot the type lacks is dropped from the delta there, so flush needs no predicate and every store reports through the same surface (ReportVariantSheds and OverridesTheTypeHas are gone). The directory store refuses variants and loads through the same call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 09:20:47 +02:00
}
using var reopened = Open(path);
Assert.That(reopened.Get<VariantGear>(other), Is.Not.Null);
Assert.That(reopened.Get<VariantGear>(BigKey), Is.Not.Null, "the other writer's variant survives the merge");
}
[Test]
public void AVariantLoadedUnderANarrowerTypeHoldsOnlyTheOverridesTheTypeHasAndReportsTheRest()
{
var path = PathOf("drift.cc");
using (var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) })))
{
writer.Commit(batch =>
{
batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base"));
batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[]
{
writer.Override<DriftWide>(nameof(DriftWide.Name), "drift variant"),
writer.Override<DriftWide>(nameof(DriftWide.Wing), "stale wing")
});
});
}
using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }));
Assert.That(narrow.GetStored(new CultRecordKey("drift-variant"))!.Variant!.Overrides.Select(entry => entry.Path[0].Slot), Is.EqualTo(new[] { 0 }),
"the dropped slot's override is shed when the record is loaded, not decided again at flush");
Assert.That(narrow.BackingStores[0].LastSchemaMigrationReports.SelectMany(report => report.IgnoredExtraSlots), Does.Contain(2));
}
// ---- batch 4: linear rule, judge only when a variant is involved, deterministic duplicates, one warning ----
[CultDocument("tests.variant_counted", "tests.variant_counted.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class CountedGear
{
public static long CodeReads;
[IgnoreMember]
private string _code = string.Empty;
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultIndex("code")]
public string Code
{
get
{
System.Threading.Interlocked.Increment(ref CodeReads);
return _code;
}
set => _code = value;
}
}
// The pin is a count of index-accessor reads, not a clock: linear work doubles with the records, quadratic work quadruples.
private long IndexReadsOpening(int records)
{
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(CountedGear) });
var path = PathOf($"counted-{records}.cc");
using (var writer = Open(path, registry: registry))
{
writer.Commit(batch =>
{
for (var index = 0; index < records; index++)
batch.Upsert(typeof(CountedGear), new CountedGear { Name = $"n{index}", Code = $"c{index}" }, new CultRecordKey($"k{index}"));
});
writer.Commit(batch => batch.UpsertVariant(new CultRecordKey("kv"), new CultRecordKey("k0"), new[]
{
writer.Override<CountedGear>(nameof(CountedGear.Name), "variant"),
writer.Override<CountedGear>(nameof(CountedGear.Code), "variant-code")
}));
}
System.Threading.Interlocked.Exchange(ref CountedGear.CodeReads, 0);
using (Open(path, registry: registry))
return System.Threading.Interlocked.Read(ref CountedGear.CodeReads);
}
[Test]
public void OpeningAStoreWithAVariantReadsEachIndexedValueAFixedNumberOfTimes()
{
var small = IndexReadsOpening(1000);
var large = IndexReadsOpening(2000);
Assert.That(large, Is.LessThan(small * 3), $"index reads: {small} at 1000 records, {large} at 2000; doubling the records must not quadruple the work");
}
[Test]
public void ASchemaThisCacheDoesNotRegisterDoesNotFailAConditionalCommitThatInvolvesNoVariant()
{
var path = PathOf("gear.cc");
var other = new CultRecordKey("other");
var narrow = CultDocumentRegistry.ForTypes(new[] { typeof(VariantGear) });
using (var seed = Open(path))
SeedBase(seed);
using (var a = Open(path))
using (var b = Open(path, registry: narrow))
{
a.Commit(batch => batch.Upsert(typeof(VariantOther), new VariantOther { Name = "foreign" }, new CultRecordKey("foreign")));
Assert.That(b.Commit(batch =>
{
batch.Expect(other, null);
batch.Upsert(typeof(VariantGear), Named("other", "fine"), other);
}), Is.True);
}
using var reopened = Open(path);
Assert.That(reopened.Get<VariantGear>(other), Is.Not.Null);
Assert.That(reopened.Get<VariantOther>(new CultRecordKey("foreign")), Is.Not.Null, "the other writer's record survives the merge");
}
[Test]
public void AVariantAnotherWriterLandedIsRefusedAsChangedWhenThisCacheHoldsNoVariant()
{
var path = PathOf("gear.cc");
var other = new CultRecordKey("other");
using (var seed = Open(path))
SeedBase(seed);
using (var a = Open(path))
using (var b = Open(path))
{
VariantOnCode(a, "shared");
Assert.Throws<CultWriteConflictException>(() => b.Commit(batch =>
{
batch.Expect(other, null);
batch.Upsert(typeof(VariantGear), Named("other", "shared"), other);
}));
}
}
[Test]
public void PlainDuplicatesFindTheSmallestKeyLiveAfterAFlushAndAfterAReopen()
{
var path = PathOf("gear.cc");
using (var cache = Open(path))
{
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("nb", "x"), new CultRecordKey("b")));
cache.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a")));
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "live");
cache.BackingStores[0].PushAll();
Assert.That(cache.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "after a flush");
}
using (var reopened = Open(path))
{
Assert.That(reopened.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "after a reopen");
reopened.Remove(new CultRecordKey("a"));
reopened.Commit(batch => batch.Upsert(typeof(VariantGear), Named("na", "x"), new CultRecordKey("a")));
Assert.That(reopened.GetByIndex<VariantGear>("code", "x")!.Name, Is.EqualTo("na"), "rewriting the smaller key does not change the choice");
}
}
[Test]
public void ADroppedSlotIsReportedOncePerRecord()
{
var path = PathOf("drift.cc");
using (var writer = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftWide) })))
{
writer.Commit(batch =>
{
batch.Upsert(typeof(DriftWide), new DriftWide { Name = "drift", Power = 1, Wing = "base wing" }, new CultRecordKey("drift-base"));
batch.UpsertVariant(new CultRecordKey("drift-variant"), new CultRecordKey("drift-base"), new[]
{
writer.Override<DriftWide>(nameof(DriftWide.Name), "drift variant"),
writer.Override<DriftWide>(nameof(DriftWide.Wing), "stale wing")
});
});
}
using var narrow = Open(path, registry: CultDocumentRegistry.ForTypes(new[] { typeof(DriftNarrow) }));
var variantReport = narrow.BackingStores[0].LastSchemaMigrationReports.Last();
Assert.That(variantReport.Warnings.Count(warning => warning.Code == "ignored_extra_slot"), Is.EqualTo(1));
Assert.That(variantReport.IgnoredExtraSlots, Is.EqualTo(new[] { 2 }));
}
// ---- 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 ----
private static string HeaderOf(string path) =>
CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path)).FormatVersion;
[Test]
public void TheHeaderIsV1WithoutVariantsAndV2WithThem()
{
var path = PathOf("gear.cc");
using var cache = Open(path);
SeedBase(cache);
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"));
SeedBig(cache);
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"));
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(path));
var variant = snapshot.Records.Single(record => record.Key == BigKey.Value);
Assert.That(variant.Payload, Is.Empty, "the payload is the tripwire old readers refuse on");
Assert.That(variant.Variant!.BaseKey, Is.EqualTo(BaseKey.Value));
Assert.That(snapshot.Records.Single(record => record.Key == BaseKey.Value).Variant, Is.Null);
cache.Commit(batch => batch.Remove(BigKey));
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v1"), "the last variant gone, the store is v1 again");
}
[Test]
public async Task AStoreWithoutVariantsRewritesByteIdenticalToTheOneWrittenBeforeVariantsExisted()
{
var fixture = Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", "pre-cut2", "single-file-v1", "store.msgpack");
var copy = PathOf("store.msgpack");
File.Copy(fixture, copy);
var before = File.ReadAllBytes(copy);
using (var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(PreCut2FixtureItem), typeof(PreCut2FixtureNote) })))
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(copy));
await cache.PullAllBackingStoresAsync();
cache.BackingStores[0].PushAll();
}
Assert.That(File.ReadAllBytes(copy), Is.EqualTo(before));
}
[Test]
public void AVariantStoreRoundTripsByteIdenticalThroughAFlush()
{
var path = PathOf("gear.cc");
using (var cache = Open(path))
{
SeedBase(cache);
SeedBig(cache);
}
var written = File.ReadAllBytes(path);
using (var cache = Open(path))
{
cache.BackingStores[0].PushAll();
}
Assert.That(File.ReadAllBytes(path), Is.EqualTo(written));
}
[Test]
public void AReadOnlyStoreResolvesItsVariants()
{
var path = PathOf("gear.cc");
using (var cache = Open(path))
{
SeedBase(cache);
SeedBig(cache);
}
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("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);
EditBase(writer, gear => gear.Tags.Add("pierce"));
var before = File.ReadAllBytes(path);
readOnly.PullAllBackingStoresAsync();
Assert.That(readOnly.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }));
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before));
}
[Test]
public void ABaseEditFromAnotherWriterReachesVariantsWhenTheStoreIsPulled()
{
var path = PathOf("gear.cc");
using var a = Open(path);
SeedBase(a);
SeedBig(a);
using var b = Open(path);
Assert.That(b.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam" }));
EditBase(a, gear => gear.Tags.Add("pierce"));
b.PullAllBackingStoresAsync();
Assert.That(b.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "beam", "pierce" }),
"the pull carried only the base; the variant was re-resolved in the admitting hold");
}
[Test]
public void AFlattenFromAnotherWriterReachesACacheHoldingTheVariant()
{
var path = PathOf("gear.cc");
using var a = Open(path);
SeedBase(a);
SeedBig(a);
using var b = Open(path);
Assert.That(b.GetStored(BigKey)!.Variant, Is.Not.Null);
a.FlattenAsync(BigKey).GetAwaiter().GetResult();
a.FlushAsync().GetAwaiter().GetResult();
b.PullAllBackingStoresAsync();
Assert.That(b.GetStored(BigKey)!.Variant, Is.Null, "a load reports what is on disk; it is not a plain write refused at a variant key");
Assert.That(b.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
}
[Test]
public void ARebaseResolvesFromTheNewBase()
{
using var cache = Open(PathOf("gear.cc"));
SeedBase(cache);
SeedBig(cache);
var spare = new CultRecordKey("spare");
cache.Commit(batch => batch.Upsert(typeof(VariantGear), new VariantGear { Name = "spare", Code = "s1", Tags = { "arc" } }, spare));
cache.Commit(batch => batch.UpsertVariant(BigKey, spare, cache.GetStored(BigKey)!.Variant!.Overrides));
Assert.That(cache.Get<VariantGear>(BigKey)!.Tags, Is.EqualTo(new[] { "arc" }));
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
Assert.That(cache.GetStored(BigKey)!.Variant!.BaseKey, Is.EqualTo(spare.Value));
}
// ---- readers ----
private string WriteStore(params CultPersistedRecord[] records) => WriteStore(CultPersistedStoreSnapshot.FormatV2, records);
private string WriteStore(string formatVersion, params CultPersistedRecord[] records)
{
var path = PathOf($"hand-{Guid.NewGuid():N}.cc");
var gear = Registry.GetRequired<VariantGear>();
File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot
{
FormatVersion = formatVersion,
SchemaCatalog = new[] { gear.ToCatalogEntry() },
Records = records
}));
return path;
}
private static CultPersistedRecord PlainGear(string key) => new()
{
Key = key,
SchemaId = Registry.GetRequired<VariantGear>().SchemaId,
StoredAt = "2026-01-01T00:00:00.0000000Z",
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(Laser(), typeof(VariantGear), Registry)
};
private static CultPersistedRecord VariantGearRecord(string key, string baseKey, byte[]? payload = null, CultOverrideOp op = CultOverrideOp.Set) => new()
{
Key = key,
SchemaId = Registry.GetRequired<VariantGear>().SchemaId,
StoredAt = "2026-01-01T00:00:00.0000000Z",
Payload = payload ?? Array.Empty<byte>(),
Variant = new CultVariantDelta(baseKey, new[]
{
new CultVariantOverride(op, new[] { new CultPathStep(0) }, string.Empty, new byte[] { 0xa1, (byte)'x' })
})
};
[Test]
public void AVariantRecordUnderAV1HeaderIsRefusedNamingTheRecord()
{
var path = WriteStore(CultPersistedStoreSnapshot.FormatV1, PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base"));
var error = Assert.Throws<CultStoreUnreadableException>(() => Open(path).Dispose())!;
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("cultcache.store.v2"));
}
[Test]
public void AVariantRecordWithAPayloadIsRefusedNamingTheRecord()
{
var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", payload: new byte[] { 0x90 }));
var error = Assert.Throws<CultStoreUnreadableException>(() => Open(path).Dispose())!;
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("payload"));
}
[Test]
public void AnOverrideWithAnUnknownOpIsRefusedNamingTheRecord()
{
var path = WriteStore(PlainGear("hand-base"), VariantGearRecord("hand-variant", "hand-base", op: (CultOverrideOp)7));
var error = Assert.Throws<CultStoreUnreadableException>(() => Open(path).Dispose())!;
Assert.That(error.Message, Does.Contain("hand-variant").And.Contain("op 7"));
}
// ---- the other stores refuse loudly until their own cuts ----
[Test]
public void TheDirectoryStoreRefusesAVariantByPushAndByCommit()
{
using var cache = CultCacheMessagePack.Create(PathOf("dir.cc"), new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true });
SeedBase(cache);
var push = Assert.Throws<NotSupportedException>(() => cache.UpsertVariantAsync(
BigKey, BaseKey, new[] { cache.Override<VariantGear>(nameof(VariantGear.Name), "laser big"), cache.Override<VariantGear>(nameof(VariantGear.Code), "laser big") }).GetAwaiter().GetResult())!;
Assert.That(push.Message, Does.Contain(BigKey.Value));
var commit = Assert.Throws<NotSupportedException>(() => SeedBig(cache))!;
Assert.That(commit.Message, Does.Contain(BigKey.Value));
Assert.That(cache.Get(BigKey), Is.Null);
}
// ---- shared vector ----
// Written once by the code under test through the production path; every runtime's tests read the committed bytes.
// CULTLIB_WRITE_VARIANT_VECTOR=1 prints the store as base64 instead of asserting.
[Test]
public void TheCommittedC1VectorResolvesAndRewritesByteIdentical()
{
var vector = VectorPath("variant-store.msgpack");
var path = PathOf("gear.cc");
if (Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1")
{
using (var cache = Open(path))
{
SeedBase(cache);
SeedBig(cache);
}
Console.WriteLine("VARIANT-VECTOR-BASE64:" + Convert.ToBase64String(File.ReadAllBytes(path)));
Assert.Pass("vector printed");
}
File.Copy(vector, path);
using (var cache = Open(path))
{
Assert.That(cache.Get<VariantGear>(BigKey)!.Power, Is.EqualTo(25));
Assert.That(cache.Get<VariantGear>(BigKey)!.Code, Is.EqualTo("laser big"));
cache.BackingStores[0].PushAll();
}
Assert.That(File.ReadAllBytes(path), Is.EqualTo(File.ReadAllBytes(vector)));
Assert.That(HeaderOf(path), Is.EqualTo("cultcache.store.v2"));
}
private static string VectorPath(string name)
{
for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent)
{
var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c1", name);
if (File.Exists(candidate) || Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1" && Directory.Exists(Path.GetDirectoryName(candidate)))
return candidate;
}
if (Environment.GetEnvironmentVariable("CULTLIB_WRITE_VARIANT_VECTOR") == "1")
return name;
throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}.");
}
[CultDocument("tests.variant_gear", "tests.variant_gear.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class VariantGear
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public int Power;
[Key(2)]
[CultIndex("code")]
public string Code = string.Empty;
[Key(3)]
public List<string> Tags = new();
}
[CultDocument("tests.variant_other", "tests.variant_other.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class VariantOther
{
[Key(0)]
[CultName]
public string Name = string.Empty;
}
[CultDocument("tests.variant_global", "tests.variant_global.v1")]
[MessagePackObject(AllowPrivate = true)]
[CultGlobal]
internal sealed class VariantGlobal
{
[Key(0)]
public string Value = string.Empty;
}
// The same schema at two versions of the type: Wide has a member Narrow later dropped.
[CultDocument("tests.variant_drift", "tests.variant_drift.v1")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class DriftWide
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public int Power;
[Key(2)]
public string Wing = string.Empty;
}
// A later type that reuses the slot Narrow dropped, for another member.
[CultDocument("tests.variant_drift", "tests.variant_drift.v3")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class DriftReused
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public int Power;
[Key(2)]
public string Colour = string.Empty;
}
[CultDocument("tests.variant_drift", "tests.variant_drift.v2")]
[MessagePackObject(AllowPrivate = true)]
internal sealed class DriftNarrow
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public int Power;
}
}
}