CultLib/src/GameCult.Networking/CultNetShardWriteForwarding.cs
2026-07-19 17:57:26 +01:00

166 lines
6.5 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.
/// </summary>
Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message);
/// <summary>
/// Forwards a raw document delete to the shard owner.
/// </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>
/// 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>
/// 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();
}
/// <inheritdoc />
public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
var endpoint = ResolvePrimaryEndpoint(shard);
message.ShardId ??= shard.ShardId;
message.ShardEpoch ??= shard.Epoch;
await SendAsync(endpoint, message).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
var endpoint = ResolvePrimaryEndpoint(shard);
message.ShardId ??= shard.ShardId;
message.ShardEpoch ??= shard.Epoch;
await SendAsync(endpoint, message).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);
}
private async Task SendAsync<T>(string endpoint, T message) where T : ICultNetSchemaMessage
{
var (host, port) = ParseEndpoint(endpoint);
using var client = CreateClient(endpoint);
client.Connect(host, port);
await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false);
client.SendCultNet(message);
}
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.");
}
}
}