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; }