using System; using System.Collections.Concurrent; using System.Linq; using System.Reflection; using System.Threading.Tasks; using GameCult.Caching; using LiteNetLib; using R3; namespace GameCult.Networking { /// /// Routes CultNet schema-v0 document messages through a . /// public sealed class CultNetDatabaseServer : IDisposable { private readonly Server _server; private readonly CultNetDatabase _database; private readonly CultNetDatabaseServerOptions _options; private readonly Func _snapshotHandler; private readonly Func _putHandler; private readonly Func _deleteHandler; private readonly Func _shardCatalogHandler; private readonly Func _subscribeHandler; private readonly Func _unsubscribeHandler; private readonly ConcurrentDictionary _subscriptions = new(StringComparer.Ordinal); private bool _disposed; /// /// Creates and attaches a database message bridge to a server. /// public CultNetDatabaseServer( Server server, CultNetDatabase database, CultNetDatabaseServerOptions? options = null) { _server = server ?? throw new ArgumentNullException(nameof(server)); _database = database ?? throw new ArgumentNullException(nameof(database)); _options = options ?? new CultNetDatabaseServerOptions(); _snapshotHandler = HandleSnapshotRequestAsync; _putHandler = HandlePutAsync; _deleteHandler = HandleDeleteAsync; _shardCatalogHandler = HandleShardCatalogRequestAsync; _subscribeHandler = HandleSubscribeAsync; _unsubscribeHandler = HandleUnsubscribeAsync; _server.OnCultNet(_snapshotHandler); _server.OnCultNet(_putHandler); _server.OnCultNet(_deleteHandler); _server.OnCultNet(_shardCatalogHandler); _server.OnCultNet(_subscribeHandler); _server.OnCultNet(_unsubscribeHandler); } /// /// Gets the database used by this bridge. /// public CultNetDatabase Database => _database; /// /// Applies a raw put message through the database shard policy. /// public Task ApplyPutAsync(CultNetDocumentPutRawMessage message) { return _database.ApplyPutAsync(message); } /// /// Applies a raw delete message through the database shard policy. /// public Task ApplyDeleteAsync(CultNetDocumentDeleteMessage message) { return _database.ApplyDeleteAsync(message); } /// /// Creates a raw snapshot response from the database cache. /// public CultNetSnapshotResponseRawMessage CreateSnapshotResponse(CultNetSnapshotRequestMessage request) { if (request == null) throw new ArgumentNullException(nameof(request)); return _database.Documents.CreateRawSnapshotResponse( _database.Cache, string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId, request); } /// /// Creates a shard catalog response from the database shard map. /// public CultNetShardCatalogResponseMessage CreateShardCatalogResponse(CultNetShardCatalogRequestMessage request) { return _database.CreateShardCatalogResponse(request); } /// /// Detaches handlers from the server. /// public void Dispose() { if (_disposed) { return; } _disposed = true; _server.RemoveCultNetMessageListener(_snapshotHandler); _server.RemoveCultNetMessageListener(_putHandler); _server.RemoveCultNetMessageListener(_deleteHandler); _server.RemoveCultNetMessageListener(_shardCatalogHandler); _server.RemoveCultNetMessageListener(_subscribeHandler); _server.RemoveCultNetMessageListener(_unsubscribeHandler); foreach (var subscription in _subscriptions.Values) { subscription.Dispose(); } _subscriptions.Clear(); } private Task HandleSnapshotRequestAsync(CultNetSnapshotRequestMessage request, NetPeer peer) { peer.SendCultNet(CreateSnapshotResponse(request)); return Task.CompletedTask; } private Task HandleShardCatalogRequestAsync(CultNetShardCatalogRequestMessage request, NetPeer peer) { peer.SendCultNet(CreateShardCatalogResponse(request)); return Task.CompletedTask; } private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, NetPeer peer) { try { await ApplyPutAsync(message).ConfigureAwait(false); } catch (CultNetShardAuthorityException ex) { if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false)) { peer.SendCultNet(CreateRoutingError(ex)); } } catch (Exception ex) { peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } } private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, NetPeer peer) { try { await ApplyDeleteAsync(message).ConfigureAwait(false); } catch (CultNetShardAuthorityException ex) { if (!await TryForwardDeleteAsync(ex, message).ConfigureAwait(false)) { peer.SendCultNet(CreateRoutingError(ex)); } } catch (Exception ex) { peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } } private Task HandleSubscribeAsync(CultNetDatabaseSubscribeMessage message, NetPeer peer) { var subscriptionId = string.IsNullOrWhiteSpace(message.SubscriptionId) ? message.MessageId : message.SubscriptionId; if (string.IsNullOrWhiteSpace(subscriptionId)) { peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." }); return Task.CompletedTask; } var key = SubscriptionKey(peer, subscriptionId); _subscriptions.AddOrUpdate( key, _ => CreateSubscription(message, subscriptionId, peer), (_, existing) => { existing.Dispose(); return CreateSubscription(message, subscriptionId, peer); }); if (message.IncludeSnapshot) { peer.SendCultNet(CreateSnapshotResponse(new CultNetSnapshotRequestMessage { MessageId = message.MessageId, SchemaIds = message.SchemaIds, RecordKeys = message.RecordKeys })); } return Task.CompletedTask; } private Task HandleUnsubscribeAsync(CultNetDatabaseUnsubscribeMessage message, NetPeer peer) { var subscriptionId = string.IsNullOrWhiteSpace(message.SubscriptionId) ? message.MessageId : message.SubscriptionId; if (!string.IsNullOrWhiteSpace(subscriptionId) && _subscriptions.TryRemove(SubscriptionKey(peer, subscriptionId), out var subscription)) { subscription.Dispose(); } return Task.CompletedTask; } private IDisposable CreateSubscription( CultNetDatabaseSubscribeMessage request, string subscriptionId, NetPeer peer) { return _database.WatchAllChanges().Subscribe(change => { var outbound = CreateChangeMessage(change, subscriptionId, request); if (outbound != null) { peer.SendCultNet(outbound); } }); } internal CultNetDatabaseChangeRawMessage? CreateChangeMessage( object change, string subscriptionId, CultNetDatabaseSubscribeMessage request) { var changeType = change.GetType(); var key = (CultRecordKey)(changeType.GetProperty("Key")?.GetValue(change) ?? new CultRecordKey(string.Empty)); var schemaId = (string?)changeType.GetProperty("SchemaId")?.GetValue(change) ?? string.Empty; if (!Matches(request, schemaId, key)) { return null; } var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated); var document = changeType.GetProperty("Document")?.GetValue(change); if (kind == CultNetDatabaseChangeKind.Removed || document == null) { return new CultNetDatabaseChangeRawMessage { MessageId = Guid.NewGuid().ToString("N"), SubscriptionId = subscriptionId, ChangeKind = "removed", RecordKey = key.Value, SchemaId = schemaId }; } return new CultNetDatabaseChangeRawMessage { MessageId = Guid.NewGuid().ToString("N"), SubscriptionId = subscriptionId, ChangeKind = kind == CultNetDatabaseChangeKind.Added ? "added" : "updated", Document = CreateRawDocumentRecord(key, document) }; } private CultNetRawDocumentRecord CreateRawDocumentRecord(CultRecordKey key, object document) { var method = typeof(CultNetDocumentRegistry) .GetMethods(BindingFlags.Public | BindingFlags.Instance) .Single(candidate => candidate.Name == nameof(CultNetDocumentRegistry.CreateRawDocumentPutMessage) && candidate.IsGenericMethodDefinition); var documentType = document.GetType(); var handleType = typeof(CultRecordHandle<>).MakeGenericType(documentType); var handle = Activator.CreateInstance(handleType, new object[] { key }); var message = method .MakeGenericMethod(documentType) .Invoke(_database.Documents, new[] { Guid.NewGuid().ToString("N"), handle, document, null }); return ((CultNetDocumentPutRawMessage)message!).Document; } private static bool Matches(CultNetDatabaseSubscribeMessage request, string schemaId, CultRecordKey key) { var schemaMatches = request.SchemaIds == null || request.SchemaIds.Length == 0 || request.SchemaIds.Contains(schemaId, StringComparer.Ordinal); var keyMatches = request.RecordKeys == null || request.RecordKeys.Length == 0 || request.RecordKeys.Contains(key.Value, StringComparer.Ordinal); return schemaMatches && keyMatches; } private static string SubscriptionKey(NetPeer peer, string subscriptionId) { return $"{peer.Id}:{subscriptionId}"; } private static CultNetErrorMessage CreateRoutingError(CultNetShardAuthorityException exception) { return new CultNetErrorMessage { Error = exception.Message, RoutingHint = new CultNetShardRoutingHint { Reason = exception.Reason, Shard = CultNetDatabase.ToMessage(exception.Shard) } }; } /// /// Attempts to forward a rejected raw put to the shard owner. /// public async Task TryForwardPutAsync( CultNetShardAuthorityException exception, CultNetDocumentPutRawMessage message) { if (!_options.ForwardNonPrimaryWrites || _options.WriteForwarder == null || exception.Reason != "not_primary") { return false; } message.ShardId ??= exception.Shard.ShardId; message.ShardEpoch ??= exception.Shard.Epoch; await _options.WriteForwarder.ForwardPutAsync(exception.Shard, message).ConfigureAwait(false); return true; } /// /// Attempts to forward a rejected raw delete to the shard owner. /// public async Task TryForwardDeleteAsync( CultNetShardAuthorityException exception, CultNetDocumentDeleteMessage message) { if (!_options.ForwardNonPrimaryWrites || _options.WriteForwarder == null || exception.Reason != "not_primary") { return false; } message.ShardId ??= exception.Shard.ShardId; message.ShardEpoch ??= exception.Shard.Epoch; await _options.WriteForwarder.ForwardDeleteAsync(exception.Shard, message).ConfigureAwait(false); return true; } } }