using System; using Svrnty.CQRS.Events.RabbitMQ.Configuration; using Svrnty.CQRS.Events.Abstractions.Subscriptions; using Svrnty.CQRS.Events.Abstractions.Configuration; using Svrnty.CQRS.Events.Abstractions.EventStore; using System.Collections.Generic; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Svrnty.CQRS.Events.Abstractions; namespace Svrnty.CQRS.Events.RabbitMQ.Configuration; /// /// Manages RabbitMQ topology (exchanges, queues, bindings). /// /// /// This class is responsible for creating and configuring RabbitMQ topology /// based on the stream and subscription configuration. /// public sealed class RabbitMQTopologyManager { private readonly RabbitMQConfiguration _config; private readonly ILogger _logger; public RabbitMQTopologyManager( RabbitMQConfiguration config, ILogger logger) { _config = config ?? throw new ArgumentNullException(nameof(config)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Gets the exchange name for a stream. /// /// The stream name. /// The full exchange name. public string GetExchangeName(string streamName) { if (string.IsNullOrWhiteSpace(streamName)) throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName)); return string.IsNullOrWhiteSpace(_config.ExchangePrefix) ? streamName : $"{_config.ExchangePrefix}.{streamName}"; } /// /// Gets the queue name for a subscription. /// /// The subscription ID. /// The consumer ID (for broadcast mode). /// The subscription mode. /// The queue name. public string GetQueueName(string subscriptionId, string? consumerId, SubscriptionMode mode) { if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId)); var queueName = mode switch { SubscriptionMode.Broadcast when !string.IsNullOrWhiteSpace(consumerId) => // Each consumer gets its own queue $"{subscriptionId}.{consumerId}", SubscriptionMode.Exclusive or SubscriptionMode.ConsumerGroup => // All consumers share one queue subscriptionId, _ => subscriptionId }; return string.IsNullOrWhiteSpace(_config.ExchangePrefix) ? queueName : $"{_config.ExchangePrefix}.{queueName}"; } /// /// Gets the routing key for an event based on the configured strategy. /// /// The stream name. /// The event. /// The routing key. public string GetRoutingKey(string streamName, ICorrelatedEvent @event) { if (string.IsNullOrWhiteSpace(streamName)) throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName)); if (@event == null) throw new ArgumentNullException(nameof(@event)); return _config.DefaultRoutingKeyStrategy switch { "EventType" => @event.GetType().Name, "StreamName" => streamName, "Wildcard" => "#", _ => @event.GetType().Name }; } /// /// Declares an exchange for a stream. /// /// The RabbitMQ channel. /// The stream name. public void DeclareExchange(IChannel channel, string streamName) { if (channel == null) throw new ArgumentNullException(nameof(channel)); if (string.IsNullOrWhiteSpace(streamName)) throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName)); if (!_config.AutoDeclareTopology) { _logger.LogDebug("Auto-declare topology is disabled, skipping exchange declaration for {StreamName}", streamName); return; } var exchangeName = GetExchangeName(streamName); try { channel.ExchangeDeclareAsync( exchange: exchangeName, type: _config.DefaultExchangeType, durable: _config.DurableExchanges, autoDelete: false, arguments: null).GetAwaiter().GetResult(); _logger.LogInformation( "Declared exchange {ExchangeName} (type: {ExchangeType}, durable: {Durable})", exchangeName, _config.DefaultExchangeType, _config.DurableExchanges); } catch (Exception ex) { _logger.LogError(ex, "Failed to declare exchange {ExchangeName}", exchangeName); throw; } } /// /// Declares a queue for a subscription. /// /// The RabbitMQ channel. /// The subscription ID. /// The consumer ID (for broadcast mode). /// The subscription mode. /// The declared queue name. public string DeclareQueue( IChannel channel, string subscriptionId, string? consumerId, SubscriptionMode mode) { if (channel == null) throw new ArgumentNullException(nameof(channel)); if (string.IsNullOrWhiteSpace(subscriptionId)) throw new ArgumentException("Subscription ID cannot be null or whitespace.", nameof(subscriptionId)); if (!_config.AutoDeclareTopology) { var queueName = GetQueueName(subscriptionId, consumerId, mode); _logger.LogDebug("Auto-declare topology is disabled, skipping queue declaration for {QueueName}", queueName); return queueName; } var queue = GetQueueName(subscriptionId, consumerId, mode); var arguments = new Dictionary(); // Add dead letter exchange if configured if (!string.IsNullOrWhiteSpace(_config.DeadLetterExchange)) { arguments["x-dead-letter-exchange"] = _config.DeadLetterExchange; } // Add message TTL if configured if (_config.MessageTTL.HasValue) { arguments["x-message-ttl"] = (int)_config.MessageTTL.Value.TotalMilliseconds; } // Add max queue length if configured if (_config.MaxQueueLength.HasValue) { arguments["x-max-length"] = _config.MaxQueueLength.Value; } try { channel.QueueDeclareAsync( queue: queue, durable: _config.DurableQueues, exclusive: false, autoDelete: mode == SubscriptionMode.Broadcast, // Auto-delete broadcast queues arguments: arguments.Count > 0 ? arguments : null).GetAwaiter().GetResult(); _logger.LogInformation( "Declared queue {QueueName} (durable: {Durable}, mode: {Mode})", queue, _config.DurableQueues, mode); return queue; } catch (Exception ex) { _logger.LogError(ex, "Failed to declare queue {QueueName}", queue); throw; } } /// /// Binds a queue to an exchange with routing keys. /// /// The RabbitMQ channel. /// The stream name. /// The queue name. /// The routing keys for binding. public void BindQueue( IChannel channel, string streamName, string queueName, IEnumerable routingKeys) { if (channel == null) throw new ArgumentNullException(nameof(channel)); if (string.IsNullOrWhiteSpace(streamName)) throw new ArgumentException("Stream name cannot be null or whitespace.", nameof(streamName)); if (string.IsNullOrWhiteSpace(queueName)) throw new ArgumentException("Queue name cannot be null or whitespace.", nameof(queueName)); if (routingKeys == null) throw new ArgumentNullException(nameof(routingKeys)); if (!_config.AutoDeclareTopology) { _logger.LogDebug("Auto-declare topology is disabled, skipping queue binding for {QueueName}", queueName); return; } var exchangeName = GetExchangeName(streamName); foreach (var routingKey in routingKeys) { try { channel.QueueBindAsync( queue: queueName, exchange: exchangeName, routingKey: routingKey, arguments: null).GetAwaiter().GetResult(); _logger.LogInformation( "Bound queue {QueueName} to exchange {ExchangeName} with routing key {RoutingKey}", queueName, exchangeName, routingKey); } catch (Exception ex) { _logger.LogError( ex, "Failed to bind queue {QueueName} to exchange {ExchangeName} with routing key {RoutingKey}", queueName, exchangeName, routingKey); throw; } } } /// /// Declares the dead letter exchange if configured. /// /// The RabbitMQ channel. public void DeclareDeadLetterExchange(IChannel channel) { if (channel == null) throw new ArgumentNullException(nameof(channel)); if (string.IsNullOrWhiteSpace(_config.DeadLetterExchange)) return; if (!_config.AutoDeclareTopology) { _logger.LogDebug("Auto-declare topology is disabled, skipping dead letter exchange declaration"); return; } try { channel.ExchangeDeclareAsync( exchange: _config.DeadLetterExchange, type: "topic", durable: true, autoDelete: false, arguments: null).GetAwaiter().GetResult(); _logger.LogInformation("Declared dead letter exchange {ExchangeName}", _config.DeadLetterExchange); } catch (Exception ex) { _logger.LogError(ex, "Failed to declare dead letter exchange {ExchangeName}", _config.DeadLetterExchange); throw; } } }