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 _snapshotHandlerV1; private readonly Func _putHandler; private readonly Func _deleteHandler; private readonly Func _shardCatalogHandler; private readonly Func _shardLogHandler; private readonly Func _subscribeHandler; private readonly Func _subscribeHandlerV1; 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; _snapshotHandlerV1 = HandleSnapshotRequestV1Async; _putHandler = HandlePutAsync; _deleteHandler = HandleDeleteAsync; _shardCatalogHandler = HandleShardCatalogRequestAsync; _shardLogHandler = HandleShardLogRequestAsync; _subscribeHandler = HandleSubscribeAsync; _subscribeHandlerV1 = HandleSubscribeV1Async; _unsubscribeHandler = HandleUnsubscribeAsync; _server.OnCultNet(_snapshotHandler); _server.OnCultNet(_snapshotHandlerV1); _server.OnCultNet(_putHandler); _server.OnCultNet(_deleteHandler); _server.OnCultNet(_shardCatalogHandler); _server.OnCultNet(_shardLogHandler); _server.OnCultNet(_subscribeHandler); _server.OnCultNet(_subscribeHandlerV1); _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)); if (!string.IsNullOrWhiteSpace(request.ShardId)) { var shard = _database.Shards.FirstOrDefault(candidate => string.Equals(candidate.ShardId, request.ShardId, StringComparison.Ordinal)); if (shard == null) { throw new InvalidOperationException($"Shard '{request.ShardId}' is not known by this database."); } if (request.ShardEpoch.HasValue && request.ShardEpoch.Value != shard.Epoch) { throw new CultNetShardAuthorityException( shard, $"Shard '{shard.ShardId}' is at epoch {shard.Epoch}, not request epoch {request.ShardEpoch.Value}.", "stale_epoch"); } return _database.CreateShardSnapshotResponse( shard, string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId, request); } return _database.Documents.CreateRawSnapshotResponse( _database.Cache, string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId, request); } /// /// Answers a typed-selection snapshot request (R-A, docs/cultnet-selection-cut.md): the door, /// then one evaluation over the database's rows in last-write order, through /// . /// public CultNetSnapshotResponseRawV1Message CreateSelectionResponse(CultNetSnapshotRequestV1Message request) { if (request == null) throw new ArgumentNullException(nameof(request)); return _database.Documents.CreateSelectionResponse( _database.Cache, string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId, request.Selection, ordinalOf: (schemaId, key) => _database.LastWriteSequence(schemaId, key) ?? 0, asOf: _database.CurrentAsOf(), // R-Q: asOf is one shard-log watermark, so a selection whose matched rows span more than // one shard's log is refused rather than answered against a watermark that is not // exact for all of them. shardIdOf: (schemaId, key) => _database.ResolveShard(schemaId, key).ShardId, cursorKey: _database.CursorKey, // S-9: once the matched rows resolve to one shard, asOf is that shard's own watermark, // not CurrentAsOf()'s database-wide maximum across every shard. asOfForShard: _database.CurrentAsOf); } /// /// Creates a shard catalog response from the database shard map. /// public CultNetShardCatalogResponseMessage CreateShardCatalogResponse(CultNetShardCatalogRequestMessage request) { return _database.CreateShardCatalogResponse(request); } /// /// Creates a shard mutation-log response from the database log. /// public CultNetShardLogResponseMessage CreateShardLogResponse(CultNetShardLogRequestMessage request) { if (request == null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(request.ShardId)) { throw new ArgumentException("Shard log request requires a shardId.", nameof(request)); } var shard = _database.Shards.FirstOrDefault(candidate => string.Equals(candidate.ShardId, request.ShardId, StringComparison.Ordinal)); if (shard == null) { return new CultNetShardLogResponseMessage { MessageId = RequestMessageId(request.MessageId), ShardId = request.ShardId, ResyncRequired = true, Reason = "unknown_shard" }; } if (request.ShardEpoch.HasValue && request.ShardEpoch.Value != shard.Epoch) { return new CultNetShardLogResponseMessage { MessageId = RequestMessageId(request.MessageId), ShardId = request.ShardId, ShardEpoch = shard.Epoch, ResyncRequired = true, Reason = "stale_epoch" }; } var compactedThrough = _database.GetCompactedMutationLogSequence(request.ShardId); if (request.AfterSequence < compactedThrough) { return new CultNetShardLogResponseMessage { MessageId = RequestMessageId(request.MessageId), ShardId = request.ShardId, ShardEpoch = shard.Epoch, ResyncRequired = true, Reason = "compacted_history", CompactedThrough = compactedThrough }; } var entries = _database.GetMutationLogMessages(request.ShardId, request.AfterSequence, request.Limit) .ToArray(); return new CultNetShardLogResponseMessage { MessageId = RequestMessageId(request.MessageId), ShardId = request.ShardId, ShardEpoch = shard.Epoch, Entries = entries }; } /// /// Detaches handlers from the server. /// public void Dispose() { if (_disposed) { return; } _disposed = true; _server.RemoveCultNetMessageListener(_snapshotHandler); _server.RemoveCultNetMessageListener(_snapshotHandlerV1); _server.RemoveCultNetMessageListener(_putHandler); _server.RemoveCultNetMessageListener(_deleteHandler); _server.RemoveCultNetMessageListener(_shardCatalogHandler); _server.RemoveCultNetMessageListener(_shardLogHandler); _server.RemoveCultNetMessageListener(_subscribeHandler); _server.RemoveCultNetMessageListener(_subscribeHandlerV1); _server.RemoveCultNetMessageListener(_unsubscribeHandler); foreach (var subscription in _subscriptions.Values) { subscription.Dispose(); } _subscriptions.Clear(); } private Task HandleSnapshotRequestAsync(CultNetSnapshotRequestMessage request, CultNetServerPeer peer) { peer.SendCultNet(CreateSnapshotResponse(request)); return Task.CompletedTask; } private Task HandleSnapshotRequestV1Async(CultNetSnapshotRequestV1Message request, CultNetServerPeer peer) { try { peer.SendCultNet(CreateSelectionResponse(request)); } catch (CultNetSelectionInvalidException ex) { peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex)); } catch (CultNetSelectionCursorException ex) { peer.SendCultNet(CultNetErrorMessage.ForCursor(ex)); } catch (CultNetSelectionReferenceOutsideTargetException ex) { peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex)); } return Task.CompletedTask; } private Task HandleShardCatalogRequestAsync(CultNetShardCatalogRequestMessage request, CultNetServerPeer peer) { peer.SendCultNet(CreateShardCatalogResponse(request)); return Task.CompletedTask; } private Task HandleShardLogRequestAsync(CultNetShardLogRequestMessage request, CultNetServerPeer peer) { try { peer.SendCultNet(CreateShardLogResponse(request)); } catch (Exception ex) { peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } return Task.CompletedTask; } private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer) { try { await ApplyPutAsync(message).ConfigureAwait(false); } catch (CultNetShardAuthorityException ex) { if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false)) { _server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}"); peer.SendCultNet(CreateRoutingError(ex)); } } catch (Exception ex) { _server.Logger.LogError($"CultNet raw put failed: {ex.Message}"); peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } } private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, CultNetServerPeer 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, CultNetServerPeer 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.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 })); } else { peer.SendCultNet(new CultNetSnapshotResponseRawMessage { MessageId = message.MessageId, Documents = Array.Empty() }); } return Task.CompletedTask; } private Task HandleSubscribeV1Async(CultNetDatabaseSubscribeV1Message message, CultNetServerPeer 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; } // S-11: the door runs first, unconditionally (R-F) - a selection that is invalid for reasons // unrelated to its hop (an empty keys list, an undeclared index, ...) reports that refusal, // not a hop refusal that has nothing to do with why it was actually rejected. try { message.Selection.Validate(_database.Cache.Registry.AllDescriptors.ToArray()); } catch (CultNetSelectionInvalidException ex) { peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex)); return Task.CompletedTask; } // This server delivers live changes through the single-row fast path (CreateChangeMessage / // CultNetSelectionEvaluator.Matches), which is set-independent by construction - the same // ceiling v0 subscriptions on this server already have. A hop-bearing selection is // set-dependent (D6) and needs the subscription server's reconcile loop instead, so it is // refused here, after the door, rather than reaching Matches' own InvalidOperationException // on the first change. The refused field names whichever hop is actually set - cited, not // always cites - so the refusal describes the selection that was actually sent. if (message.Selection.HasHop) { var hopField = message.Selection.Cites != null ? "cites" : "cited"; peer.SendCultNet(new CultNetErrorMessage { Error = $"selection_invalid: selection.{hopField} needs the subscription server's reconcile loop (D6); this server only fast-matches a single row.", Code = "selection_invalid", Details = new CultNetErrorDetails { Field = hopField } }); return Task.CompletedTask; } var key = SubscriptionKey(peer.Peer, subscriptionId); _subscriptions.AddOrUpdate( key, _ => CreateSubscription(message.Selection, subscriptionId, peer), (_, existing) => { existing.Dispose(); return CreateSubscription(message.Selection, subscriptionId, peer); }); if (message.IncludeSnapshot) { try { peer.SendCultNet(CreateSelectionResponse(new CultNetSnapshotRequestV1Message { MessageId = message.MessageId, Selection = message.Selection })); } catch (CultNetSelectionInvalidException ex) { peer.SendCultNet(CultNetErrorMessage.ForSelectionInvalid(ex)); } catch (CultNetSelectionCursorException ex) { peer.SendCultNet(CultNetErrorMessage.ForCursor(ex)); } catch (CultNetSelectionReferenceOutsideTargetException ex) { peer.SendCultNet(CultNetErrorMessage.ForReferenceOutsideTarget(ex)); } } else { peer.SendCultNet(new CultNetSnapshotResponseRawV1Message { MessageId = message.MessageId, AsOf = _database.CurrentAsOf() }); } return Task.CompletedTask; } private Task HandleUnsubscribeAsync(CultNetDatabaseUnsubscribeMessage message, CultNetServerPeer peer) { var subscriptionId = string.IsNullOrWhiteSpace(message.SubscriptionId) ? message.MessageId : message.SubscriptionId; if (!string.IsNullOrWhiteSpace(subscriptionId) && _subscriptions.TryRemove(SubscriptionKey(peer.Peer, subscriptionId), out var subscription)) { subscription.Dispose(); } return Task.CompletedTask; } private IDisposable CreateSubscription( CultNetDatabaseSubscribeMessage request, string subscriptionId, CultNetServerPeer peer) { // v0 has no engine of its own (docs/cultnet-selection-cut.md, D3/D4): it lowers into a // Selection and is matched by the one evaluator, same as a v1 subscribe. var selection = new CultNetSelection { Schemas = request.SchemaIds, Keys = request.RecordKeys, Projection = CultNetSelectionProjections.Document }; return CreateSubscription(selection, subscriptionId, peer); } private IDisposable CreateSubscription( CultNetSelection selection, string subscriptionId, CultNetServerPeer peer) { return _database.WatchAllChanges().Subscribe(change => { var outbound = CreateChangeMessage(change, subscriptionId, selection); if (outbound != null) { peer.SendCultNet(outbound); } }); } internal CultNetDatabaseChangeRawMessage? CreateChangeMessage( object change, string subscriptionId, CultNetSelection selection) { 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; var documentType = changeType.IsGenericType ? changeType.GetGenericArguments()[0] : null; var descriptor = documentType == null ? null : _database.Cache.Registry.GetRequired(documentType); var kind = (CultNetDatabaseChangeKind)(changeType.GetProperty("Kind")?.GetValue(change) ?? CultNetDatabaseChangeKind.Updated); var document = changeType.GetProperty("Document")?.GetValue(change); if (descriptor == null) { return null; } // R-M: the one binding-alias reconciler (CultNetDocumentRegistry.ExpandSchemaBindingAliases). var effectiveSelection = _database.Documents.ExpandSchemaBindingAliases(selection); if (!CultNetSelectionEvaluator.Matches(descriptor, key, document, effectiveSelection)) { return null; } 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 = _database.Documents.ToRawRecord(descriptor, key, document, DateTimeOffset.UtcNow.ToString("O")) }; } private static string SubscriptionKey(NetPeer peer, string subscriptionId) { return $"{peer.Id}:{subscriptionId}"; } private static string RequestMessageId(string messageId) { return string.IsNullOrWhiteSpace(messageId) ? Guid.NewGuid().ToString("N") : messageId; } 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; } } }