feature: RabbitMQ Notification Workers and DLQ Escalation

This commit is contained in:
voltsrage
2026-06-17 02:37:30 +08:00
parent ec780144c6
commit 0246143815
17 changed files with 1410 additions and 2 deletions
@@ -0,0 +1,151 @@
using System.Text;
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
public sealed class NotificationPublisherService : BackgroundService
{
private readonly IOptions<RabbitMqOptions> _rabbitOpts;
private readonly KafkaOptions _kafkaOptions;
private readonly ILogger<NotificationPublisherService> _logger;
public NotificationPublisherService(
IOptions<RabbitMqOptions> rabbitOpts,
IOptions<KafkaOptions> kafkaOptions,
ILogger<NotificationPublisherService> logger)
{
_rabbitOpts = rabbitOpts;
_kafkaOptions = kafkaOptions.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Short delay so topology provisioner finishes before first publish.
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
var factory = BuildRabbitFactory();
using var conn = factory.CreateConnection("notification-publisher");
using var chan = conn.CreateModel();
var props = chan.CreateBasicProperties();
props.Persistent = true; // messages survive broker restart
var consumerConfig = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "notification-publisher",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false,
};
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
consumer.Subscribe(new[]
{
_kafkaOptions.Topics.AlertGenerated,
_kafkaOptions.Topics.EncounterStatusChanged,
});
_logger.LogInformation("NotificationPublisherService started — consuming {Topics}",
string.Join(", ", _kafkaOptions.Topics.AlertGenerated, _kafkaOptions.Topics.EncounterStatusChanged));
try
{
while (!stoppingToken.IsCancellationRequested)
{
ConsumeResult<string, string>? result;
try
{
result = consumer.Consume(TimeSpan.FromMilliseconds(500));
}
catch (ConsumeException ex)
{
_logger.LogError(ex, "NotificationPublisher consume error");
continue;
}
if (result is null) continue;
try
{
if (result.Topic == _kafkaOptions.Topics.AlertGenerated)
await HandleAlertGeneratedAsync(chan, props, result.Message.Value, stoppingToken);
else if (result.Topic == _kafkaOptions.Topics.EncounterStatusChanged)
await HandleEncounterStatusChangedAsync(chan, props, result.Message.Value, stoppingToken);
consumer.Commit(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "NotificationPublisher failed to process message from {Topic}", result.Topic);
// Do not commit — message will be reprocessed after consumer restart.
}
}
}
finally
{
consumer.Close();
}
}
private Task HandleAlertGeneratedAsync(
IModel chan, IBasicProperties props, string payload, CancellationToken ct)
{
var doc = JsonDocument.Parse(payload);
var severity = doc.RootElement.GetProperty("severity").GetString();
if (!string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase))
{
// Warning-level alerts are not paged — they appear in the clinician dashboard only.
return Task.CompletedTask;
}
var body = Encoding.UTF8.GetBytes(payload);
chan.BasicPublish(
exchange: RabbitMqTopologyProvisioner.Exchange,
routingKey: RabbitMqTopologyProvisioner.PagingKey,
basicProperties: props,
body: body);
var alertId = doc.RootElement.GetProperty("alertId").GetString();
_logger.LogInformation("Published paging job to alerts.paging.queue for alert {AlertId}", alertId);
return Task.CompletedTask;
}
private Task HandleEncounterStatusChangedAsync(
IModel chan, IBasicProperties props, string payload, CancellationToken ct)
{
var doc = JsonDocument.Parse(payload);
var newStatus = doc.RootElement.GetProperty("newStatus").GetString();
if (!string.Equals(newStatus, "Discharged", StringComparison.OrdinalIgnoreCase))
return Task.CompletedTask;
var body = Encoding.UTF8.GetBytes(payload);
chan.BasicPublish(
exchange: RabbitMqTopologyProvisioner.Exchange,
routingKey: RabbitMqTopologyProvisioner.DischargeKey,
basicProperties: props,
body: body);
var encounterId = doc.RootElement.GetProperty("encounterId").GetString();
_logger.LogInformation("Published discharge summary job for encounter {EncounterId}", encounterId);
return Task.CompletedTask;
}
private IConnectionFactory BuildRabbitFactory()
{
var o = _rabbitOpts.Value;
return new ConnectionFactory
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
DispatchConsumersAsync = true,
};
}
}