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:
parent
245eef6fb7
commit
03a71ebd66
5 changed files with 111 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue