2026-05-19 10:44:00 +01:00
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Linq;
|
2026-05-19 19:55:29 +01:00
|
|
|
using System.Reflection;
|
2026-08-19 01:36:23 +02:00
|
|
|
using System.Threading;
|
2026-05-19 10:44:00 +01:00
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using R3;
|
|
|
|
|
|
|
|
|
|
namespace GameCult.Networking
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Describes the kind of domain change observed through a distributed CultCache surface.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public enum CultNetDatabaseChangeKind
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A document was added.
|
|
|
|
|
/// </summary>
|
|
|
|
|
Added,
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A document was updated.
|
|
|
|
|
/// </summary>
|
|
|
|
|
Updated,
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A document was removed.
|
|
|
|
|
/// </summary>
|
|
|
|
|
Removed,
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A document was accepted through compatible schema migration.
|
|
|
|
|
/// </summary>
|
|
|
|
|
SchemaMigrated,
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A document change was rejected.
|
|
|
|
|
/// </summary>
|
2026-05-19 18:30:03 +01:00
|
|
|
Rejected,
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A document was applied locally before authoritative commit.
|
|
|
|
|
/// </summary>
|
|
|
|
|
Predicted,
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A predicted document was replaced by the authoritative committed state.
|
|
|
|
|
/// </summary>
|
|
|
|
|
Reconciled
|
|
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// The one schema-alias matcher for every runtime-side schema match (docs/cultnet-selection-cut.md, D4).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static class CultNetSchemaAliasMatching
|
2026-06-27 22:04:16 +01:00
|
|
|
{
|
|
|
|
|
public static bool MatchesAny(IReadOnlyList<string> candidates, string schemaId)
|
|
|
|
|
{
|
|
|
|
|
return candidates.Count == 0 ||
|
|
|
|
|
candidates.Any(candidate => Matches(candidate, schemaId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static bool MatchesAny(IReadOnlyList<string> candidates, CultDocumentDescriptor descriptor)
|
|
|
|
|
{
|
|
|
|
|
return candidates.Count == 0 ||
|
|
|
|
|
candidates.Any(candidate => Matches(candidate, descriptor));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static bool Matches(string candidate, string schemaId)
|
|
|
|
|
{
|
|
|
|
|
if (string.Equals(candidate, schemaId, StringComparison.Ordinal))
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
var candidateName = InferSchemaName(candidate) ?? candidate;
|
|
|
|
|
var schemaName = InferSchemaName(schemaId) ?? schemaId;
|
|
|
|
|
return string.Equals(candidateName, schemaName, StringComparison.Ordinal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static bool Matches(string candidate, CultDocumentDescriptor descriptor)
|
|
|
|
|
{
|
|
|
|
|
if (string.Equals(candidate, descriptor.SchemaId, StringComparison.Ordinal) ||
|
|
|
|
|
string.Equals(candidate, descriptor.SchemaName, StringComparison.Ordinal) ||
|
|
|
|
|
string.Equals(candidate, descriptor.SchemaVersion, StringComparison.Ordinal))
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
if (descriptor.ToCatalogEntry().CompatibleSchemaIds.Any(compatible =>
|
|
|
|
|
string.Equals(candidate, compatible, StringComparison.Ordinal)))
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
var candidateName = InferSchemaName(candidate) ?? candidate;
|
|
|
|
|
return string.Equals(candidateName, descriptor.SchemaName, StringComparison.Ordinal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string? InferSchemaName(string schemaId)
|
|
|
|
|
{
|
|
|
|
|
var marker = schemaId.LastIndexOf(".v", StringComparison.Ordinal);
|
|
|
|
|
if (marker <= 0 || marker + 2 >= schemaId.Length)
|
|
|
|
|
return null;
|
|
|
|
|
|
|
|
|
|
var version = schemaId.Substring(marker + 2);
|
|
|
|
|
return version.All(char.IsDigit)
|
|
|
|
|
? schemaId.Substring(0, marker)
|
|
|
|
|
: null;
|
|
|
|
|
}
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
|
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#)
Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers
CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message
listeners and Server.cs's pre-verification allowlist gains them;
CultNetDatabaseSubscriptionServer's subscribe path is generalised to a
shared SubscriptionRequest so v0 (lowered) and v1 selections run the
same Subscribe/Watch/Reconcile core, with D6's rule wired in: a
hop-bearing selection runs the full reconcile diff on every change
instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives
both servers a real snapshot watermark.
Fixes the confirmed parity and correctness defects from Soul's
whole-cut pass:
- R-B: cited/cites edges are anchored to the row the direction actually
reports against (the citer under cites, the cited row under cited),
not filtered by "From is on the page" regardless of direction; order
is page-row order then (from, role, to) in code-point order.
- R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/
string.CompareOrdinal in the row tiebreak, cursor position and edge
order, so an astral key sorts the same way here as it does under
Rust's UTF-8 byte comparison.
- R-D: CultCache renders a float/double's exact decimal expansion from
its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble),
never .NET's shortest round-trip form, which rounded a tie the
opposite way from Rust at some values. The now-dead ExpandScientificNotation
is deleted.
- R-E: MatchesCitation compares a cites target through
CultNetSchemaAliasMatching instead of an exact SchemaId string
compare; ToEdge and ToRawRecord now emit the same wire schema id
(WireSchemaId) for the same row, where ToEdge previously used the
descriptor's own id and ToRawRecord the binding's override.
- R-F: CultNetSelectionEvaluator.Select validates first, unconditionally
- the validate:false/true flag on SelectPage/CreateRawSnapshotResponse
is gone, so no public evaluation path can skip the door. Mesh's
EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape
(the declaration-independent half of the door) before its own v0
transport refusals, so Keys=[]/[""] is refused there too.
- R-G: Evaluation.TotalMatched is the selection's whole matching count;
SelectPage's Matched field now reports it instead of the page's row
count. CreateRawSnapshotResponse and CreateShardSnapshotResponse
(v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll,
which evaluates once via CultNetSelectionEvaluator.EvaluateAll and
projects every matched row, instead of looping SelectPage per 200-row
page and re-filtering/re-sorting the whole row set each time.
- R-H: the cursor digest is length-prefixed for every string and list
(AppendString/AppendList) instead of delimiter-joined, closing the
collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"].
- R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical
rendering, not the string getter's culture-dependent ToString().
- R-M: WithBindingSchemaAlias is deleted; both servers' single-change
fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases
instead, the one binding-alias reconciler. The duplicated v0/shard
lower-and-page loop is gone with SelectAll above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
|
|
|
// R-M: WithBindingSchemaAlias (a second binding-alias reconciler, scoped to one descriptor) is
|
|
|
|
|
// deleted - CultNetDocumentRegistry.ExpandSchemaBindingAliases is the one copy every caller
|
|
|
|
|
// uses, CultNetDatabaseServer and CultNetDatabaseSubscriptionServer included.
|
2026-06-27 22:04:16 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:30:03 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Declares which documents this runtime may predict locally before authoritative commit.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CultNetClientAuthorityScope
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a client authority scope.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetClientAuthorityScope(
|
|
|
|
|
string ownerRuntimeId,
|
|
|
|
|
IEnumerable<string>? schemaIds = null,
|
|
|
|
|
string? keyPrefix = null)
|
|
|
|
|
{
|
|
|
|
|
OwnerRuntimeId = string.IsNullOrWhiteSpace(ownerRuntimeId)
|
|
|
|
|
? throw new ArgumentException("Value must be non-empty.", nameof(ownerRuntimeId))
|
|
|
|
|
: ownerRuntimeId;
|
|
|
|
|
SchemaIds = schemaIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
|
|
|
|
|
?? Array.Empty<string>();
|
|
|
|
|
KeyPrefix = keyPrefix;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the runtime that owns authoring for this scope.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string OwnerRuntimeId { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the schema ids governed by this scope. Empty means all schemas.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<string> SchemaIds { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the optional record-key prefix governed by this scope.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? KeyPrefix { get; }
|
|
|
|
|
|
|
|
|
|
internal bool Matches(string runtimeId, string schemaId, CultRecordKey key)
|
|
|
|
|
{
|
|
|
|
|
return string.Equals(OwnerRuntimeId, runtimeId, StringComparison.Ordinal) &&
|
2026-06-27 22:04:16 +01:00
|
|
|
CultNetSchemaAliasMatching.MatchesAny(SchemaIds, schemaId) &&
|
|
|
|
|
(string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal bool Matches(string runtimeId, CultDocumentDescriptor descriptor, CultRecordKey key)
|
|
|
|
|
{
|
|
|
|
|
return string.Equals(OwnerRuntimeId, runtimeId, StringComparison.Ordinal) &&
|
|
|
|
|
CultNetSchemaAliasMatching.MatchesAny(SchemaIds, descriptor) &&
|
2026-05-19 18:30:03 +01:00
|
|
|
(string.IsNullOrEmpty(KeyPrefix) || key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal));
|
|
|
|
|
}
|
2026-05-19 10:44:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Describes one shard owned or observed by a CultNet database surface.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CultNetShardDescriptor
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a shard descriptor.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetShardDescriptor(
|
|
|
|
|
string shardId,
|
|
|
|
|
string ownerRuntimeId,
|
|
|
|
|
long epoch,
|
|
|
|
|
bool isPrimary,
|
|
|
|
|
IEnumerable<string>? schemaIds = null,
|
2026-05-19 17:34:49 +01:00
|
|
|
string? keyPrefix = null,
|
|
|
|
|
IEnumerable<string>? primaryEndpoints = null,
|
|
|
|
|
IEnumerable<string>? replicaEndpoints = null,
|
|
|
|
|
IEnumerable<string>? readReplicaEndpoints = null,
|
|
|
|
|
string? region = null)
|
2026-05-19 10:44:00 +01:00
|
|
|
{
|
|
|
|
|
ShardId = RequireNonEmpty(shardId, nameof(shardId));
|
|
|
|
|
OwnerRuntimeId = RequireNonEmpty(ownerRuntimeId, nameof(ownerRuntimeId));
|
|
|
|
|
Epoch = epoch;
|
|
|
|
|
IsPrimary = isPrimary;
|
|
|
|
|
SchemaIds = schemaIds?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
|
|
|
|
|
?? Array.Empty<string>();
|
|
|
|
|
KeyPrefix = keyPrefix;
|
2026-05-19 17:34:49 +01:00
|
|
|
PrimaryEndpoints = CleanEndpoints(primaryEndpoints);
|
|
|
|
|
ReplicaEndpoints = CleanEndpoints(replicaEndpoints);
|
|
|
|
|
ReadReplicaEndpoints = CleanEndpoints(readReplicaEndpoints);
|
|
|
|
|
Region = region;
|
2026-05-19 10:44:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the stable shard identifier.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string ShardId { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the runtime that currently owns writes for this shard.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string OwnerRuntimeId { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the monotonic shard authority epoch.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public long Epoch { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets whether the local database instance is authoritative for writes to this shard.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public bool IsPrimary { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the schema ids governed by this shard. Empty means all schemas.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<string> SchemaIds { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the optional record-key prefix governed by this shard.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? KeyPrefix { get; }
|
2026-05-19 17:34:49 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets endpoints that can accept authoritative writes for this shard.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<string> PrimaryEndpoints { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets endpoints that replicate authoritative shard mutations.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<string> ReplicaEndpoints { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets endpoints intended for low-latency read and subscription traffic.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<string> ReadReplicaEndpoints { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets an optional locality label for this shard owner.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? Region { get; }
|
2026-05-19 10:44:00 +01:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a primary shard that accepts every schema and key.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static CultNetShardDescriptor PrimaryAll(string ownerRuntimeId = "local", string shardId = "primary")
|
|
|
|
|
{
|
|
|
|
|
return new CultNetShardDescriptor(shardId, ownerRuntimeId, epoch: 1, isPrimary: true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a read-only shard descriptor.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static CultNetShardDescriptor ReadOnly(
|
|
|
|
|
string shardId,
|
|
|
|
|
string ownerRuntimeId,
|
|
|
|
|
long epoch = 1,
|
|
|
|
|
IEnumerable<string>? schemaIds = null,
|
|
|
|
|
string? keyPrefix = null)
|
|
|
|
|
{
|
|
|
|
|
return new CultNetShardDescriptor(shardId, ownerRuntimeId, epoch, isPrimary: false, schemaIds, keyPrefix);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal bool Matches(string schemaId, CultRecordKey key)
|
|
|
|
|
{
|
2026-06-27 22:04:16 +01:00
|
|
|
var schemaMatches = CultNetSchemaAliasMatching.MatchesAny(SchemaIds, schemaId);
|
|
|
|
|
var keyMatches = string.IsNullOrEmpty(KeyPrefix) ||
|
|
|
|
|
key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal);
|
|
|
|
|
return schemaMatches && keyMatches;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal bool Matches(CultDocumentDescriptor descriptor, CultRecordKey key)
|
|
|
|
|
{
|
|
|
|
|
var schemaMatches = CultNetSchemaAliasMatching.MatchesAny(SchemaIds, descriptor);
|
2026-05-19 10:44:00 +01:00
|
|
|
var keyMatches = string.IsNullOrEmpty(KeyPrefix) ||
|
|
|
|
|
key.Value.StartsWith(KeyPrefix!, StringComparison.Ordinal);
|
|
|
|
|
return schemaMatches && keyMatches;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string RequireNonEmpty(string value, string paramName)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
|
|
|
{
|
|
|
|
|
throw new ArgumentException("Value must be non-empty.", paramName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value;
|
|
|
|
|
}
|
2026-05-19 17:34:49 +01:00
|
|
|
|
|
|
|
|
private static string[] CleanEndpoints(IEnumerable<string>? endpoints)
|
|
|
|
|
{
|
|
|
|
|
return endpoints?.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToArray()
|
|
|
|
|
?? Array.Empty<string>();
|
|
|
|
|
}
|
2026-05-19 10:44:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Options for creating a distributed CultCache database surface.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CultNetDatabaseOptions
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets or sets the local runtime id advertised as shard owner for default primary shards.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string RuntimeId { get; set; } = "local";
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets or sets explicit shard descriptors. When omitted, this instance owns one primary shard for all records.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<CultNetShardDescriptor>? Shards { get; set; }
|
|
|
|
|
/// <summary>
|
2026-05-19 18:30:03 +01:00
|
|
|
/// Gets or sets document scopes this runtime may predict before authoritative commit.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<CultNetClientAuthorityScope>? ClientAuthorityScopes { get; set; }
|
|
|
|
|
/// <summary>
|
2026-05-19 10:44:00 +01:00
|
|
|
/// Gets or sets the document registry used to create and apply raw CultNet document messages.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetDocumentRegistry? DocumentRegistry { get; set; }
|
2026-05-19 19:55:29 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets or sets the durable store for accepted shard mutation logs.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public ICultNetShardMutationLogStore? MutationLogStore { get; set; }
|
2026-05-19 10:44:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// A typed domain change emitted by a CultNet database surface.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CultNetDatabaseChange<T> where T : class
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a database change.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetDatabaseChange(
|
|
|
|
|
CultNetDatabaseChangeKind kind,
|
|
|
|
|
CultRecordKey key,
|
|
|
|
|
string schemaId,
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
T? document,
|
|
|
|
|
T? previousDocument,
|
|
|
|
|
string? message = null)
|
|
|
|
|
{
|
|
|
|
|
Kind = kind;
|
|
|
|
|
Key = key;
|
|
|
|
|
SchemaId = schemaId;
|
|
|
|
|
Shard = shard;
|
|
|
|
|
Document = document;
|
|
|
|
|
PreviousDocument = previousDocument;
|
|
|
|
|
Message = message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the change kind.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetDatabaseChangeKind Kind { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the changed record key.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultRecordKey Key { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the schema id of the changed document.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string SchemaId { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the shard that accepted or rejected the change.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetShardDescriptor Shard { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the current document for added and updated events.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public T? Document { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the previous document for updated and removed events.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public T? PreviousDocument { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets an optional diagnostic message.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string? Message { get; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:52:29 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// One ordered mutation accepted for a shard.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CultNetShardMutationLogEntry
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a shard mutation log entry.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetShardMutationLogEntry(
|
|
|
|
|
string shardId,
|
|
|
|
|
long shardEpoch,
|
|
|
|
|
long sequence,
|
|
|
|
|
string committedAt,
|
|
|
|
|
CultNetDatabaseChangeKind kind,
|
|
|
|
|
string schemaId,
|
|
|
|
|
CultRecordKey key,
|
|
|
|
|
object? document,
|
|
|
|
|
object? previousDocument)
|
|
|
|
|
{
|
|
|
|
|
ShardId = shardId;
|
|
|
|
|
ShardEpoch = shardEpoch;
|
|
|
|
|
Sequence = sequence;
|
|
|
|
|
CommittedAt = committedAt;
|
|
|
|
|
Kind = kind;
|
|
|
|
|
SchemaId = schemaId;
|
|
|
|
|
Key = key;
|
|
|
|
|
Document = document;
|
|
|
|
|
PreviousDocument = previousDocument;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the shard id.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string ShardId { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the shard epoch.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public long ShardEpoch { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the per-shard mutation sequence.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public long Sequence { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the commit timestamp.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string CommittedAt { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the mutation kind.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetDatabaseChangeKind Kind { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the schema id.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string SchemaId { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the record key.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultRecordKey Key { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the current document, when present.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public object? Document { get; }
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the previous document, when present.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public object? PreviousDocument { get; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Raised when a database write targets a shard this node does not own.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CultNetShardAuthorityException : InvalidOperationException
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a shard authority exception.
|
|
|
|
|
/// </summary>
|
2026-05-19 17:40:19 +01:00
|
|
|
public CultNetShardAuthorityException(CultNetShardDescriptor shard, string message, string reason = "not_primary") : base(message)
|
2026-05-19 10:44:00 +01:00
|
|
|
{
|
|
|
|
|
Shard = shard;
|
2026-05-19 17:40:19 +01:00
|
|
|
Reason = reason;
|
2026-05-19 10:44:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the shard that rejected the write.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetShardDescriptor Shard { get; }
|
2026-05-19 17:40:19 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the machine-readable authority rejection reason.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string Reason { get; }
|
2026-05-19 10:44:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Database-style CultNet facade over a CultCache shard set.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class CultNetDatabase : IDisposable
|
|
|
|
|
{
|
|
|
|
|
private readonly CultCache _cache;
|
|
|
|
|
private readonly CultNetDocumentRegistry _documents;
|
2026-05-19 18:30:03 +01:00
|
|
|
private readonly string _runtimeId;
|
2026-05-19 10:44:00 +01:00
|
|
|
private readonly List<CultNetShardDescriptor> _shards;
|
2026-05-19 18:30:03 +01:00
|
|
|
private readonly List<CultNetClientAuthorityScope> _clientAuthorityScopes;
|
2026-05-19 19:55:29 +01:00
|
|
|
private readonly ICultNetShardMutationLogStore? _mutationLogStore;
|
2026-05-19 18:30:03 +01:00
|
|
|
private readonly Dictionary<string, object> _predictedDocuments = new(StringComparer.Ordinal);
|
2026-05-19 17:52:29 +01:00
|
|
|
private readonly Dictionary<string, List<CultNetShardMutationLogEntry>> _mutationLogs =
|
|
|
|
|
new(StringComparer.Ordinal);
|
|
|
|
|
private readonly Dictionary<string, long> _nextLogSequences = new(StringComparer.Ordinal);
|
2026-05-19 18:05:51 +01:00
|
|
|
private readonly Dictionary<string, long> _appliedShardSequences = new(StringComparer.Ordinal);
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
// CultNet typed selection, section 2: "ordinal is the sequence of the commit that last wrote
|
|
|
|
|
// the row". Kept on every append (AppendMutationLogEntry is the one site), keyed by (schemaId,
|
|
|
|
|
// recordKey) so the evaluator's order and cursor never read a clock.
|
|
|
|
|
private readonly Dictionary<(string SchemaId, string RecordKey), long> _lastWriteSequence =
|
|
|
|
|
new();
|
2026-05-19 10:44:00 +01:00
|
|
|
private readonly Subject<object> _changes = new();
|
|
|
|
|
private bool _disposed;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a database surface over a CultCache instance.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetDatabase(CultCache cache, CultNetDatabaseOptions? options = null)
|
|
|
|
|
{
|
|
|
|
|
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
|
|
|
|
options ??= new CultNetDatabaseOptions();
|
2026-05-19 18:30:03 +01:00
|
|
|
_runtimeId = options.RuntimeId;
|
2026-05-19 10:44:00 +01:00
|
|
|
_documents = options.DocumentRegistry ?? new CultNetDocumentRegistry(cache.Registry);
|
2026-05-19 19:55:29 +01:00
|
|
|
_mutationLogStore = options.MutationLogStore;
|
2026-05-19 10:44:00 +01:00
|
|
|
_shards = (options.Shards == null || options.Shards.Count == 0
|
|
|
|
|
? new[] { CultNetShardDescriptor.PrimaryAll(options.RuntimeId) }
|
|
|
|
|
: options.Shards)
|
|
|
|
|
.ToList();
|
2026-05-19 18:30:03 +01:00
|
|
|
_clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty<CultNetClientAuthorityScope>()).ToList();
|
2026-05-19 19:55:29 +01:00
|
|
|
InitializeLogSequencesFromStore();
|
2026-05-19 10:44:00 +01:00
|
|
|
_cache.OnUpdate += PublishCacheUpdate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the local cache backing this database surface.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultCache Cache => _cache;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the document registry used by the raw CultNet lane.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetDocumentRegistry Documents => _documents;
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the shard map known to this database surface.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<CultNetShardDescriptor> Shards => _shards;
|
|
|
|
|
|
2026-05-19 18:30:03 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the document scopes this runtime may predict before authoritative commit.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<CultNetClientAuthorityScope> ClientAuthorityScopes => _clientAuthorityScopes;
|
|
|
|
|
|
2026-08-17 19:47:10 +02:00
|
|
|
/// <summary>Gets whether this database instance can authoritatively replace the document at a key.</summary>
|
|
|
|
|
public bool CanWriteAuthoritatively<T>(CultRecordKey key) where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
var descriptor = _cache.Registry.GetRequired<T>();
|
|
|
|
|
return ResolveShardInternal(descriptor, key).IsPrimary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Gets whether this database instance can submit a prediction for the document at a key.</summary>
|
|
|
|
|
public bool CanSubmitPrediction<T>(CultRecordKey key) where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
var descriptor = _cache.Registry.GetRequired<T>();
|
|
|
|
|
return _clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, descriptor, key));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:52:29 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets mutation log entries for a shard after the supplied sequence.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<CultNetShardMutationLogEntry> GetMutationLog(
|
|
|
|
|
string shardId,
|
|
|
|
|
long afterSequence = 0,
|
|
|
|
|
int? limit = null)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
|
|
|
|
|
if (!_mutationLogs.TryGetValue(shardId, out var entries))
|
|
|
|
|
{
|
|
|
|
|
return Array.Empty<CultNetShardMutationLogEntry>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var query = entries.Where(entry => entry.Sequence > afterSequence);
|
|
|
|
|
if (limit.HasValue)
|
|
|
|
|
{
|
|
|
|
|
query = query.Take(limit.Value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return query.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 19:55:29 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets replica catch-up log entries for a shard after the supplied sequence.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IReadOnlyList<CultNetShardLogEntryMessage> GetMutationLogMessages(
|
|
|
|
|
string shardId,
|
|
|
|
|
long afterSequence = 0,
|
|
|
|
|
int? limit = null)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
|
|
|
|
|
if (_mutationLogStore != null)
|
|
|
|
|
{
|
|
|
|
|
return _mutationLogStore.Read(shardId, afterSequence, limit);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return GetMutationLog(shardId, afterSequence, limit)
|
|
|
|
|
.Select(ToLogEntryMessage)
|
|
|
|
|
.ToArray();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 20:53:45 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the highest retained-log sequence that has been compacted away for a shard.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public long GetCompactedMutationLogSequence(string shardId)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
|
|
|
|
|
return _mutationLogStore?.GetCompactedThrough(shardId) ?? 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 21:07:46 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the latest known shard-log sequence for a shard.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public long GetLatestMutationLogSequence(string shardId)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
|
|
|
|
|
var compactedThrough = GetCompactedMutationLogSequence(shardId);
|
|
|
|
|
var retained = _mutationLogStore != null
|
|
|
|
|
? _mutationLogStore.Read(shardId).Select(entry => entry.Sequence)
|
|
|
|
|
: GetMutationLog(shardId).Select(entry => entry.Sequence);
|
|
|
|
|
return retained.DefaultIfEmpty(compactedThrough).Max();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling
Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22):
empty/blank schemas-keys still reached the evaluator, the shard snapshot ran
its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only
selection terms, the Mesh one-pass read could return the wrong schema's
record at a shared key, two decode rules for a foreign schema id disagreed,
dead identity-comparison code from the _projectRecord removal was still live,
and a disposed-socket race could abort a mutation run instead of recording
NO VERDICT. This is Self's fix batch for all of it.
- CultNetSelection.Validate now refuses a blank/whitespace entry inside
schemas or keys, not just an empty array. CultNetSelectionEvaluator no
longer treats [] as "no filter" (the door already refuses it before
evaluation). CultNetV0SelectionLowering is the one place that lowers an
empty or all-blank v0 list to null; CreateRawSnapshotResponse and
CreateShardSnapshotResponse both route through it, and CultMesh's own
Clean() now delegates to it instead of duplicating the rule.
- CreateShardSnapshotResponse no longer carries a private loop or its own
schema/key matching: it builds a CultNetSelection and answers through
CultNetDocumentRegistry.SelectPage (the same evaluator path as the
non-shard snapshot), with the shard's own membership check as a row
filter. Binding-alias expansion now runs on both paths identically.
- ResolveDefaultSelection decides "no schema filter" from the cleaned
recordKeys, not the raw array length. OverlaySelection stops deciding what
a missing override means: any explicit schemaIds/recordKeys override
replaces the default selection wholesale; no override keeps the default
untouched.
- CultMeshSnapshotRequestOptions.Selection is now checked by
EnsureV0Compatible before every send: fields, cites, cited, limit, cursor,
descending, and any non-default projection are refused typed
(CultNetSelectionInvalidException), never silently dropped from the v0
wire message. Mesh sending v1 is FU-Mesh-v1, not this cut.
- CultMeshHotBodySubscription.Selection returns a defensive copy on every
read so its arrays can't be mutated out from under the stored selection.
- ReadDocumentFromSnapshotResponse now searches candidates at one recordKey
in one order - exact schema, then alias, then a foreign schema id decoded
from the payload - instead of one OR'd condition where a case-insensitive
or structurally-lucky candidate could win first. The foreign-id decode
reads the payload's embedded schema version with the registry's shared
CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against
the caller's own descriptor directly, rather than resolving to "whichever
registered type owns this schema string" - that resolution is ambiguous
when two document types alias the same schema id (MeshNoteDocument and
MeshNoteAliasDocument in the existing fixtures), which is what broke on
the first pass; TryResolveDescriptorByPayloadSchema stays for its existing
caller (ResolveDescriptorForRawDocument) with a note on when not to use it.
DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode
(RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in
favour of the shared one.
- CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity
comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key
throw in CreateProjectedSnapshot, are deleted - they can no longer fire
now that a projection's dictionary key is always the record's own key.
A new test flips authorizeRecord per key while the whole request stays
authorized (so DemandActive never toggles) and proves both halves of the
remaining diff: the removal of a now-unauthorized key and the add loop
picking up a newly-authorized one.
- Every NetworkingTests.cs test that starts an IsBackground poll thread now
joins it before its server disposes (about 20 tests; one already did).
- scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no
longer throws the whole run away under PowerShell 5.1's
$ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in
a terminating NativeCommandError); Run-Tests catches it and the entry loop
records NO VERDICT instead of aborting every remaining entry.
- Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On
UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard
snapshot paths agree, including shard-membership exclusion of a document
outside the shard's key prefix and a binding schema-id override matching
on both paths.
- Mutation entries updated/added to match: the stale NET-D4-FourthMatcher
entry (its anchor was the private loop this batch deleted, not refactored)
is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's
header miscount ("two of three" loosening entries when there are only
two) is corrected and both previously-surviving loosening mutants
(MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures;
scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering
Reconcile's remaining two mutants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
|
|
|
/// Creates a raw document snapshot bounded to one shard. Answers through the one evaluator
|
|
|
|
|
/// (<see cref="CultNetSelectionEvaluator"/>, via <see cref="CultNetDocumentRegistry.SelectPage"/>),
|
|
|
|
|
/// binding-alias expansion included, with the shard membership check as the row filter -
|
|
|
|
|
/// no loop of its own (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22, S2-3).
|
2026-05-19 21:07:46 +01:00
|
|
|
/// </summary>
|
|
|
|
|
public CultNetSnapshotResponseRawMessage CreateShardSnapshotResponse(
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
string messageId,
|
|
|
|
|
CultNetSnapshotRequestMessage? filter = null)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (shard == null) throw new ArgumentNullException(nameof(shard));
|
|
|
|
|
|
2026-09-22 19:15:22 +02:00
|
|
|
var lowSchemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds);
|
|
|
|
|
var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys);
|
|
|
|
|
|
|
|
|
|
// R-R: an explicit empty schemas or keys list is v0's own "answer nothing" - it must not
|
|
|
|
|
// reach the v1 door, which refuses an empty selection.schemas/keys outright.
|
|
|
|
|
if (lowSchemas is { Length: 0 } || lowKeys is { Length: 0 })
|
|
|
|
|
{
|
|
|
|
|
return new CultNetSnapshotResponseRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId,
|
|
|
|
|
Documents = Array.Empty<CultNetRawDocumentRecord>(),
|
|
|
|
|
ShardId = shard.ShardId,
|
|
|
|
|
ShardEpoch = shard.Epoch,
|
|
|
|
|
ShardLogSequence = GetLatestMutationLogSequence(shard.ShardId)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling
Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22):
empty/blank schemas-keys still reached the evaluator, the shard snapshot ran
its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only
selection terms, the Mesh one-pass read could return the wrong schema's
record at a shared key, two decode rules for a foreign schema id disagreed,
dead identity-comparison code from the _projectRecord removal was still live,
and a disposed-socket race could abort a mutation run instead of recording
NO VERDICT. This is Self's fix batch for all of it.
- CultNetSelection.Validate now refuses a blank/whitespace entry inside
schemas or keys, not just an empty array. CultNetSelectionEvaluator no
longer treats [] as "no filter" (the door already refuses it before
evaluation). CultNetV0SelectionLowering is the one place that lowers an
empty or all-blank v0 list to null; CreateRawSnapshotResponse and
CreateShardSnapshotResponse both route through it, and CultMesh's own
Clean() now delegates to it instead of duplicating the rule.
- CreateShardSnapshotResponse no longer carries a private loop or its own
schema/key matching: it builds a CultNetSelection and answers through
CultNetDocumentRegistry.SelectPage (the same evaluator path as the
non-shard snapshot), with the shard's own membership check as a row
filter. Binding-alias expansion now runs on both paths identically.
- ResolveDefaultSelection decides "no schema filter" from the cleaned
recordKeys, not the raw array length. OverlaySelection stops deciding what
a missing override means: any explicit schemaIds/recordKeys override
replaces the default selection wholesale; no override keeps the default
untouched.
- CultMeshSnapshotRequestOptions.Selection is now checked by
EnsureV0Compatible before every send: fields, cites, cited, limit, cursor,
descending, and any non-default projection are refused typed
(CultNetSelectionInvalidException), never silently dropped from the v0
wire message. Mesh sending v1 is FU-Mesh-v1, not this cut.
- CultMeshHotBodySubscription.Selection returns a defensive copy on every
read so its arrays can't be mutated out from under the stored selection.
- ReadDocumentFromSnapshotResponse now searches candidates at one recordKey
in one order - exact schema, then alias, then a foreign schema id decoded
from the payload - instead of one OR'd condition where a case-insensitive
or structurally-lucky candidate could win first. The foreign-id decode
reads the payload's embedded schema version with the registry's shared
CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against
the caller's own descriptor directly, rather than resolving to "whichever
registered type owns this schema string" - that resolution is ambiguous
when two document types alias the same schema id (MeshNoteDocument and
MeshNoteAliasDocument in the existing fixtures), which is what broke on
the first pass; TryResolveDescriptorByPayloadSchema stays for its existing
caller (ResolveDescriptorForRawDocument) with a note on when not to use it.
DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode
(RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in
favour of the shared one.
- CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity
comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key
throw in CreateProjectedSnapshot, are deleted - they can no longer fire
now that a projection's dictionary key is always the record's own key.
A new test flips authorizeRecord per key while the whole request stays
authorized (so DemandActive never toggles) and proves both halves of the
remaining diff: the removal of a now-unauthorized key and the add loop
picking up a newly-authorized one.
- Every NetworkingTests.cs test that starts an IsBackground poll thread now
joins it before its server disposes (about 20 tests; one already did).
- scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no
longer throws the whole run away under PowerShell 5.1's
$ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in
a terminating NativeCommandError); Run-Tests catches it and the entry loop
records NO VERDICT instead of aborting every remaining entry.
- Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On
UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard
snapshot paths agree, including shard-membership exclusion of a document
outside the shard's key prefix and a binding schema-id override matching
on both paths.
- Mutation entries updated/added to match: the stale NET-D4-FourthMatcher
entry (its anchor was the private loop this batch deleted, not refactored)
is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's
header miscount ("two of three" loosening entries when there are only
two) is corrected and both previously-surviving loosening mutants
(MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures;
scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering
Reconcile's remaining two mutants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
|
|
|
var selection = new CultNetSelection
|
|
|
|
|
{
|
2026-09-22 19:15:22 +02:00
|
|
|
Schemas = lowSchemas,
|
|
|
|
|
Keys = lowKeys,
|
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#)
Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers
CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message
listeners and Server.cs's pre-verification allowlist gains them;
CultNetDatabaseSubscriptionServer's subscribe path is generalised to a
shared SubscriptionRequest so v0 (lowered) and v1 selections run the
same Subscribe/Watch/Reconcile core, with D6's rule wired in: a
hop-bearing selection runs the full reconcile diff on every change
instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives
both servers a real snapshot watermark.
Fixes the confirmed parity and correctness defects from Soul's
whole-cut pass:
- R-B: cited/cites edges are anchored to the row the direction actually
reports against (the citer under cites, the cited row under cited),
not filtered by "From is on the page" regardless of direction; order
is page-row order then (from, role, to) in code-point order.
- R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/
string.CompareOrdinal in the row tiebreak, cursor position and edge
order, so an astral key sorts the same way here as it does under
Rust's UTF-8 byte comparison.
- R-D: CultCache renders a float/double's exact decimal expansion from
its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble),
never .NET's shortest round-trip form, which rounded a tie the
opposite way from Rust at some values. The now-dead ExpandScientificNotation
is deleted.
- R-E: MatchesCitation compares a cites target through
CultNetSchemaAliasMatching instead of an exact SchemaId string
compare; ToEdge and ToRawRecord now emit the same wire schema id
(WireSchemaId) for the same row, where ToEdge previously used the
descriptor's own id and ToRawRecord the binding's override.
- R-F: CultNetSelectionEvaluator.Select validates first, unconditionally
- the validate:false/true flag on SelectPage/CreateRawSnapshotResponse
is gone, so no public evaluation path can skip the door. Mesh's
EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape
(the declaration-independent half of the door) before its own v0
transport refusals, so Keys=[]/[""] is refused there too.
- R-G: Evaluation.TotalMatched is the selection's whole matching count;
SelectPage's Matched field now reports it instead of the page's row
count. CreateRawSnapshotResponse and CreateShardSnapshotResponse
(v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll,
which evaluates once via CultNetSelectionEvaluator.EvaluateAll and
projects every matched row, instead of looping SelectPage per 200-row
page and re-filtering/re-sorting the whole row set each time.
- R-H: the cursor digest is length-prefixed for every string and list
(AppendString/AppendList) instead of delimiter-joined, closing the
collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"].
- R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical
rendering, not the string getter's culture-dependent ToString().
- R-M: WithBindingSchemaAlias is deleted; both servers' single-change
fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases
instead, the one binding-alias reconciler. The duplicated v0/shard
lower-and-page loop is gone with SelectAll above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
|
|
|
Projection = CultNetSelectionProjections.Document
|
CultNet selection cut1, commit 2 fix batch: door, shard-via-evaluator, Mesh v0 refusal, dead Reconcile code, mutation harness crash handling
Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22):
empty/blank schemas-keys still reached the evaluator, the shard snapshot ran
its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only
selection terms, the Mesh one-pass read could return the wrong schema's
record at a shared key, two decode rules for a foreign schema id disagreed,
dead identity-comparison code from the _projectRecord removal was still live,
and a disposed-socket race could abort a mutation run instead of recording
NO VERDICT. This is Self's fix batch for all of it.
- CultNetSelection.Validate now refuses a blank/whitespace entry inside
schemas or keys, not just an empty array. CultNetSelectionEvaluator no
longer treats [] as "no filter" (the door already refuses it before
evaluation). CultNetV0SelectionLowering is the one place that lowers an
empty or all-blank v0 list to null; CreateRawSnapshotResponse and
CreateShardSnapshotResponse both route through it, and CultMesh's own
Clean() now delegates to it instead of duplicating the rule.
- CreateShardSnapshotResponse no longer carries a private loop or its own
schema/key matching: it builds a CultNetSelection and answers through
CultNetDocumentRegistry.SelectPage (the same evaluator path as the
non-shard snapshot), with the shard's own membership check as a row
filter. Binding-alias expansion now runs on both paths identically.
- ResolveDefaultSelection decides "no schema filter" from the cleaned
recordKeys, not the raw array length. OverlaySelection stops deciding what
a missing override means: any explicit schemaIds/recordKeys override
replaces the default selection wholesale; no override keeps the default
untouched.
- CultMeshSnapshotRequestOptions.Selection is now checked by
EnsureV0Compatible before every send: fields, cites, cited, limit, cursor,
descending, and any non-default projection are refused typed
(CultNetSelectionInvalidException), never silently dropped from the v0
wire message. Mesh sending v1 is FU-Mesh-v1, not this cut.
- CultMeshHotBodySubscription.Selection returns a defensive copy on every
read so its arrays can't be mutated out from under the stored selection.
- ReadDocumentFromSnapshotResponse now searches candidates at one recordKey
in one order - exact schema, then alias, then a foreign schema id decoded
from the payload - instead of one OR'd condition where a case-insensitive
or structurally-lucky candidate could win first. The foreign-id decode
reads the payload's embedded schema version with the registry's shared
CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against
the caller's own descriptor directly, rather than resolving to "whichever
registered type owns this schema string" - that resolution is ambiguous
when two document types alias the same schema id (MeshNoteDocument and
MeshNoteAliasDocument in the existing fixtures), which is what broke on
the first pass; TryResolveDescriptorByPayloadSchema stays for its existing
caller (ResolveDescriptorForRawDocument) with a note on when not to use it.
DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode
(RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in
favour of the shared one.
- CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity
comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key
throw in CreateProjectedSnapshot, are deleted - they can no longer fire
now that a projection's dictionary key is always the record's own key.
A new test flips authorizeRecord per key while the whole request stays
authorized (so DemandActive never toggles) and proves both halves of the
remaining diff: the removal of a now-unauthorized key and the add loop
picking up a newly-authorized one.
- Every NetworkingTests.cs test that starts an IsBackground poll thread now
joins it before its server disposes (about 20 tests; one already did).
- scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no
longer throws the whole run away under PowerShell 5.1's
$ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in
a terminating NativeCommandError); Run-Tests catches it and the entry loop
records NO VERDICT instead of aborting every remaining entry.
- Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On
UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard
snapshot paths agree, including shard-membership exclusion of a document
outside the shard's key prefix and a binding schema-id override matching
on both paths.
- Mutation entries updated/added to match: the stale NET-D4-FourthMatcher
entry (its anchor was the private loop this batch deleted, not refactored)
is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's
header miscount ("two of three" loosening entries when there are only
two) is corrected and both previously-surviving loosening mutants
(MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures;
scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering
Reconcile's remaining two mutants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:12:49 +02:00
|
|
|
};
|
|
|
|
|
bool RowFilter(CultDocumentDescriptor descriptor, CultRecordKey key) =>
|
|
|
|
|
!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key);
|
|
|
|
|
|
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#)
Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers
CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message
listeners and Server.cs's pre-verification allowlist gains them;
CultNetDatabaseSubscriptionServer's subscribe path is generalised to a
shared SubscriptionRequest so v0 (lowered) and v1 selections run the
same Subscribe/Watch/Reconcile core, with D6's rule wired in: a
hop-bearing selection runs the full reconcile diff on every change
instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives
both servers a real snapshot watermark.
Fixes the confirmed parity and correctness defects from Soul's
whole-cut pass:
- R-B: cited/cites edges are anchored to the row the direction actually
reports against (the citer under cites, the cited row under cited),
not filtered by "From is on the page" regardless of direction; order
is page-row order then (from, role, to) in code-point order.
- R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/
string.CompareOrdinal in the row tiebreak, cursor position and edge
order, so an astral key sorts the same way here as it does under
Rust's UTF-8 byte comparison.
- R-D: CultCache renders a float/double's exact decimal expansion from
its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble),
never .NET's shortest round-trip form, which rounded a tie the
opposite way from Rust at some values. The now-dead ExpandScientificNotation
is deleted.
- R-E: MatchesCitation compares a cites target through
CultNetSchemaAliasMatching instead of an exact SchemaId string
compare; ToEdge and ToRawRecord now emit the same wire schema id
(WireSchemaId) for the same row, where ToEdge previously used the
descriptor's own id and ToRawRecord the binding's override.
- R-F: CultNetSelectionEvaluator.Select validates first, unconditionally
- the validate:false/true flag on SelectPage/CreateRawSnapshotResponse
is gone, so no public evaluation path can skip the door. Mesh's
EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape
(the declaration-independent half of the door) before its own v0
transport refusals, so Keys=[]/[""] is refused there too.
- R-G: Evaluation.TotalMatched is the selection's whole matching count;
SelectPage's Matched field now reports it instead of the page's row
count. CreateRawSnapshotResponse and CreateShardSnapshotResponse
(v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll,
which evaluates once via CultNetSelectionEvaluator.EvaluateAll and
projects every matched row, instead of looping SelectPage per 200-row
page and re-filtering/re-sorting the whole row set each time.
- R-H: the cursor digest is length-prefixed for every string and list
(AppendString/AppendList) instead of delimiter-joined, closing the
collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"].
- R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical
rendering, not the string getter's culture-dependent ToString().
- R-M: WithBindingSchemaAlias is deleted; both servers' single-change
fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases
instead, the one binding-alias reconciler. The duplicated v0/shard
lower-and-page loop is gone with SelectAll above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
|
|
|
// R-G: one evaluation answers the whole shard snapshot, not a loop that re-filters and
|
|
|
|
|
// re-sorts the shard's row set once per 200-row page.
|
|
|
|
|
var page = _documents.SelectAll(_cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, options: null, rowFilter: RowFilter);
|
2026-05-19 21:07:46 +01:00
|
|
|
|
|
|
|
|
return new CultNetSnapshotResponseRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId,
|
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#)
Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers
CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message
listeners and Server.cs's pre-verification allowlist gains them;
CultNetDatabaseSubscriptionServer's subscribe path is generalised to a
shared SubscriptionRequest so v0 (lowered) and v1 selections run the
same Subscribe/Watch/Reconcile core, with D6's rule wired in: a
hop-bearing selection runs the full reconcile diff on every change
instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives
both servers a real snapshot watermark.
Fixes the confirmed parity and correctness defects from Soul's
whole-cut pass:
- R-B: cited/cites edges are anchored to the row the direction actually
reports against (the citer under cites, the cited row under cited),
not filtered by "From is on the page" regardless of direction; order
is page-row order then (from, role, to) in code-point order.
- R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/
string.CompareOrdinal in the row tiebreak, cursor position and edge
order, so an astral key sorts the same way here as it does under
Rust's UTF-8 byte comparison.
- R-D: CultCache renders a float/double's exact decimal expansion from
its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble),
never .NET's shortest round-trip form, which rounded a tie the
opposite way from Rust at some values. The now-dead ExpandScientificNotation
is deleted.
- R-E: MatchesCitation compares a cites target through
CultNetSchemaAliasMatching instead of an exact SchemaId string
compare; ToEdge and ToRawRecord now emit the same wire schema id
(WireSchemaId) for the same row, where ToEdge previously used the
descriptor's own id and ToRawRecord the binding's override.
- R-F: CultNetSelectionEvaluator.Select validates first, unconditionally
- the validate:false/true flag on SelectPage/CreateRawSnapshotResponse
is gone, so no public evaluation path can skip the door. Mesh's
EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape
(the declaration-independent half of the door) before its own v0
transport refusals, so Keys=[]/[""] is refused there too.
- R-G: Evaluation.TotalMatched is the selection's whole matching count;
SelectPage's Matched field now reports it instead of the page's row
count. CreateRawSnapshotResponse and CreateShardSnapshotResponse
(v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll,
which evaluates once via CultNetSelectionEvaluator.EvaluateAll and
projects every matched row, instead of looping SelectPage per 200-row
page and re-filtering/re-sorting the whole row set each time.
- R-H: the cursor digest is length-prefixed for every string and list
(AppendString/AppendList) instead of delimiter-joined, closing the
collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"].
- R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical
rendering, not the string getter's culture-dependent ToString().
- R-M: WithBindingSchemaAlias is deleted; both servers' single-change
fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases
instead, the one binding-alias reconciler. The duplicated v0/shard
lower-and-page loop is gone with SelectAll above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
|
|
|
Documents = page.Documents ?? Array.Empty<CultNetRawDocumentRecord>(),
|
2026-05-19 21:07:46 +01:00
|
|
|
ShardId = shard.ShardId,
|
|
|
|
|
ShardEpoch = shard.Epoch,
|
|
|
|
|
ShardLogSequence = GetLatestMutationLogSequence(shard.ShardId)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Applies a shard-bounded snapshot and advances the replica cursor to its represented sequence.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<long> ApplyShardSnapshotResponseAsync(
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
CultNetSnapshotResponseRawMessage response)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (shard == null) throw new ArgumentNullException(nameof(shard));
|
|
|
|
|
if (response == null) throw new ArgumentNullException(nameof(response));
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(response.ShardId) &&
|
|
|
|
|
!string.Equals(response.ShardId, shard.ShardId, StringComparison.Ordinal))
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException($"Snapshot for shard '{response.ShardId}' cannot be applied to shard '{shard.ShardId}'.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.ShardEpoch.HasValue && response.ShardEpoch.Value != shard.Epoch)
|
|
|
|
|
{
|
|
|
|
|
throw new CultNetShardAuthorityException(
|
|
|
|
|
shard,
|
|
|
|
|
$"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not snapshot epoch {response.ShardEpoch.Value}.",
|
|
|
|
|
"stale_epoch");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var incomingKeys = response.Documents
|
|
|
|
|
.Where(document => ShardMatchesRawDocument(shard, document))
|
|
|
|
|
.Select(document => document.RecordKey)
|
|
|
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
|
var local = GetLocalShardDocuments(shard).ToArray();
|
|
|
|
|
foreach (var existing in local.Where(item => !incomingKeys.Contains(item.Key.Value)))
|
|
|
|
|
{
|
|
|
|
|
RemoveTrackedDocument(existing.DocumentType, existing.Key);
|
|
|
|
|
PublishUntyped(
|
|
|
|
|
existing.DocumentType,
|
|
|
|
|
CultNetDatabaseChangeKind.Removed,
|
|
|
|
|
existing.Key,
|
|
|
|
|
existing.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document: null,
|
|
|
|
|
previousDocument: existing.Document);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var document in response.Documents.Where(document => ShardMatchesRawDocument(shard, document)))
|
|
|
|
|
{
|
|
|
|
|
var key = new CultRecordKey(document.RecordKey);
|
2026-06-27 21:22:25 +01:00
|
|
|
var descriptor = _documents.ResolveDescriptorForRawDocument(document);
|
2026-05-19 21:07:46 +01:00
|
|
|
var previous = _cache.Get(key);
|
|
|
|
|
var applied = await _documents.ApplyRawDocumentPutMessageAsync(
|
|
|
|
|
_cache,
|
|
|
|
|
new CultNetDocumentPutRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = response.MessageId,
|
|
|
|
|
Document = document,
|
|
|
|
|
ShardId = shard.ShardId,
|
|
|
|
|
ShardEpoch = shard.Epoch
|
|
|
|
|
}).ConfigureAwait(false);
|
|
|
|
|
PublishUntyped(
|
|
|
|
|
descriptor.DocumentType,
|
|
|
|
|
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
|
|
|
|
key,
|
2026-06-27 21:22:25 +01:00
|
|
|
descriptor.SchemaId,
|
2026-05-19 21:07:46 +01:00
|
|
|
shard,
|
|
|
|
|
applied,
|
|
|
|
|
previous);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sequence = response.ShardLogSequence ?? 0;
|
|
|
|
|
SetAppliedShardSequence(shard.ShardId, sequence);
|
|
|
|
|
return sequence;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:05:51 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the last replicated sequence applied for a shard.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public long GetAppliedShardSequence(string shardId)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
|
|
|
|
|
return _appliedShardSequences.TryGetValue(shardId, out var sequence)
|
|
|
|
|
? sequence
|
|
|
|
|
: 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:19:09 +01:00
|
|
|
internal void SetAppliedShardSequence(string shardId, long sequence)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId));
|
|
|
|
|
if (sequence < 0) throw new ArgumentOutOfRangeException(nameof(sequence), "Sequence must be non-negative.");
|
|
|
|
|
_appliedShardSequences[shardId] = sequence;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:34:49 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a shard catalog response for optional schema/key filters.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetShardCatalogResponseMessage CreateShardCatalogResponse(CultNetShardCatalogRequestMessage request)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (request == null) throw new ArgumentNullException(nameof(request));
|
|
|
|
|
|
|
|
|
|
var schemaIds = request.SchemaIds == null || request.SchemaIds.Length == 0
|
|
|
|
|
? null
|
|
|
|
|
: request.SchemaIds;
|
|
|
|
|
var recordKeys = request.RecordKeys == null || request.RecordKeys.Length == 0
|
|
|
|
|
? null
|
|
|
|
|
: request.RecordKeys.Select(key => new CultRecordKey(key)).ToArray();
|
|
|
|
|
|
|
|
|
|
var descriptors = _shards
|
|
|
|
|
.Where(shard => MatchesCatalogFilter(shard, schemaIds, recordKeys))
|
|
|
|
|
.Select(ToMessage)
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
return new CultNetShardCatalogResponseMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = string.IsNullOrWhiteSpace(request.MessageId)
|
|
|
|
|
? Guid.NewGuid().ToString("N")
|
|
|
|
|
: request.MessageId,
|
|
|
|
|
Shards = descriptors
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:40:19 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Resolves the shard that governs the supplied schema and key.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
return ResolveShardInternal(schemaId, key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets a document by key.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task<T?> GetAsync<T>(CultRecordKey key) where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
return Task.FromResult(_cache.Get<T>(key));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 15:09:36 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Gets all documents assignable to the requested type.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public IEnumerable<T> GetAll<T>() where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
return _cache.GetAll<T>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets a typed document by its CultName value.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public T? GetByName<T>(string name) where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
return _cache.GetByName<T>(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets a typed document by an indexed value.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public T? GetByIndex<T>(string alias, string value) where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
return _cache.GetByIndex<T>(alias, value);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Adds or replaces a document at a specific key.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<CultRecordHandle<T>> PutAsync<T>(CultRecordKey key, T document) where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
|
|
|
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
var descriptor = _cache.Registry.GetRequired(document.GetType());
|
2026-06-27 22:04:16 +01:00
|
|
|
var shard = ResolveShardInternal(descriptor, key);
|
2026-05-19 10:44:00 +01:00
|
|
|
EnsurePrimary(shard, descriptor.SchemaId, key);
|
|
|
|
|
|
|
|
|
|
var previous = _cache.Get<T>(key);
|
|
|
|
|
var handle = await _cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
|
2026-08-19 01:36:23 +02:00
|
|
|
void PublishCommittedPut()
|
|
|
|
|
{
|
|
|
|
|
AppendMutationLogEntry(
|
|
|
|
|
shard,
|
|
|
|
|
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
document,
|
|
|
|
|
previous);
|
|
|
|
|
Publish(new CultNetDatabaseChange<T>(
|
|
|
|
|
previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document,
|
|
|
|
|
previous));
|
|
|
|
|
}
|
2026-09-13 16:35:56 +02:00
|
|
|
PublishCommittedPut();
|
2026-05-19 10:44:00 +01:00
|
|
|
return handle;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:30:03 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Applies a locally predicted document for a client-owned input scope.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<CultRecordHandle<T>> PutPredictedAsync<T>(CultRecordKey key, T document) where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (document == null) throw new ArgumentNullException(nameof(document));
|
|
|
|
|
|
Route each document type to one home store; attach hydrates; commit explicit batches
Cut 3 of the CultCache migration (Aetheria docs/cultcache-migration-cut.md, 2.1
and 2.2). One commit, because routing, the batch that replaces the ambient
transaction, and conditional commit share one admission path.
Deleted first:
- globals: InitializeGlobals, MaterializeMissingGlobals, the two-argument
constructor, the initializeGlobals plumbing in CultCacheMessagePack,
ContainsDurableRecord (cache and directory store)
- replication: the push-to-every-store loops and the attach-time push of
existing records
- typeof(T) as the schema owner in AddAsync/UpsertAsync and in
CultNetDatabase.PutAsync/PutPredictedAsync
- the ambient transaction: ExecuteTransactionAsync x2, CultCacheTransaction,
CommitTransaction, VisibleStoredDocuments, the AsyncLocal, the SemaphoreSlim
gate, and every ambient check
- CultCacheOpenOptions.PullOnOpen; OpenAsync is Task.FromResult(Create(...))
- DirectoryMessagePackBackingStore.ResolveLegacyUncataloguedRecordCatalog: the
catalog precommit (fe55bff) is an ancestor of the v4 page format (eea8248),
so no v4 manifest can reach it
- CultCache.DeleteAsync<T>; its one caller, CultNetDatabaseSubscriptionClient,
called it by reflection and now calls Remove(key)
Added:
- AddBackingStore(store, params Type[] homes): attach validates (clean, not
already attached, one untyped store, no claimed home type, no admitted
record changes home) and then pulls under the gate
- Home(type): most derived routed home, else the untyped store
- Admit: every add and remove, from a write, a batch or a load, validates
(home, read-only, loaded-from-home, global singleton), lands durably, then
applies and publishes; a pull hands its whole delta over in one call, so a
refused record leaves the store and the cache unchanged
- read-only stores (CacheBackingStore(readOnly), both store constructors,
CultCacheOpenOptions.ReadOnly); flush skips them and writes only dirty stores
- assignable GetGlobal<T>/GetByName<T>/GetByIndex<T>/Watch<T>; more than one
candidate throws naming them
- Commit(Action<CultCacheBatch>) returning bool, TryCommit returning
CultCommitOutcome, CultCacheBatch (Upsert, Remove, Expect, ExpectUnchanged,
sealed after commit), CultCommitRequest; CacheBackingStore.CommitBatch
evaluates conditions under the store's exclusive lock
- single-file store: <path>.lock sidecar for CommitBatch and PushAll; a clean
store commits onto the file as it is, so records it never loaded survive
- storedAt minted in one place and bumped a tick when not later than the record
it replaces
Consumer adjustments in this repo: Mesh streaming tests drop PullOnOpen; the
Studio bridge stops reflecting on it; BackingStoreTests' global test creates
its global explicitly and its transaction tests are rewritten against Commit
(the two that proved ambient hydration rules are deleted); the pre-Cut 2
refusal test expects the attach to throw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:57:58 +02:00
|
|
|
var descriptor = _cache.Registry.GetRequired(document.GetType());
|
2026-06-27 22:04:16 +01:00
|
|
|
var shard = ResolveShardInternal(descriptor, key);
|
|
|
|
|
EnsureClientAuthority(descriptor, key);
|
2026-05-19 18:30:03 +01:00
|
|
|
|
|
|
|
|
var previous = _cache.Get<T>(key);
|
|
|
|
|
var handle = await _cache.UpsertAsync(document, new CultRecordHandle<T>(key)).ConfigureAwait(false);
|
|
|
|
|
_predictedDocuments[PredictionKey(descriptor.SchemaId, key)] = document;
|
|
|
|
|
Publish(new CultNetDatabaseChange<T>(
|
|
|
|
|
CultNetDatabaseChangeKind.Predicted,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document,
|
|
|
|
|
previous));
|
|
|
|
|
return handle;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Deletes a document by key.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task DeleteAsync<T>(CultRecordKey key) where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
var descriptor = _cache.Registry.GetRequired<T>();
|
2026-06-27 22:04:16 +01:00
|
|
|
var shard = ResolveShardInternal(descriptor, key);
|
2026-05-19 10:44:00 +01:00
|
|
|
EnsurePrimary(shard, descriptor.SchemaId, key);
|
|
|
|
|
|
|
|
|
|
var previous = _cache.Get<T>(key);
|
|
|
|
|
if (previous != null)
|
|
|
|
|
{
|
|
|
|
|
_cache.Remove(new CultRecordHandle<T>(key));
|
2026-08-19 01:36:23 +02:00
|
|
|
void PublishCommittedDelete()
|
|
|
|
|
{
|
|
|
|
|
AppendMutationLogEntry(
|
|
|
|
|
shard,
|
|
|
|
|
CultNetDatabaseChangeKind.Removed,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
document: null,
|
|
|
|
|
previousDocument: previous);
|
|
|
|
|
Publish(new CultNetDatabaseChange<T>(
|
|
|
|
|
CultNetDatabaseChangeKind.Removed,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document: null,
|
|
|
|
|
previousDocument: previous));
|
|
|
|
|
}
|
2026-09-13 16:35:56 +02:00
|
|
|
PublishCommittedDelete();
|
2026-05-19 10:44:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Applies a raw document mutation after checking shard authority.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<object> ApplyPutAsync(CultNetDocumentPutRawMessage message)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (message == null) throw new ArgumentNullException(nameof(message));
|
|
|
|
|
if (message.Document == null)
|
|
|
|
|
{
|
|
|
|
|
throw new ArgumentException("CultNet raw document message is missing its document payload.", nameof(message));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var key = new CultRecordKey(message.Document.RecordKey);
|
2026-06-27 21:22:25 +01:00
|
|
|
var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document);
|
2026-06-27 22:04:16 +01:00
|
|
|
var shard = ResolveShardInternal(descriptor, key);
|
2026-06-27 21:22:25 +01:00
|
|
|
EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch);
|
2026-05-19 10:44:00 +01:00
|
|
|
var previous = _cache.Get(key);
|
|
|
|
|
var document = await _documents.ApplyRawDocumentPutMessageAsync(_cache, message).ConfigureAwait(false);
|
2026-05-19 18:30:03 +01:00
|
|
|
var kind = previous == null ? CultNetDatabaseChangeKind.Added : CultNetDatabaseChangeKind.Updated;
|
|
|
|
|
var predictionKey = PredictionKey(descriptor.SchemaId, key);
|
2026-08-19 01:36:23 +02:00
|
|
|
var reconcilesPrediction = _predictedDocuments.ContainsKey(predictionKey);
|
|
|
|
|
if (reconcilesPrediction)
|
2026-05-19 18:30:03 +01:00
|
|
|
{
|
|
|
|
|
kind = CultNetDatabaseChangeKind.Reconciled;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-19 01:36:23 +02:00
|
|
|
void PublishCommittedPut()
|
|
|
|
|
{
|
|
|
|
|
if (reconcilesPrediction)
|
|
|
|
|
_predictedDocuments.Remove(predictionKey);
|
|
|
|
|
AppendMutationLogEntry(
|
|
|
|
|
shard,
|
|
|
|
|
kind == CultNetDatabaseChangeKind.Reconciled ? CultNetDatabaseChangeKind.Updated : kind,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
document,
|
|
|
|
|
previous,
|
|
|
|
|
new CultNetShardLogEntryMessage
|
|
|
|
|
{
|
|
|
|
|
ChangeKind = previous == null ? "added" : "updated",
|
|
|
|
|
Put = message
|
|
|
|
|
});
|
|
|
|
|
PublishUntyped(
|
|
|
|
|
descriptor.DocumentType,
|
|
|
|
|
kind,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document,
|
|
|
|
|
previous);
|
|
|
|
|
}
|
2026-09-13 16:35:56 +02:00
|
|
|
PublishCommittedPut();
|
2026-05-19 10:44:00 +01:00
|
|
|
return document;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Applies a raw document delete after checking shard authority.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (message == null) throw new ArgumentNullException(nameof(message));
|
|
|
|
|
|
|
|
|
|
var key = new CultRecordKey(message.RecordKey);
|
2026-06-27 22:07:31 +01:00
|
|
|
var descriptor = _documents.ResolveDescriptorForSchemaId(message.SchemaId);
|
|
|
|
|
var shard = ResolveShardInternal(descriptor, key);
|
|
|
|
|
EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch);
|
2026-05-19 10:44:00 +01:00
|
|
|
var previous = _cache.Get(key);
|
|
|
|
|
if (previous == null)
|
|
|
|
|
{
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 19:30:46 +01:00
|
|
|
_cache.Remove(key);
|
2026-08-19 01:36:23 +02:00
|
|
|
void PublishCommittedDelete()
|
|
|
|
|
{
|
|
|
|
|
AppendMutationLogEntry(
|
|
|
|
|
shard,
|
|
|
|
|
CultNetDatabaseChangeKind.Removed,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
document: null,
|
|
|
|
|
previousDocument: previous,
|
|
|
|
|
wireEntry: new CultNetShardLogEntryMessage
|
|
|
|
|
{
|
|
|
|
|
ChangeKind = "removed",
|
|
|
|
|
Delete = message
|
|
|
|
|
});
|
|
|
|
|
PublishUntyped(
|
|
|
|
|
descriptor.DocumentType,
|
|
|
|
|
CultNetDatabaseChangeKind.Removed,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document: null,
|
|
|
|
|
previousDocument: previous);
|
|
|
|
|
}
|
2026-09-13 16:35:56 +02:00
|
|
|
PublishCommittedDelete();
|
2026-05-19 10:44:00 +01:00
|
|
|
return Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:05:51 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Applies committed shard-log entries from an authoritative peer.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task<long> ApplyShardLogResponseAsync(CultNetShardLogResponseMessage response)
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
if (response == null) throw new ArgumentNullException(nameof(response));
|
|
|
|
|
if (response.ResyncRequired)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Shard '{response.ShardId}' requires snapshot resync before log application: {response.Reason ?? "unspecified"}.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var shard = _shards.FirstOrDefault(candidate =>
|
|
|
|
|
string.Equals(candidate.ShardId, response.ShardId, StringComparison.Ordinal));
|
|
|
|
|
if (shard == null)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException($"Shard '{response.ShardId}' is not known by this database.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.ShardEpoch != shard.Epoch)
|
|
|
|
|
{
|
|
|
|
|
throw new CultNetShardAuthorityException(
|
|
|
|
|
shard,
|
|
|
|
|
$"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not response epoch {response.ShardEpoch}.",
|
|
|
|
|
"stale_epoch");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var lastApplied = GetAppliedShardSequence(response.ShardId);
|
|
|
|
|
var orderedEntries = response.Entries.OrderBy(entry => entry.Sequence).ToArray();
|
|
|
|
|
foreach (var entry in orderedEntries)
|
|
|
|
|
{
|
|
|
|
|
if (entry.Sequence <= lastApplied)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entry.Sequence != lastApplied + 1)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Shard '{response.ShardId}' log has a gap. Expected sequence {lastApplied + 1}, received {entry.Sequence}.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ApplyCommittedShardLogEntryAsync(shard, entry).ConfigureAwait(false);
|
|
|
|
|
lastApplied = entry.Sequence;
|
|
|
|
|
_appliedShardSequences[response.ShardId] = lastApplied;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return lastApplied;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Watches all changes assignable to the requested document type.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Observable<CultNetDatabaseChange<T>> Watch<T>() where T : class
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
return _changes
|
|
|
|
|
.Where(change => change is CultNetDatabaseChange<T>)
|
|
|
|
|
.Select(change => (CultNetDatabaseChange<T>)change);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:10:24 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Watches all typed domain changes as boxed change objects.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Observable<object> WatchAllChanges()
|
|
|
|
|
{
|
|
|
|
|
ThrowIfDisposed();
|
|
|
|
|
return _changes;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
/// <summary>
|
|
|
|
|
/// Watches one record.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Observable<CultNetDatabaseChange<T>> WatchRecord<T>(CultRecordKey key) where T : class
|
|
|
|
|
{
|
|
|
|
|
return Watch<T>().Where(change => change.Key.Equals(key));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Watches the global record for a document type.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Observable<CultNetDatabaseChange<T>> WatchGlobal<T>() where T : class
|
|
|
|
|
{
|
|
|
|
|
var descriptor = _cache.Registry.GetRequired<T>();
|
|
|
|
|
return WatchRecord<T>(new CultRecordKey($"global:{descriptor.SchemaId}"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Watches changes for a named document.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Observable<CultNetDatabaseChange<T>> WatchByName<T>(string name) where T : class
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Value must be non-empty.", nameof(name));
|
|
|
|
|
return Watch<T>().Where(change =>
|
|
|
|
|
(change.Document != null && ReferenceEquals(_cache.GetByName<T>(name), change.Document)) ||
|
|
|
|
|
(change.PreviousDocument != null && ReferenceEquals(_cache.GetByName<T>(name), change.PreviousDocument)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Watches changes for the current document mapped by an indexed value.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Observable<CultNetDatabaseChange<T>> WatchByIndex<T>(string alias, string value) where T : class
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(alias)) throw new ArgumentException("Value must be non-empty.", nameof(alias));
|
|
|
|
|
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Value must be non-empty.", nameof(value));
|
|
|
|
|
return Watch<T>().Where(change =>
|
|
|
|
|
(change.Document != null && ReferenceEquals(_cache.GetByIndex<T>(alias, value), change.Document)) ||
|
|
|
|
|
(change.PreviousDocument != null && ReferenceEquals(_cache.GetByIndex<T>(alias, value), change.PreviousDocument)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Releases database subscriptions.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
if (_disposed)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_disposed = true;
|
|
|
|
|
_cache.OnUpdate -= PublishCacheUpdate;
|
|
|
|
|
_changes.Dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void PublishCacheUpdate(object? previous, object? current)
|
|
|
|
|
{
|
|
|
|
|
var document = current ?? previous;
|
|
|
|
|
if (document == null)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var documentType = document.GetType();
|
|
|
|
|
var descriptor = _cache.Registry.GetRequired(documentType);
|
|
|
|
|
var key = current != null
|
|
|
|
|
? GetTrackedKey(current, documentType)
|
|
|
|
|
: new CultRecordKey(string.Empty);
|
2026-06-27 22:04:16 +01:00
|
|
|
var shard = ResolveShardInternal(descriptor, key);
|
2026-05-19 10:44:00 +01:00
|
|
|
var changeType = typeof(CultNetDatabaseChange<>).MakeGenericType(documentType);
|
|
|
|
|
var change = Activator.CreateInstance(
|
|
|
|
|
changeType,
|
|
|
|
|
current == null ? CultNetDatabaseChangeKind.Removed :
|
|
|
|
|
previous == null ? CultNetDatabaseChangeKind.Added :
|
|
|
|
|
CultNetDatabaseChangeKind.Updated,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
current,
|
|
|
|
|
previous,
|
|
|
|
|
null);
|
|
|
|
|
if (change != null)
|
|
|
|
|
{
|
|
|
|
|
_changes.OnNext(change);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private CultRecordKey GetTrackedKey(object document, Type documentType)
|
|
|
|
|
{
|
|
|
|
|
var method = typeof(CultCache).GetMethod(nameof(CultCache.TryGetHandle))!
|
|
|
|
|
.MakeGenericMethod(documentType);
|
|
|
|
|
var handleObject = method.Invoke(_cache, new[] { document });
|
|
|
|
|
if (handleObject == null)
|
|
|
|
|
{
|
|
|
|
|
return new CultRecordKey(string.Empty);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var keyProperty = handleObject.GetType().GetProperty("Key");
|
|
|
|
|
return keyProperty?.GetValue(handleObject) is CultRecordKey key
|
|
|
|
|
? key
|
|
|
|
|
: new CultRecordKey(string.Empty);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 21:07:46 +01:00
|
|
|
private IEnumerable<TrackedShardDocument> GetLocalShardDocuments(CultNetShardDescriptor shard)
|
|
|
|
|
{
|
|
|
|
|
foreach (var document in _cache.AllEntries)
|
|
|
|
|
{
|
|
|
|
|
var documentType = document.GetType();
|
|
|
|
|
var descriptor = _cache.Registry.GetRequired(documentType);
|
|
|
|
|
var key = GetTrackedKey(document, documentType);
|
2026-06-27 22:04:16 +01:00
|
|
|
if (!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor, key))
|
2026-05-19 21:07:46 +01:00
|
|
|
{
|
|
|
|
|
yield return new TrackedShardDocument(documentType, descriptor.SchemaId, key, document);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private bool ShardMatchesRawDocument(CultNetShardDescriptor shard, CultNetRawDocumentRecord document)
|
|
|
|
|
{
|
2026-06-27 21:22:25 +01:00
|
|
|
if (document == null ||
|
|
|
|
|
string.IsNullOrWhiteSpace(document.SchemaId) ||
|
|
|
|
|
string.IsNullOrWhiteSpace(document.RecordKey))
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var descriptor = _documents.ResolveDescriptorForRawDocument(document);
|
2026-06-27 22:04:16 +01:00
|
|
|
return shard.Matches(descriptor, new CultRecordKey(document.RecordKey));
|
2026-06-27 21:22:25 +01:00
|
|
|
}
|
|
|
|
|
catch (InvalidOperationException)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-05-19 21:07:46 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void RemoveTrackedDocument(Type documentType, CultRecordKey key)
|
|
|
|
|
{
|
2026-05-24 19:30:46 +01:00
|
|
|
_cache.Remove(key);
|
2026-05-19 21:07:46 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
private void Publish<T>(CultNetDatabaseChange<T> change) where T : class
|
|
|
|
|
{
|
|
|
|
|
_changes.OnNext(change);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 21:07:46 +01:00
|
|
|
private sealed class TrackedShardDocument
|
|
|
|
|
{
|
|
|
|
|
public TrackedShardDocument(Type documentType, string schemaId, CultRecordKey key, object document)
|
|
|
|
|
{
|
|
|
|
|
DocumentType = documentType;
|
|
|
|
|
SchemaId = schemaId;
|
|
|
|
|
Key = key;
|
|
|
|
|
Document = document;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public Type DocumentType { get; }
|
|
|
|
|
public string SchemaId { get; }
|
|
|
|
|
public CultRecordKey Key { get; }
|
|
|
|
|
public object Document { get; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:52:29 +01:00
|
|
|
private void AppendMutationLogEntry(
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
CultNetDatabaseChangeKind kind,
|
|
|
|
|
CultRecordKey key,
|
|
|
|
|
string schemaId,
|
|
|
|
|
object? document,
|
2026-05-19 19:55:29 +01:00
|
|
|
object? previousDocument,
|
|
|
|
|
CultNetShardLogEntryMessage? wireEntry = null)
|
2026-05-19 17:52:29 +01:00
|
|
|
{
|
2026-05-19 18:05:51 +01:00
|
|
|
var sequence = NextMutationLogSequence(shard.ShardId);
|
2026-05-19 19:55:29 +01:00
|
|
|
var committedAt = DateTimeOffset.UtcNow.ToString("O");
|
|
|
|
|
var entry = new CultNetShardMutationLogEntry(
|
2026-05-19 17:52:29 +01:00
|
|
|
shard.ShardId,
|
|
|
|
|
shard.Epoch,
|
|
|
|
|
sequence,
|
2026-05-19 19:55:29 +01:00
|
|
|
committedAt,
|
2026-05-19 17:52:29 +01:00
|
|
|
kind,
|
|
|
|
|
schemaId,
|
|
|
|
|
key,
|
|
|
|
|
document,
|
2026-05-19 19:55:29 +01:00
|
|
|
previousDocument);
|
|
|
|
|
var storedWireEntry = _mutationLogStore == null
|
|
|
|
|
? null
|
|
|
|
|
: wireEntry ?? ToLogEntryMessage(entry);
|
|
|
|
|
RecordMutationLogEntry(entry, storedWireEntry);
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
_lastWriteSequence[(schemaId, key.Value)] = sequence;
|
2026-05-19 18:05:51 +01:00
|
|
|
}
|
2026-05-19 17:52:29 +01:00
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// The shard-log sequence of the commit that last wrote this row, or null when the row has
|
|
|
|
|
/// never been committed through this database. The order the CultNet selection evaluator
|
|
|
|
|
/// pages by (docs/cultnet-selection-cut.md, section 2).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public long? LastWriteSequence(string schemaId, CultRecordKey key) =>
|
|
|
|
|
_lastWriteSequence.TryGetValue((schemaId, key.Value), out var sequence) ? sequence : null;
|
|
|
|
|
|
CultNet selection cut1 fix batch: R-A/B/C/D/E/F/G/H/J/M (C#)
Wires v1 into both C# servers (R-A): CultNetDatabaseServer registers
CultNetSnapshotRequestV1Message/CultNetDatabaseSubscribeV1Message
listeners and Server.cs's pre-verification allowlist gains them;
CultNetDatabaseSubscriptionServer's subscribe path is generalised to a
shared SubscriptionRequest so v0 (lowered) and v1 selections run the
same Subscribe/Watch/Reconcile core, with D6's rule wired in: a
hop-bearing selection runs the full reconcile diff on every change
instead of the single-row fast path. CultNetDatabase.CurrentAsOf gives
both servers a real snapshot watermark.
Fixes the confirmed parity and correctness defects from Soul's
whole-cut pass:
- R-B: cited/cites edges are anchored to the row the direction actually
reports against (the citer under cites, the cited row under cited),
not filtered by "From is on the page" regardless of direction; order
is page-row order then (from, role, to) in code-point order.
- R-C: a new CultNetCodePointComparer replaces every StringComparer.Ordinal/
string.CompareOrdinal in the row tiebreak, cursor position and edge
order, so an astral key sorts the same way here as it does under
Rust's UTF-8 byte comparison.
- R-D: CultCache renders a float/double's exact decimal expansion from
its IEEE-754 bits via BigInteger (RenderExactFloat/RenderExactDouble),
never .NET's shortest round-trip form, which rounded a tie the
opposite way from Rust at some values. The now-dead ExpandScientificNotation
is deleted.
- R-E: MatchesCitation compares a cites target through
CultNetSchemaAliasMatching instead of an exact SchemaId string
compare; ToEdge and ToRawRecord now emit the same wire schema id
(WireSchemaId) for the same row, where ToEdge previously used the
descriptor's own id and ToRawRecord the binding's override.
- R-F: CultNetSelectionEvaluator.Select validates first, unconditionally
- the validate:false/true flag on SelectPage/CreateRawSnapshotResponse
is gone, so no public evaluation path can skip the door. Mesh's
EnsureV0Compatible runs the new CultNetSelectionValidation.ValidateShape
(the declaration-independent half of the door) before its own v0
transport refusals, so Keys=[]/[""] is refused there too.
- R-G: Evaluation.TotalMatched is the selection's whole matching count;
SelectPage's Matched field now reports it instead of the page's row
count. CreateRawSnapshotResponse and CreateShardSnapshotResponse
(v0/shard full walks) call the new CultNetDocumentRegistry.SelectAll,
which evaluates once via CultNetSelectionEvaluator.EvaluateAll and
projects every matched row, instead of looping SelectPage per 200-row
page and re-filtering/re-sorting the whole row set each time.
- R-H: the cursor digest is length-prefixed for every string and list
(AppendString/AppendList) instead of delimiter-joined, closing the
collision ["a|b"] == ["a","b"] and ["a,b"] == ["a","b"].
- R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical
rendering, not the string getter's culture-dependent ToString().
- R-M: WithBindingSchemaAlias is deleted; both servers' single-change
fast path calls CultNetDocumentRegistry.ExpandSchemaBindingAliases
instead, the one binding-alias reconciler. The duplicated v0/shard
lower-and-page loop is gone with SelectAll above.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:04 +02:00
|
|
|
/// <summary>
|
|
|
|
|
/// The current write-sequence watermark this database can present an exact snapshot for (R-A):
|
|
|
|
|
/// the highest <see cref="LastWriteSequence"/> assigned to any row, 0 when nothing has been
|
|
|
|
|
/// committed yet. This is the evaluator's <c>asOf</c> for a v1 snapshot or subscription page - it
|
|
|
|
|
/// advances on every committed write, so a cursor minted against an older watermark refuses as
|
|
|
|
|
/// <c>cursor_stale</c> once a page's answer could differ.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public ulong CurrentAsOf() => _lastWriteSequence.Count == 0 ? 0UL : (ulong)_lastWriteSequence.Values.Max();
|
|
|
|
|
|
2026-05-19 19:55:29 +01:00
|
|
|
private void RecordMutationLogEntry(
|
|
|
|
|
CultNetShardMutationLogEntry entry,
|
|
|
|
|
CultNetShardLogEntryMessage? wireEntry = null)
|
2026-05-19 18:05:51 +01:00
|
|
|
{
|
|
|
|
|
if (!_mutationLogs.TryGetValue(entry.ShardId, out var entries))
|
2026-05-19 17:52:29 +01:00
|
|
|
{
|
|
|
|
|
entries = new List<CultNetShardMutationLogEntry>();
|
2026-05-19 18:05:51 +01:00
|
|
|
_mutationLogs[entry.ShardId] = entries;
|
2026-05-19 17:52:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
entries.Add(entry);
|
2026-05-19 18:05:51 +01:00
|
|
|
if (!_nextLogSequences.TryGetValue(entry.ShardId, out var next) ||
|
|
|
|
|
next <= entry.Sequence)
|
|
|
|
|
{
|
|
|
|
|
_nextLogSequences[entry.ShardId] = entry.Sequence + 1;
|
|
|
|
|
}
|
2026-05-19 19:55:29 +01:00
|
|
|
|
|
|
|
|
if (_mutationLogStore != null && wireEntry != null)
|
|
|
|
|
{
|
|
|
|
|
_mutationLogStore.Append(entry.ShardId, NormalizeWireLogEntry(entry, wireEntry));
|
|
|
|
|
}
|
2026-05-19 18:05:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private long NextMutationLogSequence(string shardId)
|
|
|
|
|
{
|
|
|
|
|
var sequence = _nextLogSequences.TryGetValue(shardId, out var next)
|
|
|
|
|
? next
|
|
|
|
|
: 1;
|
|
|
|
|
_nextLogSequences[shardId] = sequence + 1;
|
|
|
|
|
return sequence;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 19:55:29 +01:00
|
|
|
private void InitializeLogSequencesFromStore()
|
|
|
|
|
{
|
|
|
|
|
if (_mutationLogStore == null)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var shard in _shards)
|
|
|
|
|
{
|
|
|
|
|
var highest = _mutationLogStore.Read(shard.ShardId)
|
|
|
|
|
.Select(entry => entry.Sequence)
|
|
|
|
|
.DefaultIfEmpty(0)
|
|
|
|
|
.Max();
|
|
|
|
|
if (highest > 0)
|
|
|
|
|
{
|
|
|
|
|
_nextLogSequences[shard.ShardId] = highest + 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:05:51 +01:00
|
|
|
private async Task ApplyCommittedShardLogEntryAsync(
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
CultNetShardLogEntryMessage entry)
|
|
|
|
|
{
|
|
|
|
|
if (entry.Put != null)
|
|
|
|
|
{
|
|
|
|
|
await ApplyCommittedPutAsync(shard, entry).ConfigureAwait(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entry.Delete != null)
|
|
|
|
|
{
|
|
|
|
|
ApplyCommittedDelete(shard, entry);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Shard '{shard.ShardId}' log entry {entry.Sequence} has no put or delete payload.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task ApplyCommittedPutAsync(
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
CultNetShardLogEntryMessage entry)
|
|
|
|
|
{
|
|
|
|
|
var message = entry.Put!;
|
|
|
|
|
if (message.Document == null)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Shard '{shard.ShardId}' log entry {entry.Sequence} put is missing its document payload.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!string.Equals(message.ShardId, shard.ShardId, StringComparison.Ordinal) ||
|
|
|
|
|
message.ShardEpoch != shard.Epoch)
|
|
|
|
|
{
|
|
|
|
|
throw new CultNetShardAuthorityException(
|
|
|
|
|
shard,
|
|
|
|
|
$"Shard '{shard.ShardId}' log entry {entry.Sequence} targets a different shard or epoch.",
|
|
|
|
|
"stale_epoch");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var key = new CultRecordKey(message.Document.RecordKey);
|
2026-06-27 21:22:25 +01:00
|
|
|
var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document);
|
2026-05-19 18:05:51 +01:00
|
|
|
var previous = _cache.Get(key);
|
|
|
|
|
var document = await _documents.ApplyRawDocumentPutMessageAsync(_cache, message).ConfigureAwait(false);
|
|
|
|
|
var kind = ChangeKindFromWire(entry.ChangeKind);
|
|
|
|
|
if (kind == CultNetDatabaseChangeKind.Removed)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException(
|
|
|
|
|
$"Shard '{shard.ShardId}' log entry {entry.Sequence} has a removed kind with a put payload.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:30:03 +01:00
|
|
|
var predictionKey = PredictionKey(descriptor.SchemaId, key);
|
|
|
|
|
if (_predictedDocuments.Remove(predictionKey))
|
|
|
|
|
{
|
|
|
|
|
kind = CultNetDatabaseChangeKind.Reconciled;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:05:51 +01:00
|
|
|
RecordMutationLogEntry(new CultNetShardMutationLogEntry(
|
|
|
|
|
shard.ShardId,
|
|
|
|
|
shard.Epoch,
|
|
|
|
|
entry.Sequence,
|
|
|
|
|
entry.CommittedAt,
|
2026-05-19 18:30:03 +01:00
|
|
|
kind == CultNetDatabaseChangeKind.Reconciled ? CultNetDatabaseChangeKind.Updated : kind,
|
2026-05-19 18:05:51 +01:00
|
|
|
descriptor.SchemaId,
|
|
|
|
|
key,
|
|
|
|
|
document,
|
2026-05-19 19:55:29 +01:00
|
|
|
previous),
|
|
|
|
|
entry);
|
2026-05-19 18:05:51 +01:00
|
|
|
PublishUntyped(
|
|
|
|
|
descriptor.DocumentType,
|
|
|
|
|
kind,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document,
|
|
|
|
|
previous);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ApplyCommittedDelete(
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
CultNetShardLogEntryMessage entry)
|
|
|
|
|
{
|
|
|
|
|
var message = entry.Delete!;
|
|
|
|
|
if (!string.Equals(message.ShardId, shard.ShardId, StringComparison.Ordinal) ||
|
|
|
|
|
message.ShardEpoch != shard.Epoch)
|
|
|
|
|
{
|
|
|
|
|
throw new CultNetShardAuthorityException(
|
|
|
|
|
shard,
|
|
|
|
|
$"Shard '{shard.ShardId}' log entry {entry.Sequence} targets a different shard or epoch.",
|
|
|
|
|
"stale_epoch");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var key = new CultRecordKey(message.RecordKey);
|
|
|
|
|
var descriptor = _cache.Registry.GetRequiredBySchemaId(message.SchemaId);
|
|
|
|
|
var previous = _cache.Get(key);
|
|
|
|
|
if (previous != null)
|
|
|
|
|
{
|
2026-05-24 19:30:46 +01:00
|
|
|
_cache.Remove(key);
|
2026-05-19 18:05:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
RecordMutationLogEntry(new CultNetShardMutationLogEntry(
|
|
|
|
|
shard.ShardId,
|
|
|
|
|
shard.Epoch,
|
|
|
|
|
entry.Sequence,
|
|
|
|
|
entry.CommittedAt,
|
|
|
|
|
CultNetDatabaseChangeKind.Removed,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
key,
|
|
|
|
|
document: null,
|
2026-05-19 19:55:29 +01:00
|
|
|
previousDocument: previous),
|
|
|
|
|
entry);
|
2026-05-19 18:05:51 +01:00
|
|
|
PublishUntyped(
|
|
|
|
|
descriptor.DocumentType,
|
|
|
|
|
CultNetDatabaseChangeKind.Removed,
|
|
|
|
|
key,
|
|
|
|
|
descriptor.SchemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document: null,
|
|
|
|
|
previousDocument: previous);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 19:55:29 +01:00
|
|
|
private CultNetShardLogEntryMessage ToLogEntryMessage(CultNetShardMutationLogEntry entry)
|
|
|
|
|
{
|
|
|
|
|
if (entry.Kind == CultNetDatabaseChangeKind.Removed || entry.Document == null)
|
|
|
|
|
{
|
|
|
|
|
return new CultNetShardLogEntryMessage
|
|
|
|
|
{
|
|
|
|
|
Sequence = entry.Sequence,
|
|
|
|
|
CommittedAt = entry.CommittedAt,
|
|
|
|
|
ChangeKind = "removed",
|
|
|
|
|
Delete = new CultNetDocumentDeleteMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = Guid.NewGuid().ToString("N"),
|
|
|
|
|
SchemaId = entry.SchemaId,
|
|
|
|
|
RecordKey = entry.Key.Value,
|
|
|
|
|
ShardId = entry.ShardId,
|
|
|
|
|
ShardEpoch = entry.ShardEpoch
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new CultNetShardLogEntryMessage
|
|
|
|
|
{
|
|
|
|
|
Sequence = entry.Sequence,
|
|
|
|
|
CommittedAt = entry.CommittedAt,
|
|
|
|
|
ChangeKind = entry.Kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
|
|
|
|
|
Put = new CultNetDocumentPutRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = Guid.NewGuid().ToString("N"),
|
|
|
|
|
Document = CreateRawDocumentRecord(entry.Key, entry.Document),
|
|
|
|
|
ShardId = entry.ShardId,
|
|
|
|
|
ShardEpoch = entry.ShardEpoch
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private CultNetShardLogEntryMessage NormalizeWireLogEntry(
|
|
|
|
|
CultNetShardMutationLogEntry entry,
|
|
|
|
|
CultNetShardLogEntryMessage wireEntry)
|
|
|
|
|
{
|
|
|
|
|
return new CultNetShardLogEntryMessage
|
|
|
|
|
{
|
|
|
|
|
Sequence = entry.Sequence,
|
|
|
|
|
CommittedAt = string.IsNullOrWhiteSpace(entry.CommittedAt) ? wireEntry.CommittedAt : entry.CommittedAt,
|
|
|
|
|
ChangeKind = entry.Kind == CultNetDatabaseChangeKind.Removed
|
|
|
|
|
? "removed"
|
|
|
|
|
: entry.Kind == CultNetDatabaseChangeKind.Added ? "added" : "updated",
|
|
|
|
|
Put = wireEntry.Put == null
|
|
|
|
|
? null
|
|
|
|
|
: new CultNetDocumentPutRawMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = string.IsNullOrWhiteSpace(wireEntry.Put.MessageId)
|
|
|
|
|
? Guid.NewGuid().ToString("N")
|
|
|
|
|
: wireEntry.Put.MessageId,
|
|
|
|
|
Document = wireEntry.Put.Document,
|
|
|
|
|
ShardId = entry.ShardId,
|
|
|
|
|
ShardEpoch = entry.ShardEpoch
|
|
|
|
|
},
|
|
|
|
|
Delete = wireEntry.Delete == null
|
|
|
|
|
? null
|
|
|
|
|
: new CultNetDocumentDeleteMessage
|
|
|
|
|
{
|
|
|
|
|
MessageId = string.IsNullOrWhiteSpace(wireEntry.Delete.MessageId)
|
|
|
|
|
? Guid.NewGuid().ToString("N")
|
|
|
|
|
: wireEntry.Delete.MessageId,
|
|
|
|
|
SchemaId = wireEntry.Delete.SchemaId,
|
|
|
|
|
RecordKey = wireEntry.Delete.RecordKey,
|
|
|
|
|
ShardId = entry.ShardId,
|
|
|
|
|
ShardEpoch = entry.ShardEpoch
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private CultNetRawDocumentRecord CreateRawDocumentRecord(CultRecordKey key, object document)
|
|
|
|
|
{
|
|
|
|
|
var method = typeof(CultNetDocumentRegistry)
|
|
|
|
|
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
|
|
|
|
.Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) &&
|
|
|
|
|
candidate.IsGenericMethodDefinition);
|
|
|
|
|
var documentType = document.GetType();
|
|
|
|
|
var handleType = typeof(CultRecordHandle<>).MakeGenericType(documentType);
|
|
|
|
|
var handle = Activator.CreateInstance(handleType, new object[] { key });
|
|
|
|
|
var message = method
|
|
|
|
|
.MakeGenericMethod(documentType)
|
|
|
|
|
.Invoke(_documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null });
|
|
|
|
|
return ((CultNetDocumentPutRawMessage)message!).Document;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:05:51 +01:00
|
|
|
private static CultNetDatabaseChangeKind ChangeKindFromWire(string changeKind)
|
|
|
|
|
{
|
|
|
|
|
return changeKind switch
|
|
|
|
|
{
|
|
|
|
|
"added" => CultNetDatabaseChangeKind.Added,
|
|
|
|
|
"updated" => CultNetDatabaseChangeKind.Updated,
|
|
|
|
|
"removed" => CultNetDatabaseChangeKind.Removed,
|
|
|
|
|
_ => throw new InvalidOperationException($"Unsupported shard log change kind '{changeKind}'.")
|
|
|
|
|
};
|
2026-05-19 17:52:29 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:30:03 +01:00
|
|
|
private void EnsureClientAuthority(string schemaId, CultRecordKey key)
|
|
|
|
|
{
|
|
|
|
|
if (_clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, schemaId, key)))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var shard = ResolveShardInternal(schemaId, key);
|
|
|
|
|
throw new CultNetShardAuthorityException(
|
|
|
|
|
shard,
|
|
|
|
|
$"Runtime '{_runtimeId}' does not have client prediction authority for schema '{schemaId}' key '{key.Value}'.",
|
|
|
|
|
"not_client_authority");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 22:04:16 +01:00
|
|
|
private void EnsureClientAuthority(CultDocumentDescriptor descriptor, CultRecordKey key)
|
|
|
|
|
{
|
|
|
|
|
if (_clientAuthorityScopes.Any(scope => scope.Matches(_runtimeId, descriptor, key)))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var shard = ResolveShardInternal(descriptor, key);
|
|
|
|
|
throw new CultNetShardAuthorityException(
|
|
|
|
|
shard,
|
|
|
|
|
$"Runtime '{_runtimeId}' does not have client prediction authority for schema '{descriptor.SchemaId}' key '{key.Value}'.",
|
|
|
|
|
"not_client_authority");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 18:30:03 +01:00
|
|
|
private static string PredictionKey(string schemaId, CultRecordKey key)
|
|
|
|
|
{
|
|
|
|
|
return $"{schemaId}:{key.Value}";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
private void PublishUntyped(
|
|
|
|
|
Type documentType,
|
|
|
|
|
CultNetDatabaseChangeKind kind,
|
|
|
|
|
CultRecordKey key,
|
|
|
|
|
string schemaId,
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
object? document,
|
|
|
|
|
object? previousDocument)
|
|
|
|
|
{
|
|
|
|
|
var changeType = typeof(CultNetDatabaseChange<>).MakeGenericType(documentType);
|
|
|
|
|
var change = Activator.CreateInstance(
|
|
|
|
|
changeType,
|
|
|
|
|
kind,
|
|
|
|
|
key,
|
|
|
|
|
schemaId,
|
|
|
|
|
shard,
|
|
|
|
|
document,
|
|
|
|
|
previousDocument,
|
|
|
|
|
null);
|
|
|
|
|
if (change != null)
|
|
|
|
|
{
|
|
|
|
|
_changes.OnNext(change);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:40:19 +01:00
|
|
|
private CultNetShardDescriptor ResolveShardInternal(string schemaId, CultRecordKey key)
|
2026-05-19 10:44:00 +01:00
|
|
|
{
|
|
|
|
|
return _shards.FirstOrDefault(shard => shard.Matches(schemaId, key)) ?? _shards[0];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 22:04:16 +01:00
|
|
|
private CultNetShardDescriptor ResolveShardInternal(CultDocumentDescriptor descriptor, CultRecordKey key)
|
|
|
|
|
{
|
|
|
|
|
return _shards.FirstOrDefault(shard => shard.Matches(descriptor, key)) ?? _shards[0];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:34:49 +01:00
|
|
|
private static void EnsurePrimary(CultNetShardDescriptor shard, string schemaId, CultRecordKey key, long? expectedEpoch = null)
|
2026-05-19 10:44:00 +01:00
|
|
|
{
|
2026-05-19 17:34:49 +01:00
|
|
|
if (expectedEpoch.HasValue && expectedEpoch.Value != shard.Epoch)
|
|
|
|
|
{
|
|
|
|
|
throw new CultNetShardAuthorityException(
|
|
|
|
|
shard,
|
2026-05-19 17:40:19 +01:00
|
|
|
$"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not requested epoch {expectedEpoch.Value}.",
|
|
|
|
|
"stale_epoch");
|
2026-05-19 17:34:49 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
if (shard.IsPrimary)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new CultNetShardAuthorityException(
|
|
|
|
|
shard,
|
2026-05-19 17:40:19 +01:00
|
|
|
$"Shard '{shard.ShardId}' owned by '{shard.OwnerRuntimeId}' does not accept local writes for schema '{schemaId}' key '{key.Value}'.",
|
|
|
|
|
"not_primary");
|
2026-05-19 10:44:00 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-27 21:39:27 +01:00
|
|
|
private bool MatchesCatalogFilter(
|
2026-05-19 17:34:49 +01:00
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
IReadOnlyList<string>? schemaIds,
|
|
|
|
|
IReadOnlyList<CultRecordKey>? recordKeys)
|
|
|
|
|
{
|
2026-06-27 21:39:27 +01:00
|
|
|
var schemaMatches = SchemaMatchesCatalogFilter(shard, schemaIds);
|
2026-05-19 17:34:49 +01:00
|
|
|
var keyMatches = recordKeys == null ||
|
|
|
|
|
recordKeys.Count == 0 ||
|
|
|
|
|
recordKeys.Any(key => string.IsNullOrEmpty(shard.KeyPrefix) ||
|
|
|
|
|
key.Value.StartsWith(shard.KeyPrefix!, StringComparison.Ordinal));
|
|
|
|
|
return schemaMatches && keyMatches;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-27 21:39:27 +01:00
|
|
|
private bool SchemaMatchesCatalogFilter(
|
|
|
|
|
CultNetShardDescriptor shard,
|
|
|
|
|
IReadOnlyList<string>? schemaIds)
|
|
|
|
|
{
|
|
|
|
|
if (schemaIds == null || schemaIds.Count == 0 || shard.SchemaIds.Count == 0)
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
var requestedSchemaIds = schemaIds.ToHashSet(StringComparer.Ordinal);
|
|
|
|
|
if (shard.SchemaIds.Any(requestedSchemaIds.Contains))
|
|
|
|
|
return true;
|
|
|
|
|
|
|
|
|
|
foreach (var shardSchemaId in shard.SchemaIds)
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
CultNet selection cut1, commit 2a: delete _projectRecord, collapse the fourth schema matcher
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.
Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).
Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:44:23 +02:00
|
|
|
if (CultNetSchemaAliasMatching.MatchesAny(schemaIds, _cache.Registry.GetRequiredBySchemaId(shardSchemaId)))
|
2026-06-27 21:39:27 +01:00
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
catch (InvalidOperationException)
|
|
|
|
|
{
|
|
|
|
|
// Shards can advertise schemas that are not loaded in this process.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:34:49 +01:00
|
|
|
internal static CultNetShardDescriptorMessage ToMessage(CultNetShardDescriptor shard)
|
|
|
|
|
{
|
|
|
|
|
return new CultNetShardDescriptorMessage
|
|
|
|
|
{
|
|
|
|
|
ShardId = shard.ShardId,
|
|
|
|
|
OwnerRuntimeId = shard.OwnerRuntimeId,
|
|
|
|
|
Epoch = shard.Epoch,
|
|
|
|
|
IsPrimary = shard.IsPrimary,
|
|
|
|
|
SchemaIds = shard.SchemaIds.ToArray(),
|
|
|
|
|
KeyPrefix = shard.KeyPrefix,
|
|
|
|
|
PrimaryEndpoints = shard.PrimaryEndpoints.ToArray(),
|
|
|
|
|
ReplicaEndpoints = shard.ReplicaEndpoints.ToArray(),
|
|
|
|
|
ReadReplicaEndpoints = shard.ReadReplicaEndpoints.ToArray(),
|
|
|
|
|
Region = shard.Region
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 10:44:00 +01:00
|
|
|
private void ThrowIfDisposed()
|
|
|
|
|
{
|
|
|
|
|
if (_disposed)
|
|
|
|
|
{
|
|
|
|
|
throw new ObjectDisposedException(nameof(CultNetDatabase));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|