Pin CultNet publish-after-commit, add write-routed interop, update contract status

- NetworkingTests.CultNetPutInsideCommitPublishesOnlyAfterCommit: a record
  staged in a batch reaches no CultNet observer before the store commits; a
  put made while staging is its own write. Chosen over refusing CultNet writes
  inside a batch, which would need ambient "inside a batch" state.
- Interop peer mode write-routed <catalog.cc> <run.cc>: cultcache.interop-note
  routes to the catalog file, cultcache.interop-run-note to the run file, one
  record each.
- store-composition contract: the C# column reads implemented for routing,
  attach, schema, lookups, globals, batch and conditional commit; overwriting
  a store is open, remove, upsert, flush. persistence-format: C# implements
  batch and conditional commit.
- tests README lists the new suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-13 17:58:00 +02:00
commit 03a71ebd66
5 changed files with 111 additions and 17 deletions

View file

@ -56,10 +56,9 @@ nothing.
writes the whole snapshot and is last-writer-wins, so processes sharing a store
must all use conditional commit.
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
C# implements batch and conditional commit (`CultCache.Commit`, `TryCommit`,
`CultCacheBatch`). Rust implements both. TypeScript and Python implement
neither yet. Routing, batches and conditions are specified in
`cultcache-store-composition.md`.
## Canonical Store Shape

View file

