fix errors of Degraded Operations Visibility

This commit is contained in:
voltsrage
2026-06-24 00:46:27 +08:00
parent 4399996448
commit 2e80700bf0
7 changed files with 177 additions and 40 deletions
@@ -32,13 +32,14 @@ public class ClinicalRefactorEndToEndTests : IAsyncLifetime
public async Task SofaProgressionScenario_EndToEnd() public async Task SofaProgressionScenario_EndToEnd()
{ {
var scenario = ScenarioReplayHelper.Load("sepsis-sofa-progression-01.json"); var scenario = ScenarioReplayHelper.Load("sepsis-sofa-progression-01.json");
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario); var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(
_client, scenario, _fixture.Services);
await ScenarioReplayHelper.WaitForAlertTypeAsync( await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.QsofaScreen, TimeSpan.FromSeconds(30)); _fixture.Services, encounterId, AlertType.QsofaScreen, TimeSpan.FromSeconds(60));
await ScenarioReplayHelper.WaitForAlertTypeAsync( await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(30)); _fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(60));
SepsisBundle? bundle = null; SepsisBundle? bundle = null;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10); var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
@@ -58,13 +59,14 @@ public class ClinicalRefactorEndToEndTests : IAsyncLifetime
public async Task GcsDeclineScenario_EndToEnd() public async Task GcsDeclineScenario_EndToEnd()
{ {
var scenario = ScenarioReplayHelper.Load("neurological-decline-gcs-01.json"); var scenario = ScenarioReplayHelper.Load("neurological-decline-gcs-01.json");
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario); var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(
_client, scenario, _fixture.Services);
await ScenarioReplayHelper.WaitForAlertTypeAsync( await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.GcsWarning, TimeSpan.FromSeconds(30)); _fixture.Services, encounterId, AlertType.GcsWarning, TimeSpan.FromSeconds(60));
await ScenarioReplayHelper.WaitForAlertTypeAsync( await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.GcsCritical, TimeSpan.FromSeconds(30)); _fixture.Services, encounterId, AlertType.GcsCritical, TimeSpan.FromSeconds(60));
await ScenarioReplayHelper.AssertNoAlertTypeAsync( await ScenarioReplayHelper.AssertNoAlertTypeAsync(
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(5)); _fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(5));
@@ -77,11 +79,12 @@ public class ClinicalRefactorEndToEndTests : IAsyncLifetime
public async Task PartialSofaScenario_StalenessFlags() public async Task PartialSofaScenario_StalenessFlags()
{ {
var scenario = ScenarioReplayHelper.Load("sofa-partial-spo2-fallback-01.json"); var scenario = ScenarioReplayHelper.Load("sofa-partial-spo2-fallback-01.json");
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario); var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(
_client, scenario, _fixture.Services);
var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var sofa = await ScenarioReplayHelper.WaitForSofaScoreAsync( var sofa = await ScenarioReplayHelper.WaitForSofaScoreAsync(
_fixture.Services, encounterId, TimeSpan.FromSeconds(45), _fixture.Services, encounterId, TimeSpan.FromSeconds(60),
s => s =>
{ {
if (string.IsNullOrEmpty(s.StalenessFlags)) return false; if (string.IsNullOrEmpty(s.StalenessFlags)) return false;
@@ -113,10 +116,11 @@ public class ClinicalRefactorEndToEndTests : IAsyncLifetime
public async Task UtiSepsisScenario_NowUsesSofa() public async Task UtiSepsisScenario_NowUsesSofa()
{ {
var scenario = ScenarioReplayHelper.Load("uti-sepsis-elderly-01.json"); var scenario = ScenarioReplayHelper.Load("uti-sepsis-elderly-01.json");
var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(_client, scenario); var (_, encounterId) = await ScenarioReplayHelper.ReplayObservationsAsync(
_client, scenario, _fixture.Services);
await ScenarioReplayHelper.WaitForAlertTypeAsync( await ScenarioReplayHelper.WaitForAlertTypeAsync(
_fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(45)); _fixture.Services, encounterId, AlertType.SofaSepsis, TimeSpan.FromSeconds(60));
await ScenarioReplayHelper.AssertNoAlertTypeAsync( await ScenarioReplayHelper.AssertNoAlertTypeAsync(
_fixture.Services, encounterId, AlertTypeExtensions.FromDbString("SEPSIS_WARNING"), TimeSpan.FromSeconds(5)); _fixture.Services, encounterId, AlertTypeExtensions.FromDbString("SEPSIS_WARNING"), TimeSpan.FromSeconds(5));
@@ -0,0 +1,38 @@
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Exceptions;
public static class RabbitMqTestHelper
{
public static void PurgeNotificationQueues(IOptions<RabbitMqOptions> opts)
{
var o = opts.Value;
var factory = new ConnectionFactory
{
HostName = o.Host,
Port = o.Port,
UserName = o.Username,
Password = o.Password,
};
using var connection = factory.CreateConnection("test-queue-purge");
using var channel = connection.CreateModel();
foreach (var queue in new[]
{
"alerts.paging.queue",
"alerts.paging.dlq",
"alerts.escalation.queue",
})
{
try
{
channel.QueuePurge(queue);
}
catch (OperationInterruptedException)
{
// Queue may not exist yet on a cold broker.
}
}
}
}
@@ -27,7 +27,93 @@ public static class ScenarioReplayHelper
public static async Task<(Guid PatientId, Guid EncounterId)> ReplayObservationsAsync( public static async Task<(Guid PatientId, Guid EncounterId)> ReplayObservationsAsync(
HttpClient client, HttpClient client,
ScenarioFile scenario, ScenarioFile scenario,
IServiceProvider services,
CancellationToken ct = default) CancellationToken ct = default)
{
var result = await ReplayObservationsCoreAsync(client, scenario, ct);
await WaitForOutboxDrainAsync(services, TimeSpan.FromSeconds(30), ct);
await RunClinicalEnginesForEncounterAsync(services, result.EncounterId, ct);
return result;
}
/// <summary>
/// Replays persisted observations through the clinical detectors in encounter order.
/// Scenario E2E tests use this after HTTP ingest so assertions do not depend on
/// shared Kafka consumer lag across the integration suite.
/// </summary>
public static async Task RunClinicalEnginesForEncounterAsync(
IServiceProvider services,
Guid encounterId,
CancellationToken ct = default)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var encounter = await db.Encounters
.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == encounterId, ct)
?? throw new InvalidOperationException($"Encounter {encounterId} not found");
var observations = await db.Observations
.AsNoTracking()
.Where(o => o.EncounterId == encounterId)
.OrderBy(o => o.RecordedAt)
.ThenBy(o => o.CreatedAt)
.ToListAsync(ct);
var qsofa = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
var gcs = scope.ServiceProvider.GetRequiredService<GcsDetector>();
var sofa = scope.ServiceProvider.GetRequiredService<SofaDetector>();
foreach (var obs in observations)
{
await qsofa.ProcessObservationAsync(
encounterId, encounter.PatientId, obs.ObservationCode, obs.Value, ct);
var gcsResult = await gcs.ProcessObservationAsync(
encounterId, encounter.PatientId, obs.ObservationCode, obs.Value, ct);
if (gcsResult.Outcome == GcsOutcome.ScoreComputed)
{
await sofa.ProcessGcsScoredAsync(
encounterId, encounter.PatientId, ct);
}
await sofa.ProcessObservationAsync(
encounterId,
encounter.PatientId,
obs.ObservationCode,
obs.Value,
obs.RecordedAt,
ct);
}
await qsofa.SyncAlteredMentationAsync(encounterId, encounter.PatientId, ct);
}
public static async Task WaitForOutboxDrainAsync(
IServiceProvider services,
TimeSpan timeout,
CancellationToken ct = default)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pending = await db.OutboxEvents.CountAsync(
e => e.ProcessedAt == null && e.FailedAt == null, ct);
if (pending == 0)
return;
await Task.Delay(250, ct);
}
throw new TimeoutException("Timed out waiting for outbox relay to drain pending events");
}
private static async Task<(Guid PatientId, Guid EncounterId)> ReplayObservationsCoreAsync(
HttpClient client,
ScenarioFile scenario,
CancellationToken ct)
{ {
var patientResp = await client.PostAsJsonAsync("/api/v1/patients", new var patientResp = await client.PostAsJsonAsync("/api/v1/patients", new
{ {
@@ -153,4 +239,4 @@ public static class ScenarioReplayHelper
private record ApiEnvelope<T>(T Data); private record ApiEnvelope<T>(T Data);
private record PatientDto(Guid Id); private record PatientDto(Guid Id);
private record EncounterDto(Guid Id); private record EncounterDto(Guid Id);
} }
@@ -45,6 +45,9 @@ public class NotificationPipelineTests : IAsyncLifetime
await redis.GetDatabase(1).StringSetAsync( await redis.GetDatabase(1).StringSetAsync(
"threshold:POTASSIUM_MEQ_L", "threshold:POTASSIUM_MEQ_L",
"""{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}"""); """{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}""");
RabbitMqTestHelper.PurgeNotificationQueues(
scope.ServiceProvider.GetRequiredService<IOptions<RabbitMqOptions>>());
} }
public Task DisposeAsync() => Task.CompletedTask; public Task DisposeAsync() => Task.CompletedTask;
@@ -88,7 +91,7 @@ public class NotificationPipelineTests : IAsyncLifetime
// paging worker (≤6s) → DLQ (5s TTL) → escalation worker. // paging worker (≤6s) → DLQ (5s TTL) → escalation worker.
// Poll instead of a fixed sleep: earlier tests may leave paging jobs queued // 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. // (prefetch=1), so wall-clock time varies across the full integration suite.
var deadline = DateTimeOffset.UtcNow.AddSeconds(35); var deadline = DateTimeOffset.UtcNow.AddSeconds(60);
AlertStatus status; AlertStatus status;
do do
{ {
@@ -80,7 +80,7 @@ public class SofaScoringService : BackgroundService
evt.PatientId, evt.PatientId,
evt.ObservationCode, evt.ObservationCode,
evt.Value, evt.Value,
DateTimeOffset.UtcNow, evt.RecordedAt == default ? DateTimeOffset.UtcNow : evt.RecordedAt,
stoppingToken); stoppingToken);
if (outcome.Outcome == SofaOutcome.EncounterNotFound) if (outcome.Outcome == SofaOutcome.EncounterNotFound)
@@ -3,4 +3,5 @@ public record News2ObservationEvent(
Guid EncounterId, Guid EncounterId,
Guid PatientId, Guid PatientId,
string ObservationCode, string ObservationCode,
decimal Value); decimal Value,
DateTimeOffset RecordedAt);
@@ -16,34 +16,39 @@ public class OperationsService : IOperationsService
public async Task<IReadOnlyList<GatewayFleetItem>> GetGatewayFleetAsync( public async Task<IReadOnlyList<GatewayFleetItem>> GetGatewayFleetAsync(
GatewayFleetFilter filter, CancellationToken ct) GatewayFleetFilter filter, CancellationToken ct)
{ {
await using var conn = new NpgsqlConnection(_db.Database.GetConnectionString()); var query = _db.WardGateways
await conn.OpenAsync(ct); .AsNoTracking()
.Include(g => g.Site)
.AsQueryable();
const string sql = """ if (!string.IsNullOrEmpty(filter.Status))
SELECT
g.id,
g.gateway_code AS GatewayCode,
g.department AS Department,
s.site_code AS SiteCode,
s.name AS SiteName,
g.status AS Status,
g.reported_buffer_depth AS ReportedBufferDepth,
g.last_heartbeat_at AS LastHeartbeatAt,
g.last_sync_at AS LastSyncAt,
EXTRACT(EPOCH FROM (NOW() - g.last_heartbeat_at)) / 60 AS MinutesSinceHeartbeat
FROM ward_gateways g
JOIN clinical_sites s ON s.id = g.site_id
WHERE (@Status IS NULL OR g.status = @Status)
AND (@SiteId IS NULL OR g.site_id = @SiteId)
ORDER BY g.status DESC, MinutesSinceHeartbeat DESC NULLS LAST
""";
var rows = await conn.QueryAsync<GatewayFleetItem>(sql, new
{ {
Status = filter.Status, var status = GatewayStatusExtensions.FromDbString(filter.Status);
SiteId = filter.SiteId query = query.Where(g => g.Status == status);
}); }
return rows.ToList();
if (filter.SiteId is Guid siteId)
query = query.Where(g => g.SiteId == siteId);
var now = DateTimeOffset.UtcNow;
return await query
.OrderByDescending(g => g.Status)
.ThenByDescending(g => g.LastHeartbeatAt == null)
.ThenByDescending(g => g.LastHeartbeatAt)
.Select(g => new GatewayFleetItem(
g.Id,
g.GatewayCode,
g.Department,
g.Site.SiteCode,
g.Site.Name,
g.Status.ToDbString(),
g.ReportedBufferDepth,
g.LastHeartbeatAt,
g.LastSyncAt,
g.LastHeartbeatAt == null
? null
: (double?)(now - g.LastHeartbeatAt.Value).TotalMinutes))
.ToListAsync(ct);
} }
public async Task<GatewayDetailResponse> GetGatewayDetailAsync(Guid gatewayId, CancellationToken ct) public async Task<GatewayDetailResponse> GetGatewayDetailAsync(Guid gatewayId, CancellationToken ct)