using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using RabbitMQ.Client; using RabbitMQ.Client.Exceptions; public sealed class RabbitMqTopologyProvisioner : IHostedService { public const string Exchange = "clinical.notifications.exchange"; public const string PagingKey = "alerts.paging"; public const string EscalKey = "alerts.escalation"; public const string DischargeKey = "notifications.discharge"; private readonly RabbitMqOptions _opts; private readonly IHostEnvironment _env; private readonly ILogger _logger; public RabbitMqTopologyProvisioner( IOptions opts, IHostEnvironment env, ILogger logger) { _opts = opts.Value; _env = env; _logger = logger; } public Task StartAsync(CancellationToken ct) { var factory = BuildFactory(); using var connection = factory.CreateConnection(); using var channel = connection.CreateModel(); if (_env.IsEnvironment("Testing")) { // Use a throwaway channel — a failed purge on a missing queue closes the // channel, which would break the declare calls below. using var cleanup = connection.CreateModel(); try { // Dev runs provision the DLQ with a 5-minute TTL; delete it so the // test timeout (5 s) is applied when the queue is re-declared below. cleanup.QueueDelete("alerts.paging.dlq", ifUnused: false, ifEmpty: false); } catch (OperationInterruptedException ex) { _logger.LogDebug(ex, "DLQ delete skipped — queue may not exist yet"); } foreach (var queue in new[] { "alerts.paging.queue", "alerts.escalation.queue" }) { try { cleanup.QueuePurge(queue); } catch (OperationInterruptedException ex) { _logger.LogDebug(ex, "Queue purge skipped for {Queue}", queue); } } } // Direct exchange — routing key determines destination queue. channel.ExchangeDeclare(Exchange, ExchangeType.Direct, durable: true); // --- alerts.paging.queue --- // Dead-letters to the default exchange with routing key = alerts.paging.dlq. // Prefetch is set per-consumer, not here. channel.QueueDeclare( queue: "alerts.paging.queue", durable: true, exclusive: false, autoDelete: false, arguments: new Dictionary { ["x-dead-letter-exchange"] = "", // default exchange ["x-dead-letter-routing-key"] = "alerts.paging.dlq", }); channel.QueueBind("alerts.paging.queue", Exchange, PagingKey); // --- alerts.paging.dlq --- // Messages land here after NACK from the paging worker. // After x-message-ttl expires, re-routes to clinical.notifications.exchange // with routing key alerts.escalation → reaches alerts.escalation.queue. channel.QueueDeclare( queue: "alerts.paging.dlq", durable: true, exclusive: false, autoDelete: false, arguments: new Dictionary { ["x-message-ttl"] = (int)_opts.PagingAckTimeoutMs, ["x-dead-letter-exchange"] = Exchange, ["x-dead-letter-routing-key"] = EscalKey, }); // DLQ is reached via the default exchange — no binding to the direct exchange needed. // --- alerts.escalation.queue --- channel.QueueDeclare( queue: "alerts.escalation.queue", durable: true, exclusive: false, autoDelete: false, arguments: null); channel.QueueBind("alerts.escalation.queue", Exchange, EscalKey); // --- notifications.discharge.queue --- channel.QueueDeclare( queue: "notifications.discharge.queue", durable: true, exclusive: false, autoDelete: false, arguments: null); channel.QueueBind("notifications.discharge.queue", Exchange, DischargeKey); // --- notifications.appointment.queue (placeholder) --- channel.QueueDeclare( queue: "notifications.appointment.queue", durable: true, exclusive: false, autoDelete: false, arguments: null); channel.QueueBind("notifications.appointment.queue", Exchange, "notifications.appointment"); _logger.LogInformation( "RabbitMQ topology provisioned. Exchange={Exchange} PagingDlqTtlMs={Ttl}", Exchange, _opts.PagingAckTimeoutMs); return Task.CompletedTask; } public Task StopAsync(CancellationToken ct) => Task.CompletedTask; public IConnectionFactory BuildFactory() => new ConnectionFactory { HostName = _opts.Host, Port = _opts.Port, UserName = _opts.Username, Password = _opts.Password, DispatchConsumersAsync = true, }; }