feature: RabbitMQ Notification Workers and DLQ Escalation
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Minio.DataModel.Args;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
public sealed class DischargeSummaryWorkerService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<RabbitMqOptions> _rabbitOpts;
|
||||
private readonly IOptions<MinioOptions> _minioOpts;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ILogger<DischargeSummaryWorkerService> _logger;
|
||||
|
||||
public DischargeSummaryWorkerService(
|
||||
IOptions<RabbitMqOptions> rabbitOpts,
|
||||
IOptions<MinioOptions> minioOpts,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<DischargeSummaryWorkerService> logger)
|
||||
{
|
||||
_rabbitOpts = rabbitOpts;
|
||||
_minioOpts = minioOpts;
|
||||
_scopes = scopes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var o = _rabbitOpts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("discharge-summary-worker");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
channel.BasicQos(0, prefetchCount: 3, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.Received += async (sender, ea) =>
|
||||
{
|
||||
await HandleDischargeSummaryAsync(channel, ea, stoppingToken);
|
||||
};
|
||||
|
||||
channel.BasicConsume("notifications.discharge.queue", autoAck: false, consumer);
|
||||
|
||||
_logger.LogInformation("DischargeSummaryWorkerService consuming notifications.discharge.queue");
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task HandleDischargeSummaryAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
var doc = JsonDocument.Parse(payload);
|
||||
var encounterId = Guid.Parse(doc.RootElement.GetProperty("encounterId").GetString()!);
|
||||
|
||||
try
|
||||
{
|
||||
var summary = await BuildSummaryAsync(encounterId, ct);
|
||||
await UploadToMinioAsync(encounterId, summary, ct);
|
||||
|
||||
channel.BasicAck(ea.DeliveryTag, multiple: false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"[DISCHARGE-SUMMARY] Uploaded for encounter {EncounterId}", encounterId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "DischargeSummaryWorker failed for encounter {EncounterId}", encounterId);
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> BuildSummaryAsync(Guid encounterId, CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var encounter = await db.Encounters
|
||||
.Include(e => e.Patient)
|
||||
.FirstOrDefaultAsync(e => e.Id == encounterId, ct);
|
||||
|
||||
if (encounter is null)
|
||||
return $"DISCHARGE SUMMARY\nEncounter {encounterId} not found in database.";
|
||||
|
||||
var obsCount = await db.Observations.CountAsync(o => o.EncounterId == encounterId, ct);
|
||||
var alertCount = await db.ClinicalAlerts.CountAsync(a => a.EncounterId == encounterId, ct);
|
||||
var orders = await db.Orders
|
||||
.Where(o => o.EncounterId == encounterId)
|
||||
.Select(o => new { o.OrderType, o.Description, o.Status, o.OrderedAt, o.ResultedAt })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== VIGILCARE DISCHARGE SUMMARY ===");
|
||||
sb.AppendLine($"Generated: {DateTimeOffset.UtcNow:u}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Patient: {encounter.Patient.FirstName} {encounter.Patient.LastName}");
|
||||
sb.AppendLine($"MRN: {encounter.Patient.Mrn}");
|
||||
sb.AppendLine($"Date of Birth: {encounter.Patient.DateOfBirth:yyyy-MM-dd}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Encounter ID: {encounter.Id}");
|
||||
sb.AppendLine($"Type: {encounter.EncounterType}");
|
||||
sb.AppendLine($"Department: {encounter.Department}");
|
||||
sb.AppendLine($"Attending: {encounter.AttendingPhysician}");
|
||||
sb.AppendLine($"Admitted: {encounter.AdmittedAt:u}");
|
||||
sb.AppendLine($"Discharged: {encounter.DischargedAt:u}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Observations Recorded: {obsCount}");
|
||||
sb.AppendLine($"Clinical Alerts: {alertCount}");
|
||||
sb.AppendLine();
|
||||
|
||||
if (orders.Any())
|
||||
{
|
||||
sb.AppendLine("--- Orders ---");
|
||||
foreach (var o in orders)
|
||||
sb.AppendLine($" [{o.Status}] {o.OrderType.ToDbString()}: {o.Description} (ordered: {o.OrderedAt:u})");
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("NOTE: This summary is generated automatically. Clinician review required.");
|
||||
sb.AppendLine("NOTE: HIPAA Notice — This document contains protected health information.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private async Task UploadToMinioAsync(Guid encounterId, string content, CancellationToken ct)
|
||||
{
|
||||
var mo = _minioOpts.Value;
|
||||
var client = MinioClientFactory.Build(mo);
|
||||
var bucket = mo.BucketName;
|
||||
|
||||
var exists = await client.BucketExistsAsync(
|
||||
new BucketExistsArgs().WithBucket(bucket), ct);
|
||||
|
||||
if (!exists)
|
||||
await client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), ct);
|
||||
|
||||
var objectKey = $"discharge-summaries/{encounterId}/summary.pdf";
|
||||
var bytes = Encoding.UTF8.GetBytes(content);
|
||||
|
||||
await client.PutObjectAsync(new PutObjectArgs()
|
||||
.WithBucket(bucket)
|
||||
.WithObject(objectKey)
|
||||
.WithStreamData(new MemoryStream(bytes))
|
||||
.WithObjectSize(bytes.Length)
|
||||
.WithContentType("text/plain"),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
public sealed class EscalationWorkerService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<RabbitMqOptions> _opts;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ILogger<EscalationWorkerService> _logger;
|
||||
|
||||
public EscalationWorkerService(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<EscalationWorkerService> logger)
|
||||
{
|
||||
_opts = opts;
|
||||
_scopes = scopes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var o = _opts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("escalation-worker");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
channel.BasicQos(0, prefetchCount: 5, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.Received += async (sender, ea) =>
|
||||
{
|
||||
await HandleEscalationAsync(channel, ea, stoppingToken);
|
||||
};
|
||||
|
||||
channel.BasicConsume("alerts.escalation.queue", autoAck: false, consumer);
|
||||
|
||||
_logger.LogInformation("EscalationWorkerService consuming alerts.escalation.queue");
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task HandleEscalationAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
var doc = JsonDocument.Parse(payload);
|
||||
var alertId = Guid.Parse(doc.RootElement.GetProperty("alertId").GetString()!);
|
||||
var encounterId = doc.RootElement.GetProperty("encounterId").GetString();
|
||||
|
||||
_logger.LogCritical(
|
||||
"[ESCALATION] Paging on-call backup — AlertId={AlertId} EncounterId={EncounterId}",
|
||||
alertId, encounterId);
|
||||
|
||||
try
|
||||
{
|
||||
await UpdateAlertStatusEscalatedAsync(alertId, ct);
|
||||
channel.BasicAck(ea.DeliveryTag, multiple: false);
|
||||
|
||||
_logger.LogWarning(
|
||||
"[ESCALATION-DONE] Alert {AlertId} status → Escalated in PostgreSQL", alertId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "EscalationWorker failed for alert {AlertId}", alertId);
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateAlertStatusEscalatedAsync(Guid alertId, CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var alert = await db.ClinicalAlerts.FindAsync(new object[] { alertId }, ct);
|
||||
if (alert is null) return;
|
||||
|
||||
// Only escalate if still open — if acknowledged between NACK and TTL expiry, leave it.
|
||||
if (alert.Status != AlertStatus.Open) return;
|
||||
|
||||
alert.Status = AlertStatus.Escalated;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
+151
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
public sealed class PagingWorkerService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<RabbitMqOptions> _opts;
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly ILogger<PagingWorkerService> _logger;
|
||||
|
||||
public PagingWorkerService(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<PagingWorkerService> logger)
|
||||
{
|
||||
_opts = opts;
|
||||
_scopes = scopes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); // wait for topology
|
||||
|
||||
var o = _opts.Value;
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = o.Host,
|
||||
Port = o.Port,
|
||||
UserName = o.Username,
|
||||
Password = o.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("paging-worker");
|
||||
using var channel = connection.CreateModel();
|
||||
|
||||
// Prefetch 1: hold exactly one page at a time.
|
||||
// Releasing the next message only after ACK or NACK keeps the worker
|
||||
// from consuming more alerts than it can actively page on.
|
||||
channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.Received += async (sender, ea) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandlePageAsync(channel, ea, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "PagingWorker failed — NACKing to DLQ");
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
|
||||
}
|
||||
};
|
||||
|
||||
channel.BasicConsume("alerts.paging.queue", autoAck: false, consumer);
|
||||
|
||||
_logger.LogInformation("PagingWorkerService consuming alerts.paging.queue (prefetch=1, timeout={Timeout}ms)",
|
||||
o.PagingAckTimeoutMs);
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task HandlePageAsync(RabbitMQ.Client.IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
|
||||
{
|
||||
var o = _opts.Value;
|
||||
var payload = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
var doc = JsonDocument.Parse(payload);
|
||||
var alertId = Guid.Parse(doc.RootElement.GetProperty("alertId").GetString()!);
|
||||
var encounterId = doc.RootElement.GetProperty("encounterId").GetString();
|
||||
var details = doc.RootElement.TryGetProperty("details", out var d) ? d.GetString() : null;
|
||||
var physician = doc.RootElement.TryGetProperty("attendingPhysician", out var p) ? p.GetString() : "unknown";
|
||||
|
||||
_logger.LogWarning(
|
||||
"[PAGE] Paging attending physician '{Physician}' for encounter {EncounterId} — " +
|
||||
"AlertId={AlertId} Details={Details}",
|
||||
physician, encounterId, alertId, details);
|
||||
|
||||
var deadline = DateTimeOffset.UtcNow.AddMilliseconds(o.PagingAckTimeoutMs);
|
||||
|
||||
while (DateTimeOffset.UtcNow < deadline && !ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(2_000, ct);
|
||||
|
||||
var acknowledged = await IsAlertAcknowledgedAsync(alertId, ct);
|
||||
if (acknowledged)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[PAGE-ACK] Alert {AlertId} acknowledged — ACKing RabbitMQ message", alertId);
|
||||
channel.BasicAck(ea.DeliveryTag, multiple: false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout: NACK with requeue=false → message routes to alerts.paging.dlq.
|
||||
// After x-message-ttl expires on the DLQ, it re-routes to alerts.escalation.queue.
|
||||
_logger.LogWarning(
|
||||
"[PAGE-TIMEOUT] No acknowledgment within {TimeoutMs}ms for alert {AlertId} — " +
|
||||
"NACKing to DLQ for escalation",
|
||||
o.PagingAckTimeoutMs, alertId);
|
||||
channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false);
|
||||
}
|
||||
|
||||
private async Task<bool> IsAlertAcknowledgedAsync(Guid alertId, CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var status = await db.ClinicalAlerts
|
||||
.Where(a => a.Id == alertId)
|
||||
.Select(a => a.Status)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
return status is AlertStatus.Acknowledged or AlertStatus.Resolved;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
public sealed class MinioOptions
|
||||
{
|
||||
public const string Section = "Minio";
|
||||
public string Endpoint { get; init; } = "localhost:9005";
|
||||
public string AccessKey { get; init; } = "minioadmin";
|
||||
public string SecretKey { get; init; } = "minioadmin";
|
||||
public string BucketName { get; init; } = "vigilcare";
|
||||
public bool UseSSL { get; init; } = false;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
public sealed class RabbitMqOptions
|
||||
{
|
||||
public const string Section = "RabbitMq";
|
||||
public string Host { get; init; } = "localhost";
|
||||
public int Port { get; init; } = 5674;
|
||||
public string Username { get; init; } = "guest";
|
||||
public string Password { get; init; } = "guest";
|
||||
// Drives both the paging worker poll timeout and the DLQ x-message-ttl.
|
||||
// In production: 300000 (5 min). In tests: 5000 (5 sec).
|
||||
public int PagingAckTimeoutMs { get; init; } = 300000;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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<RabbitMqTopologyProvisioner> _logger;
|
||||
|
||||
public RabbitMqTopologyProvisioner(
|
||||
IOptions<RabbitMqOptions> opts,
|
||||
IHostEnvironment env,
|
||||
ILogger<RabbitMqTopologyProvisioner> 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<string, object>
|
||||
{
|
||||
["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<string, object>
|
||||
{
|
||||
["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,
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,14 @@ try
|
||||
builder.Services.AddSingleton(
|
||||
new ElasticsearchClient(new Uri(esOptions.Uri)));
|
||||
|
||||
builder.Services.Configure<RabbitMqOptions>(
|
||||
builder.Configuration.GetSection(RabbitMqOptions.Section));
|
||||
builder.Services.Configure<MinioOptions>(
|
||||
builder.Configuration.GetSection(MinioOptions.Section));
|
||||
|
||||
builder.Services.AddSingleton<RabbitMqTopologyProvisioner>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<RabbitMqTopologyProvisioner>());
|
||||
|
||||
builder.Services.AddScoped<IPatientService, PatientService>();
|
||||
builder.Services.AddScoped<IEncounterService, EncounterService>();
|
||||
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
|
||||
@@ -56,6 +64,10 @@ try
|
||||
builder.Services.AddHostedService<ElasticIndexProvisioner>();
|
||||
builder.Services.AddHostedService<EsIndexerService>();
|
||||
builder.Services.AddHostedService<SepsisEngineService>();
|
||||
builder.Services.AddHostedService<NotificationPublisherService>();
|
||||
builder.Services.AddHostedService<PagingWorkerService>();
|
||||
builder.Services.AddHostedService<EscalationWorkerService>();
|
||||
builder.Services.AddHostedService<DischargeSummaryWorkerService>();
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(opts =>
|
||||
|
||||
@@ -116,6 +116,8 @@ public class ObservationService : IObservationService
|
||||
department = encounter.Department,
|
||||
alertType = alert.AlertType.ToDbString(),
|
||||
severity = alert.Severity.ToDbString(),
|
||||
details = alert.Details,
|
||||
attendingPhysician = encounter.AttendingPhysician,
|
||||
triggeredAt = alert.TriggeredAt,
|
||||
partitionKey = encounterId.ToString()
|
||||
}, encounterId.ToString()));
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Minio;
|
||||
|
||||
public static class MinioClientFactory
|
||||
{
|
||||
public static IMinioClient Build(MinioOptions opts)
|
||||
{
|
||||
var client = new MinioClient()
|
||||
.WithEndpoint(opts.Endpoint)
|
||||
.WithCredentials(opts.AccessKey, opts.SecretKey);
|
||||
|
||||
if (opts.UseSSL) client = client.WithSSL();
|
||||
|
||||
return client.Build();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Minio" Version="6.0.3" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="6.8.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
|
||||
|
||||
@@ -10,5 +10,8 @@
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" }
|
||||
]
|
||||
},
|
||||
"RabbitMq": {
|
||||
"PagingAckTimeoutMs": 5000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,5 +54,19 @@
|
||||
"Observations": "observations",
|
||||
"ClinicalAlerts": "clinical_alerts"
|
||||
}
|
||||
},
|
||||
"RabbitMq": {
|
||||
"Host": "localhost",
|
||||
"Port": 5674,
|
||||
"Username": "guest",
|
||||
"Password": "guest",
|
||||
"PagingAckTimeoutMs": 300000
|
||||
},
|
||||
"Minio": {
|
||||
"Endpoint": "localhost:9005",
|
||||
"AccessKey": "minioadmin",
|
||||
"SecretKey": "minioadmin",
|
||||
"BucketName": "vigilcare",
|
||||
"UseSSL": false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user