using System.Text.Json; using Microsoft.EntityFrameworkCore; using Prometheus; using Serilog.Context; using StackExchange.Redis; public class ObservationService : IObservationService { private readonly AppDbContext _db; private readonly IConnectionMultiplexer _redis; private readonly ILogger _logger; private readonly ClinicalMetrics _metrics; public ObservationService( AppDbContext db, IConnectionMultiplexer redis, ILogger logger, ClinicalMetrics metrics) { _db = db; _redis = redis; _logger = logger; _metrics = metrics; } public async Task IngestAsync(Guid encounterId, IngestObservationRequest req) { // Step 1 — encounter must be active var encounter = await _db.Encounters .Include(e => e.Patient) .AsNoTracking() .FirstOrDefaultAsync(e => e.Id == encounterId); if (encounter is null) throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); if (encounter.Status != EncounterStatus.Active) throw new ConflictException( $"Cannot record observations for an encounter with status '{encounter.Status}'.", "ENCOUNTER_NOT_ACTIVE"); // Step 2 — idempotency check before entering the transaction // The unique partial index is the database safety net for concurrent retries. // The pre-check here avoids the exception-and-rollback path for the common retry case. if (!string.IsNullOrEmpty(req.IdempotencyKey)) { var existing = await _db.Observations .AsNoTracking() .FirstOrDefaultAsync(o => o.IdempotencyKey == req.IdempotencyKey); if (existing is not null) { _logger.LogInformation( "Duplicate idempotency key {Key} for encounter {EncounterId} — returning original", req.IdempotencyKey, encounterId); return IngestResult.Duplicate(existing); } } // Step 3 — plausibility check if (!PlausibilityValidator.IsPlausible(req.ObservationCode, req.Value, out var reason)) throw new ValidationException(reason!, "OBSERVATION_OUT_OF_PLAUSIBLE_RANGE"); using (LogContext.PushProperty("EncounterId", encounterId)) using (LogContext.PushProperty("PatientId", encounter.PatientId)) { using var timer = _metrics.ObservationIngestDuration.NewTimer(); // Re-use an outer transaction (e.g. FhirBundleProcessor) when one is already active. await using var tx = _db.Database.CurrentTransaction is null ? await _db.Database.BeginTransactionAsync() : null; try { // Step 4 — insert observation var observation = new Observation { Id = Guid.NewGuid(), EncounterId = encounterId, ObservationCode = req.ObservationCode, Value = req.Value, Unit = req.Unit, Source = req.Source, IdempotencyKey = req.IdempotencyKey, RecordedAt = req.RecordedAt, CreatedAt = DateTimeOffset.UtcNow }; _db.Observations.Add(observation); // Step 5 — load threshold from Redis; fall back to PostgreSQL on miss var threshold = await LoadThresholdAsync(req.ObservationCode); if (threshold is null) throw new ValidationException( $"No alert threshold is configured for observation code '{req.ObservationCode}'. " + "Register a threshold before recording observations for this code.", "UNKNOWN_OBSERVATION_CODE"); ClinicalAlert? alert = null; // Step 6 — critical threshold detection (synchronous) if (IsCriticalBreach(req.Value, threshold)) { alert = new ClinicalAlert { Id = Guid.NewGuid(), EncounterId = encounterId, PatientId = encounter.PatientId, ObservationId = observation.Id, AlertType = AlertTypeExtensions.CriticalFor(req.ObservationCode), Severity = AlertSeverity.Critical, Details = BuildCriticalDetails(req, threshold), Status = AlertStatus.Open, TriggeredAt = DateTimeOffset.UtcNow }; _db.ClinicalAlerts.Add(alert); _db.OutboxEvents.Add(BuildOutboxEvent("alert.generated", new { alertId = alert.Id, encounterId, patientId = encounter.PatientId, department = encounter.Department.ToDbString(), alertType = alert.AlertType.ToDbString(), severity = alert.Severity.ToDbString(), details = alert.Details, attendingPhysician = encounter.AttendingPhysician, triggeredAt = alert.TriggeredAt, partitionKey = encounterId.ToString() }, encounterId.ToString())); } // Step 7 — outbox event for the observation (always; Kafka consumer handles warnings) _db.OutboxEvents.Add(BuildOutboxEvent("observation.recorded", new { observationId = observation.Id, encounterId, patientId = encounter.PatientId, mrn = encounter.Patient?.Mrn, observationCode = req.ObservationCode, value = req.Value, unit = req.Unit, source = req.Source.ToDbString(), recordedAt = req.RecordedAt, partitionKey = encounterId.ToString() }, encounterId.ToString())); // Step 8 — COMMIT await _db.SaveChangesAsync(); if (tx is not null) await tx.CommitAsync(); _metrics.ObservationsIngestedTotal .WithLabels(req.ObservationCode, req.Source.ToDbString()) .Inc(); if (alert is not null) { _metrics.ClinicalAlertsTotal .WithLabels(alert.AlertType.ToDbString(), alert.Severity.ToDbString()) .Inc(); _logger.LogWarning( "Critical threshold breach. Code={Code} Value={Value} AlertId={AlertId}", req.ObservationCode, req.Value, alert.Id); } _logger.LogInformation( "Observation {ObservationId} ingested for encounter {EncounterId}. AlertCreated={AlertCreated}", observation.Id, encounterId, alert is not null); return IngestResult.Created(observation, alert); } catch (DbUpdateException ex) when (IsUniqueViolation(ex)) { if (tx is not null) await tx.RollbackAsync(); var existing = await _db.Observations .AsNoTracking() .FirstOrDefaultAsync(o => o.IdempotencyKey == req.IdempotencyKey); if (existing is not null) return IngestResult.Duplicate(existing); throw new ConflictException("Concurrent duplicate submission.", "CONCURRENT_DUPLICATE"); } catch { if (tx is not null) await tx.RollbackAsync(); throw; } } } public async Task ApplySyncedObservationAsync(SyncedObservation obs, CancellationToken ct) { var encounter = await _db.Encounters .Include(e => e.Patient) .FirstOrDefaultAsync(e => e.Id == obs.EncounterId, ct) ?? throw new ValidationException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); if (encounter.Status != EncounterStatus.Active) throw new ValidationException("Encounter not active.", "ENCOUNTER_NOT_ACTIVE"); await using var tx = await _db.Database.BeginTransactionAsync(ct); try { var observation = new Observation { Id = obs.ClientRef, EncounterId = obs.EncounterId, ObservationCode = obs.ObservationCode, Value = obs.Value, Unit = obs.Unit ?? "", Source = ObservationSourceExtensions.FromDbString(obs.Source), IdempotencyKey = obs.IdempotencyKey, RecordedAt = obs.RecordedAt, CreatedAt = DateTimeOffset.UtcNow }; _db.Observations.Add(observation); var threshold = await LoadThresholdAsync(obs.ObservationCode); if (threshold is not null && IsCriticalBreach(obs.Value, threshold)) { var hasOpenAlert = await _db.ClinicalAlerts.AnyAsync(a => a.EncounterId == obs.EncounterId && a.ObservationId == observation.Id, ct); if (!hasOpenAlert) { var alert = new ClinicalAlert { Id = Guid.NewGuid(), EncounterId = obs.EncounterId, PatientId = encounter.PatientId, ObservationId = observation.Id, AlertType = AlertTypeExtensions.CriticalFor(obs.ObservationCode), Severity = AlertSeverity.Critical, Details = BuildCriticalDetails( new IngestObservationRequest(obs.ObservationCode, obs.Value, obs.Unit ?? "", observation.Source, obs.RecordedAt, obs.IdempotencyKey), threshold), Status = AlertStatus.Open, TriggeredAt = obs.RecordedAt }; _db.ClinicalAlerts.Add(alert); _db.OutboxEvents.Add(BuildOutboxEvent("alert.generated", new { alertId = alert.Id, encounterId = obs.EncounterId, patientId = encounter.PatientId, alertType = alert.AlertType.ToDbString(), severity = alert.Severity.ToDbString(), details = alert.Details, triggeredAt = alert.TriggeredAt, partitionKey = obs.EncounterId.ToString() }, obs.EncounterId.ToString())); } } _db.OutboxEvents.Add(BuildOutboxEvent("observation.recorded", new { observationId = observation.Id, encounterId = obs.EncounterId, patientId = encounter.PatientId, observationCode = obs.ObservationCode, value = obs.Value, unit = obs.Unit, source = obs.Source, recordedAt = obs.RecordedAt, partitionKey = obs.EncounterId.ToString() }, obs.EncounterId.ToString())); await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); } catch (DbUpdateException ex) when (IsUniqueViolation(ex)) { await tx.RollbackAsync(ct); // silent skip — idempotency key already applied } } private async Task LoadThresholdAsync(string observationCode) { var cache = _redis.GetDatabase(); var cacheKey = $"threshold:{observationCode}"; var cached = await cache.StringGetAsync(cacheKey); if (cached.HasValue) return JsonSerializer.Deserialize(cached!); // Cache miss — read from PostgreSQL and write back var threshold = await _db.AlertThresholds .AsNoTracking() .FirstOrDefaultAsync(t => t.ObservationCode == observationCode); if (threshold is null) return null; var entry = new ThresholdCacheEntry( threshold.ObservationCode, threshold.CriticalLow, threshold.WarningLow, threshold.WarningHigh, threshold.CriticalHigh); await cache.StringSetAsync(cacheKey, JsonSerializer.Serialize(entry)); _logger.LogDebug("Cache miss for threshold {Code} — loaded from PostgreSQL", observationCode); return entry; } private static bool IsCriticalBreach(decimal value, ThresholdCacheEntry t) => (t.CriticalLow.HasValue && value < t.CriticalLow.Value) || (t.CriticalHigh.HasValue && value > t.CriticalHigh.Value); private static string BuildCriticalDetails(IngestObservationRequest req, ThresholdCacheEntry t) { if (t.CriticalLow.HasValue && req.Value < t.CriticalLow.Value) return $"{req.ObservationCode} value {req.Value} {req.Unit} is below critical low of {t.CriticalLow} {req.Unit}."; return $"{req.ObservationCode} value {req.Value} {req.Unit} is above critical high of {t.CriticalHigh} {req.Unit}."; } private static OutboxEvent BuildOutboxEvent(string topic, object payload, string partitionKey) => new() { Id = Guid.NewGuid(), Topic = topic, Payload = JsonSerializer.Serialize(payload), PartitionKey = partitionKey, CreatedAt = DateTimeOffset.UtcNow }; private static bool IsUniqueViolation(DbUpdateException ex) => ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505"; }