using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace GameCult.Networking { /// /// Fetches committed shard-log entries from an authoritative shard owner. /// public interface ICultNetShardLogFetcher { /// /// Fetches shard-log entries after the supplied sequence. /// Task FetchAsync( CultNetShardDescriptor shard, long afterSequence, int? limit = null); } /// /// Options for background shard-log replication. /// public sealed class CultNetShardReplicatorOptions { /// /// Gets or sets the interval used by the background pull loop. /// public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(1); /// /// Gets or sets the maximum number of log entries requested per pull. /// public int? BatchSize { get; set; } = 256; /// /// Gets or sets the fetcher used to read from shard primaries. /// public ICultNetShardLogFetcher? Fetcher { get; set; } /// /// Gets or sets a callback for background pull failures. /// public Action? OnError { get; set; } } /// /// Pulls committed shard logs from primaries and applies them to a local replica database. /// public sealed class CultNetShardReplicator : IDisposable { private readonly CultNetDatabase _database; private readonly CultNetShardReplicatorOptions _options; private readonly List _subscriptions = new(); private readonly ConcurrentDictionary _pullsInFlight = new(StringComparer.Ordinal); private bool _disposed; /// /// Creates a shard replicator. /// public CultNetShardReplicator( CultNetDatabase database, CultNetShardReplicatorOptions? options = null) { _database = database ?? throw new ArgumentNullException(nameof(database)); _options = options ?? new CultNetShardReplicatorOptions(); } /// /// Starts background pull loops for non-primary shards with advertised primaries. /// public void Start() { ThrowIfDisposed(); if (_options.Fetcher == null) { throw new InvalidOperationException("A shard log fetcher is required before starting replication."); } if (_subscriptions.Count > 0) { return; } foreach (var shard in _database.Shards.Where(shard => !shard.IsPrimary && shard.PrimaryEndpoints.Count > 0)) { _subscriptions.Add(new Timer( _ => _ = PullIfIdleAsync(shard), state: null, dueTime: TimeSpan.Zero, period: _options.PollInterval)); } } /// /// Pulls and applies one batch for a shard id. /// public Task PullOnceAsync(string shardId) { ThrowIfDisposed(); if (string.IsNullOrWhiteSpace(shardId)) throw new ArgumentException("Value must be non-empty.", nameof(shardId)); var shard = _database.Shards.FirstOrDefault(candidate => string.Equals(candidate.ShardId, shardId, StringComparison.Ordinal)); if (shard == null) { throw new InvalidOperationException($"Shard '{shardId}' is not known by this database."); } return PullOnceAsync(shard); } /// /// Pulls and applies one batch for a shard. /// public async Task PullOnceAsync(CultNetShardDescriptor shard) { ThrowIfDisposed(); if (shard == null) throw new ArgumentNullException(nameof(shard)); if (_options.Fetcher == null) { throw new InvalidOperationException("A shard log fetcher is required before pulling replication."); } if (shard.IsPrimary) { throw new InvalidOperationException($"Shard '{shard.ShardId}' is primary on this node and does not need replica pulling."); } var afterSequence = _database.GetAppliedShardSequence(shard.ShardId); var response = await _options.Fetcher .FetchAsync(shard, afterSequence, _options.BatchSize) .ConfigureAwait(false); return await _database.ApplyShardLogResponseAsync(response).ConfigureAwait(false); } /// public void Dispose() { if (_disposed) { return; } _disposed = true; foreach (var subscription in _subscriptions) { subscription.Dispose(); } _subscriptions.Clear(); _pullsInFlight.Clear(); } private async Task PullIfIdleAsync(CultNetShardDescriptor shard) { if (!_pullsInFlight.TryAdd(shard.ShardId, 0)) { return; } try { await PullOnceAsync(shard).ConfigureAwait(false); } catch (Exception ex) { _options.OnError?.Invoke(ex); } finally { _pullsInFlight.TryRemove(shard.ShardId, out _); } } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(CultNetShardReplicator)); } } } /// /// Options for the schema-v0 client-based shard-log fetcher. /// public sealed class CultNetSchemaShardLogFetcherOptions { /// /// 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. /// public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5); /// /// Gets or sets how long to wait for a log response before failing. /// public TimeSpan ResponseTimeout { get; set; } = TimeSpan.FromSeconds(5); /// /// Gets or sets a callback used to customize each ephemeral fetch client. /// public Action? ConfigureClient { get; set; } } /// /// Fetches shard-log batches over the CultNet schema-v0 client transport. /// public sealed class CultNetSchemaShardLogFetcher : ICultNetShardLogFetcher { private readonly CultNetSchemaShardLogFetcherOptions _options; /// /// Creates a schema-v0 shard-log fetcher. /// public CultNetSchemaShardLogFetcher(CultNetSchemaShardLogFetcherOptions? options = null) { _options = options ?? new CultNetSchemaShardLogFetcherOptions(); } /// public async Task FetchAsync( CultNetShardDescriptor shard, long afterSequence, int? limit = null) { if (shard == null) throw new ArgumentNullException(nameof(shard)); var endpoint = ResolvePrimaryEndpoint(shard); var (host, port) = CultNetSchemaWriteForwarder.ParseEndpoint(endpoint); var messageId = Guid.NewGuid().ToString("N"); var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); using var client = new Client(_options.Security ?? ClientSecurityOptions.Development()) { AllowUnverifiedCultNetMessages = true }; _options.ConfigureClient?.Invoke(client); client.OnCultNet(response => { if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal)) { completion.TrySetResult(response); } }); client.OnCultNet(error => completion.TrySetException(new InvalidOperationException(error.Error))); client.Connect(host, port); await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); client.SendCultNet(new CultNetShardLogRequestMessage { MessageId = messageId, ShardId = shard.ShardId, ShardEpoch = shard.Epoch, AfterSequence = afterSequence, Limit = limit }); return await WaitForResponseAsync(completion.Task, endpoint).ConfigureAwait(false); } 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 async Task WaitForResponseAsync( Task responseTask, string endpoint) { var timeoutTask = Task.Delay(_options.ResponseTimeout); var completed = await Task.WhenAny(responseTask, timeoutTask).ConfigureAwait(false); if (completed != responseTask) { throw new TimeoutException($"Timed out waiting for shard log response from {endpoint}."); } return await responseTask.ConfigureAwait(false); } private static string ResolvePrimaryEndpoint(CultNetShardDescriptor shard) { return shard.PrimaryEndpoints.FirstOrDefault() ?? throw new InvalidOperationException( $"Shard '{shard.ShardId}' does not advertise a primary endpoint."); } } }