CultLib/src/GameCult.Networking/CultNetShardWriteForwarding.cs
Libby 541d734675
Some checks failed
CultMesh QUIC native / Build win32-x64 (pull_request) Has been skipped
CultMesh QUIC native / Build linux-x64 (pull_request) Has been skipped
CultMesh QUIC native / Release native binaries (pull_request) Has been skipped
CultMesh QUIC native / Verify committed binaries match SHA256SUMS (pull_request) Has been cancelled
CultMesh portability and performance / ubuntu-latest reactive semantics and leases (pull_request) Has been cancelled
C#: a forward is retryable only when provably not sent; unconfirmed otherwise; refusals carry fixed text
CultNetWriteNotSentException names the pre-send boundary (connect, hello, no endpoint,
or a send port that proves nothing was written). Every other forwarding failure keeps
the session claim. ForUnownedSchema and the routing refusal no longer carry exception text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:02:56 +00:00

229 lines
11 KiB
C#

using System;
using System.Linq;
using System.Threading.Tasks;
namespace GameCult.Networking
{
/// <summary>
/// Forwards shard writes to an authoritative owner.
/// </summary>
public interface ICultNetShardWriteForwarder
{
/// <summary>
/// Forwards a raw document put to the shard owner. Completes only when the owner answered that it admitted the write;
/// throws <see cref="CultNetWriteRefusedException"/> when it refused, <see cref="CultNetWriteNotSentException"/> when
/// nothing could have reached it (the only failure after which the id may be forwarded again), and any other exception
/// when the frame may have gone out and was not answered (unconfirmed, including <see cref="CultNetWriteUnconfirmedException"/>). The server relays to the original peer exactly what the owner answered.
/// </summary>
Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message);
/// <summary>
/// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as
/// <see cref="ForwardPutAsync"/> does.
/// </summary>
Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message);
}
/// <summary>
/// Options controlling shard write forwarding from a database server bridge.
/// </summary>
public sealed class CultNetDatabaseServerOptions
{
/// <summary>
/// Gets or sets whether non-primary writes may be forwarded to shard owners.
/// </summary>
public bool ForwardNonPrimaryWrites { get; set; }
/// <summary>
/// Gets or sets the forwarder used when non-primary write forwarding is enabled.
/// </summary>
public ICultNetShardWriteForwarder? WriteForwarder { get; set; }
/// <summary>
/// Gets or sets the authority asked for every peer put and delete. None configured refuses them all: a connection
/// key admits a peer to the transport, never to write. A write forwarded to a primary is judged here for the
/// original peer and again at the primary for the forwarder's connection.
/// </summary>
public ICultNetRemoteWriteAuthority? RemoteWriteAuthority { get; set; }
}
/// <summary>
/// Options for the schema-v0 client-based shard write forwarder.
/// </summary>
public sealed class CultNetSchemaWriteForwarderOptions
{
/// <summary>
/// Gets or sets client security options used to connect to primary endpoints.
/// </summary>
public ClientSecurityOptions? Security { get; set; }
/// <summary>
/// Gets or sets how long to wait for a connection before failing the forwarded write.
/// </summary>
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Gets or sets a callback used to customize each ephemeral forwarding client.
/// </summary>
public Action<Client>? ConfigureClient { get; set; }
/// <summary>
/// Gets or sets the schema-v0 client factory. Defaults to an endpoint-aware adapter:
/// rudp:// endpoints use the C# RUDP schema client and cultnet:// endpoints use LiteNetLib.
/// </summary>
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
/// <summary>
/// Gets or sets how long the primary has to answer a forwarded write (from the send). Its receipt completes the forward,
/// its refusal fails it with <see cref="CultNetWriteRefusedException"/>, and silence fails it with
/// <see cref="CultNetWriteUnconfirmedException"/>. Defaults to <see cref="CultNetWriteCompletion.ReceiptResultWindow"/>; a
/// primary that predates write receipts never answers, so forwarding to one is unconfirmed after this window.
/// </summary>
public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow;
}
/// <summary>
/// Forwards shard writes over the CultNet schema-v0 client transport.
/// </summary>
public sealed class CultNetSchemaWriteForwarder : ICultNetShardWriteForwarder
{
private readonly CultNetSchemaWriteForwarderOptions _options;
/// <summary>
/// Creates a schema-v0 write forwarder.
/// </summary>
public CultNetSchemaWriteForwarder(CultNetSchemaWriteForwarderOptions? options = null)
{
_options = options ?? new CultNetSchemaWriteForwarderOptions();
}
/// <summary>
/// Forwards a put under its original message id and completes on the primary's result frame: a receipt returns, a refusal
/// throws <see cref="CultNetWriteRefusedException"/> and silence throws <see cref="CultNetWriteUnconfirmedException"/>.
/// </summary>
public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
if (shard == null) throw new ArgumentNullException(nameof(shard));
message.ShardId ??= shard.ShardId;
message.ShardEpoch ??= shard.Epoch;
await SendAsync(shard, message, message.MessageId).ConfigureAwait(false);
}
/// <summary>
/// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as
/// <see cref="ForwardPutAsync"/> does.
/// </summary>
public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
if (shard == null) throw new ArgumentNullException(nameof(shard));
message.ShardId ??= shard.ShardId;
message.ShardEpoch ??= shard.Epoch;
await SendAsync(shard, message, message.MessageId).ConfigureAwait(false);
}
/// <summary>
/// Parses a CultNet endpoint URI into host and port.
/// </summary>
public static (string Host, int Port) ParseEndpoint(string endpoint)
{
if (string.IsNullOrWhiteSpace(endpoint))
{
throw new ArgumentException("Endpoint must be non-empty.", nameof(endpoint));
}
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) ||
!IsSupportedSchemaEndpointScheme(uri.Scheme) ||
string.IsNullOrWhiteSpace(uri.Host))
{
throw new FormatException(
$"CultNet endpoint '{endpoint}' must use cultnet+tcp://host:port, cultnet://host:port, or rudp://host:port.");
}
return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port);
}
// The write frame has not been handed to the transport until this returns: any failure here is CultNetWriteNotSentException,
// the one failure the caller may retry under the same id.
private async Task<ICultNetSchemaClient> ConnectBeforeSendAsync(CultNetShardDescriptor shard, string messageId)
{
ICultNetSchemaClient? client = null;
try
{
var endpoint = ResolvePrimaryEndpoint(shard);
var (host, port) = ParseEndpoint(endpoint);
client = CreateClient(endpoint);
client.Connect(host, port);
await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false);
// The hello comes first on the ordered schema channel: it opts this connection in to the primary's receipts.
client.SendCultNet(new CultNetHelloMessage
{
RuntimeId = "gamecult-networking-shard-forwarder",
RuntimeKind = "dotnet",
SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]
});
return client;
}
catch (Exception failure)
{
client?.Dispose();
if (failure is CultNetWriteNotSentException) throw;
throw new CultNetWriteNotSentException(messageId, failure);
}
}
private async Task SendAsync<T>(CultNetShardDescriptor shard, T message, string messageId) where T : ICultNetSchemaMessage
{
using var client = await ConnectBeforeSendAsync(shard, messageId).ConfigureAwait(false);
// From the write's send on, every failure (timeout, reset, silence, an exception from the send itself) propagates as it
// is: the frame may have gone out, so the caller treats it as unconfirmed.
await CultNetWriteCompletion.SendAndAwaitReceiptAsync(
(admitted, refuse, refuseV1) =>
{
client.OnCultNet(admitted);
client.OnCultNet(refuse);
client.OnCultNet(refuseV1);
return null;
},
() => client.SendCultNet(message),
messageId,
_options.ResultWindow).ConfigureAwait(false);
}
private ICultNetSchemaClient CreateClient(string endpoint)
{
return _options.CreateClient?.Invoke()
?? CultNetSchemaClients.CreateForEndpoint(endpoint, _options.Security, _options.ConfigureClient);
}
private static bool IsSupportedSchemaEndpointScheme(string scheme)
{
return string.Equals(scheme, "cultnet+tcp", StringComparison.OrdinalIgnoreCase) ||
string.Equals(scheme, "cultnet", StringComparison.OrdinalIgnoreCase) ||
string.Equals(scheme, "rudp", StringComparison.OrdinalIgnoreCase);
}
private async Task WaitForConnectionAsync(ICultNetSchemaClient client, string endpoint)
{
var deadline = DateTimeOffset.UtcNow + _options.ConnectTimeout;
while (!client.Connected)
{
if (DateTimeOffset.UtcNow >= deadline)
{
throw new TimeoutException($"Timed out connecting to shard primary endpoint {endpoint}.");
}
await Task.Delay(TimeSpan.FromMilliseconds(25)).ConfigureAwait(false);
}
}
private static string ResolvePrimaryEndpoint(CultNetShardDescriptor shard)
{
if (shard == null) throw new ArgumentNullException(nameof(shard));
return shard.PrimaryEndpoints.FirstOrDefault()
?? throw new InvalidOperationException(
$"Shard '{shard.ShardId}' does not advertise a primary endpoint.");
}
}
}