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>
This commit is contained in:
parent
b090ca97f9
commit
568e8e356f
14 changed files with 586 additions and 174 deletions
33
scripts/mutate-cultnet-selection-fixbatch2.entries.ps1
Normal file
33
scripts/mutate-cultnet-selection-fixbatch2.entries.ps1
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Entries for scripts/mutate-dotnet.ps1: CultNet typed selection, Cut 1, commit 2 fix batch
|
||||
# (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22). Targets the dead-identity-code deletion
|
||||
# in src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs (S2-2): Reconcile must still walk
|
||||
# its diff when the request stays authorized (not gated on DemandActive changing), and its add loop
|
||||
# must still fire. Test project: tests/GameCult.Networking.Tests.
|
||||
|
||||
@(
|
||||
@{
|
||||
Id = 'NET-RECONCILE-EarlyReturn-Loosening'
|
||||
Rule = 'Reconcile walks the delivered/added diff whenever it runs; it must not early-return merely because request-level authorization (and so DemandActive) has not changed - per-record authorization can still have moved.'
|
||||
Mutant = 'loosening'
|
||||
File = 'src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority'
|
||||
Old = 'if (!_projections.TryGetValue(key, out var projection)) return;
|
||||
var authorized = _authorizeRequest?.Invoke(request, key.Peer) != false;'
|
||||
New = 'if (!_projections.TryGetValue(key, out var projection)) return;
|
||||
var authorized = _authorizeRequest?.Invoke(request, key.Peer) != false;
|
||||
if (projection.DemandActive == authorized) return;'
|
||||
},
|
||||
@{
|
||||
Id = 'NET-RECONCILE-AddLoop-Revert'
|
||||
Rule = 'Reconcile''s add loop sends an upsert (added: true) for every key in the new snapshot that was not already delivered.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority'
|
||||
Old = 'foreach (var current in next.BySourceRecordKey)
|
||||
{
|
||||
if (!projection.DeliveredBySourceRecordKey.ContainsKey(current.Key))
|
||||
SendUpsert(key.Peer, key.Id, current.Value, added: true);
|
||||
}'
|
||||
New = ''
|
||||
}
|
||||
)
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
# Entries for scripts/mutate-dotnet.ps1: CultNet typed selection, Cut 1, commit 2d - the CultMesh
|
||||
# collapse (docs/cultnet-selection-cut.md, sections 4/7/11). Targets: src/GameCult.Mesh/CultMesh.cs
|
||||
# Entries for scripts/mutate-dotnet.ps1: CultNet typed selection, Cut 1, commit 2 fix batch
|
||||
# (docs/cultnet-selection-cut.md, sections 4/7/11). Targets: src/GameCult.Mesh/CultMesh.cs
|
||||
# (ReadDocumentFromSnapshotResponse's one exact read) and src/GameCult.Mesh/CultMeshSnapshots.cs
|
||||
# (ResolveDefaultSelection's "no schema filter when keys are given" rule). Test project:
|
||||
# tests/GameCult.Mesh.Tests.
|
||||
#
|
||||
# Every entry below carries its own File, so this runs as one invocation with both -Target paths.
|
||||
#
|
||||
# Two of the three loosening entries here SURVIVED against the current fixtures (recorded, not
|
||||
# swapped for an easier mutant): MESH-DEFAULT-Loosening and MESH-KEY-Loosening. Both are genuine
|
||||
# weaker readings of the rule they sit beside; no test in tests/GameCult.Mesh.Tests exercises an
|
||||
# explicit empty (non-null) recordKeys array or a record-key case variation, so nothing currently
|
||||
# distinguishes the loosened behavior from the correct one. That is a fixture gap in
|
||||
# CultMeshStreamingTests.cs, not evidence the mutant is unreachable in principle.
|
||||
# There are two loosening entries here (MESH-DEFAULT-Loosening and MESH-KEY-Loosening); the header
|
||||
# above this line previously miscounted them as "two of three" (S2-9/S2-10). Both were genuine
|
||||
# weaker readings of the rule they sit beside and both SURVIVED against the pre-fix-batch fixtures,
|
||||
# because nothing in tests/GameCult.Mesh.Tests exercised an explicit empty (non-null) recordKeys
|
||||
# array or a record-key case variation. CultMeshStreamingTests.cs now carries
|
||||
# FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema (MESH-DEFAULT) and the
|
||||
# case-variant-key block appended to DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments
|
||||
# (MESH-KEY); both mutants below now die.
|
||||
|
||||
@(
|
||||
@{
|
||||
|
|
@ -20,18 +22,20 @@
|
|||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Mesh/CultMeshSnapshots.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.SnapshotEndpoint_ProvidesTypedHandlesAndSchemaAliases'
|
||||
Old = 'var schemas = schemaIds != null
|
||||
Old = 'var cleanedKeys = Clean(recordKeys);
|
||||
var schemas = schemaIds != null
|
||||
? Clean(schemaIds)
|
||||
: (recordKeys is { Count: > 0 } ? null : new[] { descriptor.SchemaId });'
|
||||
New = 'var schemas = schemaIds != null ? Clean(schemaIds) : new[] { descriptor.SchemaId };'
|
||||
: (cleanedKeys != null ? null : new[] { descriptor.SchemaId });'
|
||||
New = 'var cleanedKeys = Clean(recordKeys);
|
||||
var schemas = schemaIds != null ? Clean(schemaIds) : new[] { descriptor.SchemaId };'
|
||||
},
|
||||
@{
|
||||
Id = 'MESH-DEFAULT-Loosening'
|
||||
Rule = 'ResolveDefaultSelection: the no-schema-filter branch triggers on a non-empty recordKeys, not merely a non-null one.'
|
||||
Rule = 'ResolveDefaultSelection: the no-schema-filter branch triggers on a non-empty (post-cleaning) recordKeys, not merely a non-null one - an explicit empty recordKeys array still defaults to the document type''s own schema (docs/cultnet-selection-cut.md, S2-7/rule 1''s v0 lowering).'
|
||||
Mutant = 'loosening'
|
||||
File = 'src/GameCult.Mesh/CultMeshSnapshots.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.SnapshotEndpoint_ProvidesTypedHandlesAndSchemaAliases'
|
||||
Old = 'recordKeys is { Count: > 0 } ? null : new[] { descriptor.SchemaId }'
|
||||
Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema'
|
||||
Old = 'cleanedKeys != null ? null : new[] { descriptor.SchemaId }'
|
||||
New = 'recordKeys != null ? null : new[] { descriptor.SchemaId }'
|
||||
},
|
||||
@{
|
||||
|
|
@ -55,14 +59,14 @@
|
|||
},
|
||||
@{
|
||||
Id = 'MESH-FOREIGN-Revert'
|
||||
Rule = 'ReadDocumentFromSnapshotResponse: a foreign/runtime-generated schema id at the right recordKey still matches when its payload decodes as TDocument.'
|
||||
Rule = 'ReadDocumentFromSnapshotResponse: a foreign/runtime-generated schema id at the right recordKey still matches when its payload''s embedded schema (read by the registry''s shared TryReadSchemaVersion) alias-matches the caller''s own descriptor.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Mesh/CultMesh.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments'
|
||||
Old = 'if (string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) ||
|
||||
CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor) ||
|
||||
TryDecodeAsMessagePack<TDocument>(candidate, out _))'
|
||||
New = 'if (string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) ||
|
||||
CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor))'
|
||||
Old = 'else if (byPayload == null &&
|
||||
CultNetDocumentRegistry.TryReadSchemaVersion(candidate.Payload) is { } payloadSchemaVersion &&
|
||||
CultNetSchemaAliasMatching.Matches(payloadSchemaVersion, descriptor))
|
||||
byPayload = candidate;'
|
||||
New = ''
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -93,21 +93,24 @@
|
|||
New = 'return;'
|
||||
},
|
||||
@{
|
||||
Id = 'NET-D4-FourthMatcher-Revert'
|
||||
Rule = 'D4/Self''s ruling 2026-09-22: CreateShardSnapshotResponse''s schema filter is the one alias matcher (CultNetSchemaAliasMatching.MatchesAny), not a private fourth copy.'
|
||||
# S2-3 (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22, commit 2 fix batch):
|
||||
# CreateShardSnapshotResponse no longer carries its own schema/key filter at all - it went
|
||||
# through CultNetSelectionEvaluator.SelectPage, with the shard's own row filter as the only
|
||||
# thing this method still owns. NET-D4-FourthMatcher-Revert's original anchor
|
||||
# ("requestedSchemaIds != null && !CultNetSchemaAliasMatching.MatchesAny(...)") was the private
|
||||
# loop this fix batch deleted, not merely refactored; the schema-matching mutants it proved are
|
||||
# now covered by this file's CultNetSelectionEvaluator.cs entries (NET-S1/S2 etc.) plus
|
||||
# tests/GameCult.Networking.Tests.NetworkingTests.CultNetDatabase_ShardAndNonShardSnapshot_*.
|
||||
# This entry now proves the one thing CreateShardSnapshotResponse still owns: the shard
|
||||
# membership check.
|
||||
Id = 'NET-D4-ShardMembership-Revert'
|
||||
Rule = 'CreateShardSnapshotResponse''s row filter still enforces shard membership (shard.Matches), even though schema/key filtering is now entirely the evaluator''s.'
|
||||
Mutant = 'revert'
|
||||
File = 'src/GameCult.Networking/CultNetDatabase.cs'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias'
|
||||
Old = '(requestedSchemaIds != null && !CultNetSchemaAliasMatching.MatchesAny(requestedSchemaIds, descriptor)) ||'
|
||||
New = '(requestedSchemaIds != null) ||'
|
||||
Killer = 'FullyQualifiedName=GameCult.Networking.Tests.NetworkingTests.CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnUnfilteredRequest'
|
||||
Old = '!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key);'
|
||||
New = '!string.IsNullOrWhiteSpace(key.Value);'
|
||||
}
|
||||
# A loosening mutant here ('(requestedSchemaIds != null && requestedSchemaIds.Length == 0) ||',
|
||||
# i.e. reject only an empty-but-non-null list rather than "no requested id matches") was run by
|
||||
# hand against CultNetDatabase_Creates_ShardSnapshotResponse_ForCompatibleSchemaAlias and
|
||||
# SURVIVED: the fixture puts exactly one document in the whole cache, so an over-permissive
|
||||
# filter still returns the same single row. This is a finding about the fixture, not a defended
|
||||
# rule - a second document of a different schema in the same shard is needed to make the
|
||||
# loosening fail, and is not added here (no easier mutant substituted per the Hands brief).
|
||||
#
|
||||
# Q-J (docs/cultnet-selection-cut.md section 2 "Numbers", 2026-09-22): the door's canonical-decimal
|
||||
# grammar (CultNetSelection.cs) and the comparator that reads it (CultNetSelectionEvaluator.cs's
|
||||
|
|
|
|||
|
|
@ -58,7 +58,25 @@ function Get-SidecarPath([string]$path) { "$path.mutation-original" }
|
|||
function Run-Tests([string]$filter) {
|
||||
$args = @('test', $testProjectPath, '-c', 'Debug', '--nologo')
|
||||
if ($filter) { $args += @('--filter', $filter) }
|
||||
$output = & dotnet @args 2>&1 | Out-String
|
||||
# A test-host crash (e.g. S2-1's disposed-socket race on a background poll thread) can write to
|
||||
# stderr; under PowerShell 5.1, `2>&1` on a native command wraps each stderr line in a
|
||||
# NativeCommandError, and with the script's own $ErrorActionPreference = 'Stop' that becomes a
|
||||
# terminating error that would otherwise throw the whole run away. Run this one call under
|
||||
# 'Continue' and catch anything that still throws, so a host crash is captured as output and
|
||||
# classified as NO VERDICT by the caller instead of aborting every remaining entry.
|
||||
$previousPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$crashed = $false
|
||||
try {
|
||||
$output = & dotnet @args 2>&1 | Out-String
|
||||
}
|
||||
catch {
|
||||
$output = "$_"
|
||||
$crashed = $true
|
||||
}
|
||||
finally {
|
||||
$ErrorActionPreference = $previousPreference
|
||||
}
|
||||
Write-Host $output
|
||||
$passedMatch = [regex]::Match($output, 'Passed:\s+(\d+)')
|
||||
$failedMatch = [regex]::Match($output, 'Failed:\s+(\d+)')
|
||||
|
|
@ -67,7 +85,8 @@ function Run-Tests([string]$filter) {
|
|||
$failedLine = $output -match 'Failed!'
|
||||
[pscustomobject]@{
|
||||
Compiled = $compiled
|
||||
Green = $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1
|
||||
Crashed = $crashed
|
||||
Green = (-not $crashed) -and $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1
|
||||
Passed = if ($passedMatch.Success) { [int]$passedMatch.Groups[1].Value } else { 0 }
|
||||
Failed = if ($failedMatch.Success) { [int]$failedMatch.Groups[1].Value } else { 0 }
|
||||
}
|
||||
|
|
@ -169,10 +188,18 @@ foreach ($mutation in $mutations) {
|
|||
try {
|
||||
foreach ($file in $files) { Write-Text $targets[$file] $texts[$file] }
|
||||
$run = Run-Tests $mutation.Killer
|
||||
if (-not $run.Compiled) { $verdict = 'NO VERDICT (did not compile)' }
|
||||
if ($run.Crashed) { $verdict = 'NO VERDICT (test host crashed)' }
|
||||
elseif (-not $run.Compiled) { $verdict = 'NO VERDICT (did not compile)' }
|
||||
elseif ($run.Failed -ge 1) { $verdict = 'KILLED' }
|
||||
else { $verdict = 'SURVIVED' }
|
||||
}
|
||||
catch {
|
||||
# Not yet reached by Run-Tests itself (it no longer throws on a host crash), but a defensive
|
||||
# backstop: any other exception during this entry's run is recorded and the campaign continues
|
||||
# rather than aborting every remaining entry, never labeled "unreachable".
|
||||
Write-Host "NO VERDICT: $($mutation.Id) - test run threw: $_"
|
||||
$verdict = 'NO VERDICT (harness exception)'
|
||||
}
|
||||
finally {
|
||||
foreach ($file in $files) {
|
||||
[System.IO.File]::WriteAllBytes($targets[$file], $originalBytes[$file])
|
||||
|
|
|
|||
|
|
@ -2723,10 +2723,16 @@ namespace GameCult.Mesh
|
|||
}
|
||||
|
||||
// One exact read (docs/cultnet-selection-cut.md, section 4/7): the record this recordKey
|
||||
// names, decoded as TDocument when its schema is this schema, a declared alias of it, or
|
||||
// (a foreign/runtime-generated schema id this runtime does not recognize by any alias) its
|
||||
// payload decodes as TDocument. No re-filter of the server's answer by schema alone, and no
|
||||
// fallback that returns a record under the wrong key.
|
||||
// names, decoded as TDocument. Candidates at that key are searched in one order - exact
|
||||
// schema, then a declared alias of it, then (a foreign/runtime-generated schema id this
|
||||
// runtime does not recognize by any alias) its payload's own embedded schema stamp,
|
||||
// alias-matched against TDocument's descriptor. The parse step
|
||||
// (CultNetDocumentRegistry.TryReadSchemaVersion) is the one decode rule shared with
|
||||
// DecodeSnapshotDocuments; the match itself stays a direct alias-match against the caller's
|
||||
// own descriptor rather than a global "which registered type owns this schema string" lookup,
|
||||
// because that lookup is ambiguous when two document types alias the same schema id (as
|
||||
// MeshNoteDocument/MeshNoteAliasDocument do in tests/GameCult.Mesh.Tests). No re-filter of the
|
||||
// server's answer by schema alone, and no fallback that returns a record under the wrong key.
|
||||
private static TDocument ReadDocumentFromSnapshotResponse<TDocument>(
|
||||
CultNetSnapshotResponseRawMessage response,
|
||||
string schemaId,
|
||||
|
|
@ -2735,20 +2741,27 @@ namespace GameCult.Mesh
|
|||
{
|
||||
if (response == null) throw new ArgumentNullException(nameof(response));
|
||||
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
|
||||
CultNetRawDocumentRecord? record = null;
|
||||
|
||||
CultNetRawDocumentRecord? exact = null;
|
||||
CultNetRawDocumentRecord? aliased = null;
|
||||
CultNetRawDocumentRecord? byPayload = null;
|
||||
|
||||
foreach (var candidate in response.Documents)
|
||||
{
|
||||
if (!string.Equals(candidate.RecordKey, recordKey, StringComparison.Ordinal))
|
||||
continue;
|
||||
if (string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal) ||
|
||||
CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor) ||
|
||||
TryDecodeAsMessagePack<TDocument>(candidate, out _))
|
||||
{
|
||||
record = candidate;
|
||||
break;
|
||||
}
|
||||
|
||||
if (exact == null && string.Equals(candidate.SchemaId, schemaId, StringComparison.Ordinal))
|
||||
exact = candidate;
|
||||
else if (aliased == null && CultNetSchemaAliasMatching.Matches(candidate.SchemaId, descriptor))
|
||||
aliased = candidate;
|
||||
else if (byPayload == null &&
|
||||
CultNetDocumentRegistry.TryReadSchemaVersion(candidate.Payload) is { } payloadSchemaVersion &&
|
||||
CultNetSchemaAliasMatching.Matches(payloadSchemaVersion, descriptor))
|
||||
byPayload = candidate;
|
||||
}
|
||||
|
||||
var record = exact ?? aliased ?? byPayload;
|
||||
if (record == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
@ -2764,23 +2777,6 @@ namespace GameCult.Mesh
|
|||
return (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload);
|
||||
}
|
||||
|
||||
private static bool TryDecodeAsMessagePack<TDocument>(CultNetRawDocumentRecord record, out TDocument? document)
|
||||
where TDocument : class
|
||||
{
|
||||
document = null;
|
||||
if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal))
|
||||
return false;
|
||||
try
|
||||
{
|
||||
document = (TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a rudp://host:port endpoint into its host/port parts.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ namespace GameCult.Mesh
|
|||
SubscriptionId = Require(subscriptionId, nameof(subscriptionId));
|
||||
ConsumerRuntimeId = Require(consumerRuntimeId, nameof(consumerRuntimeId));
|
||||
BodyId = Require(bodyId, nameof(bodyId));
|
||||
Selection = new CultNetSelection
|
||||
_selection = new CultNetSelection
|
||||
{
|
||||
Keys = new[]
|
||||
{
|
||||
|
|
@ -215,8 +215,19 @@ namespace GameCult.Mesh
|
|||
public string ConsumerRuntimeId { get; }
|
||||
public string BodyId { get; }
|
||||
|
||||
/// <summary>The view record, the body's latest-publication record, and any additional keys/schemas, as one selection.</summary>
|
||||
public CultNetSelection Selection { get; }
|
||||
private readonly CultNetSelection _selection;
|
||||
|
||||
/// <summary>
|
||||
/// The view record, the body's latest-publication record, and any additional keys/schemas, as
|
||||
/// one selection. Returns a fresh copy on every read: <see cref="CultNetSelection"/>'s arrays
|
||||
/// are wire-mutable by design, and this contract does not let a caller mutate them out from
|
||||
/// under it (docs/cultnet-selection-cut.md, S2-8).
|
||||
/// </summary>
|
||||
public CultNetSelection Selection => new()
|
||||
{
|
||||
Schemas = (string[]?)_selection.Schemas?.Clone(),
|
||||
Keys = (string[]?)_selection.Keys?.Clone()
|
||||
};
|
||||
|
||||
private static string Require(string value, string parameterName) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ namespace GameCult.Mesh
|
|||
(_client as ICultNetSchemaClientHealth)?.BackgroundFailure)
|
||||
.ConfigureAwait(false);
|
||||
var selection = CultMesh.OverlaySelection(_defaults.Selection, schemaIds, recordKeys);
|
||||
CultMesh.EnsureV0Compatible(selection);
|
||||
var options = _defaults with { Selection = selection };
|
||||
var messageId = CultMesh.CreateSnapshotMessageId(options);
|
||||
var completion = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
|
||||
|
|
@ -729,6 +730,7 @@ namespace GameCult.Mesh
|
|||
if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint));
|
||||
|
||||
var resolvedOptions = options ?? new CultMeshSnapshotRequestOptions();
|
||||
EnsureV0Compatible(resolvedOptions.Selection);
|
||||
var messageId = CreateSnapshotMessageId(resolvedOptions);
|
||||
var completion = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
|
@ -819,6 +821,34 @@ namespace GameCult.Mesh
|
|||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses, loudly and typed, any selection term the v0 CultNet wire cannot carry. Mesh keeps
|
||||
/// sending v0 in this cut (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): only
|
||||
/// <c>schemas</c> and <c>keys</c> reach the wire message, so <c>fields</c>, <c>cites</c>,
|
||||
/// <c>cited</c>, <c>limit</c>, <c>cursor</c>, <c>descending</c>, and any non-default
|
||||
/// <c>projection</c> are refused here instead of being silently dropped. A v1-carrying Mesh
|
||||
/// transport is FU-Mesh-v1, not this cut.
|
||||
/// </summary>
|
||||
internal static void EnsureV0Compatible(CultNetSelection? selection)
|
||||
{
|
||||
if (selection == null)
|
||||
return;
|
||||
if (selection.Fields != null)
|
||||
throw new CultNetSelectionInvalidException("fields", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.fields cannot reach the wire (FU-Mesh-v1).");
|
||||
if (selection.Cites != null)
|
||||
throw new CultNetSelectionInvalidException("cites", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cites cannot reach the wire (FU-Mesh-v1).");
|
||||
if (selection.Cited != null)
|
||||
throw new CultNetSelectionInvalidException("cited", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cited cannot reach the wire (FU-Mesh-v1).");
|
||||
if (selection.Limit != null)
|
||||
throw new CultNetSelectionInvalidException("limit", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.limit cannot reach the wire (FU-Mesh-v1).");
|
||||
if (selection.Cursor != null)
|
||||
throw new CultNetSelectionInvalidException("cursor", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.cursor cannot reach the wire (FU-Mesh-v1).");
|
||||
if (selection.Descending)
|
||||
throw new CultNetSelectionInvalidException("descending", null, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.descending cannot reach the wire (FU-Mesh-v1).");
|
||||
if (selection.Projection != CultNetSelectionProjections.Header)
|
||||
throw new CultNetSelectionInvalidException("projection", selection.Projection, "CultMesh sends cultnet.snapshot_request.v0 in this cut; selection.projection cannot reach the wire (FU-Mesh-v1).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves one selection for a typed convenience call: explicit <paramref name="schemaIds"/>
|
||||
/// win; otherwise an explicit, non-empty <paramref name="recordKeys"/> means no schema filter;
|
||||
|
|
@ -830,36 +860,37 @@ namespace GameCult.Mesh
|
|||
IReadOnlyList<string>? recordKeys,
|
||||
CultDocumentDescriptor descriptor)
|
||||
{
|
||||
var cleanedKeys = Clean(recordKeys);
|
||||
var schemas = schemaIds != null
|
||||
? Clean(schemaIds)
|
||||
: (recordKeys is { Count: > 0 } ? null : new[] { descriptor.SchemaId });
|
||||
return new CultNetSelection { Schemas = schemas, Keys = Clean(recordKeys) };
|
||||
: (cleanedKeys != null ? null : new[] { descriptor.SchemaId });
|
||||
return new CultNetSelection { Schemas = schemas, Keys = cleanedKeys };
|
||||
}
|
||||
|
||||
/// <summary>Overlays explicit schema/record-key overrides onto a default selection.</summary>
|
||||
/// <summary>
|
||||
/// Overlays explicit schema/record-key overrides onto a default selection: the caller's
|
||||
/// selection replaces the default wholesale rather than merging field by field, so neither
|
||||
/// side decides what a missing override means (docs/cultnet-selection-cut.md, Self's rulings
|
||||
/// 2026-09-22, S2-7). No override at all keeps the default selection untouched.
|
||||
/// </summary>
|
||||
internal static CultNetSelection OverlaySelection(
|
||||
CultNetSelection? defaults,
|
||||
IReadOnlyList<string>? schemaIds,
|
||||
IReadOnlyList<string>? recordKeys)
|
||||
{
|
||||
if (schemaIds == null && recordKeys == null)
|
||||
return defaults ?? new CultNetSelection();
|
||||
|
||||
return new CultNetSelection
|
||||
{
|
||||
Schemas = schemaIds != null ? Clean(schemaIds) : defaults?.Schemas,
|
||||
Keys = recordKeys != null ? Clean(recordKeys) : defaults?.Keys
|
||||
Schemas = Clean(schemaIds),
|
||||
Keys = Clean(recordKeys)
|
||||
};
|
||||
}
|
||||
|
||||
private static string[]? Clean(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is not { Count: > 0 })
|
||||
return null;
|
||||
|
||||
var filtered = values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return filtered.Length == 0 ? null : filtered;
|
||||
}
|
||||
// The v0-compatibility list cleaning is CultNetV0SelectionLowering's, not a second copy here
|
||||
// (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22).
|
||||
private static string[]? Clean(IReadOnlyList<string>? values) => CultNetV0SelectionLowering.Lower(values);
|
||||
|
||||
internal static string CreateSnapshotMessageId(CultMeshSnapshotRequestOptions options)
|
||||
{
|
||||
|
|
@ -943,7 +974,8 @@ namespace GameCult.Mesh
|
|||
var canDeserializeAsSchemaAlias =
|
||||
CultNetSchemaAliasMatching.Matches(record.SchemaId, descriptor) ||
|
||||
(binding != null && CultNetSchemaAliasMatching.Matches(binding.SchemaId, descriptor)) ||
|
||||
RawSnapshotPayloadMatchesSchema(record.Payload, descriptor);
|
||||
(CultNetDocumentRegistry.TryReadSchemaVersion(record.Payload) is { } payloadSchemaVersion &&
|
||||
CultNetSchemaAliasMatching.Matches(payloadSchemaVersion, descriptor));
|
||||
if (!canDeserializeWithBinding && !canDeserializeAsSchemaAlias)
|
||||
continue;
|
||||
|
||||
|
|
@ -968,47 +1000,5 @@ namespace GameCult.Mesh
|
|||
|
||||
return documents;
|
||||
}
|
||||
|
||||
// Decode-time fallback for a wire schemaId this runtime does not recognize by any alias
|
||||
// (e.g. a foreign/runtime-generated id): trust the payload's own embedded schemaVersion
|
||||
// instead. This is a decode concern, not a selector engine — the server's answer is not
|
||||
// re-filtered here, and schema identity itself still goes through the one alias matcher.
|
||||
private static bool RawSnapshotPayloadMatchesSchema(
|
||||
byte[] payload,
|
||||
CultDocumentDescriptor descriptor)
|
||||
{
|
||||
var schemaVersion = TryReadSchemaVersion(payload);
|
||||
return !string.IsNullOrWhiteSpace(schemaVersion) &&
|
||||
CultNetSchemaAliasMatching.Matches(schemaVersion!, descriptor);
|
||||
}
|
||||
|
||||
private static string? TryReadSchemaVersion(byte[] payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var array = MessagePackSerializer.Deserialize<object[]>(payload, CultNetSchemaMessageSerialization.Options);
|
||||
if (array.Length > 0 && array[0] is string schemaVersion)
|
||||
return schemaVersion;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Fall through to map decoding; different runtimes may encode object-like payloads.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var map = MessagePackSerializer.Deserialize<IReadOnlyDictionary<string, object?>>(payload, CultNetSchemaMessageSerialization.Options);
|
||||
if (map.TryGetValue("schemaVersion", out var schemaVersion) && schemaVersion is string schemaVersionText)
|
||||
return schemaVersionText;
|
||||
if (map.TryGetValue("schema_version", out var snakeSchemaVersion) && snakeSchemaVersion is string snakeSchemaVersionText)
|
||||
return snakeSchemaVersionText;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -628,7 +628,10 @@ namespace GameCult.Networking
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a raw document snapshot bounded to one shard.
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public CultNetSnapshotResponseRawMessage CreateShardSnapshotResponse(
|
||||
CultNetShardDescriptor shard,
|
||||
|
|
@ -638,26 +641,25 @@ namespace GameCult.Networking
|
|||
ThrowIfDisposed();
|
||||
if (shard == null) throw new ArgumentNullException(nameof(shard));
|
||||
|
||||
var requestedSchemaIds = filter?.SchemaIds;
|
||||
var requestedRecordKeys = filter?.RecordKeys != null
|
||||
? new HashSet<string>(filter.RecordKeys, StringComparer.Ordinal)
|
||||
: null;
|
||||
var documents = new List<CultNetRawDocumentRecord>();
|
||||
foreach (var document in _cache.AllEntries)
|
||||
var selection = new CultNetSelection
|
||||
{
|
||||
var documentType = document.GetType();
|
||||
var descriptor = _cache.Registry.GetRequired(documentType);
|
||||
var key = GetTrackedKey(document, documentType);
|
||||
if (string.IsNullOrWhiteSpace(key.Value) ||
|
||||
!shard.Matches(descriptor.SchemaId, key) ||
|
||||
(requestedSchemaIds != null && !CultNetSchemaAliasMatching.MatchesAny(requestedSchemaIds, descriptor)) ||
|
||||
(requestedRecordKeys != null && !requestedRecordKeys.Contains(key.Value)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds),
|
||||
Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys),
|
||||
Projection = CultNetSelectionProjections.Document,
|
||||
Limit = CultNetSelectionEvaluator.LimitMax
|
||||
};
|
||||
bool RowFilter(CultDocumentDescriptor descriptor, CultRecordKey key) =>
|
||||
!string.IsNullOrWhiteSpace(key.Value) && shard.Matches(descriptor.SchemaId, key);
|
||||
|
||||
documents.Add(CreateRawDocumentRecord(key, document));
|
||||
}
|
||||
var documents = new List<CultNetRawDocumentRecord>();
|
||||
string? cursor = null;
|
||||
do
|
||||
{
|
||||
selection.Cursor = cursor;
|
||||
var page = _documents.SelectPage(_cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, validate: false, options: null, rowFilter: RowFilter);
|
||||
documents.AddRange(page.Documents ?? Array.Empty<CultNetRawDocumentRecord>());
|
||||
cursor = page.Next;
|
||||
} while (cursor != null);
|
||||
|
||||
return new CultNetSnapshotResponseRawMessage
|
||||
{
|
||||
|
|
|
|||
|
|
@ -251,6 +251,9 @@ namespace GameCult.Networking
|
|||
? CreateProjectedSnapshot(request, key.Peer)
|
||||
: ProjectedSnapshot.Empty;
|
||||
|
||||
// With _projectRecord gone (docs/cultnet-selection-cut.md, S2-2), a projection's dictionary
|
||||
// key is always the record's own RecordKey/SchemaId, so the two can never disagree here;
|
||||
// the identity branch that used to catch a re-projected key is dead and deleted.
|
||||
foreach (var previous in projection.DeliveredBySourceRecordKey.ToArray())
|
||||
{
|
||||
if (!next.BySourceRecordKey.TryGetValue(previous.Key, out var current))
|
||||
|
|
@ -258,13 +261,7 @@ namespace GameCult.Networking
|
|||
SendRemoval(key.Peer, key.Id, previous.Value);
|
||||
continue;
|
||||
}
|
||||
if (!string.Equals(previous.Value.RecordKey, current.RecordKey, StringComparison.Ordinal) ||
|
||||
!string.Equals(previous.Value.SchemaId, current.SchemaId, StringComparison.Ordinal))
|
||||
{
|
||||
SendRemoval(key.Peer, key.Id, previous.Value);
|
||||
SendUpsert(key.Peer, key.Id, current, added: true);
|
||||
}
|
||||
else if (!Equivalent(previous.Value, current))
|
||||
if (!Equivalent(previous.Value, current))
|
||||
{
|
||||
SendUpsert(key.Peer, key.Id, current, added: false);
|
||||
}
|
||||
|
|
@ -299,16 +296,15 @@ namespace GameCult.Networking
|
|||
SchemaIds = request.SchemaIds,
|
||||
RecordKeys = request.RecordKeys
|
||||
});
|
||||
// The snapshot is one row per record key by construction, so the duplicate-key throw this
|
||||
// used to guard can no longer fire (docs/cultnet-selection-cut.md, S2-2); deleted rather
|
||||
// than tested around.
|
||||
var bySourceRecordKey = new Dictionary<string, CultNetRawDocumentRecord>(StringComparer.Ordinal);
|
||||
var matchedRecordKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var source in snapshot.Documents)
|
||||
{
|
||||
var sourceRecordKey = source.RecordKey;
|
||||
if (_authorizeRecord?.Invoke(request, peer, sourceRecordKey, source.SchemaId) == false)
|
||||
continue;
|
||||
if (!matchedRecordKeys.Add(source.RecordKey))
|
||||
throw new InvalidOperationException(
|
||||
$"Database subscription snapshot produced duplicate record key '{source.RecordKey}'.");
|
||||
bySourceRecordKey[sourceRecordKey] = source;
|
||||
}
|
||||
return new ProjectedSnapshot(bySourceRecordKey);
|
||||
|
|
@ -337,14 +333,8 @@ namespace GameCult.Networking
|
|||
SendUpsert(peer, subscriptionId, current, added: true);
|
||||
return;
|
||||
}
|
||||
if (!string.Equals(previous.RecordKey, current.RecordKey, StringComparison.Ordinal) ||
|
||||
!string.Equals(previous.SchemaId, current.SchemaId, StringComparison.Ordinal))
|
||||
{
|
||||
SendRemoval(peer, subscriptionId, previous);
|
||||
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
|
||||
SendUpsert(peer, subscriptionId, current, added: true);
|
||||
return;
|
||||
}
|
||||
// Same S2-2 fact as Reconcile: previous/current are looked up by the record's own key, so
|
||||
// they can never disagree in RecordKey/SchemaId here either.
|
||||
if (!Equivalent(previous, current))
|
||||
{
|
||||
projection.DeliveredBySourceRecordKey[sourceRecordKey] = current;
|
||||
|
|
|
|||
|
|
@ -285,8 +285,8 @@ namespace GameCult.Networking
|
|||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
var selection = new CultNetSelection
|
||||
{
|
||||
Schemas = filter?.SchemaIds,
|
||||
Keys = filter?.RecordKeys,
|
||||
Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds),
|
||||
Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys),
|
||||
Projection = CultNetSelectionProjections.Document,
|
||||
Limit = CultNetSelectionEvaluator.LimitMax
|
||||
};
|
||||
|
|
@ -350,15 +350,19 @@ namespace GameCult.Networking
|
|||
Func<string, CultRecordKey, long> ordinalOf,
|
||||
ulong asOf,
|
||||
bool validate,
|
||||
CultNetDocumentMessageOptions? options)
|
||||
CultNetDocumentMessageOptions? options,
|
||||
Func<CultDocumentDescriptor, CultRecordKey, bool>? rowFilter = null)
|
||||
{
|
||||
selection = ExpandSchemaBindingAliases(selection);
|
||||
if (validate)
|
||||
selection.Validate(_documents.AllDescriptors.ToArray());
|
||||
|
||||
var rows = cache.AllStoredDocuments
|
||||
.Select(stored => new CultNetSelectionEvaluator.Row(
|
||||
stored.Descriptor, stored.Key, stored.Document, ordinalOf(stored.Descriptor.SchemaId, stored.Key), stored.StoredAt))
|
||||
var stored = cache.AllStoredDocuments;
|
||||
if (rowFilter != null)
|
||||
stored = stored.Where(entry => rowFilter(entry.Descriptor, entry.Key));
|
||||
var rows = stored
|
||||
.Select(entry => new CultNetSelectionEvaluator.Row(
|
||||
entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt))
|
||||
.ToArray();
|
||||
var evaluation = CultNetSelectionEvaluator.Select(_documents, rows, selection, asOf);
|
||||
var records = evaluation.Rows.Select(row => ToRawRecord(row, options)).ToArray();
|
||||
|
|
@ -593,7 +597,19 @@ namespace GameCult.Networking
|
|||
string.Equals(candidate.SchemaName, schemaName, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload)
|
||||
/// <summary>
|
||||
/// Resolves the descriptor this registry's own bindings (or every registered descriptor, when
|
||||
/// none are bound) associate with a raw payload's embedded schema version, when exactly one
|
||||
/// registered type owns that schema string. Used by <see cref="ResolveDescriptorForRawDocument"/>
|
||||
/// to recover a document's type from a foreign/runtime-generated schema id. Ambiguous - and
|
||||
/// so not the right tool - when the caller already knows the target descriptor and two
|
||||
/// registered types alias the same schema id: match <see cref="TryReadSchemaVersion"/>'s
|
||||
/// result against that descriptor with <see cref="CultNetSchemaAliasMatching"/> directly
|
||||
/// instead (docs/cultnet-selection-cut.md, section 4/S2-5; this is what CultMesh's
|
||||
/// foreign-schema decode does). Trusts the payload's own schema stamp; it does not re-run a
|
||||
/// selection or re-check authorization.
|
||||
/// </summary>
|
||||
public CultDocumentDescriptor? TryResolveDescriptorByPayloadSchema(byte[] payload)
|
||||
{
|
||||
var schemaVersion = TryReadSchemaVersion(payload);
|
||||
if (string.IsNullOrWhiteSpace(schemaVersion))
|
||||
|
|
@ -624,7 +640,16 @@ namespace GameCult.Networking
|
|||
.Select(group => group.First());
|
||||
}
|
||||
|
||||
private static string? TryReadSchemaVersion(byte[] payload)
|
||||
/// <summary>
|
||||
/// Reads a raw payload's embedded <c>schemaVersion</c> stamp, when it carries one - the parse
|
||||
/// step of the registry's payload-schema decode rule, shared with
|
||||
/// <see cref="TryResolveDescriptorByPayloadSchema"/> and every "foreign schema id" fallback in
|
||||
/// CultNet and CultMesh (docs/cultnet-selection-cut.md, section 4/S2-5). A caller matching
|
||||
/// against one already-known descriptor (rather than resolving "whichever registered type owns
|
||||
/// this schema string", which is ambiguous when two document types alias the same schema id)
|
||||
/// should alias-match this string with <see cref="CultNetSchemaAliasMatching"/> directly.
|
||||
/// </summary>
|
||||
public static string? TryReadSchemaVersion(byte[] payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
|
|||
|
|
@ -195,6 +195,31 @@ namespace GameCult.Networking
|
|||
public bool HasHop => Cites != null || Cited != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lowers a v0 <c>schemaIds</c>/<c>recordKeys</c> filter into cultnet.selection.v1's null-means-
|
||||
/// every vocabulary (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0's old cleaning
|
||||
/// carried over as the one place that decides it. An absent list, an empty one, or one made only
|
||||
/// of blank entries all lower to null; a mixed list keeps its non-blank entries, deduplicated.
|
||||
/// This is a v0-compatibility rule, not a meaning <see cref="CultNetSelectionEvaluator"/> or
|
||||
/// <see cref="CultNetSelectionValidation"/> carries - v1 selections are refused at the door
|
||||
/// instead (S1).
|
||||
/// </summary>
|
||||
public static class CultNetV0SelectionLowering
|
||||
{
|
||||
/// <summary>Lowers one v0 filter list; see the type summary.</summary>
|
||||
public static string[]? Lower(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is not { Count: > 0 })
|
||||
return null;
|
||||
|
||||
var filtered = values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return filtered.Length == 0 ? null : filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The two wire values <see cref="CultNetSelection.Projection"/> may carry.</summary>
|
||||
public static class CultNetSelectionProjections
|
||||
{
|
||||
|
|
@ -302,6 +327,22 @@ namespace GameCult.Networking
|
|||
throw new CultNetSelectionInvalidException("schemas", null, "selection.schemas is present and empty; omit it to reach every schema.");
|
||||
if (selection.Keys != null && selection.Keys.Length == 0)
|
||||
throw new CultNetSelectionInvalidException("keys", null, "selection.keys is present and empty; omit it to reach every key.");
|
||||
if (selection.Schemas != null)
|
||||
{
|
||||
foreach (var schema in selection.Schemas)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(schema))
|
||||
throw new CultNetSelectionInvalidException("schemas", schema, "selection.schemas carries an empty or whitespace entry.");
|
||||
}
|
||||
}
|
||||
if (selection.Keys != null)
|
||||
{
|
||||
foreach (var key in selection.Keys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
throw new CultNetSelectionInvalidException("keys", key, "selection.keys carries an empty or whitespace entry.");
|
||||
}
|
||||
}
|
||||
if (selection.Projection is not (CultNetSelectionProjections.Header or CultNetSelectionProjections.Document))
|
||||
throw new CultNetSelectionInvalidException("projection", selection.Projection, $"selection.projection \"{selection.Projection}\" is neither \"header\" nor \"document\".");
|
||||
|
||||
|
|
|
|||
|
|
@ -79,9 +79,13 @@ namespace GameCult.Networking
|
|||
|
||||
private static bool MatchesSchemaKeysFields(CultDocumentDescriptor descriptor, CultRecordKey key, object? document, CultNetSelection selection)
|
||||
{
|
||||
if (selection.Schemas is { Length: > 0 } && !CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor))
|
||||
// The door (CultNetSelectionValidation.Validate) refuses an empty schemas/keys list before
|
||||
// a selection ever reaches evaluation, so a non-null array here is always non-empty; the
|
||||
// evaluator does not special-case [] as "no filter" (docs/cultnet-selection-cut.md,
|
||||
// Self's rulings 2026-09-22, S2-3).
|
||||
if (selection.Schemas != null && !CultNetSchemaAliasMatching.MatchesAny(selection.Schemas, descriptor))
|
||||
return false;
|
||||
if (selection.Keys is { Length: > 0 } && !selection.Keys.Contains(key.Value, StringComparer.Ordinal))
|
||||
if (selection.Keys != null && !selection.Keys.Contains(key.Value, StringComparer.Ordinal))
|
||||
return false;
|
||||
if (selection.Fields == null)
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -2745,6 +2745,94 @@ public sealed class CultMeshStreamingTests
|
|||
CultMesh.Verse("starbridge", "unity-pilot"));
|
||||
|
||||
(await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key");
|
||||
|
||||
// MESH-KEY-Loosening (docs/cultnet-selection-cut.md, section 4/S2-4): the recordKey match is
|
||||
// exact (ordinal), not case-insensitive. A case-variant key must not win even when it appears
|
||||
// first in the response.
|
||||
var caseVariantKey = key.Value.ToUpperInvariant();
|
||||
var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot<MeshNoteAliasDocument>(
|
||||
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
|
||||
{
|
||||
MessageId = "case-variant-key",
|
||||
Documents = new[]
|
||||
{
|
||||
new CultNetRawDocumentRecord
|
||||
{
|
||||
SchemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId,
|
||||
RecordKey = caseVariantKey,
|
||||
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
PayloadEncoding = "messagepack",
|
||||
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
||||
new MeshNoteDocument
|
||||
{
|
||||
Schema = "tests.mesh_note.v1",
|
||||
Text = "wrong-case-key",
|
||||
Revision = 13
|
||||
},
|
||||
typeof(MeshNoteDocument))
|
||||
},
|
||||
new CultNetRawDocumentRecord
|
||||
{
|
||||
SchemaId = CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId,
|
||||
RecordKey = key.Value,
|
||||
StoredAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
PayloadEncoding = "messagepack",
|
||||
Payload = CultDocumentMessagePackSerialization.SerializeUntyped(
|
||||
new MeshNoteDocument
|
||||
{
|
||||
Schema = "tests.mesh_note.v1",
|
||||
Text = "right-case-key",
|
||||
Revision = 14
|
||||
},
|
||||
typeof(MeshNoteDocument))
|
||||
}
|
||||
}
|
||||
}),
|
||||
key.Value,
|
||||
CultMesh.Verse("starbridge", "unity-pilot"));
|
||||
|
||||
(await caseVariantKeyHandle.LatestAsync()).Text.Should().Be("right-case-key");
|
||||
}
|
||||
|
||||
// MESH-DEFAULT-Loosening (docs/cultnet-selection-cut.md, section 4/S2-7): ResolveDefaultSelection's
|
||||
// no-schema-filter branch must trigger on a non-empty recordKeys, not merely a non-null one. An
|
||||
// explicit empty recordKeys array is a v0-compatible list that lowers to null (rule 1's v0
|
||||
// lowering), so it does not suppress the type's own schema default - the wire must carry
|
||||
// [T.SchemaId], not schemas=null.
|
||||
[Test]
|
||||
public async Task FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var key = new CultRecordKey("mesh-note:empty-keys-default");
|
||||
await cache.UpsertAsync(new MeshNoteDocument
|
||||
{
|
||||
Schema = "tests.mesh_note.v1",
|
||||
Text = "empty-keys-default",
|
||||
Revision = 1
|
||||
}, new CultRecordHandle<MeshNoteDocument>(key));
|
||||
var registry = new CultNetDocumentRegistry(CultDocumentRegistry.Shared)
|
||||
.Register(CultNetDocumentBinding.ForDocument<MeshNoteDocument>(CultDocumentRegistry.Shared));
|
||||
var requests = new List<CultNetSnapshotRequestMessage>();
|
||||
|
||||
using var session = CultMesh.SnapshotSession(
|
||||
"cultnet://empty-keys-default.test:3075",
|
||||
new CultMeshSnapshotRequestOptions
|
||||
{
|
||||
CreateClient = () => new MeshSnapshotSchemaClient(request =>
|
||||
{
|
||||
requests.Add(request);
|
||||
return registry.CreateRawSnapshotResponse(cache, request.MessageId, request);
|
||||
})
|
||||
},
|
||||
registry);
|
||||
|
||||
var documents = await session.FetchDocumentsAsync<MeshNoteDocument>(recordKeys: Array.Empty<string>());
|
||||
|
||||
documents.Should().ContainSingle().Which.Text.Should().Be("empty-keys-default");
|
||||
requests.Should().ContainSingle();
|
||||
requests[0].SchemaIds.Should().ContainSingle().Which
|
||||
.Should().Be(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId);
|
||||
requests[0].RecordKeys.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -1425,6 +1425,7 @@ namespace GameCult.Networking.Tests
|
|||
|
||||
var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2));
|
||||
await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2));
|
||||
serverThread.Join();
|
||||
|
||||
Assert.That(response.MessageId, Is.EqualTo("rudp-schema-host-test"));
|
||||
Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.host"));
|
||||
|
|
@ -1488,6 +1489,7 @@ namespace GameCult.Networking.Tests
|
|||
await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2));
|
||||
await FetchOnce("second");
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
}
|
||||
|
||||
// A batch is a value: nothing staged in it reaches CultNet observers before the store commits it. A put made
|
||||
|
|
@ -1577,6 +1579,7 @@ namespace GameCult.Networking.Tests
|
|||
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
|
||||
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(update.SubscriptionId, Is.EqualTo("note"));
|
||||
Assert.That(update.ChangeKind, Is.EqualTo("added"));
|
||||
Assert.That(update.Document, Is.Not.Null);
|
||||
|
|
@ -1654,6 +1657,7 @@ namespace GameCult.Networking.Tests
|
|||
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
|
||||
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(update.Document, Is.Not.Null);
|
||||
Assert.That(update.Document!.RecordKey, Is.EqualTo("tests:public:live"));
|
||||
}
|
||||
|
|
@ -1749,11 +1753,98 @@ namespace GameCult.Networking.Tests
|
|||
});
|
||||
await Task.Delay(100);
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
|
||||
Assert.That(changes.Count, Is.EqualTo(changeCountAfterRevocation));
|
||||
Assert.That(targetCache.Get(new CultRecordKey(recordKeyTwo)), Is.Null);
|
||||
}
|
||||
|
||||
// Reconcile's remaining responsibility after the S2-2 deletion (docs/cultnet-selection-cut.md,
|
||||
// Self's rulings 2026-09-22): flip authorizeRecord's answer per key while the whole request
|
||||
// stays authorized (so DemandActive never toggles), and prove both halves of the diff -
|
||||
// removing a now-unauthorized key and the add loop picking up a newly-authorized one.
|
||||
[Test]
|
||||
public async Task DatabaseSubscriptionServer_Reconcile_FlipsPerRecordAuthorizationWithoutChangingRequestAuthority()
|
||||
{
|
||||
const string schemaId = "tests.networking_note";
|
||||
const string recordKeyA = "tests:record-authority:a";
|
||||
const string recordKeyB = "tests:record-authority:b";
|
||||
var authorizedKeys = new HashSet<string>(StringComparer.Ordinal) { recordKeyA };
|
||||
var cache = new CultCache();
|
||||
var database = new CultNetDatabase(cache);
|
||||
await database.PutAsync(new CultRecordKey(recordKeyA), new NetworkSchemaNote
|
||||
{
|
||||
Schema = "tests.networking_note.v1",
|
||||
Text = "a"
|
||||
});
|
||||
await database.PutAsync(new CultRecordKey(recordKeyB), new NetworkSchemaNote
|
||||
{
|
||||
Schema = "tests.networking_note.v1",
|
||||
Text = "b"
|
||||
});
|
||||
|
||||
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
|
||||
{
|
||||
RuntimeId = "database-record-authority-subscription-server",
|
||||
Socket = BindUdpSocket()
|
||||
});
|
||||
using var subscriptions = new CultNetDatabaseSubscriptionServer(
|
||||
server,
|
||||
database,
|
||||
authorizeRequest: (_, _) => true,
|
||||
authorizeRecord: (_, _, recordKey, _) => authorizedKeys.Contains(recordKey));
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var serverThread = new Thread(() =>
|
||||
{
|
||||
while (!cancellation.IsCancellationRequested)
|
||||
{
|
||||
_ = server.PollOnceAsync().GetAwaiter().GetResult();
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
}) { IsBackground = true };
|
||||
serverThread.Start();
|
||||
try
|
||||
{
|
||||
using var client = CultNetSchemaClients.CreateRudp("database-record-authority-subscription-client");
|
||||
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
|
||||
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.OnCultNet<CultNetSnapshotResponseRawMessage>(message => subscribed.TrySetResult(message));
|
||||
client.OnCultNet<CultNetDatabaseChangeRawMessage>(message => changes.Enqueue(message));
|
||||
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
|
||||
await WaitUntilAsync(() => client.Connected, TimeSpan.FromSeconds(2));
|
||||
client.SendCultNet(new CultNetDatabaseSubscribeMessage
|
||||
{
|
||||
MessageId = "subscribe-record-authority",
|
||||
SubscriptionId = "record-authority",
|
||||
ConsumerRuntimeId = "record-authority-client",
|
||||
SchemaIds = new[] { schemaId },
|
||||
IncludeSnapshot = true
|
||||
});
|
||||
var snapshot = await AwaitWithTimeout(subscribed.Task, TimeSpan.FromSeconds(2));
|
||||
Assert.That(snapshot.Documents.Select(document => document.RecordKey), Is.EqualTo(new[] { recordKeyA }));
|
||||
|
||||
authorizedKeys.Clear();
|
||||
authorizedKeys.Add(recordKeyB);
|
||||
subscriptions.Reconcile();
|
||||
|
||||
// SendRemoval carries the removed key on the message's own RecordKey field (no
|
||||
// Document); SendUpsert carries the full record under Document.
|
||||
await WaitUntilAsync(
|
||||
() => changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA) &&
|
||||
changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB),
|
||||
TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.That(changes.Any(change => change.ChangeKind == "removed" && change.RecordKey == recordKeyA), Is.True);
|
||||
Assert.That(changes.Any(change => change.ChangeKind == "added" && change.Document?.RecordKey == recordKeyB), Is.True);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DatabaseSubscriptionServer_ProjectsBodyDemandAndWithdrawalFromExactSubscription()
|
||||
{
|
||||
|
|
@ -1814,6 +1905,7 @@ namespace GameCult.Networking.Tests
|
|||
});
|
||||
var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2));
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(removed.ConsumerRuntimeId, Is.EqualTo("eve-unity"));
|
||||
Assert.That(removed.Active, Is.False);
|
||||
}
|
||||
|
|
@ -1962,6 +2054,7 @@ namespace GameCult.Networking.Tests
|
|||
|
||||
var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2));
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:field:fog" }));
|
||||
Assert.That(observed.SchemaIds, Is.EqualTo(new[] { "gamecult.fields.splats.v1" }));
|
||||
Assert.That(observed.BodyIds, Is.Empty);
|
||||
|
|
@ -2015,6 +2108,7 @@ namespace GameCult.Networking.Tests
|
|||
var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2));
|
||||
await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2));
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
|
||||
Assert.That(removed.SubscriptionId, Is.EqualTo("disconnect-world-body"));
|
||||
Assert.That(removed.Active, Is.False);
|
||||
|
|
@ -2072,6 +2166,7 @@ namespace GameCult.Networking.Tests
|
|||
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
|
||||
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(update.SubscriptionId, Is.EqualTo("notes"));
|
||||
Assert.That(update.Document, Is.TypeOf<NetworkSchemaNote>());
|
||||
Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live"));
|
||||
|
|
@ -2132,6 +2227,7 @@ namespace GameCult.Networking.Tests
|
|||
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
|
||||
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("live"));
|
||||
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
|
||||
}
|
||||
|
|
@ -2196,6 +2292,7 @@ namespace GameCult.Networking.Tests
|
|||
update = await AwaitWithTimeout(updated.Task, TimeSpan.FromSeconds(2));
|
||||
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(update.Text, Is.EqualTo("updated"));
|
||||
Assert.That(value.Current.Text, Is.EqualTo("updated"));
|
||||
}
|
||||
|
|
@ -2246,6 +2343,7 @@ namespace GameCult.Networking.Tests
|
|||
await WaitUntilAsync(() => value.HasValue, TimeSpan.FromSeconds(2));
|
||||
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(value.Current.Text, Is.EqualTo("materialized-on-demand"));
|
||||
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
|
||||
}
|
||||
|
|
@ -2300,6 +2398,7 @@ namespace GameCult.Networking.Tests
|
|||
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
|
||||
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(update.SchemaId, Is.EqualTo(wireSchema));
|
||||
Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("wire-live"));
|
||||
}
|
||||
|
|
@ -2418,6 +2517,7 @@ namespace GameCult.Networking.Tests
|
|||
() => server.Peers.Count == 1 && server.Peers.Single().PendingReliablePacketCount == 0,
|
||||
TimeSpan.FromSeconds(2));
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
Assert.That(response.Documents, Has.Length.EqualTo(1));
|
||||
Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024));
|
||||
Assert.That(server.Stats.BytesSent, Is.LessThan(512 * 1024),
|
||||
|
|
@ -2498,6 +2598,7 @@ namespace GameCult.Networking.Tests
|
|||
if (completed != receivedAll.Task)
|
||||
Assert.Fail($"Concurrent publish timed out after receiving {receivedIds.Count} of {expectedMessages} logical messages.");
|
||||
cancellation.Cancel();
|
||||
serverThread.Join();
|
||||
|
||||
Assert.That(receivedIds.Count, Is.EqualTo(expectedMessages));
|
||||
}
|
||||
|
|
@ -3225,6 +3326,100 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(response.Documents[0].Payload, Is.EqualTo(CultDocumentMessagePackSerialization.SerializeUntyped(note, note.GetType())));
|
||||
}
|
||||
|
||||
// S2-3 (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): the shard snapshot answers
|
||||
// through the same evaluator as the non-shard path, no loop of its own. An unfiltered request
|
||||
// ({schemas:null, keys:null}) must return the same rows from both.
|
||||
[Test]
|
||||
public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnUnfilteredRequest()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var registry = new CultNetDocumentRegistry(cache.Registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
|
||||
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
|
||||
var shard = new CultNetShardDescriptor(
|
||||
"unfiltered-notes",
|
||||
"runtime-a",
|
||||
epoch: 1,
|
||||
isPrimary: true,
|
||||
schemaIds: [descriptor.SchemaId],
|
||||
keyPrefix: "unfiltered-note:",
|
||||
primaryEndpoints: ["cultnet://runtime-a:3080"]);
|
||||
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
||||
{
|
||||
DocumentRegistry = registry,
|
||||
Shards = [shard]
|
||||
});
|
||||
await database.PutAsync(new CultRecordKey("unfiltered-note:one"), new NetworkSchemaNote
|
||||
{
|
||||
Schema = "tests.networking_note.v1",
|
||||
Text = "one"
|
||||
});
|
||||
await database.PutAsync(new CultRecordKey("unfiltered-note:two"), new NetworkSchemaNote
|
||||
{
|
||||
Schema = "tests.networking_note.v1",
|
||||
Text = "two"
|
||||
});
|
||||
// Outside the shard's key prefix: must reach the non-shard snapshot but never the
|
||||
// shard-scoped one, so an unfiltered request still proves shard membership is enforced.
|
||||
await database.PutAsync(new CultRecordKey("other-note:outside-shard"), new NetworkSchemaNote
|
||||
{
|
||||
Schema = "tests.networking_note.v1",
|
||||
Text = "outside"
|
||||
});
|
||||
|
||||
var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-unfiltered", filter: null);
|
||||
var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-unfiltered", filter: null);
|
||||
|
||||
Assert.That(
|
||||
shardScoped.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal),
|
||||
Is.EqualTo(new[] { "unfiltered-note:one", "unfiltered-note:two" }));
|
||||
Assert.That(
|
||||
nonShard.Documents.Select(document => document.RecordKey).OrderBy(value => value, StringComparer.Ordinal),
|
||||
Is.EqualTo(new[] { "other-note:outside-shard", "unfiltered-note:one", "unfiltered-note:two" }));
|
||||
Assert.That(shardScoped.Documents, Has.Length.EqualTo(2));
|
||||
}
|
||||
|
||||
// S2-3, second probe: an override of the binding schema id (rather than the descriptor's own)
|
||||
// must match on both the shard and non-shard paths - both run binding-alias expansion.
|
||||
[Test]
|
||||
public async Task CultNetDatabase_ShardAndNonShardSnapshot_AgreeOnBindingSchemaIdOverride()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var registry = new CultNetDocumentRegistry(cache.Registry)
|
||||
.Register(CultNetDocumentBinding.ForDocument<NetworkSchemaNote>(cache.Registry));
|
||||
var descriptor = cache.Registry.GetRequired<NetworkSchemaNote>();
|
||||
var shard = new CultNetShardDescriptor(
|
||||
"override-notes",
|
||||
"runtime-a",
|
||||
epoch: 1,
|
||||
isPrimary: true,
|
||||
schemaIds: [descriptor.SchemaId],
|
||||
keyPrefix: "override-note:",
|
||||
primaryEndpoints: ["cultnet://runtime-a:3081"]);
|
||||
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
||||
{
|
||||
DocumentRegistry = registry,
|
||||
Shards = [shard]
|
||||
});
|
||||
var key = new CultRecordKey("override-note:one");
|
||||
await database.PutAsync(key, new NetworkSchemaNote
|
||||
{
|
||||
Schema = "tests.networking_note.v1",
|
||||
Text = "override"
|
||||
});
|
||||
|
||||
var request = registry.CreateSnapshotRequest(
|
||||
"snapshot-override-request",
|
||||
schemaIds: ["tests.networking_note.v1"],
|
||||
recordKeys: [key.Value]);
|
||||
var nonShard = registry.CreateRawSnapshotResponse(cache, "snapshot-nonshard-override", request);
|
||||
var shardScoped = database.CreateShardSnapshotResponse(shard, "snapshot-shard-override", request);
|
||||
|
||||
Assert.That(nonShard.Documents, Has.Length.EqualTo(1));
|
||||
Assert.That(shardScoped.Documents, Has.Length.EqualTo(1));
|
||||
Assert.That(shardScoped.Documents[0].RecordKey, Is.EqualTo(nonShard.Documents[0].RecordKey));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultNetDatabase_Routes_TypedWrite_ToShardSchemaAlias()
|
||||
{
|
||||
|
|
@ -3596,6 +3791,7 @@ namespace GameCult.Networking.Tests
|
|||
|
||||
var response = await AwaitWithTimeout(responseCompletion.Task, TimeSpan.FromSeconds(2));
|
||||
await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2));
|
||||
serverThread.Join();
|
||||
|
||||
Assert.That(response.MessageId, Is.EqualTo("rudp-schema-client-test"));
|
||||
Assert.That(response.Schemas.Single().SchemaId, Is.EqualTo("rudp.schema.test"));
|
||||
|
|
@ -3665,6 +3861,7 @@ namespace GameCult.Networking.Tests
|
|||
});
|
||||
var response = await client.FetchAsync($"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}");
|
||||
await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2));
|
||||
serverThread.Join();
|
||||
|
||||
Assert.That(response.Verses.Single().VerseId, Is.EqualTo("rudp-verse"));
|
||||
}
|
||||
|
|
@ -3734,6 +3931,7 @@ namespace GameCult.Networking.Tests
|
|||
$"rudp://127.0.0.1:{((IPEndPoint)serverSocket.LocalEndPoint!).Port}",
|
||||
new CultMeshPeerExchangeRequestMessage { VerseId = "local" });
|
||||
await AwaitWithTimeout(serverDone.Task, TimeSpan.FromSeconds(2));
|
||||
serverThread.Join();
|
||||
|
||||
Assert.That(response.Peers.Single().PeerId, Is.EqualTo("rudp-peer"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue