From 0db1fe544803b78b1e8c82f01d82e7b40ac6fd60 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 16:13:20 +0200 Subject: [PATCH] Apply Soul fixes to the store composition docs and routing tests Conditional commit, not plain flush, is what protects against unseen data. C# batch and conditional commit are specified, not implemented. Home-type claims use exact equality. TypeScript attach does not read. CultMath has no encoding. Reflective and generated descriptors differ until Cut 4. Routing tests now check the inherited Name, the global flag precondition, and that a refused write publishes no change. Co-Authored-By: Claude Opus 5 --- .../README.md | 6 +++++- .../Contracts/cultcache-persistence-format.md | 14 +++++++++----- .../Contracts/cultcache-store-composition.md | 19 ++++++++++++------- .../StoreRoutingTests.cs | 5 +++++ 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/GameCult.Caching.MessagePack.Generator/README.md b/src/GameCult.Caching.MessagePack.Generator/README.md index 92acbcb0..df3e0ecc 100644 --- a/src/GameCult.Caching.MessagePack.Generator/README.md +++ b/src/GameCult.Caching.MessagePack.Generator/README.md @@ -8,7 +8,11 @@ and MessagePack payload codecs where it can, for classes marked Infrastructure for the caching stack. Application code does not reference it directly; it arrives through `GameCult.Caching.MessagePack.Analyzers`. Without -it, `CultDocumentRegistry` builds the same descriptors by reflection. +it, `CultDocumentRegistry` builds descriptors by reflection, and the two differ: +reflective descriptors include inherited members and order unkeyed members by +declaration token; generated descriptors include only declared members and +order unkeyed members by name. For derived documents or unkeyed members the two +produce different schema ids. This is scheduled to be fixed in Cut 4. ## What It Generates diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 3a8701d2..26844020 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -56,8 +56,10 @@ nothing. writes the whole snapshot and is last-writer-wins, so processes sharing a store must all use conditional commit. -C# and Rust implement batch and conditional commit. TypeScript and Python -implement neither yet. Routing, batches and conditions are specified in +Batch and conditional commit are specified for C# in +`cultcache-store-composition.md` and not yet implemented there; see its runtime +status table. Rust implements both. TypeScript and Python implement neither +yet. Routing, batches and conditions are specified in `cultcache-store-composition.md`. ## Canonical Store Shape @@ -356,12 +358,14 @@ The v1 concurrent single-file policy is: lock. 2. Writers take an exclusive sidecar lock derived from the `.cc` path. 3. Writers re-read the current snapshot after taking the lock. -4. Writers merge or reject local staged changes against the latest generation. +4. Writers using conditional commit evaluate their conditions against the + latest snapshot and write nothing if one fails. A plain flush compares + nothing and is last-writer-wins. 5. Writers write a temp file, flush it, atomically replace the `.cc` file, and release the lock. -This preserves the key invariant: every reader sees a complete snapshot, and no -writer commits over unseen data without passing through the merge/reject point. +This preserves the key invariant: every reader sees a complete snapshot. Only a +conditional commit is protected against committing over unseen data. ## Live Change Observation diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index d79ebe15..7abaffb3 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -21,10 +21,13 @@ which route wrote it. - C# routes by assignable CLR type. TypeScript, Rust and Python route by the exact type string they already use. - A second untyped store is a registration error. A home type already claimed by - another store is a registration error. + another store, by exact type equality, is a registration error. Routes to a + base type and a derived type may coexist; the most derived wins. - A write whose document has no home, once any store is attached, is an error. A cache with zero stores is an in-memory cache and needs no home. - Nothing is mirrored or replicated. A write touches exactly one store. +- This is the contract in every runtime. A runtime that still mirrors is + non-conforming until it deletes its mirrors. ## Attachment is hydration @@ -102,7 +105,8 @@ against what is durably on disk at that moment: - `Expect(key, current)`: the record at `key` must still be the one this cache observed, or, with `current` null, must be absent. Only named keys are - constrained; unrelated concurrent writes do not fail the commit. + constrained; unrelated concurrent writes do not fail the commit. Passing any + instance other than the one the cache holds throws. - `ExpectUnchanged()`: the store's persisted record set must equal what this cache last loaded from it, compared as the ordered list of `(key, schemaId, storedAt)`. Any insert, delete or replace fails it. @@ -142,22 +146,23 @@ another runtime need not obey the `storedAt` rule. ## Value-type encoding -Vector-like value types (Unity.Mathematics, CultMath, consumer structs) encode -as **fixed-length positional arrays of primitive components**: a two-component +Vector-like value types (Unity.Mathematics, consumer structs) encode as +**fixed-length positional arrays of primitive components**: a two-component float vector is `[f32, f32]`, a three-component one `[f32, f32, f32]`, an integer pair `[int, int]`. No ext types, no maps, no names. The persisted member type name is the CLR full name, the same rule as `System.Int32`. A reader in -another runtime needs only the component count and primitive type. +another runtime needs only the component count and primitive type. CultMath has +no serialization; a CultMath encoding, if added, uses this shape. ## Runtime status | Rule | C# | Rust | TypeScript | Python | |---|---|---|---|---| | One home store, no mirrors | contract; implementation replaces replication | routes by type, still pushes to later matching stores | routes by type, still mirrors | routes by type, still writes every matching store | -| Attach hydrates; loading never writes | contract; attach still pushes existing records | hydrates without writing | hydrates without writing | hydrates without writing | +| Attach hydrates; loading never writes | contract; attach still pushes existing records | attach does not read; pull loads without writing | attach does not read; pull loads without writing | attach does not read; pull loads without writing | | Runtime type decides schema | contract; generic parameter still decides | n/a (explicit type ids) | n/a | n/a | | Assignable lookups and watches | `Get`/`GetAll` only | exact type ids | exact type ids | exact type ids | -| Globals never invented, singleton | contract; constructor still invents defaults | no globals | single `__global__`, invents nothing | single `__global__`, invents nothing | +| Globals never invented, singleton | contract; constructor still invents defaults | no globals | single `__global__`; a write with a new key replaces it; refused only on pull; invents nothing | single `__global__`, invents nothing | | Explicit batch commit | contract; ambient transaction still present | `put_prepared_batch`, one store, all-or-nothing | not implemented | `put_envelopes`, one type per call, not all-or-nothing | | Conditional commit | contract | store-level `compare_exchange` family | not implemented | not implemented | | Per-assembly options, value-type arrays | contract | n/a | n/a | n/a | diff --git a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs index 53b54211..d792fc38 100644 --- a/tests/GameCult.Caching.Tests/StoreRoutingTests.cs +++ b/tests/GameCult.Caching.Tests/StoreRoutingTests.cs @@ -69,6 +69,7 @@ namespace GameCult.Caching.Tests Assert.That(loaded, Is.InstanceOf(), "the handle's type parameter chose the schema"); Assert.That(((RoutingWeapon)loaded!).Damage, Is.EqualTo(12)); + Assert.That(((RoutingWeapon)loaded!).Name, Is.EqualTo("lance"), "the inherited member did not round-trip"); } [Test] @@ -137,11 +138,14 @@ namespace GameCult.Caching.Tests using var cache = new CultCache(CultDocumentRegistry.ForTypes(new[] { typeof(RoutingNote) })); cache.AddBackingStore(new RefusingStore(PathOf("refusing.cc"))); var key = new CultRecordKey("refused"); + var changes = 0; + using var subscription = cache.Watch().Subscribe(_ => changes++); Assert.That( async () => await cache.UpsertAsync(typeof(RoutingNote), new RoutingNote { Name = "refused" }, key), Throws.InvalidOperationException); Assert.That(cache.Get(key), Is.Null, "the store refused the write but the cache kept the document"); + Assert.That(changes, Is.Zero, "the store refused the write but the cache published a change"); } private string PathOf(string fileName) => Path.Combine(_directory, fileName); @@ -163,6 +167,7 @@ namespace GameCult.Caching.Tests typeof(CultDocumentDescriptor) .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! .SetValue(registry.GetRequired(), true); + Assert.That(registry.GetRequired().IsGlobal, Is.True, "precondition: the global flag did not take"); return registry; }