From 03a71ebd6685e05a241cf020596ae84015e1e88b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 13 Sep 2026 17:58:00 +0200 Subject: [PATCH] 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 : 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 --- .../Contracts/cultcache-persistence-format.md | 7 ++- .../Contracts/cultcache-store-composition.md | 22 +++++---- tests/GameCult.Caching.InteropPeer/Program.cs | 46 ++++++++++++++++++- tests/GameCult.Caching.Tests/README.md | 15 ++++-- .../NetworkingTests.cs | 38 +++++++++++++++ 5 files changed, 111 insertions(+), 17 deletions(-) diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 26844020..d65a4919 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -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 diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 7abaffb3..ccb3c0ec 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -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`/`GetAll` 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. diff --git a/tests/GameCult.Caching.InteropPeer/Program.cs b/tests/GameCult.Caching.InteropPeer/Program.cs index 5de07335..11dadaad 100644 --- a/tests/GameCult.Caching.InteropPeer/Program.cs +++ b/tests/GameCult.Caching.InteropPeer/Program.cs @@ -16,10 +16,18 @@ static async Task ProgramMainAsync(string[] args) { if (args.Length == 0) { - throw new InvalidOperationException("Expected mode: write | read"); + throw new InvalidOperationException("Expected mode: write | read | write-routed "); } var mode = args[0]; + if (mode == "write-routed") + { + if (args.Length != 3) + throw new InvalidOperationException("Expected: write-routed "); + 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(new CultRecordKey(note.DocumentId))); + cache.UpsertAsync(runNote, new CultRecordHandle(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(); } + +[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; +} diff --git a/tests/GameCult.Caching.Tests/README.md b/tests/GameCult.Caching.Tests/README.md index 94be3ff6..8f8d83c1 100644 --- a/tests/GameCult.Caching.Tests/README.md +++ b/tests/GameCult.Caching.Tests/README.md @@ -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 diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 99b54710..ae37be98 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -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(); + using var subscription = database.Watch().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(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(batched)?.Text, Is.EqualTo("batched")); + var reopened = new CultCache(); + reopened.AddBackingStore(new SingleFileMessagePackBackingStore(path)); + Assert.That(reopened.Get(batched)?.Text, Is.EqualTo("batched")); + Assert.That(reopened.Get(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() {