CultLib/tests/GameCult.Caching.Tests/UniqueIndexTests.cs
Libby 7760219b7c
Some checks failed
CultMesh QUIC native / Build win32-x64 (push) Has been skipped
CultMesh QUIC native / Build linux-x64 (push) Has been skipped
CultMesh QUIC native / Release native binaries (push) Has been skipped
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (push) Failing after 20m15s
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (push) Successful in 48s
CultCache: [CultIndex] is unique at commit and load; [CultRole] names a selection role; replicas apply a commit's changes as one batch (#90)
2026-11-11 03:48:08 +00:00

459 lines
22 KiB
C#

#nullable enable
using System;
using System.IO;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Caching.Tests
{
// variants:ruling:unique-indexes. Within one document type and one [CultIndex] alias, no two held records hold one
// non-blank value; CultCache.Resolve judges every commit and every load, and a refusal never echoes the value.
public class UniqueIndexTests
{
private const string Canary = "CANARY-7f3a";
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[]
{
typeof(UqItem), typeof(UqTwin), typeof(UqTagged), typeof(UqDecimal), typeof(UqDouble), typeof(UqStamp)
});
// Emitted, so CultDocumentRegistry.Shared never scans them (one schema declared two ways, and a member Shared would refuse).
// The same schema name, version and shape, once as a role and once as a unique index: a role store holds the pair, the index type refuses it.
private static readonly Type UqLoadRole = EmittedDocumentTypes.Emit(
"UqLoadRole", "tests.uq_load", "tests.uq_load.v1", new[] { new EmittedDocumentTypes.Field("Code", typeof(string), 0, Role: "code") });
private static readonly Type UqLoadIndex = EmittedDocumentTypes.Emit(
"UqLoadIndex", "tests.uq_load", "tests.uq_load.v1", new[] { new EmittedDocumentTypes.Field("Code", typeof(string), 0, Index: "code") });
private static readonly Type UqBoth = EmittedDocumentTypes.Emit(
"UqBoth", "tests.uq_both", "tests.uq_both.v1", new[] { new EmittedDocumentTypes.Field("Both", typeof(string), 0, Index: "x", Role: "x") });
private static object Load(Type type, string code) => EmittedDocumentTypes.New(type, ("Code", code));
private string _directory = string.Empty;
[SetUp]
public void CreateDirectory()
{
_directory = Path.Combine(Path.GetTempPath(), $"cultlib-uidx-{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, CultDocumentRegistry? registry = null) =>
CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry ?? Registry });
private static CultRecordKey Key(string name) => new(name);
private static UqItem Item(string? sku) => new() { Sku = sku };
private static InvalidOperationException Refused(TestDelegate write) => Assert.Throws<InvalidOperationException>(write)!;
[Test]
public void ABatchLeavingTwoRecordsWithOneValueIsRefusedNamingBothKeysAndTheIndex()
{
var path = PathOf("items.cc");
using var cache = Open(path);
cache.Commit(batch => batch.Upsert(typeof(UqItem), Item("keep"), Key("seed")));
var before = File.ReadAllBytes(path);
var refused = Refused(() => cache.Commit(batch =>
{
batch.Upsert(typeof(UqItem), Item(Canary), Key("zeta"));
batch.Upsert(typeof(UqItem), Item(Canary), Key("alpha"));
}));
Assert.That(refused.Message, Does.Contain("alpha").And.Contain("zeta").And.Contain("sku").And.Contain("tests.uq_item"));
Assert.That(refused.Message.IndexOf("alpha", StringComparison.Ordinal), Is.LessThan(refused.Message.IndexOf("zeta", StringComparison.Ordinal)), "keys in ordinal order");
Assert.That(refused.Message, Does.Not.Contain(Canary), "the value is never echoed");
Assert.That(cache.Get(Key("alpha")), Is.Null);
Assert.That(cache.Get(Key("zeta")), Is.Null);
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the store is byte-identical");
Assert.That(cache.GetByIndex<UqItem>("sku", Canary), Is.Null);
}
[Test]
public async Task AWriteOfAHeldValueAtAnotherKeyIsRefused()
{
using var cache = Open(PathOf("items.cc"));
await cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("first"));
var single = Assert.ThrowsAsync<InvalidOperationException>(() => cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("second")))!;
Assert.That(single.Message, Does.Contain("first").And.Contain("second").And.Contain("sku").And.Not.Contain(Canary));
var batched = Refused(() => cache.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("third"))));
Assert.That(batched.Message, Does.Contain("first").And.Contain("third"));
Assert.That(cache.Get(Key("second")), Is.Null);
Assert.That(cache.Get(Key("third")), Is.Null);
Assert.That(cache.GetByIndex<UqItem>("sku", Canary), Is.Not.Null);
await cache.UpsertAsync(typeof(UqItem), Item(Canary), Key("first"));
}
[Test]
public void SwappingTwoValuesInOneCommitLandsAndBothAreFound()
{
var path = PathOf("items.cc");
using (var cache = Open(path))
{
cache.Commit(batch =>
{
batch.Upsert(typeof(UqItem), Item("one"), Key("a"));
batch.Upsert(typeof(UqItem), Item("two"), Key("b"));
});
cache.Commit(batch =>
{
batch.Upsert(typeof(UqItem), Item("two"), Key("a"));
batch.Upsert(typeof(UqItem), Item("one"), Key("b"));
});
Assert.That(cache.GetStored(Key("a"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "two")));
Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "one")));
}
using var reopened = Open(path);
Assert.That(reopened.GetStored(Key("a"))!.Document, Is.SameAs(reopened.GetByIndex<UqItem>("sku", "two")));
Assert.That(reopened.GetStored(Key("b"))!.Document, Is.SameAs(reopened.GetByIndex<UqItem>("sku", "one")));
}
[Test]
public void AValueMovesWhenItsHolderIsRemovedOrChangedInTheSameCommit()
{
using var cache = Open(PathOf("items.cc"));
cache.Commit(batch =>
{
batch.Upsert(typeof(UqItem), Item("one"), Key("a"));
batch.Upsert(typeof(UqItem), Item("two"), Key("b"));
});
cache.Commit(batch =>
{
batch.Remove(Key("a"));
batch.Upsert(typeof(UqItem), Item("one"), Key("c"));
});
Assert.That(cache.GetStored(Key("c"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "one")), "removed holder released its value");
cache.Commit(batch =>
{
batch.Upsert(typeof(UqItem), Item("three"), Key("b"));
batch.Upsert(typeof(UqItem), Item("two"), Key("d"));
});
Assert.That(cache.GetStored(Key("d"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "two")), "changed holder released its value");
Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "three")));
Assert.That(cache.GetByIndex<UqItem>("sku", "one")!.Sku, Is.EqualTo("one"));
}
[Test]
public void BlankValuesNeverConflict()
{
var path = PathOf("items.cc");
using (var cache = Open(path))
{
cache.Commit(batch =>
{
batch.Upsert(typeof(UqItem), Item(null), Key("a"));
batch.Upsert(typeof(UqItem), Item(""), Key("b"));
batch.Upsert(typeof(UqItem), Item(" "), Key("c"));
batch.Upsert(typeof(UqItem), Item(null), Key("d"));
});
Assert.That(cache.GetByIndex<UqItem>("sku", ""), Is.Null);
Assert.That(cache.GetByIndex<UqItem>("sku", " "), Is.Null);
}
using var reopened = Open(path);
Assert.That(reopened.Get(Key("d")), Is.Not.Null, "a store of blank values reopens");
}
[Test]
public void TwoTypesMayShareAnAliasAndAValue()
{
using var cache = Open(PathOf("items.cc"));
cache.Commit(batch =>
{
batch.Upsert(typeof(UqItem), Item("same"), Key("item"));
batch.Upsert(typeof(UqTwin), new UqTwin { Sku = "same" }, Key("twin"));
});
Assert.That(cache.GetByIndex<UqItem>("sku", "same"), Is.Not.Null);
Assert.That(cache.GetByIndex<UqTwin>("sku", "same"), Is.Not.Null);
Assert.That(Registry.GetRequired<UqItem>().DeclaredMembers.Single().IndexAlias, Is.EqualTo("sku"), "a unique index addresses selection as a role does");
}
[Test]
public void RoleMembersMayShareValuesAndStaySelectable()
{
var path = PathOf("tagged.cc");
using (var cache = Open(path))
{
cache.Commit(batch =>
{
batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "weapon" }, Key("a"));
batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "weapon" }, Key("b"));
});
Assert.That(cache.GetByIndex<UqTagged>("kind", "weapon"), Is.Null, "a role is not a lookup");
}
using var reopened = Open(path);
Assert.That(reopened.Get(Key("b")), Is.Not.Null, "a role pair reopens");
var member = Registry.GetRequired<UqTagged>().DeclaredMembers.Single(candidate => candidate.MemberName == nameof(UqTagged.Kind));
Assert.That(member.IndexAlias, Is.EqualTo("kind"), "the role still supplies the selection address");
}
[Test]
public void AStoreHoldingDuplicatesRefusesToOpenNamingBothKeysAndTheIndex()
{
var path = PathOf("load.cc");
var roleRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadRole });
using (var writer = Open(path, roleRegistry))
{
writer.Commit(batch =>
{
batch.Upsert(UqLoadRole, Load(UqLoadRole, Canary), Key("m"));
batch.Upsert(UqLoadRole, Load(UqLoadRole, Canary), Key("k"));
});
}
var before = File.ReadAllBytes(path);
var indexRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadIndex });
var refused = Refused(() => Open(path, indexRegistry).Dispose());
Assert.That(refused.Message, Does.Contain("k").And.Contain("m").And.Contain("code").And.Not.Contain(Canary));
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is untouched");
using var cache = new CultCache(indexRegistry);
var store = new SingleFileMessagePackBackingStore(path);
Refused(() => cache.AddBackingStore(store));
Assert.That(cache.BackingStores, Is.Empty, "the refused store is not attached");
Assert.That(cache.Get(Key("k")), Is.Null);
}
[Test]
public void AWriteOntoAMovedFileIsAConflictAndTheRetryIsRefused()
{
var path = PathOf("items.cc");
using var seed = Open(path);
seed.Commit(batch => batch.Upsert(typeof(UqItem), Item("base"), Key("a")));
using var second = Open(path);
using (var first = Open(path))
first.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("b")));
var conflict = Assert.Throws<CultWriteConflictException>(
() => second.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("c"))))!;
Assert.That(conflict.Message, Does.Contain("unique index"));
Assert.That(second.Get(Key("b")), Is.Not.Null, "the moved record is loaded");
var retry = Refused(() => second.Commit(batch => batch.Upsert(typeof(UqItem), Item(Canary), Key("c"))));
Assert.That(retry.Message, Does.Contain("b").And.Contain("c").And.Not.Contain(Canary));
using var reopened = Open(path);
Assert.That(reopened.Get(Key("c")), Is.Null, "the file never holds the pair");
Assert.That(reopened.Get(Key("b")), Is.Not.Null);
}
[Test]
public void CachelessWritesOfAUniqueIndexedTypeAreRefused()
{
var path = PathOf("items.cc");
using (var cache = Open(path))
cache.Commit(batch => batch.Upsert(typeof(UqItem), Item("a"), Key("a")));
var before = File.ReadAllBytes(path);
var store = new SingleFileMessagePackBackingStore(path);
Refused(() => store.WriteDocument(Key("x"), Item(Canary), registry: Registry));
var entry = Registry.GetRequired<UqItem>().ToCatalogEntry();
Refused(() => store.WriteRecord(
new CultPersistedRecord { Key = "y", SchemaId = entry.SchemaId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0x90 } },
entry,
registry: Registry));
Refused(() => store.WriteRecord(
new CultPersistedRecord { Key = "y", SchemaId = entry.SchemaId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0x90 } },
entry));
Refused(() => store.WriteRecord(
new CultPersistedRecord { Key = "y", SchemaId = entry.SchemaId, StoredAt = DateTimeOffset.UtcNow.ToString("O"), Payload = new byte[] { 0x90 } },
entry,
registry: CultDocumentRegistry.ForTypes(new[] { typeof(UqTagged) })));
Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is byte-identical, whichever registry (none, another) was lent");
store.WriteDocument(Key("t"), new UqTagged { Kind = "k" }, registry: Registry);
using var reopened = Open(path);
Assert.That(reopened.Get(Key("t")), Is.Not.Null, "a role-only type still writes cacheless");
}
// The rule is a function of the whole commit: it must not stop at a prefix, a mixed batch or a small load.
[TestCase(0, 1)]
[TestCase(0, 6)]
[TestCase(2, 5)]
[TestCase(5, 6)]
public void ADuplicateAnywhereInALargeMixedBatchIsRefused(int first, int second)
{
using var cache = Open(PathOf("items.cc"));
var refused = Refused(() => cache.Commit(batch =>
{
batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "same" }, Key("tag-a"));
for (var i = 0; i < 7; i++)
batch.Upsert(typeof(UqItem), Item(i == first || i == second ? "dup" : "v" + i), Key("item-" + i));
batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "same" }, Key("tag-b"));
}));
Assert.That(refused.Message, Does.Contain("item-" + first).And.Contain("item-" + second).And.Not.Contain("dup"));
Assert.That(cache.Get(Key("tag-a")), Is.Null, "nothing landed");
}
[Test]
public void ADuplicateBesideARecordOfAnotherTypeSharingTheAliasIsStillRefused()
{
using var cache = Open(PathOf("items.cc"));
var refused = Refused(() => cache.Commit(batch =>
{
batch.Upsert(typeof(UqTwin), new UqTwin { Sku = "same" }, Key("twin"));
batch.Upsert(typeof(UqItem), Item("same"), Key("item-1"));
batch.Upsert(typeof(UqItem), Item("same"), Key("item-2"));
}));
Assert.That(refused.Message, Does.Contain("item-1").And.Contain("item-2").And.Not.Contain("twin"));
}
[Test]
public void ValuesDifferingOnlyByCaseAreDifferentValues()
{
using var cache = Open(PathOf("items.cc"));
cache.Commit(batch =>
{
batch.Upsert(typeof(UqItem), Item("Sku"), Key("a"));
batch.Upsert(typeof(UqItem), Item("sku"), Key("b"));
});
Assert.That(cache.GetStored(Key("a"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "Sku")));
Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex<UqItem>("sku", "sku")));
}
[Test]
public void ALoadOfManyRecordsRefusesAPairWhereverItSits([Values(0, 3, 6)] int pairAt)
{
var path = PathOf("load.cc");
var roleRegistry = CultDocumentRegistry.ForTypes(new[] { UqLoadRole });
using (var writer = Open(path, roleRegistry))
{
writer.Commit(batch =>
{
for (var i = 0; i < 8; i++)
batch.Upsert(UqLoadRole, Load(UqLoadRole, i == pairAt || i == pairAt + 1 ? "dup" : "v" + i), Key("rec-" + i));
});
}
var refused = Refused(() => Open(path, CultDocumentRegistry.ForTypes(new[] { UqLoadIndex })).Dispose());
Assert.That(refused.Message, Does.Contain("rec-" + pairAt).And.Contain("rec-" + (pairAt + 1)).And.Not.Contain("dup"));
}
[Test]
public void AMixedBatchOntoAMovedFileIsAConflictWhateverItsOrder()
{
var path = PathOf("items.cc");
using var seed = Open(path);
seed.Commit(batch => batch.Upsert(typeof(UqItem), Item("base"), Key("a")));
using var second = Open(path);
using (var first = Open(path))
first.Commit(batch => batch.Upsert(typeof(UqItem), Item("moved"), Key("b")));
Assert.Throws<CultWriteConflictException>(() => second.Commit(batch =>
{
batch.Upsert(typeof(UqTagged), new UqTagged { Kind = "k" }, Key("tag"));
batch.Upsert(typeof(UqItem), Item("fresh"), Key("c"));
}));
}
[Test]
public void ANumericIndexValueIsOneCanonicalValueInEveryCulture()
{
var culture = (CultureInfo)CultureInfo.InvariantCulture.Clone();
culture.NumberFormat.NumberDecimalSeparator = ",";
var previous = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = culture;
try
{
using var cache = Open(PathOf("numbers.cc"));
cache.Commit(batch => batch.Upsert(typeof(UqDecimal), new UqDecimal { Price = 1.0m }, Key("d1")));
Assert.That(cache.GetByIndex<UqDecimal>("price", "1"), Is.Not.Null, "1.0m is the canonical value 1");
var sameDecimal = Refused(() => cache.Commit(batch => batch.Upsert(typeof(UqDecimal), new UqDecimal { Price = 1.00m }, Key("d2"))));
Assert.That(sameDecimal.Message, Does.Contain("d1").And.Contain("d2"));
cache.Commit(batch => batch.Upsert(typeof(UqDecimal), new UqDecimal { Price = 1.5m }, Key("d3")));
Assert.That(cache.GetByIndex<UqDecimal>("price", "1.5"), Is.Not.Null, "lookups name the invariant spelling, not the current culture's");
Assert.That(cache.GetByIndex<UqDecimal>("price", "1,5"), Is.Null);
culture.DateTimeFormat.ShortDatePattern = "yyyy.dd.MM";
culture.DateTimeFormat.DateSeparator = ".";
cache.Commit(batch => batch.Upsert(typeof(UqStamp), new UqStamp { When = new DateTime(2026, 1, 2, 3, 4, 5) }, Key("t1")));
Assert.That(cache.GetByIndex<UqStamp>("when", "01/02/2026 03:04:05"), Is.Not.Null, "a date is its invariant spelling");
cache.Commit(batch => batch.Upsert(typeof(UqDouble), new UqDouble { Level = 0.0 }, Key("z1")));
var signedZero = Refused(() => cache.Commit(batch => batch.Upsert(typeof(UqDouble), new UqDouble { Level = -0.0 }, Key("z2"))));
Assert.That(signedZero.Message, Does.Contain("z1").And.Contain("z2"));
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
[Test]
public void AMemberWithBothAttributesIsRefusedAtRegistration()
{
var refused = Refused(() => CultDocumentRegistry.ForTypes(new[] { UqBoth }));
Assert.That(refused.Message, Does.Contain("Both").And.Contain("[CultIndex]").And.Contain("[CultRole]"));
}
[CultDocument("tests.uq_item", "tests.uq_item.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class UqItem
{
[Key(0)] [CultIndex("sku")] public string? Sku { get; set; }
}
[CultDocument("tests.uq_twin", "tests.uq_twin.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class UqTwin
{
[Key(0)] [CultIndex("sku")] public string? Sku { get; set; }
}
[CultDocument("tests.uq_decimal", "tests.uq_decimal.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class UqDecimal
{
[Key(0)] [CultIndex("price")] public decimal Price { get; set; }
}
[CultDocument("tests.uq_stamp", "tests.uq_stamp.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class UqStamp
{
[Key(0)] [CultIndex("when")] public DateTime When { get; set; }
}
[CultDocument("tests.uq_double", "tests.uq_double.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class UqDouble
{
[Key(0)] [CultIndex("level")] public double Level { get; set; }
}
[CultDocument("tests.uq_tagged", "tests.uq_tagged.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class UqTagged
{
[Key(0)] [CultRole("kind")] public string? Kind { get; set; }
}
}
}