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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-13 16:13:20 +02:00
commit 0db1fe5448
4 changed files with 31 additions and 13 deletions

View file

@ -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

View file

@ -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

View file

@ -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<T>`/`GetAll<T>` 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 |

View file

@ -69,6 +69,7 @@ namespace GameCult.Caching.Tests
Assert.That(loaded, Is.InstanceOf<RoutingWeapon>(), "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<RoutingNote>().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("<IsGlobal>k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(registry.GetRequired<RoutingGlobal>(), true);
Assert.That(registry.GetRequired<RoutingGlobal>().IsGlobal, Is.True, "precondition: the global flag did not take");
return registry;
}