using System; using System.Linq; using System.Threading.Tasks; namespace GameCult.Networking { /// /// Forwards shard writes to an authoritative owner. /// public interface ICultNetShardWriteForwarder { /// /// Forwards a raw document put to the shard owner. /// Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message); /// /// Forwards a raw document delete to the shard owner. /// Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message); } /// /// Options controlling shard write forwarding from a database server bridge. /// public sealed class CultNetDatabaseServerOptions { /// /// Gets or sets whether non-primary writes may be forwarded to shard owners. /// public bool ForwardNonPrimaryWrites { get; set; } /// /// Gets or sets the forwarder used when non-primary write forwarding is enabled. /// public ICultNetShardWriteForwarder? WriteForwarder { get; set; } } /// /// Options for the schema-v0 client-based shard write forwarder. /// public sealed class CultNetSchemaWriteForwarderOptions { /// /// Gets or sets client security options used to connect to primary endpoints. /// public ClientSecurityOptions? Security { get; set; } /// /// Gets or sets how long to wait for a connection before failing the forwarded write. /// public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5); /// /// Gets or sets a callback used to customize each ephemeral forwarding client. /// public Action? ConfigureClient { get; set; } } /// /// Forwards shard writes over the CultNet schema-v0 client transport. /// public sealed class CultNetSchemaWriteForwarder : ICultNetShardWriteForwarder { private readonly CultNetSchemaWriteForwarderOptions _options; /// /// Creates a schema-v0 write forwarder. /// public CultNetSchemaWriteForwarder(CultNetSchemaWriteForwarderOptions? options = null) { _options = options ?? new CultNetSchemaWriteForwarderOptions(); } /// 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); } /// 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); } /// /// Parses a CultNet endpoint URI into host and port. /// 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) || !string.Equals(uri.Scheme, "cultnet", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(uri.Host)) { throw new FormatException($"CultNet endpoint '{endpoint}' must use cultnet://host:port."); } return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port); } private async Task SendAsync(string endpoint, T message) where T : ICultNetSchemaMessage { var (host, port) = ParseEndpoint(endpoint); using var client = new Client(_options.Security ?? ClientSecurityOptions.Development()) { AllowUnverifiedCultNetMessages = true }; _options.ConfigureClient?.Invoke(client); client.Connect(host, port); await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); client.SendCultNet(message); } private async Task WaitForConnectionAsync(Client 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."); } } }