CultLib/src/GameCult.Mesh/CultMeshSnapshots.cs
Meta Vens 5e33d81e25 R-Q3, R-R: v0 lowering preserves v0's pre-cut answers exactly
CultNetV0SelectionLowering.Lower collapsed a non-null-but-empty filter list to
null ("no filter"). That corrupted two pre-cut v0 behaviours the same
function feeds:

- CultNetDatabase.CreateShardSnapshotResponse and
  CultNetDocumentRegistry.CreateRawSnapshotResponse (b3d9cf7) both test
  `filter?.RecordKeys != null`, not a length, so `recordKeys: []` always
  answered empty. Lowering the empty list to null made it answer every row
  instead. This corrects the ruling from the commit-2 fix batch that lowered
  an empty v0 list to "no filter".
- CultMesh.ResolveDefaultSelection used the same collapse to decide whether an
  explicit empty recordKeys should still fall back to the document type's own
  schema default; under the corrected Lower it must not.

Lower(null) still means "no filter"; Lower(non-null) is always a real
(possibly empty) array. The evaluator already treats a non-null empty Keys
array as "match nothing" (CultNetSelectionEvaluator.MatchesSchemaKeysFields);
only the v0 lowering was erasing that signal before selections reached it.

Also restores the pre-cut requested-key order for
CultNetDocumentRegistry.CreateRawSnapshotResponse: pre-cut, requested record
keys were iterated in the order the caller sent them (a HashSet<string> built
from the array, which enumerates in insertion order); the new shared
evaluator's constant ordinal fell back to schema-and-key sort order instead.
ordinalOf now ranks rows by their position in the requested key list when one
was given.

Rewrote CultMeshStreamingTests.FetchDocumentsAsync_WithExplicitEmptyRecordKeys
(renamed ...AnswersEmpty) to assert the corrected behaviour instead of the
ruling it used to pin. Added
CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty,
CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder and
CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty to
NetworkingTests.cs.

Not done in this commit: CultNetDatabase.CreateShardSnapshotResponse (the
shard-replication snapshot path) still orders by the evaluator's default
schema-and-key tiebreak. Its own pre-cut order was raw cache iteration order,
which the new evaluator has no equivalent for and shard replication does not
appear to depend on row order; flagged as a discrepancy rather than changed
without a test that would pin the "right" order. The side-by-side b3d9cf7 vs
HEAD comparison run the spec asks for was not executed in this pass; the
pre-cut behaviour above comes from reading CultNetDatabase.cs and
CultNetDocumentRegistry.cs at b3d9cf7 directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:13:31 +02:00

