From d53778288b4cdc61ee026f1ef7da68416be14b08 Mon Sep 17 00:00:00 2001 From: Libby Date: Wed, 11 Nov 2026 01:45:10 +0000 Subject: [PATCH] CultCache: cacheless WriteRecord refuses unclassifiable index aliases; index values are culture-invariant canonical; pin the refusal across batch size, order, mixed types and loads Co-Authored-By: Claude Sonnet 5.5 --- .../Contracts/cultcache-store-composition.md | 3 +- src/GameCult.Caching/CultCache.cs | 34 ++-- src/GameCult.Caching/README.md | 2 +- .../UniqueIndexTests.cs | 150 +++++++++++++++++- .../CultNetSelectionEvaluatorTests.cs | 33 ++++ 5 files changed, 209 insertions(+), 13 deletions(-) diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 6e240628..01f7a5c9 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -112,7 +112,8 @@ which route wrote it. landing record, before the store is written and before any `Sequence` is minted. An admission is refused when it would leave two records of one type holding one `[CultIndex]` value (blank is no value), and a load of a store - holding such a pair refuses naming both keys and the index. A conditional commit on a clean file store is judged a second time + holding such a pair refuses naming both keys and the index. Concurrent writers on a + directory store are not covered by this rule. A conditional commit on a clean file store is judged a second time against the file it merges onto whenever the cache holds variants in any store, the batch lands a variant, or the file holds one. That second read runs the getters again for the records the batch lands, for any variant it re-resolves, diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 66332b5c..709fc64d 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1013,7 +1013,7 @@ namespace GameCult.Caching .Where(member => member.IsUnique) .ToDictionary( member => member.IndexAlias!, - member => member.Getter, + member => member.IndexValue, StringComparer.Ordinal); var descriptorMembers = members .Select(member => new CultDocumentMemberDescriptor @@ -1883,6 +1883,10 @@ namespace GameCult.Caching public Func Getter { get; set; } = default!; public Func GetterNullable { get; set; } = default!; + // What a [CultIndex] value is: culture-invariant, and a number is its canonical decimal (1.0m and 1.00m are one value, + // 0.0 and -0.0 are one value), so the value that decides a refusal is the value a lookup names. + public Func IndexValue { get; set; } = default!; + // D8/Q-J (revised 2026-09-22): non-null exactly for the closed CLR numeric set (Nullable // unwrapped), rendered as the member's exact canonical decimal string - never a double, so // no CLR numeric type's range or precision is lost rendering it. docs/cultnet-selection-cut.md @@ -1921,6 +1925,13 @@ namespace GameCult.Caching IsNumeric = IsNumericClrType(memberType), Getter = document => getValue(document)?.ToString() ?? string.Empty, GetterNullable = document => getValue(document)?.ToString(), + IndexValue = document => getValue(document) switch + { + null => string.Empty, + var raw when IsNumericClrType(raw.GetType()) => RenderCanonicalNumber(raw) ?? string.Empty, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + var raw => raw.ToString() ?? string.Empty + }, NumberOf = document => { var raw = getValue(document); @@ -4620,7 +4631,7 @@ namespace GameCult.Caching /// Whether the record's payload holds element ids, or null when the caller cannot tell. /// The registry the payload was encoded with. Given, the write decides the header for copied records that registry can read. It never decides ownership: the answer is the same lent or not. alone owns the record at the key when it declares the stored id (its own or one it lists compatible), or when it is the type that reads the stored entry and reads it whole, so an older id of its own schema name it reads whole is its own and is restamped. Everything else is refused with the file left as it was: a record of another registered type included, whatever the registry holds, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs must not enter a cache or another store; that throws . - /// The store is attached to a cache; write through the cache. Also thrown when the record's type declares a (when the registry holds that type): its records are written through a cache. + /// The store is attached to a cache; write through the cache. Also thrown when the record's type declares a , and when the catalog entry declares an index alias and the registry lent for the call does not hold the type (none, or another): the call cannot tell a unique index from a role, so such records are written through a cache. /// The file at the path is not a store this runtime can read. public void WriteRecord(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds = null, CultDocumentRegistry? registry = null) { @@ -4628,7 +4639,7 @@ namespace GameCult.Caching if (catalogEntry == null) throw new ArgumentNullException(nameof(catalogEntry)); Cacheless(registry, () => { - RefuseUniqueIndexedCacheless(AttachedRegistry, catalogEntry.SchemaId); + RefuseUniqueIndexedCacheless(AttachedRegistry, catalogEntry); WriteOne(record, catalogEntry, holdsIds); }); } @@ -4647,7 +4658,7 @@ namespace GameCult.Caching /// The stored-at stamp, or null for now. /// The registry that describes and encodes the document, or null for . The document type's entry is taken from it, and the registry decides the header for copied records it can read. It never decides ownership, so the answer is the same for any registry that holds the type: the document's entry owns a record whose id it declares (its own or one it lists compatible), and a record of an older id of its own schema name that it reads whole; everything else is refused, another registered type's record included, and a record it would read only by dropping persisted slots. Retyping a key is a removal and an add. It serves this call only: the store keeps no registry. /// The call holds the store's gate while it runs. User code it runs (property getters while the document is encoded, constructors while a copied record is read) must not enter a cache or another store; that throws , carried by the encoder's wrapper when the code ran inside it. - /// The store is attached to a cache; write through the cache. Also thrown when the record's type declares a (when the registry holds that type): its records are written through a cache. + /// The store is attached to a cache; write through the cache. Also thrown when the record's type declares a , and when the catalog entry declares an index alias and the registry lent for the call does not hold the type (none, or another): the call cannot tell a unique index from a role, so such records are written through a cache. /// The file at the path is not a store this runtime can read. /// The document holds an element id or list a cache write refuses. public void WriteDocument(CultRecordKey key, TDocument document, string? storedAt = null, CultDocumentRegistry? registry = null) @@ -4656,7 +4667,7 @@ namespace GameCult.Caching if (document == null) throw new ArgumentNullException(nameof(document)); var resolvedRegistry = registry ?? CultDocumentRegistry.Shared; var catalogEntry = resolvedRegistry.GetRequired().ToCatalogEntry(); - RefuseUniqueIndexedCacheless(resolvedRegistry, catalogEntry.SchemaId); + RefuseUniqueIndexedCacheless(resolvedRegistry, catalogEntry); Cacheless(resolvedRegistry, () => { var ids = CultElementIds.Plan(typeof(TDocument), document, key.Value, deterministic: false); @@ -4682,12 +4693,17 @@ namespace GameCult.Caching }); } - // A unique index is judged against every held record, which a cacheless write does not hold. - private static void RefuseUniqueIndexedCacheless(CultDocumentRegistry? registry, string schemaId) + // A unique index is judged against every held record, which a cacheless write does not hold. A write is refused when the + // registry lent for the call holds a type with a [CultIndex] under the schema id, and also when the entry declares an index + // alias the call cannot classify (no registry, or one not holding the type): the catalog does not say unique from role. + private static void RefuseUniqueIndexedCacheless(CultDocumentRegistry? registry, CultSchemaCatalogEntry entry) { - if (registry != null && registry.HoldsUniqueIndex(schemaId)) + var unique = registry != null && registry.HoldsUniqueIndex(entry.SchemaId); + var unclassified = entry.Members.Any(member => member.IndexAlias != null) && (registry == null || !registry.Declared(entry.SchemaId)); + if (unique || unclassified) throw new InvalidOperationException( - "A record of a type with a unique index is written through a cache; a cacheless write cannot judge uniqueness."); + "A record of a type with a unique index is written through a cache; a cacheless write cannot judge uniqueness" + + (unclassified ? " and the registry lent for the call does not say whether this schema's index is unique." : ".")); } private void WriteOne(CultPersistedRecord record, CultSchemaCatalogEntry catalogEntry, bool? holdsIds) diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index c0eb2a8e..471361dd 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -36,7 +36,7 @@ Available cache attributes: - `CultDocument(schemaName, schemaVersion)` - `CultName` -- `CultIndex(alias?)`: a unique lookup. Within one document type, a non-blank value names one record; a commit that would leave two records holding one is refused, and a store holding such a pair refuses to load. +- `CultIndex(alias?)`: a unique lookup. Within one document type, a non-blank value names one record; a commit that would leave two records holding one is refused, and a store holding such a pair refuses to load. Uniqueness is judged by a `CultCache` over the single-file store; a cacheless `WriteDocument`/`WriteRecord` of such a type is refused (also when the catalog declares an index alias the call cannot classify), and concurrent writers on a directory store are not covered (`unique-index-directory-store`). A value is culture-invariant, and a number is its canonical decimal (`1.0m` and `1.00m`, `0.0` and `-0.0` are one value). - `CultRole(alias?)`: a selection and reference role. Many records may share a value; it is never a lookup. - `CultGlobal` - `CultReference(targetType?, many: false)` diff --git a/tests/GameCult.Caching.Tests/UniqueIndexTests.cs b/tests/GameCult.Caching.Tests/UniqueIndexTests.cs index 4808cea2..45817296 100644 --- a/tests/GameCult.Caching.Tests/UniqueIndexTests.cs +++ b/tests/GameCult.Caching.Tests/UniqueIndexTests.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.IO; +using System.Globalization; using System.Linq; using System.Threading.Tasks; using GameCult.Caching; @@ -18,7 +19,7 @@ namespace GameCult.Caching.Tests private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { - typeof(UqItem), typeof(UqTwin), typeof(UqTagged) + 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). @@ -276,12 +277,136 @@ namespace GameCult.Caching.Tests entry, registry: Registry)); - Assert.That(File.ReadAllBytes(path), Is.EqualTo(before), "the file is byte-identical"); + 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("sku", "Sku"))); + Assert.That(cache.GetStored(Key("b"))!.Document, Is.SameAs(cache.GetByIndex("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(() => 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("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("price", "1.5"), Is.Not.Null, "lookups name the invariant spelling, not the current culture's"); + Assert.That(cache.GetByIndex("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("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() { @@ -303,6 +428,27 @@ namespace GameCult.Caching.Tests [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 diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs index 86b966ee..b700ec19 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs @@ -486,6 +486,39 @@ namespace GameCult.Networking.Tests Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" })); } + // [CultIndex] means unique and is also a selection address: a unique member filters by equality and by number like a role. + [Test] + public void AUniqueIndexMemberIsAlsoSelectable() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SelUnique) }); + var rows = new[] + { + Row(registry, new SelUnique { Sku = "s1", Rank = 1 }, "a", 1), + Row(registry, new SelUnique { Sku = "s2", Rank = 7 }, "b", 2) + }; + + var byValue = CultNetSelectionEvaluator.Select(registry, rows, + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "sku", Op = "any_of", Values = new[] { "s2" } } } }, asOf: 2); + var byNumber = CultNetSelectionEvaluator.Select(registry, rows, + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "rank", Op = "gt", Number = "3" } } }, asOf: 2); + + Assert.That(byValue.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "b" })); + Assert.That(byNumber.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "b" })); + } + + [CultDocument("cultnet.selection-tests.unique", "cultnet.selection-tests.unique.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SelUnique + { + [Key(0)] + [CultIndex("sku")] + public string Sku = string.Empty; + + [Key(1)] + [CultIndex("rank")] + public int Rank; + } + public abstract class SelFixtureMiddle { [Key(0)]