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
@@ -18,8 +18,15 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{ {
["ConnectionStrings:DefaultConnection"] = ["ConnectionStrings:DefaultConnection"] =
"Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password", "Host=localhost;Port=5436;Database=vigilcare_test;Username=postgres;Password=password",
["Redis:ConnectionString"] = "localhost:6382,defaultDatabase=1,allowAdmin=true" ["Redis:ConnectionString"] = "localhost:6382,defaultDatabase=1,allowAdmin=true",
["RabbitMq:Host"] = "localhost",
["RabbitMq:Port"] = "5674",
["RabbitMq:Username"] = "guest",
["RabbitMq:Password"] = "guest",
["RabbitMq:PagingAckTimeoutMs"] = "5000",
}); });
config.AddJsonFile("appsettings.Testing.json", optional: true, reloadOnChange: false);
}); });
} }
@@ -0,0 +1,166 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Minio.DataModel.Args;
using Minio.Exceptions;
using StackExchange.Redis;
[Collection("Integration")]
public class NotificationPipelineTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _http;
public NotificationPipelineTests(ApiFixture fixture)
{
_fixture = fixture;
_http = fixture.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await DbResetHelper.ResetAsync(db);
db.AlertThresholds.Add(new AlertThreshold
{
Id = Guid.NewGuid(),
ObservationCode = "POTASSIUM_MEQ_L",
DisplayName = "Serum Potassium",
Unit = "mEq/L",
CriticalLow = 2.5m,
WarningLow = 3.5m,
WarningHigh = 5.0m,
CriticalHigh = 6.5m,
CreatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await redis.GetDatabase(1).StringSetAsync(
"threshold:POTASSIUM_MEQ_L",
"""{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}""");
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task DischargeEncounter_UploadsSummaryToMinIO()
{
var encounterId = await CreateActiveEncounterAsync();
var resp = await _http.PatchAsJsonAsync(
$"/api/v1/encounters/{encounterId}/status",
new { status = "Discharged" });
resp.EnsureSuccessStatusCode();
// outbox relay → Kafka → notification-publisher → RabbitMQ → discharge worker → MinIO
await Task.Delay(TimeSpan.FromSeconds(8));
var mo = _fixture.Services.GetRequiredService<IOptions<MinioOptions>>().Value;
var client = MinioClientFactory.Build(mo);
var objectKey = $"discharge-summaries/{encounterId}/summary.pdf";
var exists = false;
try
{
await client.StatObjectAsync(new StatObjectArgs()
.WithBucket(mo.BucketName)
.WithObject(objectKey));
exists = true;
}
catch (ObjectNotFoundException) { }
exists.Should().BeTrue($"MinIO object not found: {objectKey}");
}
[Fact]
public async Task CriticalAlert_Unacknowledged_EscalatesAfterTimeout()
{
var encounterId = await CreateActiveEncounterAsync();
var alertId = await IngestCriticalPotassiumAsync(encounterId);
// paging worker (≤6s) → DLQ (5s TTL) → escalation worker.
// Poll instead of a fixed sleep: earlier tests may leave paging jobs queued
// (prefetch=1), so wall-clock time varies across the full integration suite.
var deadline = DateTimeOffset.UtcNow.AddSeconds(35);
AlertStatus status;
do
{
await Task.Delay(500);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
status = (await db.ClinicalAlerts.FindAsync(alertId))!.Status;
}
while (status == AlertStatus.Open && DateTimeOffset.UtcNow < deadline);
status.Should().Be(AlertStatus.Escalated);
}
[Fact]
public async Task CriticalAlert_AcknowledgedBeforeTimeout_DoesNotEscalate()
{
var encounterId = await CreateActiveEncounterAsync();
var alertId = await IngestCriticalPotassiumAsync(encounterId);
await Task.Delay(TimeSpan.FromSeconds(3));
var ackResp = await _http.PostAsJsonAsync(
$"/api/v1/alerts/{alertId}/acknowledge",
new AcknowledgeAlertRequest("Dr. Kwame Mensah", "Reviewed — will adjust potassium replacement."));
ackResp.EnsureSuccessStatusCode();
await Task.Delay(TimeSpan.FromSeconds(8));
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var alert = await db.ClinicalAlerts.FindAsync(alertId);
alert.Should().NotBeNull();
alert!.Status.Should().Be(AlertStatus.Acknowledged);
}
private async Task<Guid> CreateActiveEncounterAsync()
{
var patientId = await CreatePatientAsync();
var resp = await _http.PostAsJsonAsync(
$"/api/v1/patients/{patientId}/encounters",
new OpenEncounterRequest(EncounterType.Inpatient, "ICU", "Dr. Osei"));
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
}
private async Task<Guid> CreatePatientAsync()
{
var resp = await _http.PostAsJsonAsync(
"/api/v1/patients",
new RegisterPatientRequest("Eleanor", "Vance", new DateOnly(1962, 9, 14), "Female"));
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
}
private async Task<Guid> IngestCriticalPotassiumAsync(Guid encounterId)
{
var ingestResp = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/observations",
new BatchIngestRequest(new List<IngestObservationRequest>
{
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", ObservationSource.Device, DateTimeOffset.UtcNow, null),
}));
ingestResp.EnsureSuccessStatusCode();
var body = await ingestResp.Content.ReadFromJsonAsync<JsonDocument>();
var alertId = body!.RootElement.GetProperty("data").GetProperty("alertId").GetGuid();
alertId.Should().NotBe(Guid.Empty);
return alertId;
}
}
@@ -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);
}
}
@@ -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,
};
}
+12
View File
@@ -41,6 +41,14 @@ try
builder.Services.AddSingleton( builder.Services.AddSingleton(
new ElasticsearchClient(new Uri(esOptions.Uri))); 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<IPatientService, PatientService>();
builder.Services.AddScoped<IEncounterService, EncounterService>(); builder.Services.AddScoped<IEncounterService, EncounterService>();
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>(); builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
@@ -56,6 +64,10 @@ try
builder.Services.AddHostedService<ElasticIndexProvisioner>(); builder.Services.AddHostedService<ElasticIndexProvisioner>();
builder.Services.AddHostedService<EsIndexerService>(); builder.Services.AddHostedService<EsIndexerService>();
builder.Services.AddHostedService<SepsisEngineService>(); builder.Services.AddHostedService<SepsisEngineService>();
builder.Services.AddHostedService<NotificationPublisherService>();
builder.Services.AddHostedService<PagingWorkerService>();
builder.Services.AddHostedService<EscalationWorkerService>();
builder.Services.AddHostedService<DischargeSummaryWorkerService>();
builder.Services.AddControllers() builder.Services.AddControllers()
.AddJsonOptions(opts => .AddJsonOptions(opts =>
@@ -116,6 +116,8 @@ public class ObservationService : IObservationService
department = encounter.Department, department = encounter.Department,
alertType = alert.AlertType.ToDbString(), alertType = alert.AlertType.ToDbString(),
severity = alert.Severity.ToDbString(), severity = alert.Severity.ToDbString(),
details = alert.Details,
attendingPhysician = encounter.AttendingPhysician,
triggeredAt = alert.TriggeredAt, triggeredAt = alert.TriggeredAt,
partitionKey = encounterId.ToString() partitionKey = encounterId.ToString()
}, 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> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Minio" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> <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.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" /> <PackageReference Include="Serilog.Sinks.Seq" Version="9.1.0" />
@@ -10,5 +10,8 @@
"WriteTo": [ "WriteTo": [
{ "Name": "Console" } { "Name": "Console" }
] ]
},
"RabbitMq": {
"PagingAckTimeoutMs": 5000
} }
} }
+14
View File
@@ -54,5 +54,19 @@
"Observations": "observations", "Observations": "observations",
"ClinicalAlerts": "clinical_alerts" "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
} }
} }
+30 -1
View File
@@ -69,8 +69,37 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
rabbitmq:
image: rabbitmq:3.13-management-alpine
container_name: vigilcare_rabbitmq
ports:
- "5674:5672" # AMQP
- "15674:15672" # management UI
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:RELEASE.2024-07-04T14-25-45Z
container_name: vigilcare_minio
command: server /data --console-address ":9001"
ports:
- "9005:9000" # S3 API
- "9006:9001" # console
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- minio_data:/data
volumes: volumes:
pg_data: pg_data:
seq_data: seq_data:
kafka_data: kafka_data:
es_data: es_data:
minio_data:
+474
View File
@@ -0,0 +1,474 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="${COMPOSE_FILE:-${SCRIPT_DIR}/../docker-compose.yml}"
BASE_URL="${BASE_URL:-http://localhost:5270}"
RABBITMQ_MGMT_URL="${RABBITMQ_MGMT_URL:-http://localhost:15674}"
RABBITMQ_USER="${RABBITMQ_USER:-guest}"
RABBITMQ_PASS="${RABBITMQ_PASS:-guest}"
MINIO_ENDPOINT="${MINIO_ENDPOINT:-localhost:9005}"
MINIO_ACCESS_KEY="${MINIO_ACCESS_KEY:-minioadmin}"
MINIO_SECRET_KEY="${MINIO_SECRET_KEY:-minioadmin}"
MINIO_BUCKET="${MINIO_BUCKET:-vigilcare}"
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5436}"
PGDATABASE="${PGDATABASE:-vigilcare}"
PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-password}"
RELAY_WAIT_SECS="${RELAY_WAIT_SECS:-45}"
KAFKA_READY_WAIT_SECS="${KAFKA_READY_WAIT_SECS:-30}"
PAGING_PIPELINE_WAIT_SECS="${PAGING_PIPELINE_WAIT_SECS:-30}"
MINIO_WAIT_SECS="${MINIO_WAIT_SECS:-15}"
ACK_PIPELINE_WAIT_SECS="${ACK_PIPELINE_WAIT_SECS:-10}"
SCRIPT_RUN_ID="$(date -u +"%Y%m%d%H%M%S")"
RECORDED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
EXPECTED_QUEUES=(
"alerts.paging.queue"
"alerts.paging.dlq"
"alerts.escalation.queue"
"notifications.discharge.queue"
"notifications.appointment.queue"
)
TMP_FILES=()
cleanup() {
local f
for f in "${TMP_FILES[@]}"; do
rm -f "${f}" "${f}.status" 2>/dev/null || true
done
}
trap cleanup EXIT
if ! command -v curl >/dev/null 2>&1; then
echo "Missing dependency: curl"
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "Missing dependency: jq"
exit 1
fi
if ! command -v docker >/dev/null 2>&1 || [[ ! -f "${COMPOSE_FILE}" ]]; then
echo "Missing dependency: docker compose (${COMPOSE_FILE})"
exit 1
fi
compose() {
docker compose -f "${COMPOSE_FILE}" "$@"
}
kafka_exec() {
compose exec -T kafka "$@"
}
psql_cmd() {
local sql="$1"
if command -v psql >/dev/null 2>&1; then
PGPASSWORD="${PGPASSWORD}" psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
else
compose exec -T postgres psql -U "${PGUSER}" -d "${PGDATABASE}" -tAc "${sql}"
fi
}
request() {
local method="$1"
local url="$2"
local body="${3:-}"
local tmp_body
tmp_body="$(mktemp)"
TMP_FILES+=("${tmp_body}")
local status
if [[ -n "${body}" ]]; then
status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}" \
-H "Content-Type: application/json" -d "${body}")"
else
status="$(curl -sS -o "${tmp_body}" -w "%{http_code}" -X "${method}" "${url}")"
fi
echo "${status}" > "${tmp_body}.status"
echo "${tmp_body}"
}
assert_status() {
local expected="$1"
local body_file="$2"
local status
status="$(cat "${body_file}.status")"
if [[ "${status}" != "${expected}" ]]; then
echo "Expected HTTP ${expected}, got ${status}"
echo "Response body:"
cat "${body_file}"
echo
return 1
fi
}
rabbit_api() {
curl -sS -u "${RABBITMQ_USER}:${RABBITMQ_PASS}" "${RABBITMQ_MGMT_URL}/api/${1}"
}
queue_field() {
local queue="$1"
local field="$2"
rabbit_api "queues/%2F/${queue}" | jq -r ".${field} // 0"
}
wait_for_kafka() {
local elapsed=0
while (( elapsed < KAFKA_READY_WAIT_SECS )); do
if kafka_exec /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 >/dev/null 2>&1; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
echo "Kafka did not become ready within ${KAFKA_READY_WAIT_SECS}s"
return 1
}
wait_for_outbox_processed() {
local outbox_id="$1"
local elapsed=0
while (( elapsed < RELAY_WAIT_SECS )); do
local processed
processed="$(psql_cmd "SELECT processed_at IS NOT NULL FROM outbox_events WHERE id = '${outbox_id}'")"
if [[ "${processed}" == "t" ]]; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "Outbox row ${outbox_id} was not processed within ${RELAY_WAIT_SECS}s"
return 1
}
wait_for_alert_outbox_relayed() {
local encounter_id="$1"
local elapsed=0
while (( elapsed < RELAY_WAIT_SECS )); do
local outbox_id
outbox_id="$(psql_cmd "SELECT id FROM outbox_events WHERE topic = 'alert.generated' AND partition_key = '${encounter_id}' ORDER BY created_at DESC LIMIT 1")"
if [[ -n "${outbox_id}" ]]; then
local processed
processed="$(psql_cmd "SELECT processed_at IS NOT NULL FROM outbox_events WHERE id = '${outbox_id}'")"
if [[ "${processed}" == "t" ]]; then
return 0
fi
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "alert.generated outbox for encounter ${encounter_id} was not relayed within ${RELAY_WAIT_SECS}s"
return 1
}
wait_for_paging_activity() {
local elapsed=0
while (( elapsed < PAGING_PIPELINE_WAIT_SECS )); do
local unacked ready total
unacked="$(queue_field "alerts.paging.queue" "messages_unacknowledged")"
ready="$(queue_field "alerts.paging.queue" "messages_ready")"
total="$(queue_field "alerts.paging.queue" "messages")"
if [[ "${unacked}" -ge 1 || "${ready}" -ge 1 || "${total}" -ge 1 ]]; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
echo "No paging activity on alerts.paging.queue within ${PAGING_PIPELINE_WAIT_SECS}s"
return 1
}
wait_for_alert_status() {
local alert_id="$1"
local expected="$2"
local timeout_secs="$3"
local elapsed=0
while (( elapsed < timeout_secs )); do
local status
status="$(psql_cmd "SELECT status FROM clinical_alerts WHERE id = '${alert_id}'")"
if [[ "${status}" == "${expected}" ]]; then
return 0
fi
sleep 1
elapsed=$((elapsed + 1))
done
local final
final="$(psql_cmd "SELECT status FROM clinical_alerts WHERE id = '${alert_id}'")"
echo "Alert ${alert_id} did not reach status ${expected} within ${timeout_secs}s (last status=${final:-unknown})"
return 1
}
minio_object_exists() {
local object_key="$1"
if command -v mc >/dev/null 2>&1; then
mc alias set local "http://${MINIO_ENDPOINT}" "${MINIO_ACCESS_KEY}" "${MINIO_SECRET_KEY}" --api S3v4 >/dev/null 2>&1 || true
mc stat "local/${MINIO_BUCKET}/${object_key}" >/dev/null 2>&1
return $?
fi
docker run --rm --network host --entrypoint /bin/sh minio/mc:latest \
-c "mc alias set local http://${MINIO_ENDPOINT} ${MINIO_ACCESS_KEY} ${MINIO_SECRET_KEY} >/dev/null 2>&1 && mc stat local/${MINIO_BUCKET}/${object_key}" \
>/dev/null 2>&1
}
create_patient() {
local payload='{"firstName":"Notify","lastName":"Verifier","dateOfBirth":"1972-03-18","gender":"Female"}'
local resp
resp="$(request POST "${BASE_URL}/api/v1/patients" "${payload}")"
assert_status "201" "${resp}"
jq -r '.data.id' "${resp}"
}
create_encounter() {
local patient_id="$1"
local suffix="$2"
local payload
payload="$(jq -nc \
--arg physician "Dr. Notify ${suffix}" \
'{encounterType:"Inpatient",department:"ICU",attendingPhysician:$physician}')"
local resp
resp="$(request POST "${BASE_URL}/api/v1/patients/${patient_id}/encounters" "${payload}")"
assert_status "201" "${resp}"
jq -r '.data.id' "${resp}"
}
ingest_critical_potassium() {
local encounter_id="$1"
local idempotency_key="$2"
local payload
payload="$(jq -nc \
--arg recordedAt "${RECORDED_AT}" \
--arg key "${idempotency_key}" \
'{observations:[{observationCode:"POTASSIUM_MEQ_L",value:2.1,unit:"mEq/L",source:"LAB",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
local resp
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_id}/observations" "${payload}")"
assert_status "201" "${resp}"
if [[ "$(jq -r '.data.alertGenerated' "${resp}")" != "true" ]]; then
echo "Expected critical potassium ingest to generate an alert"
exit 1
fi
jq -r '.data.alertId' "${resp}"
}
TOTAL_STEPS=8
echo "Running RabbitMQ + MinIO notification pipeline verification against ${BASE_URL}"
echo "Script run id: ${SCRIPT_RUN_ID}"
echo ""
echo "[0/${TOTAL_STEPS}] Preflight — API, Postgres, Kafka, RabbitMQ, and MinIO reachable"
preflight_status="$(curl -sS -o /dev/null -w "%{http_code}" "${BASE_URL}/api/v1/alert-thresholds" || true)"
if [[ "${preflight_status}" != "200" ]]; then
echo "API not reachable at ${BASE_URL} (HTTP ${preflight_status})."
echo "Start the API with: dotnet run --project VigilCareClinicalAPI"
exit 1
fi
if ! psql_cmd "SELECT 1" >/dev/null 2>&1; then
echo "Postgres not reachable on ${PGHOST}:${PGPORT}."
echo "Start the stack with: docker compose up -d"
exit 1
fi
if ! wait_for_kafka; then
exit 1
fi
rabbit_health="$(rabbit_api "health/checks/alarms" 2>/dev/null | jq -r '.status // empty' || true)"
if [[ -z "${rabbit_health}" ]]; then
echo "RabbitMQ management API not reachable at ${RABBITMQ_MGMT_URL}."
echo "Start RabbitMQ with: docker compose up -d rabbitmq"
exit 1
fi
if ! curl -sS -o /dev/null -w "%{http_code}" "http://${MINIO_ENDPOINT}/minio/health/live" | grep -q '^200$'; then
echo "MinIO not reachable at http://${MINIO_ENDPOINT}."
echo "Start MinIO with: docker compose up -d minio"
exit 1
fi
echo "OK: API, Postgres, Kafka, RabbitMQ, and MinIO are up"
echo ""
echo "[1/${TOTAL_STEPS}] Verifying RabbitMQ topology (exchange, five queues, DLQ arguments)"
exchange_name="$(rabbit_api "exchanges/%2F/clinical.notifications.exchange" | jq -r '.name // empty')"
if [[ "${exchange_name}" != "clinical.notifications.exchange" ]]; then
echo "Exchange clinical.notifications.exchange not found. Start the API so RabbitMqTopologyProvisioner runs."
exit 1
fi
for queue in "${EXPECTED_QUEUES[@]}"; do
queue_name="$(rabbit_api "queues/%2F/${queue}" | jq -r '.name // empty')"
if [[ "${queue_name}" != "${queue}" ]]; then
echo "Queue ${queue} not found (got '${queue_name}')."
exit 1
fi
done
dlq_ttl_ms="$(rabbit_api "queues/%2F/alerts.paging.dlq" | jq -r '.arguments["x-message-ttl"] // empty')"
dlq_dlx="$(rabbit_api "queues/%2F/alerts.paging.dlq" | jq -r '.arguments["x-dead-letter-exchange"] // empty')"
dlq_dlk="$(rabbit_api "queues/%2F/alerts.paging.dlq" | jq -r '.arguments["x-dead-letter-routing-key"] // empty')"
paging_dlk="$(rabbit_api "queues/%2F/alerts.paging.queue" | jq -r '.arguments["x-dead-letter-routing-key"] // empty')"
if [[ -z "${dlq_ttl_ms}" ]]; then
echo "alerts.paging.dlq is missing x-message-ttl"
exit 1
fi
if [[ "${dlq_dlx}" != "clinical.notifications.exchange" ]]; then
echo "Expected DLQ x-dead-letter-exchange=clinical.notifications.exchange, got '${dlq_dlx}'"
exit 1
fi
if [[ "${dlq_dlk}" != "alerts.escalation" ]]; then
echo "Expected DLQ x-dead-letter-routing-key=alerts.escalation, got '${dlq_dlk}'"
exit 1
fi
if [[ "${paging_dlk}" != "alerts.paging.dlq" ]]; then
echo "Expected paging queue DLK=alerts.paging.dlq, got '${paging_dlk}'"
exit 1
fi
ttl_sec=$(( dlq_ttl_ms / 1000 ))
ESCALATION_WAIT_SECS="${ESCALATION_WAIT_SECS:-$(( ttl_sec * 2 + 25 ))}"
echo "OK: exchange + 5 queues provisioned; DLQ TTL=${dlq_ttl_ms}ms (escalation wait budget=${ESCALATION_WAIT_SECS}s)"
echo ""
echo "[2/${TOTAL_STEPS}] Verifying non-alert observation does not enqueue paging jobs"
patient_no_page="$(create_patient)"
encounter_no_page="$(create_encounter "${patient_no_page}" "NoPage")"
paging_before="$(queue_field "alerts.paging.queue" "messages")"
normal_payload="$(jq -nc \
--arg recordedAt "${RECORDED_AT}" \
--arg key "notify-normal-${SCRIPT_RUN_ID}" \
'{observations:[{observationCode:"POTASSIUM_MEQ_L",value:3.8,unit:"mEq/L",source:"LAB",recordedAt:$recordedAt,idempotencyKey:$key}]}')"
resp="$(request POST "${BASE_URL}/api/v1/encounters/${encounter_no_page}/observations" "${normal_payload}")"
assert_status "201" "${resp}"
if [[ "$(jq -r '.data.alertGenerated' "${resp}")" != "false" ]]; then
echo "Expected normal potassium ingest to skip alert generation"
exit 1
fi
sleep 3
paging_after="$(queue_field "alerts.paging.queue" "messages")"
if [[ "${paging_after}" -gt "${paging_before}" ]]; then
echo "Paging queue grew after non-alert ingest (${paging_before} -> ${paging_after})"
exit 1
fi
echo "OK: normal observation produced no paging queue traffic"
echo ""
echo "[3/${TOTAL_STEPS}] Verifying critical alert reaches alerts.paging.queue via Kafka bridge"
patient_page="$(create_patient)"
encounter_page="$(create_encounter "${patient_page}" "Page")"
alert_page_id="$(ingest_critical_potassium "${encounter_page}" "notify-critical-page-${SCRIPT_RUN_ID}")"
if ! wait_for_alert_outbox_relayed "${encounter_page}"; then
exit 1
fi
if ! wait_for_paging_activity; then
echo "Hint: confirm NotificationPublisherService is running and alert.generated severity is Critical."
exit 1
fi
echo "OK: critical alert ${alert_page_id} reached alerts.paging.queue (worker holding or ready message present)"
echo ""
echo "[4/${TOTAL_STEPS}] Verifying acknowledge-before-timeout path (no escalation)"
ack_payload='{"clinicianId":"DR-SCRIPT","note":"Acknowledged from notification verification script."}'
resp="$(request POST "${BASE_URL}/api/v1/alerts/${alert_page_id}/acknowledge" "${ack_payload}")"
assert_status "200" "${resp}"
sleep "${ACK_PIPELINE_WAIT_SECS}"
if ! wait_for_alert_status "${alert_page_id}" "ACKNOWLEDGED" 5; then
exit 1
fi
echo "OK: alert ${alert_page_id} is ACKNOWLEDGED (not escalated)"
echo ""
echo "[5/${TOTAL_STEPS}] Verifying full DLQ escalation path (unacknowledged critical alert)"
patient_escalate="$(create_patient)"
encounter_escalate="$(create_encounter "${patient_escalate}" "Escalate")"
alert_escalate_id="$(ingest_critical_potassium "${encounter_escalate}" "notify-critical-escalate-${SCRIPT_RUN_ID}")"
if ! wait_for_alert_outbox_relayed "${encounter_escalate}"; then
exit 1
fi
if ! wait_for_paging_activity; then
exit 1
fi
echo "Waiting up to ${ESCALATION_WAIT_SECS}s for paging timeout + DLQ TTL + escalation worker..."
if ! wait_for_alert_status "${alert_escalate_id}" "ESCALATED" "${ESCALATION_WAIT_SECS}"; then
echo "Hint: default DLQ TTL is 300000ms (~10 min total). Set PagingAckTimeoutMs=5000 in appsettings.json and restart the API for a faster run, or raise ESCALATION_WAIT_SECS."
exit 1
fi
echo "OK: alert ${alert_escalate_id} escalated to ESCALATED in PostgreSQL"
echo ""
echo "[6/${TOTAL_STEPS}] Verifying discharge summary upload to MinIO"
patient_discharge="$(create_patient)"
encounter_discharge="$(create_encounter "${patient_discharge}" "Discharge")"
discharge_payload='{"status":"Discharged"}'
resp="$(request PATCH "${BASE_URL}/api/v1/encounters/${encounter_discharge}/status" "${discharge_payload}")"
assert_status "200" "${resp}"
discharge_outbox_id="$(psql_cmd "SELECT id FROM outbox_events WHERE topic = 'encounter.status.changed' AND partition_key = '${encounter_discharge}' ORDER BY created_at DESC LIMIT 1")"
if [[ -z "${discharge_outbox_id}" ]]; then
echo "No encounter.status.changed outbox row for encounter ${encounter_discharge}"
exit 1
fi
wait_for_outbox_processed "${discharge_outbox_id}"
object_key="discharge-summaries/${encounter_discharge}/summary.pdf"
elapsed=0
while (( elapsed < MINIO_WAIT_SECS )); do
if minio_object_exists "${object_key}"; then
break
fi
sleep 1
elapsed=$((elapsed + 1))
done
if ! minio_object_exists "${object_key}"; then
echo "MinIO object not found: ${MINIO_BUCKET}/${object_key}"
echo "Hint: confirm DischargeSummaryWorkerService is running and notifications.discharge.queue is draining."
exit 1
fi
echo "OK: discharge summary object exists at ${MINIO_BUCKET}/${object_key}"
echo ""
echo "[7/${TOTAL_STEPS}] Verifying discharge queue drained after summary upload"
elapsed=0
while (( elapsed < MINIO_WAIT_SECS )); do
discharge_ready="$(queue_field "notifications.discharge.queue" "messages_ready")"
discharge_unacked="$(queue_field "notifications.discharge.queue" "messages_unacknowledged")"
if [[ "${discharge_ready}" == "0" && "${discharge_unacked}" == "0" ]]; then
break
fi
sleep 1
elapsed=$((elapsed + 1))
done
discharge_ready="$(queue_field "notifications.discharge.queue" "messages_ready")"
discharge_unacked="$(queue_field "notifications.discharge.queue" "messages_unacknowledged")"
if [[ "${discharge_ready}" != "0" || "${discharge_unacked}" != "0" ]]; then
echo "Expected notifications.discharge.queue to be empty (ready=${discharge_ready}, unacked=${discharge_unacked})"
exit 1
fi
echo "OK: notifications.discharge.queue is drained"
echo ""
echo "All ${TOTAL_STEPS} notification pipeline checks passed."
echo ""
echo "Prerequisites: docker compose up -d && dotnet run --project VigilCareClinicalAPI"
echo "Optional: set PagingAckTimeoutMs=5000 in appsettings.json (restart API) for ~15s escalation instead of ~10 min."
echo "Optional: ESCALATION_WAIT_SECS=330 when using the production 300000ms DLQ TTL."