1011 lines
48 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Networking;
using MessagePack;
namespace GameCult.Mesh
{
/// <summary>
/// Options for one scoped CultNet snapshot request.
/// </summary>
public sealed record CultMeshSnapshotRequestOptions
{
/// <summary>Gets or sets the selection to request. Absent selects every schema and key.</summary>
public CultNetSelection? Selection { get; set; }
/// <summary>Gets or sets the target shard id, when the endpoint is shard-aware.</summary>
public string? ShardId { get; set; }
/// <summary>Gets or sets the target shard epoch, when the endpoint is shard-aware.</summary>
public long? ShardEpoch { get; set; }
/// <summary>Gets or sets the response timeout.</summary>
public TimeSpan ResponseTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>Gets or sets the connection timeout.</summary>
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>Gets or sets the message id prefix used for snapshot requests.</summary>
public string? MessageIdPrefix { get; set; }
/// <summary>Gets or sets client security options for endpoint-created LiteNetLib clients.</summary>
public ClientSecurityOptions? Security { get; set; }
/// <summary>Gets or sets a callback used to configure endpoint-created LiteNetLib clients.</summary>
public Action<Client>? ConfigureClient { get; set; }
/// <summary>Gets or sets a custom schema client factory.</summary>
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
/// <summary>Gets or sets the runtime id used by endpoint-created RUDP clients.</summary>
public string? RudpRuntimeId { get; set; }
/// <summary>Gets or sets the connection id used by endpoint-created RUDP clients.</summary>
public uint RudpConnectionId { get; set; } = 0x43554c54;
/// <summary>Gets or sets the connect payload used by endpoint-created RUDP clients.</summary>
public string RudpConnectPayload { get; set; } = "cultnet-schema-rudp";
/// <summary>Gets or sets the maximum fragment size used by endpoint-created RUDP clients.</summary>
public int RudpMaxFragmentBytes { get; set; } = 1024;
/// <summary>Gets or sets the resend delay used by endpoint-created RUDP clients.</summary>
public long RudpResendDelayMs { get; set; } = 25;
}
/// <summary>
/// Reuses one connected CultNet schema client for an ordered sequence of snapshot requests.
/// </summary>
public sealed class CultMeshSnapshotSession : IDisposable
{
private readonly string _endpoint;
private readonly CultMeshSnapshotRequestOptions _defaults;
private readonly ICultNetSchemaClient _client;
private readonly CultMeshSession? _sharedSession;
private readonly IDisposable? _responseSubscription;
private readonly IDisposable? _errorSubscription;
private readonly CultNetDocumentRegistry _registry;
private readonly SemaphoreSlim _requests = new(1, 1);
private readonly object _completionLock = new();
private TaskCompletionSource<CultNetSnapshotResponseRawMessage>? _completion;
private string? _messageId;
private bool _disposed;
internal CultMeshSnapshotSession(
string endpoint,
CultMeshSnapshotRequestOptions options,
CultNetDocumentRegistry? registry)
{
_endpoint = string.IsNullOrWhiteSpace(endpoint)
? throw new ArgumentException("Value must be non-empty.", nameof(endpoint))
: endpoint;
_defaults = options with { };
_registry = registry ?? new CultNetDocumentRegistry();
_client = CultMesh.CreateSnapshotClient(endpoint, _defaults)();
_client.OnCultNet<CultNetSnapshotResponseRawMessage>(OnResponse);
_client.OnCultNet<CultNetErrorMessage>(OnError);
var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint);
_client.Connect(host, port);
}
private CultMeshSnapshotSession(
CultMeshSession session,
CultMeshSnapshotRequestOptions options,
CultNetDocumentRegistry? registry)
{
_sharedSession = session ?? throw new ArgumentNullException(nameof(session));
_endpoint = session.State.Path?.Endpoint ?? session.Target.AuthorityRuntimeId;
_defaults = options with { };
_registry = registry ?? new CultNetDocumentRegistry();
_client = session.Channel;
_responseSubscription = session.OnCultNet<CultNetSnapshotResponseRawMessage>(OnResponse);
_errorSubscription = session.OnCultNet<CultNetErrorMessage>(OnError);
}
public static async Task<CultMeshSnapshotSession> ConnectAsync(
CultMeshSessionManager sessions,
CultMeshSessionTarget target,
CultMeshSnapshotRequestOptions options,
CultNetDocumentRegistry? registry = null,
CancellationToken cancellationToken = default)
{
if (sessions == null) throw new ArgumentNullException(nameof(sessions));
var session = await sessions.ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken).ConfigureAwait(false);
return new CultMeshSnapshotSession(session, options ?? new CultMeshSnapshotRequestOptions(), registry);
}
public static async Task<CultMeshSnapshotSession> ConnectAsync(
CultMeshSessionManager sessions,
CultMeshSessionTarget target,
CultMeshProtocolId protocol,
CultMeshSnapshotRequestOptions options,
CultNetDocumentRegistry? registry = null,
CancellationToken cancellationToken = default)
{
if (sessions == null) throw new ArgumentNullException(nameof(sessions));
var session = await sessions.ConnectAsync(target, protocol, cancellationToken).ConfigureAwait(false);
return new CultMeshSnapshotSession(session, options ?? new CultMeshSnapshotRequestOptions(), registry);
}
/// <summary>Fetches one raw snapshot while retaining the underlying endpoint connection.</summary>
public async Task<CultNetSnapshotResponseRawMessage> FetchSnapshotAsync(
IReadOnlyList<string>? schemaIds = null,
IReadOnlyList<string>? recordKeys = null)
{
ThrowIfDisposed();
await _requests.WaitAsync().ConfigureAwait(false);
try
{
await CultMesh.WaitForSnapshotClientConnectionAsync(
_client,
_endpoint,
_defaults.ConnectTimeout,
(_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>(
TaskCreationOptions.RunContinuationsAsynchronously);
lock (_completionLock)
{
_messageId = messageId;
_completion = completion;
}
_client.SendCultNet(new CultNetSnapshotRequestMessage
{
MessageId = messageId,
SchemaIds = selection.Schemas,
RecordKeys = selection.Keys,
ShardId = string.IsNullOrWhiteSpace(options.ShardId) ? null : options.ShardId,
ShardEpoch = options.ShardEpoch
});
return await CultMesh.WaitForSnapshotResponseAsync(
completion.Task,
_endpoint,
options,
messageId,
(_client as ICultNetSchemaClientHealth)?.BackgroundFailure)
.ConfigureAwait(false);
}
finally
{
lock (_completionLock)
{
_messageId = null;
_completion = null;
}
_requests.Release();
}
}
/// <summary>Fetches and decodes typed documents over the retained endpoint connection.</summary>
public async Task<IReadOnlyList<TDocument>> FetchDocumentsAsync<TDocument>(
IReadOnlyList<string>? recordKeys = null,
IReadOnlyList<string>? schemaIds = null)
where TDocument : class
{
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
var selection = CultMesh.ResolveDefaultSelection(schemaIds, recordKeys, descriptor);
var snapshot = await FetchSnapshotAsync(selection.Schemas, selection.Keys).ConfigureAwait(false);
return CultMesh.DecodeSnapshotDocuments<TDocument>(snapshot, _registry);
}
/// <summary>Closes the retained endpoint connection and rejects any active request.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
lock (_completionLock)
_completion?.TrySetException(new ObjectDisposedException(nameof(CultMeshSnapshotSession)));
_responseSubscription?.Dispose();
_errorSubscription?.Dispose();
if (_sharedSession == null) _client.Dispose();
_requests.Dispose();
}
private void OnResponse(CultNetSnapshotResponseRawMessage response)
{
lock (_completionLock)
{
if (string.Equals(response.MessageId, _messageId, StringComparison.Ordinal))
_completion?.TrySetResult(response);
}
}
private void OnError(CultNetErrorMessage error)
{
lock (_completionLock)
_completion?.TrySetException(new InvalidOperationException(error.Error));
}
private void ThrowIfDisposed()
{
if (_disposed) throw new ObjectDisposedException(nameof(CultMeshSnapshotSession));
}
}
/// <summary>
/// Options for a typed remote snapshot surface bound to one CultNet endpoint.
/// </summary>
public sealed class CultMeshSnapshotEndpointOptions
{
/// <summary>Gets or sets the Verse context used by handles from this endpoint.</summary>
public CultMeshVerseContext? Context { get; set; }
/// <summary>Gets or sets the document registry used for raw snapshot payload decoding.</summary>
public CultNetDocumentRegistry? DocumentRegistry { get; set; }
/// <summary>Gets or sets request options applied to each snapshot fetch.</summary>
public CultMeshSnapshotRequestOptions? Request { get; set; }
/// <summary>Gets or sets the source id advertised by diagnostics. Defaults to the endpoint.</summary>
public string? SourceId { get; set; }
/// <summary>Gets or sets the route hint for resulting handles.</summary>
public CultMeshRouteHint? RouteHint { get; set; }
/// <summary>Gets or sets the polling interval for watch fallback.</summary>
public TimeSpan PollInterval { get; set; } = TimeSpan.FromMilliseconds(250);
}
/// <summary>
/// Describes one typed document handle to bind from a snapshot endpoint.
/// </summary>
public interface ICultMeshSnapshotDocumentBinding
{
/// <summary>Gets the CLR document type requested by this binding.</summary>
Type DocumentType { get; }
/// <summary>Gets the remote record key to read.</summary>
string RecordKey { get; }
/// <summary>Gets the optional semantic document id for the resulting handle.</summary>
string? DocumentId { get; }
/// <summary>Binds this document request to an endpoint-backed handle.</summary>
ICultMeshDocumentHandle Bind(CultMeshSnapshotEndpoint endpoint);
/// <summary>Binds this document request to an endpoint-backed handle that syncs into a local node before returning values.</summary>
ICultMeshDocumentHandle BindSynced(CultMeshSnapshotEndpoint endpoint, CultMeshNode node, bool flush);
}
/// <summary>
/// Describes one typed document handle to bind from a snapshot endpoint.
/// </summary>
public sealed class CultMeshSnapshotDocumentBinding<TDocument> : ICultMeshSnapshotDocumentBinding
where TDocument : class
{
internal CultMeshSnapshotDocumentBinding(string recordKey, string? documentId)
{
RecordKey = string.IsNullOrWhiteSpace(recordKey)
? throw new ArgumentException("Value must be non-empty.", nameof(recordKey))
: recordKey;
DocumentId = documentId;
}
/// <inheritdoc />
public Type DocumentType => typeof(TDocument);
/// <inheritdoc />
public string RecordKey { get; }
/// <inheritdoc />
public string? DocumentId { get; }
/// <inheritdoc />
public ICultMeshDocumentHandle Bind(CultMeshSnapshotEndpoint endpoint)
{
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
return endpoint.Document<TDocument>(RecordKey, DocumentId);
}
/// <inheritdoc />
public ICultMeshDocumentHandle BindSynced(CultMeshSnapshotEndpoint endpoint, CultMeshNode node, bool flush)
{
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));
return endpoint.SyncedDocument<TDocument>(node, RecordKey, DocumentId, flush);
}
}
/// <summary>
/// Result from syncing a remote snapshot into a local CultMesh node.
/// </summary>
public sealed class CultMeshSnapshotSyncResult
{
internal CultMeshSnapshotSyncResult(
CultNetSnapshotResponseRawMessage snapshot,
IReadOnlyList<object> appliedDocuments)
{
Snapshot = snapshot ?? throw new ArgumentNullException(nameof(snapshot));
AppliedDocuments = appliedDocuments ?? throw new ArgumentNullException(nameof(appliedDocuments));
}
/// <summary>Gets the raw snapshot that was fetched from the endpoint.</summary>
public CultNetSnapshotResponseRawMessage Snapshot { get; }
/// <summary>Gets the documents applied into the local node cache.</summary>
public IReadOnlyList<object> AppliedDocuments { get; }
/// <summary>Gets the number of documents applied into the local node cache.</summary>
public int AppliedCount => AppliedDocuments.Count;
/// <summary>Gets the source shard log sequence reported by the snapshot, when present.</summary>
public long ShardLogSequence => Snapshot.ShardLogSequence ?? 0L;
}
/// <summary>
/// Snapshot endpoint view with a local node sync policy already bound.
/// </summary>
public sealed class CultMeshSyncedSnapshotEndpoint
{
internal CultMeshSyncedSnapshotEndpoint(
CultMeshSnapshotEndpoint endpoint,
CultMeshNode node,
bool flush)
{
Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint));
Node = node ?? throw new ArgumentNullException(nameof(node));
Flush = flush;
}
/// <summary>Gets the remote endpoint that provides snapshots.</summary>
public CultMeshSnapshotEndpoint Endpoint { get; }
/// <summary>Gets the local node snapshots are synced into.</summary>
public CultMeshNode Node { get; }
/// <summary>Gets whether each sync should flush the local node afterward.</summary>
public bool Flush { get; }
/// <summary>Creates a typed document handle that syncs into the local node before returning values.</summary>
public CultMeshDocumentHandle<TDocument> Document<TDocument>(
string recordKey,
string? documentId = null)
where TDocument : class
{
return Endpoint.SyncedDocument<TDocument>(Node, recordKey, documentId, Flush);
}
/// <summary>Creates a schema-aware catalog from typed endpoint bindings that sync into the local node before returning values.</summary>
public CultMeshDocumentCatalog Documents(params ICultMeshSnapshotDocumentBinding[] documents)
{
return Endpoint.SyncedDocuments(Node, Flush, documents);
}
}
/// <summary>
/// Typed snapshot surface for one remote CultNet endpoint.
/// </summary>
public sealed class CultMeshSnapshotEndpoint
{
internal CultMeshSnapshotEndpoint(string endpoint, CultMeshSnapshotEndpointOptions? options)
{
Endpoint = string.IsNullOrWhiteSpace(endpoint)
? throw new ArgumentException("Value must be non-empty.", nameof(endpoint))
: endpoint;
var resolvedOptions = options ?? new CultMeshSnapshotEndpointOptions();
Context = resolvedOptions.Context ?? CultMesh.Verse("remote", "cultmesh-snapshot-client").Context;
DocumentRegistry = resolvedOptions.DocumentRegistry ?? new CultNetDocumentRegistry();
Request = resolvedOptions.Request is null ? new CultMeshSnapshotRequestOptions() : resolvedOptions.Request with { };
RouteHint = resolvedOptions.RouteHint ?? new CultMeshRouteHint(CultMeshLocalityKind.Network, Endpoint);
SourceId = string.IsNullOrWhiteSpace(resolvedOptions.SourceId) ? Endpoint : resolvedOptions.SourceId!;
PollInterval = resolvedOptions.PollInterval;
}
/// <summary>Gets the endpoint this typed surface reads from.</summary>
public string Endpoint { get; }
/// <summary>Gets the Verse context used by document handles from this endpoint.</summary>
public CultMeshVerseContext Context { get; }
/// <summary>Gets the document registry used for raw snapshot payload decoding.</summary>
public CultNetDocumentRegistry DocumentRegistry { get; }
/// <summary>Gets request options applied to each snapshot fetch.</summary>
public CultMeshSnapshotRequestOptions Request { get; }
/// <summary>Gets the source id advertised by diagnostics.</summary>
public string SourceId { get; }
/// <summary>Gets the route hint for resulting handles.</summary>
public CultMeshRouteHint RouteHint { get; }
/// <summary>Gets the polling interval for watch fallback.</summary>
public TimeSpan PollInterval { get; }
/// <summary>Returns a view of this endpoint whose typed document handles sync into the supplied local node.</summary>
public CultMeshSyncedSnapshotEndpoint SyncTo(CultMeshNode node, bool flush = false)
{
return new CultMeshSyncedSnapshotEndpoint(this, node, flush);
}
/// <summary>Fetches one raw snapshot with this endpoint's configured request policy.</summary>
public Task<CultNetSnapshotResponseRawMessage> FetchSnapshotAsync(
IReadOnlyList<string>? schemaIds = null,
IReadOnlyList<string>? recordKeys = null)
{
return CultMesh.FetchSnapshotAsync(Endpoint, CreateRequest(schemaIds, recordKeys));
}
/// <summary>Fetches one snapshot and applies it into the node's cache.</summary>
public async Task<CultMeshSnapshotSyncResult> SyncSnapshotAsync(
CultMeshNode node,
IReadOnlyList<string>? schemaIds = null,
IReadOnlyList<string>? recordKeys = null,
bool flush = false)
{
if (node == null) throw new ArgumentNullException(nameof(node));
var snapshot = await FetchSnapshotAsync(schemaIds, recordKeys).ConfigureAwait(false);
var applied = await node.Database.Documents.ApplyRawSnapshotResponseAsync(node.Cache, snapshot)
.ConfigureAwait(false);
if (flush)
await node.FlushAsync().ConfigureAwait(false);
return new CultMeshSnapshotSyncResult(snapshot, applied);
}
/// <summary>Fetches and decodes documents assignable to the requested type or matching its schema.</summary>
public Task<IReadOnlyList<TDocument>> FetchDocumentsAsync<TDocument>(
IReadOnlyList<string>? recordKeys = null,
IReadOnlyList<string>? schemaIds = null)
where TDocument : class
{
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
var selection = CultMesh.ResolveDefaultSelection(schemaIds, recordKeys, descriptor);
return CultMesh.FetchSnapshotDocumentsAsync<TDocument>(
Endpoint,
CreateRequest(selection.Schemas, selection.Keys),
DocumentRegistry);
}
/// <summary>Fetches typed documents and syncs their raw snapshot into the node's cache.</summary>
public async Task<IReadOnlyList<TDocument>> SyncDocumentsAsync<TDocument>(
CultMeshNode node,
IReadOnlyList<string>? recordKeys = null,
IReadOnlyList<string>? schemaIds = null,
bool flush = false)
where TDocument : class
{
if (node == null) throw new ArgumentNullException(nameof(node));
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
var selection = CultMesh.ResolveDefaultSelection(schemaIds, recordKeys, descriptor);
var result = await SyncSnapshotAsync(
node,
selection.Schemas,
selection.Keys,
flush)
.ConfigureAwait(false);
return CultMesh.DecodeSnapshotDocuments<TDocument>(result.Snapshot, DocumentRegistry);
}
/// <summary>Fetches one typed document by record key.</summary>
public async Task<TDocument> FetchDocumentAsync<TDocument>(string recordKey)
where TDocument : class
{
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey));
var documents = await FetchDocumentsAsync<TDocument>(new[] { recordKey }).ConfigureAwait(false);
return documents.FirstOrDefault()
?? throw new InvalidOperationException(
$"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'.");
}
/// <summary>Fetches one typed document by record key and syncs it into the node's cache.</summary>
public async Task<TDocument> SyncDocumentAsync<TDocument>(
CultMeshNode node,
string recordKey,
bool flush = false)
where TDocument : class
{
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey));
var documents = await SyncDocumentsAsync<TDocument>(node, new[] { recordKey }, flush: flush)
.ConfigureAwait(false);
return documents.FirstOrDefault()
?? throw new InvalidOperationException(
$"CultNet snapshot endpoint '{Endpoint}' did not return {typeof(TDocument).FullName} record '{recordKey}'.");
}
/// <summary>Creates a typed document handle that fetches one endpoint record and syncs it into a local node before returning values.</summary>
public CultMeshDocumentHandle<TDocument> SyncedDocument<TDocument>(
CultMeshNode node,
string recordKey,
string? documentId = null,
bool flush = false)
where TDocument : class
{
if (node == null) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey));
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
var sources = new[]
{
CultMesh.ProjectionSource($"{SourceId}:{recordKey}", descriptor.SchemaId, "synced CultNet snapshot endpoint")
};
var watch = CultMesh.PollingQueryWatcher<CultMeshDocumentQueryParameters, TDocument>(
async (_parameters, _context) => await SyncDocumentAsync<TDocument>(node, recordKey, flush).ConfigureAwait(false),
new CultMeshPollingWatchOptions<TDocument>(PollInterval));
return CultMesh.Document<TDocument>(
string.IsNullOrWhiteSpace(documentId) ? recordKey : documentId!,
Context,
_ => SyncDocumentAsync<TDocument>(node, recordKey, flush),
queryContext => watch(CultMeshDocumentQueryParameters.Empty, queryContext),
sources,
RouteHint);
}
/// <summary>Creates a typed document handle over one remote endpoint record.</summary>
public CultMeshDocumentHandle<TDocument> Document<TDocument>(
string recordKey,
string? documentId = null)
where TDocument : class
{
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey));
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
var sources = new[]
{
CultMesh.ProjectionSource($"{SourceId}:{recordKey}", descriptor.SchemaId, "CultNet snapshot endpoint")
};
var watch = CultMesh.PollingQueryWatcher<CultMeshDocumentQueryParameters, TDocument>(
async (_parameters, _context) => await FetchDocumentAsync<TDocument>(recordKey).ConfigureAwait(false),
new CultMeshPollingWatchOptions<TDocument>(PollInterval));
return CultMesh.Document<TDocument>(
string.IsNullOrWhiteSpace(documentId) ? recordKey : documentId!,
Context,
_ => FetchDocumentAsync<TDocument>(recordKey),
queryContext => watch(CultMeshDocumentQueryParameters.Empty, queryContext),
sources,
RouteHint);
}
/// <summary>Creates a schema-aware catalog over endpoint-backed document handles.</summary>
public CultMeshDocumentCatalog Documents(params ICultMeshDocumentHandle[] documents)
{
return CultMesh.Documents(documents);
}
/// <summary>Creates a schema-aware catalog from endpoint-backed typed document bindings.</summary>
public CultMeshDocumentCatalog Documents(params ICultMeshSnapshotDocumentBinding[] documents)
{
if (documents == null) throw new ArgumentNullException(nameof(documents));
return CultMesh.Documents(documents.Select(document =>
{
if (document == null) throw new ArgumentNullException(nameof(documents));
return document.Bind(this);
}));
}
/// <summary>Creates a schema-aware catalog from typed endpoint bindings that sync into a local node before returning values.</summary>
public CultMeshDocumentCatalog SyncedDocuments(
CultMeshNode node,
params ICultMeshSnapshotDocumentBinding[] documents)
{
return SyncedDocuments(node, flush: false, documents);
}
/// <summary>Creates a schema-aware catalog from typed endpoint bindings that sync into a local node before returning values.</summary>
public CultMeshDocumentCatalog SyncedDocuments(
CultMeshNode node,
bool flush = false,
params ICultMeshSnapshotDocumentBinding[] documents)
{
if (node == null) throw new ArgumentNullException(nameof(node));
if (documents == null) throw new ArgumentNullException(nameof(documents));
return CultMesh.Documents(documents.Select(document =>
{
if (document == null) throw new ArgumentNullException(nameof(documents));
return document.BindSynced(this, node, flush);
}));
}
private CultMeshSnapshotRequestOptions CreateRequest(
IReadOnlyList<string>? schemaIds,
IReadOnlyList<string>? recordKeys)
{
var request = Request with { Selection = CultMesh.OverlaySelection(Request.Selection, schemaIds, recordKeys) };
if (string.IsNullOrWhiteSpace(request.RudpRuntimeId))
request.RudpRuntimeId = Context.RuntimeId;
if (string.IsNullOrWhiteSpace(request.MessageIdPrefix))
request.MessageIdPrefix = $"cultmesh:{Context.RuntimeId}:snapshot";
return request;
}
}
public static partial class CultMesh
{
/// <summary>
/// Binds one CultNet endpoint as a typed snapshot surface.
/// </summary>
public static CultMeshSnapshotEndpoint SnapshotEndpoint(
string endpoint,
CultMeshSnapshotEndpointOptions? options = null)
{
return new CultMeshSnapshotEndpoint(endpoint, options);
}
/// <summary>
/// Opens one reusable CultNet snapshot connection for ordered bulk document transfer.
/// </summary>
public static CultMeshSnapshotSession SnapshotSession(
string endpoint,
CultMeshSnapshotRequestOptions? options = null,
CultNetDocumentRegistry? registry = null)
{
return new CultMeshSnapshotSession(
endpoint,
options ?? new CultMeshSnapshotRequestOptions(),
registry);
}
/// <summary>
/// Describes one typed document to bind from a CultMesh snapshot endpoint.
/// </summary>
public static CultMeshSnapshotDocumentBinding<TDocument> SnapshotDocument<TDocument>(
string recordKey,
string? documentId = null)
where TDocument : class
{
return new CultMeshSnapshotDocumentBinding<TDocument>(recordKey, documentId);
}
/// <summary>
/// Fetches one typed snapshot document and syncs it into a local node in one call.
/// </summary>
public static Task<TDocument> SyncDocumentFromPeerSnapshotAsync<TDocument>(
CultMeshNode node,
string endpoint,
string recordKey,
CultMeshSnapshotEndpointOptions? options = null,
bool flush = false)
where TDocument : class
{
if (node == null) throw new ArgumentNullException(nameof(node));
if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey));
return SnapshotEndpoint(endpoint, options).SyncDocumentAsync<TDocument>(node, recordKey, flush);
}
/// <summary>
/// Fetches one typed snapshot document through a caller-provided schema client and syncs it into a local node in one call.
/// </summary>
public static Task<TDocument> SyncDocumentFromPeerSnapshotAsync<TDocument>(
CultMeshNode node,
Func<ICultNetSchemaClient> createClient,
string endpoint,
string recordKey,
CultMeshSnapshotEndpointOptions? options = null,
bool flush = false)
where TDocument : class
{
if (node == null) throw new ArgumentNullException(nameof(node));
if (createClient == null) throw new ArgumentNullException(nameof(createClient));
if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint));
if (string.IsNullOrWhiteSpace(recordKey)) throw new ArgumentException("Value must be non-empty.", nameof(recordKey));
var request = options?.Request is null ? new CultMeshSnapshotRequestOptions() : options.Request with { };
request.CreateClient = createClient;
var resolvedOptions = new CultMeshSnapshotEndpointOptions
{
Context = options?.Context,
DocumentRegistry = options?.DocumentRegistry,
Request = request,
SourceId = options?.SourceId,
RouteHint = options?.RouteHint,
PollInterval = options?.PollInterval ?? TimeSpan.FromMilliseconds(250)
};
return SnapshotEndpoint(endpoint, resolvedOptions).SyncDocumentAsync<TDocument>(node, recordKey, flush);
}
/// <summary>
/// Fetches one scoped raw CultNet snapshot from an endpoint.
/// </summary>
public static Task<CultNetSnapshotResponseRawMessage> FetchSnapshotAsync(
string endpoint,
CultMeshSnapshotRequestOptions? options = null)
{
if (string.IsNullOrWhiteSpace(endpoint)) throw new ArgumentException("Value must be non-empty.", nameof(endpoint));
var resolvedOptions = options ?? new CultMeshSnapshotRequestOptions();
return FetchSnapshotAsync(CreateSnapshotClient(endpoint, resolvedOptions), endpoint, resolvedOptions);
}
/// <summary>
/// Fetches one scoped raw CultNet snapshot through a caller-provided schema client factory.
/// </summary>
public static async Task<CultNetSnapshotResponseRawMessage> FetchSnapshotAsync(
Func<ICultNetSchemaClient> createClient,
string endpoint,
CultMeshSnapshotRequestOptions? options = null)
{
if (createClient == null) throw new ArgumentNullException(nameof(createClient));
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);
using var client = createClient();
client.OnCultNet<CultNetSnapshotResponseRawMessage>(response =>
{
if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal))
completion.TrySetResult(response);
});
client.OnCultNet<CultNetErrorMessage>(error =>
completion.TrySetException(new InvalidOperationException(error.Error)));
var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint);
client.Connect(host, port);
await WaitForSnapshotClientConnectionAsync(
client,
endpoint,
resolvedOptions.ConnectTimeout,
(client as ICultNetSchemaClientHealth)?.BackgroundFailure)
.ConfigureAwait(false);
client.SendCultNet(new CultNetSnapshotRequestMessage
{
MessageId = messageId,
SchemaIds = resolvedOptions.Selection?.Schemas,
RecordKeys = resolvedOptions.Selection?.Keys,
ShardId = string.IsNullOrWhiteSpace(resolvedOptions.ShardId) ? null : resolvedOptions.ShardId,
ShardEpoch = resolvedOptions.ShardEpoch
});
return await WaitForSnapshotResponseAsync(
completion.Task,
endpoint,
resolvedOptions,
messageId,
(client as ICultNetSchemaClientHealth)?.BackgroundFailure)
.ConfigureAwait(false);
}
/// <summary>
/// Fetches a scoped raw CultNet snapshot and applies it into the node's cache.
/// </summary>
public static async Task<IReadOnlyList<object>> ApplySnapshotAsync(
CultMeshNode node,
string endpoint,
CultMeshSnapshotRequestOptions? options = null)
{
if (node == null) throw new ArgumentNullException(nameof(node));
var snapshot = await FetchSnapshotAsync(endpoint, options).ConfigureAwait(false);
return await node.Database.Documents.ApplyRawSnapshotResponseAsync(node.Cache, snapshot).ConfigureAwait(false);
}
/// <summary>
/// Fetches a scoped raw CultNet snapshot and decodes documents assignable to the requested type.
/// </summary>
public static async Task<IReadOnlyList<TDocument>> FetchSnapshotDocumentsAsync<TDocument>(
string endpoint,
CultMeshSnapshotRequestOptions? options = null,
CultNetDocumentRegistry? registry = null)
where TDocument : class
{
var snapshot = await FetchSnapshotAsync(endpoint, options).ConfigureAwait(false);
return DecodeSnapshotDocuments<TDocument>(snapshot, registry ?? new CultNetDocumentRegistry());
}
internal static Func<ICultNetSchemaClient> CreateSnapshotClient(
string endpoint,
CultMeshSnapshotRequestOptions options)
{
if (options.CreateClient != null)
return options.CreateClient;
return () =>
{
if (Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) &&
string.Equals(uri.Scheme, "rudp", StringComparison.OrdinalIgnoreCase))
{
return CultNetSchemaClients.CreateRudp(
string.IsNullOrWhiteSpace(options.RudpRuntimeId)
? "cultmesh-snapshot-client"
: options.RudpRuntimeId!,
options.RudpConnectionId,
options.RudpConnectPayload,
options.RudpMaxFragmentBytes,
options.RudpResendDelayMs);
}
return CultNetSchemaClients.CreateForEndpoint(endpoint, options.Security, options.ConfigureClient);
};
}
/// <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;
// The door runs first (docs/cultnet-selection-cut.md, R-F): Mesh has no descriptor list to
// check field/role reachability against, but the declaration-independent half of the door -
// an empty or blank-only schemas/keys list, an unrecognised projection - applies here just
// as it does inside CultNetSelectionEvaluator.Select, so Keys=[]/[""] is refused rather than
// silently lowered to "every key".
CultNetSelectionValidation.ValidateShape(selection);
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 <paramref name="recordKeys"/> - including one that lowers to an
/// empty array - means no schema filter; otherwise the selection defaults to the document
/// type's own schema. R-R (2026-09-22): an empty <paramref name="recordKeys"/> is a real filter
/// (answers nothing), not "no filter", so it must not fall through to the schema default
/// either. The one owner of that rule (docs/cultnet-selection-cut.md, section 4).
/// </summary>
internal static CultNetSelection ResolveDefaultSelection(
IReadOnlyList<string>? schemaIds,
IReadOnlyList<string>? recordKeys,
CultDocumentDescriptor descriptor)
{
var cleanedKeys = Clean(recordKeys);
var schemas = schemaIds != null
? Clean(schemaIds)
: (cleanedKeys != null ? null : new[] { descriptor.SchemaId });
return new CultNetSelection { Schemas = schemas, Keys = cleanedKeys };
}
/// <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 = Clean(schemaIds),
Keys = Clean(recordKeys)
};
}
// 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)
{
var prefix = string.IsNullOrWhiteSpace(options.MessageIdPrefix)
? "cultmesh:snapshot"
: options.MessageIdPrefix!;
return $"{prefix}:{Guid.NewGuid():N}";
}
internal static async Task WaitForSnapshotClientConnectionAsync(
ICultNetSchemaClient client,
string endpoint,
TimeSpan timeout,
Task<Exception>? backgroundFailure = null)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (!client.Connected)
{
if (backgroundFailure?.IsCompleted == true)
{
var error = await backgroundFailure.ConfigureAwait(false);
throw new InvalidOperationException(
$"CultNet snapshot client failed while connecting to {endpoint}.",
error);
}
if (DateTimeOffset.UtcNow >= deadline)
throw new TimeoutException($"Timed out connecting to CultNet snapshot endpoint {endpoint}.");
await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false);
}
}
internal static async Task<CultNetSnapshotResponseRawMessage> WaitForSnapshotResponseAsync(
Task<CultNetSnapshotResponseRawMessage> responseTask,
string endpoint,
CultMeshSnapshotRequestOptions options,
string messageId,
Task<Exception>? backgroundFailure = null)
{
var timeoutTask = Task.Delay(options.ResponseTimeout);
var completed = backgroundFailure == null
? await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false)
: await Task.WhenAny(responseTask, timeoutTask, backgroundFailure).ConfigureAwait(false);
if (completed == backgroundFailure)
{
var error = await backgroundFailure.ConfigureAwait(false);
throw new InvalidOperationException(
$"CultNet snapshot client failed while waiting for response '{messageId}' from {endpoint}.",
error);
}
if (completed != responseTask)
{
throw new TimeoutException(
$"Timed out waiting for CultNet snapshot response '{messageId}' from {endpoint} " +
$"for schemas [{string.Join(", ", options.Selection?.Schemas ?? Array.Empty<string>())}] " +
$"and records [{string.Join(", ", options.Selection?.Keys ?? Array.Empty<string>())}].");
}
return await responseTask.ConfigureAwait(false);
}
internal static IReadOnlyList<TDocument> DecodeSnapshotDocuments<TDocument>(
CultNetSnapshotResponseRawMessage snapshot,
CultNetDocumentRegistry registry)
where TDocument : class
{
if (snapshot == null) throw new ArgumentNullException(nameof(snapshot));
if (registry == null) throw new ArgumentNullException(nameof(registry));
var descriptor = CultDocumentRegistry.Shared.GetRequired<TDocument>();
var documents = new List<TDocument>(snapshot.Documents.Length);
foreach (var record in snapshot.Documents)
{
if (record == null)
continue;
var binding = registry.GetBySchemaId(record.SchemaId);
var canDeserializeWithBinding =
binding != null &&
typeof(TDocument).IsAssignableFrom(binding.DocumentType);
var canDeserializeAsSchemaAlias =
CultNetSchemaAliasMatching.Matches(record.SchemaId, descriptor) ||
(binding != null && CultNetSchemaAliasMatching.Matches(binding.SchemaId, descriptor)) ||
CultNetDocumentRegistry.PayloadMatchesSchema(record.Payload, descriptor);
if (!canDeserializeWithBinding && !canDeserializeAsSchemaAlias)
continue;
if (!string.Equals(record.PayloadEncoding, "messagepack", StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"CultNet raw document payloadEncoding must be \"messagepack\", not \"{record.PayloadEncoding}\".");
}
if (binding != null)
{
var decoded = binding.PayloadDeserializer(record.Payload);
documents.Add(decoded is TDocument typed
? typed
: ConvertUntypedDocument<TDocument>(decoded));
}
else
{
documents.Add((TDocument)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(TDocument), record.Payload));
}
}
return documents;
}
}
}