@ -158,14 +158,18 @@ no serialization; a CultMath encoding, if added, uses this shape.
| 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 | 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__`; 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 |
| One home store, no mirrors | implemented | 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 | implemented | 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 | implemented | n/a (explicit type ids) | n/a | n/a |
| Assignable lookups and watches | implemented | exact type ids | exact type ids | exact type ids |
| Globals never invented, singleton | implemented | 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 | implemented | `put_prepared_batch`, one store, all-or-nothing | not implemented | `put_envelopes`, one type per call, not all-or-nothing |
| Conditional commit | implemented | store-level `compare_exchange` family | not implemented | not implemented |
| Per-assembly options, value-type arrays | contract | n/a | n/a | n/a |
A "contract" cell means this document is ahead of the C# code; the C# change
lands with `StoreRoutingTests` turning green.
A "contract" cell means this document is ahead of the C# code.
Opening a store always hydrates it. A consumer that wants a store to hold only
what it writes removes the records it does not keep, upserts, and flushes; the
flush replaces the file in one atomic step. There is no open-without-reading
option.

View file

@ -16,10 +16,18 @@ static async Task<int> ProgramMainAsync(string[] args)
{
if (args.Length == 0)
{
throw new InvalidOperationException("Expected mode: write | read");
throw new InvalidOperationException("Expected mode: write | read | write-routed <catalog.cc> <run.cc>");
}
var mode = args[0];
if (mode == "write-routed")
{
if (args.Length != 3)
throw new InvalidOperationException("Expected: write-routed <catalog.cc> <run.cc>");
WriteRouted(args[1], args[2]);
return 0;
}
var options = ParseArgs(args.Skip(1).ToArray());
var file = RequireArg(options, "file");
switch (mode)
@ -69,6 +77,32 @@ static async Task ReadAsync(string file)
WriteJsonLine(note);
}
// Two routed single-file stores: each file is a complete single-store snapshot holding only its own type.
static void WriteRouted(string catalogFile, string runFile)
{
using var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogFile), typeof(CultCacheInteropNote));
cache.AddBackingStore(new SingleFileMessagePackBackingStore(runFile), typeof(CultCacheInteropRunNote));
var note = new CultCacheInteropNote
{
DocumentId = "note:csharp-routed",
AuthorRuntimeId = "csharp",
Title = "csharp wrote a routed catalog note",
Body = "One home store per document type.",
Tags = ["csharp", "routed"]
};
var runNote = new CultCacheInteropRunNote
{
DocumentId = "run-note:csharp-routed",
AuthorRuntimeId = "csharp",
Body = "The run store holds only run records."
};
cache.UpsertAsync(note, new CultRecordHandle<CultCacheInteropNote>(new CultRecordKey(note.DocumentId)));
cache.UpsertAsync(runNote, new CultRecordHandle<CultCacheInteropRunNote>(new CultRecordKey(runNote.DocumentId)));
cache.FlushAllBackingStores();
WriteJsonLine(note);
}
static CultCache BuildCache(string file)
{
var cache = new CultCache();
@ -132,3 +166,13 @@ public sealed class CultCacheInteropNote
[Key(4)] public string Body { get; set; } = string.Empty;
[Key(5)] public string[] Tags { get; set; } = Array.Empty<string>();
}
[CultDocument("cultcache.interop-run-note", "cultcache.interop_run_note.v1")]
[MessagePackObject]
public sealed class CultCacheInteropRunNote
{
[Key(0)] public string SchemaVersion { get; set; } = "cultcache.interop_run_note.v1";
[Key(1)] [CultName] public string DocumentId { get; set; } = string.Empty;
[Key(2)] public string AuthorRuntimeId { get; set; } = string.Empty;
[Key(3)] public string Body { get; set; } = string.Empty;
}

View file

@ -17,12 +17,21 @@ The tests currently cover:
- `CultCacheMessagePack.OpenAsync`, persisted globals, dirty state, and flush on
dispose
- cache lookups (`Get`, `GetAll`, runtime-type upserts)
- the ambient cache transaction (`ExecuteTransactionAsync`), until Cut 3
replaces it with the explicit batch commit
- `CultCache.Commit` batches on a directory store: visibility after the store
commits, abandoned stages, sealed batches, observers committing again
- explicit Cult document payload codecs, generated metadata, canonical schema
fixtures, and schema drift reports
- hand-written MessagePack snapshot and record serialization
- `StoreRoutingTests`: the Cut 1 store-routing contract, red until Cut 3
- `StoreRoutingTests`: the store-composition contract (routing, attachment is
hydration, read-only stores, global singletons, assignable lookups, batches,
all-or-nothing loads)
- `ConditionalCommitTests`: `Expect`, `ExpectUnchanged`, `TryCommit`, the
single-file lock, strictly increasing `storedAt`, and the same conditions on a
directory store
- `DirectoryStoreDurabilityTests`: failed manifest replace, content-addressed
pages, the commit lease, orphan pages and tampered pages, with faults from
real file locks
- `PreCut2StoreFormatTests`: stores written before Cut 2 still open
Selective hydration, the directory store's stage probes, pre-v4 directory
formats, and the SoA table are no longer in the tree; the SoA table is parked

View file

@ -1490,6 +1490,44 @@ namespace GameCult.Networking.Tests
cancellation.Cancel();
}
// A batch is a value: nothing staged in it reaches CultNet observers before the store commits it. A put made
// while a batch is being staged is its own write, not part of the batch.
[Test]
public void CultNetPutInsideCommitPublishesOnlyAfterCommit()
{
var path = Path.Combine(Path.GetTempPath(), $"cultnet-commit-{Guid.NewGuid():N}.cc");
try
{
var cache = new CultCache();
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path));
var database = new CultNetDatabase(cache);
var published = new List<CultRecordKey>();
using var subscription = database.Watch<NetworkSchemaNote>().Subscribe(change => published.Add(change.Key));
var batched = new CultRecordKey("tests:commit:batched");
var put = new CultRecordKey("tests:commit:put");
Assert.That(cache.Commit(batch =>
{
batch.Upsert(new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "batched" }, new CultRecordHandle<NetworkSchemaNote>(batched));
database.PutAsync(put, new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = "put" }).GetAwaiter().GetResult();
Assert.That(published, Is.EqualTo(new[] { put }), "a record staged in the batch was published before the store committed it");
Assert.That(cache.Get(batched), Is.Null);
}), Is.True);
Assert.That(published, Is.EqualTo(new[] { put }));
Assert.That(cache.Get<NetworkSchemaNote>(batched)?.Text, Is.EqualTo("batched"));
var reopened = new CultCache();
reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path));
Assert.That(reopened.Get<NetworkSchemaNote>(batched)?.Text, Is.EqualTo("batched"));
Assert.That(reopened.Get<NetworkSchemaNote>(put)?.Text, Is.EqualTo("put"));
}
finally
{
if (File.Exists(path)) File.Delete(path);
if (File.Exists(path + ".lock")) File.Delete(path + ".lock");
}
}
[Test]
public async Task DatabaseSubscriptionServer_StreamsRecordChangesOverRudp()
{