feature: MinIO Data Lake Writer (Parquet, Partitioned)

This commit is contained in:
voltsrage
2026-06-17 18:15:43 +08:00
parent df99bf3c91
commit 65be22fd1c
13 changed files with 818 additions and 4 deletions
+15 -4
View File
@@ -4,7 +4,7 @@ A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apa
## Domain Model — How It Maps to a Real Clinical System ## Domain Model — How It Maps to a Real Clinical System
In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, sepsis engine, and data lake each consume the same stream independently. In a hospital, a patient presents for care and an encounter is opened. Bedside monitors and lab systems post observations continuously against that encounter. A rules engine evaluates each observation against configured thresholds and flags abnormal values as clinical alerts. Clinicians acknowledge and resolve alerts. If a critical alert goes unacknowledged for five minutes, the system escalates to the on-call backup. All events flow through Kafka so the Elasticsearch dashboard, sepsis engine, and data lake writer consume the same stream independently.
``` ```
Patient ─────────────────────────── one patient = one MRN, many lifetime encounters Patient ─────────────────────────── one patient = one MRN, many lifetime encounters
@@ -55,7 +55,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, and updates `openAlertCount` on alert events; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients) - **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, and updates `openAlertCount` on alert events; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients)
- **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates SIRS criteria (temperature, heart rate, respiratory rate, WBC) per encounter using Redis keys with a 30-minute TTL sliding window; on ≥2 active criteria, inserts a `SEPSIS_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`) - **Sepsis Early Warning Engine** — `SepsisEngineService` Kafka consumer evaluates SIRS criteria (temperature, heart rate, respiratory rate, WBC) per encounter using Redis keys with a 30-minute TTL sliding window; on ≥2 active criteria, inserts a `SEPSIS_WARNING / CRITICAL` alert idempotently (`INSERT WHERE NOT EXISTS`)
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf` - **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
- **Data Lake Writer** — Kafka consumer writing partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/` by date); flush policy: 1,000 events or 5 minutes, whichever comes first; columnar format for 10-year regulatory retention - **Data Lake Writer (Phase 9 - in progress)** — `DataLakeWriterService` Kafka consumer buffers `observation.recorded`, `alert.generated`, and `encounter.status.changed` events, flushes partitioned Parquet files to MinIO (`/observations/`, `/alerts/`, `/encounters/` by date), and commits offsets after successful uploads
- **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ - **Reconciliation Jobs** — three scheduled checks: (1) unacknowledged CRITICAL alerts older than 30 minutes, (2) pending orders without results after 4 hours, (3) active inpatients with no observation in 2 hours; each finding creates a `reconciliation_alerts` row and publishes to RabbitMQ
- **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope - **Standard Envelope** — all responses use a consistent `{ success, statusCode, data, error }` wrapper; validation errors use the same shape; `ApiBehaviorOptions` overridden so model validation also produces the standard envelope
- **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; Grafana dashboards (`http://localhost:3101`, admin/admin) for clinical metrics including `alerts_unacknowledged_gauge`; per-request correlation IDs in request logs and response headers - **Observability** — Serilog structured logging enriched with `correlationId`, `encounterId`, `patientId` on alert paths; Seq sink (`http://localhost:5345`); Prometheus (`http://localhost:9101`) scrapes `GET /metrics`; Grafana dashboards (`http://localhost:3101`, admin/admin) for clinical metrics including `alerts_unacknowledged_gauge`; per-request correlation IDs in request logs and response headers
@@ -87,6 +87,7 @@ IHostedServices (background):
PagingWorkerService → RabbitMQ paging.queue → log page → NACK on timeout (or requeue on shutdown) PagingWorkerService → RabbitMQ paging.queue → log page → NACK on timeout (or requeue on shutdown)
EscalationWorkerService → RabbitMQ escalation.queue → update alert status EscalationWorkerService → RabbitMQ escalation.queue → update alert status
DischargeSummaryWorkerService → RabbitMQ discharge.queue → MinIO PDF DischargeSummaryWorkerService → RabbitMQ discharge.queue → MinIO PDF
DataLakeWriterService → Kafka (data-lake-writer) → Parquet files in MinIO
ReconciliationScheduler → three scheduled safety checks → reconciliation_alerts + RabbitMQ ReconciliationScheduler → three scheduled safety checks → reconciliation_alerts + RabbitMQ
``` ```
@@ -182,6 +183,14 @@ VigilCareClinicalAPI/
│ └── RabbitMqTopologyProvisioner.cs # Declares exchange, queues, DLQ bindings on startup │ └── RabbitMqTopologyProvisioner.cs # Declares exchange, queues, DLQ bindings on startup
├── Storage/ ├── Storage/
│ └── MinioClientFactory.cs │ └── MinioClientFactory.cs
├── DataLake/
│ ├── DataLakeOptions.cs # Flush thresholds and bucket settings
│ ├── DataLakeWriterService.cs # consumer group: data-lake-writer; Kafka → Parquet → MinIO
│ └── ParquetFileBuilder.cs # Topic row models → Parquet byte arrays
├── Models/Records/
│ ├── Observation/ObservationRow.cs # Parquet row contract for observation events
│ ├── Alert/AlertRow.cs # Parquet row contract for alert events
│ └── Encounter/EncounterStatusRow.cs # Parquet row contract for encounter status events
├── Data/ ├── Data/
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints │ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
│ ├── Configurations/ # IEntityTypeConfiguration per entity │ ├── Configurations/ # IEntityTypeConfiguration per entity
@@ -213,7 +222,9 @@ tests/
├── SirsDetectorTests.cs # Redis SIRS state SET/DEL/MGET logic ├── SirsDetectorTests.cs # Redis SIRS state SET/DEL/MGET logic
├── SirsEvaluatorTests.cs # Per-code criterion evaluation ├── SirsEvaluatorTests.cs # Per-code criterion evaluation
├── NotificationPipelineTests.cs # RabbitMQ topology, DLQ routing ├── NotificationPipelineTests.cs # RabbitMQ topology, DLQ routing
── ReconciliationTests.cs # Three reconciliation checks, deduplication, RabbitMQ publish ── ReconciliationTests.cs # Three reconciliation checks, deduplication, RabbitMQ publish
├── ObservabilityPhase8Tests.cs # /metrics families and correlation header behavior
└── DataLakePhase9Tests.cs # Kafka → MinIO Parquet flow and schema checks
``` ```
--- ---
@@ -817,4 +828,4 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
| 6 | RabbitMQ exchange and queue topology; `NotificationPublisherService`; `PagingWorkerService`; DLQ escalation (`EscalationWorkerService`); discharge summary (`DischargeSummaryWorkerService` → MinIO); integration tests | Done | | 6 | RabbitMQ exchange and queue topology; `NotificationPublisherService`; `PagingWorkerService`; DLQ escalation (`EscalationWorkerService`); discharge summary (`DischargeSummaryWorkerService` → MinIO); integration tests | Done |
| 7 | Reconciliation scheduler — unacknowledged critical alerts, stale pending orders, disconnected monitors; `reconciliation_alerts` table; RabbitMQ publish; integration tests | Done | | 7 | Reconciliation scheduler — unacknowledged critical alerts, stale pending orders, disconnected monitors; `reconciliation_alerts` table; RabbitMQ publish; integration tests | Done |
| 8 | Prometheus metrics (`GET /metrics`); Grafana dashboards; eight application metric families | In progress | | 8 | Prometheus metrics (`GET /metrics`); Grafana dashboards; eight application metric families | In progress |
| 9 | Data lake writer — Kafka consumer group `data-lake-writer`; Parquet flush to MinIO | Planned | | 9 | Data lake writer — Kafka consumer group `data-lake-writer`; Parquet flush to MinIO; integration tests (`DataLakePhase9Tests`) | In progress |
@@ -0,0 +1,249 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Minio.DataModel.Args;
using Parquet;
using StackExchange.Redis;
public class DataLakePhase9Tests : IClassFixture<ApiFixture>
{
private readonly ApiFixture _fixture;
private readonly HttpClient _http;
private readonly MinioOptions _minioOpts;
public DataLakePhase9Tests(ApiFixture fixture)
{
_fixture = fixture;
_http = fixture.CreateClient();
_minioOpts = fixture.Services
.GetRequiredService<IOptions<MinioOptions>>().Value;
}
[Fact]
public async Task ObservationEvents_FlushesToParquetInMinIO()
{
await EnsureThresholdsAsync();
var patientId = await CreatePatientAsync();
var encounterId = await CreateActiveEncounterAsync(patientId);
for (var i = 0; i < 3; i++)
{
var resp = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/observations",
new BatchIngestRequest(new List<IngestObservationRequest>
{
new("HEART_RATE", 72 + i, "bpm", ObservationSource.Device, DateTimeOffset.UtcNow, null)
}));
resp.EnsureSuccessStatusCode();
}
await Task.Delay(TimeSpan.FromSeconds(20));
var keys = await ListMinioObjectsAsync("observations/");
Assert.True(keys.Count > 0,
$"No Parquet files found under observations/ in MinIO bucket '{_minioOpts.BucketName}'.");
Assert.All(keys, k => Assert.EndsWith(".parquet", k));
}
[Fact]
public async Task AlertEvents_FlushesToParquetInMinIO()
{
await EnsureThresholdsAsync();
for (var i = 0; i < 3; i++)
{
var pid = await CreatePatientAsync();
var eid = await CreateActiveEncounterAsync(pid);
var resp = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{eid}/observations",
new BatchIngestRequest(new List<IngestObservationRequest>
{
new("POTASSIUM_MEQ_L", 2.1m, "mEq/L", ObservationSource.Device, DateTimeOffset.UtcNow, null)
}));
resp.EnsureSuccessStatusCode();
}
await Task.Delay(TimeSpan.FromSeconds(20));
var keys = await ListMinioObjectsAsync("alerts/");
Assert.True(keys.Count > 0,
$"No Parquet files found under alerts/ in MinIO bucket '{_minioOpts.BucketName}'.");
}
[Fact]
public async Task EncounterStatusEvents_FlushesToParquetInMinIO()
{
await EnsureThresholdsAsync();
for (var i = 0; i < 3; i++)
{
var pid = await CreatePatientAsync();
_ = await CreateActiveEncounterAsync(pid);
}
await Task.Delay(TimeSpan.FromSeconds(20));
var keys = await ListMinioObjectsAsync("encounters/");
Assert.True(keys.Count > 0,
$"No Parquet files found under encounters/ in MinIO bucket '{_minioOpts.BucketName}'.");
}
[Fact]
public async Task ObservationParquetFile_ContainsCorrectColumns()
{
await EnsureThresholdsAsync();
var patientId = await CreatePatientAsync();
var encounterId = await CreateActiveEncounterAsync(patientId);
for (var i = 0; i < 3; i++)
{
var resp = await _http.PostAsJsonAsync(
$"/api/v1/encounters/{encounterId}/observations",
new BatchIngestRequest(new List<IngestObservationRequest>
{
new("TEMP_C", 37.5m, "°C", ObservationSource.Device, DateTimeOffset.UtcNow, null)
}));
resp.EnsureSuccessStatusCode();
}
await Task.Delay(TimeSpan.FromSeconds(20));
var keys = await ListMinioObjectsAsync("observations/");
Assert.True(keys.Count > 0);
var bytes = await DownloadMinioObjectAsync(keys[0]);
Assert.True(bytes.Length > 0);
using var ms = new MemoryStream(bytes);
using var reader = await ParquetReader.CreateAsync(ms);
var columnNames = reader.Schema.DataFields.Select(f => f.Name).ToHashSet();
Assert.Contains("observation_id", columnNames);
Assert.Contains("encounter_id", columnNames);
Assert.Contains("observation_code", columnNames);
Assert.Contains("value", columnNames);
Assert.Contains("kafka_partition", columnNames);
Assert.Contains("kafka_offset", columnNames);
}
private async Task<List<string>> ListMinioObjectsAsync(string prefix)
{
var client = MinioClientFactory.Build(_minioOpts);
var keys = new List<string>();
var listArgs = new ListObjectsArgs()
.WithBucket(_minioOpts.BucketName)
.WithPrefix(prefix)
.WithRecursive(true);
await foreach (var item in client.ListObjectsEnumAsync(listArgs))
{
keys.Add(item.Key);
}
return keys;
}
private async Task<byte[]> DownloadMinioObjectAsync(string objectKey)
{
var client = MinioClientFactory.Build(_minioOpts);
using var ms = new MemoryStream();
await client.GetObjectAsync(new GetObjectArgs()
.WithBucket(_minioOpts.BucketName)
.WithObject(objectKey)
.WithCallbackStream(stream => stream.CopyTo(ms)));
return ms.ToArray();
}
private async Task<Guid> CreatePatientAsync()
{
var resp = await _http.PostAsJsonAsync("/api/v1/patients", new
{
firstName = "DataLake",
lastName = "Test",
dateOfBirth = "1980-11-12",
gender = "Female",
});
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
return body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
}
private async Task<Guid> CreateActiveEncounterAsync(Guid patientId)
{
var resp = await _http.PostAsJsonAsync(
$"/api/v1/patients/{patientId}/encounters",
new
{
encounterType = "INPATIENT",
department = "ICU",
attendingPhysician = "Dr. Osei",
admittedAt = DateTimeOffset.UtcNow,
});
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadFromJsonAsync<JsonDocument>();
var encounterId = body!.RootElement.GetProperty("data").GetProperty("id").GetGuid();
// Encounter may be created as Scheduled depending on fixture seed path; move it
// to Active if required, but tolerate Conflict when already Active.
var activateResp = await _http.PatchAsJsonAsync(
$"/api/v1/encounters/{encounterId}/status",
new { status = "Active" });
if (activateResp.StatusCode != System.Net.HttpStatusCode.Conflict)
activateResp.EnsureSuccessStatusCode();
return encounterId;
}
private async Task EnsureThresholdsAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await EnsureThresholdAsync(db, "HEART_RATE", "Heart Rate", "bpm", 30, 50, 100, 150);
await EnsureThresholdAsync(db, "POTASSIUM_MEQ_L", "Serum Potassium", "mEq/L", 2.5m, 3.5m, 5.0m, 6.5m);
await EnsureThresholdAsync(db, "TEMP_C", "Temperature", "°C", 34m, 36m, 37.8m, 40m);
await db.SaveChangesAsync();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
var cache = redis.GetDatabase(1);
await cache.StringSetAsync("threshold:HEART_RATE",
"""{"ObservationCode":"HEART_RATE","CriticalLow":30,"WarningLow":50,"WarningHigh":100,"CriticalHigh":150}""");
await cache.StringSetAsync("threshold:POTASSIUM_MEQ_L",
"""{"ObservationCode":"POTASSIUM_MEQ_L","CriticalLow":2.5,"WarningLow":3.5,"WarningHigh":5.0,"CriticalHigh":6.5}""");
await cache.StringSetAsync("threshold:TEMP_C",
"""{"ObservationCode":"TEMP_C","CriticalLow":34,"WarningLow":36,"WarningHigh":37.8,"CriticalHigh":40}""");
}
private static async Task EnsureThresholdAsync(
AppDbContext db,
string code,
string displayName,
string unit,
decimal criticalLow,
decimal warningLow,
decimal warningHigh,
decimal criticalHigh)
{
var existing = await db.AlertThresholds.FirstOrDefaultAsync(t => t.ObservationCode == code);
if (existing is not null) return;
db.AlertThresholds.Add(new AlertThreshold
{
Id = Guid.NewGuid(),
ObservationCode = code,
DisplayName = displayName,
Unit = unit,
CriticalLow = criticalLow,
WarningLow = warningLow,
WarningHigh = warningHigh,
CriticalHigh = criticalHigh,
CreatedAt = DateTimeOffset.UtcNow
});
}
}
@@ -0,0 +1,17 @@
public sealed class DataLakeOptions
{
public const string Section = "DataLake";
// Maximum events buffered before a forced flush. At ~17 obs/sec steady state
// this fires approximately once per minute. Set low in tests (35) to avoid
// waiting for real traffic.
public int FlushCount { get; init; } = 1_000;
// Maximum age of the oldest buffered event before a time-based flush is forced.
// Production: 300 seconds (5 minutes). Tests: 10 seconds.
public int FlushIntervalSeconds { get; init; } = 300;
// MinIO bucket. The discharge summary worker from Phase 6 uses the same bucket
// under a different prefix — all VigilCare data stays in one bucket.
public string BucketName { get; init; } = "vigilcare";
}
@@ -0,0 +1,302 @@
using System.Text.Json;
using Confluent.Kafka;
using Microsoft.Extensions.Options;
using Minio.DataModel.Args;
public sealed class DataLakeWriterService : BackgroundService
{
private readonly KafkaOptions _kafkaOptions;
private readonly DataLakeOptions _opts;
private readonly MinioOptions _minioOpts;
private readonly ILogger<DataLakeWriterService> _logger;
// Buffer key: identifies one Parquet file-to-be.
// Events sharing a topic, date, and Kafka partition land in the same file.
private record BufferKey(string Topic, string DatePath, int Partition);
private record BufferedEvent(string Payload, long Offset);
private readonly Dictionary<BufferKey, List<BufferedEvent>> _buffer = new();
// Track the highest offset per topic-partition for post-flush commit.
private readonly Dictionary<TopicPartition, TopicPartitionOffset> _highWatermarks = new();
public DataLakeWriterService(
IOptions<KafkaOptions> kafkaOptions,
IOptions<DataLakeOptions> opts,
IOptions<MinioOptions> minioOpts,
ILogger<DataLakeWriterService> logger)
{
_kafkaOptions = kafkaOptions.Value;
_opts = opts.Value;
_minioOpts = minioOpts.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var consumerConfig = new ConsumerConfig
{
BootstrapServers = _kafkaOptions.BootstrapServers,
GroupId = "data-lake-writer",
AutoOffsetReset = AutoOffsetReset.Earliest,
EnableAutoCommit = false,
};
using var consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
consumer.Subscribe(new[]
{
_kafkaOptions.Topics.ObservationRecorded,
_kafkaOptions.Topics.AlertGenerated,
_kafkaOptions.Topics.EncounterStatusChanged,
});
_logger.LogInformation(
"DataLakeWriterService started. FlushCount={FlushCount} FlushIntervalSeconds={FlushInterval}",
_opts.FlushCount, _opts.FlushIntervalSeconds);
var lastFlush = DateTimeOffset.UtcNow;
try
{
while (!ct.IsCancellationRequested)
{
ConsumeResult<string, string>? result;
try
{
result = consumer.Consume(TimeSpan.FromMilliseconds(500));
}
catch (ConsumeException ex)
{
_logger.LogError(ex, "DataLakeWriter consume error");
continue;
}
if (result is not null)
AddToBuffer(result);
var totalBuffered = _buffer.Values.Sum(v => v.Count);
var shouldFlushCount = totalBuffered >= _opts.FlushCount;
var shouldFlushTime = DateTimeOffset.UtcNow - lastFlush
>= TimeSpan.FromSeconds(_opts.FlushIntervalSeconds);
if ((shouldFlushCount || shouldFlushTime) && totalBuffered > 0)
{
await FlushAsync(consumer, ct);
lastFlush = DateTimeOffset.UtcNow;
}
}
}
finally
{
// Final flush on shutdown so buffered events are not lost.
if (_buffer.Values.Sum(v => v.Count) > 0)
{
try { await FlushAsync(consumer, ct); }
catch (Exception ex)
{
_logger.LogError(ex, "DataLakeWriter shutdown flush failed — some events may be re-read on next start");
}
}
consumer.Close();
}
}
private void AddToBuffer(ConsumeResult<string, string> result)
{
var datePath = ExtractDatePath(result.Topic, result.Message.Value);
var key = new BufferKey(result.Topic, datePath, result.Partition.Value);
if (!_buffer.TryGetValue(key, out var list))
{
list = new List<BufferedEvent>();
_buffer[key] = list;
}
list.Add(new BufferedEvent(result.Message.Value, result.Offset.Value));
// Track highest offset per topic-partition for post-flush commit.
var tp = new TopicPartition(result.Topic, result.Partition);
_highWatermarks[tp] = new TopicPartitionOffset(tp, result.Offset + 1);
}
private async Task FlushAsync(IConsumer<string, string> consumer, CancellationToken ct)
{
var filesWritten = 0;
foreach (var (key, events) in _buffer)
{
if (events.Count == 0) continue;
try
{
var firstOffset = events.Min(e => e.Offset);
var objectKey = BuildObjectKey(key, firstOffset);
var bytes = await BuildParquetAsync(key.Topic, events, key.Partition);
await UploadToMinioAsync(objectKey, bytes, ct);
filesWritten++;
_logger.LogInformation(
"[DATA-LAKE] Wrote {Count} events → {ObjectKey} ({Bytes} bytes)",
events.Count, objectKey, bytes.Length);
}
catch (Exception ex)
{
// Log and continue — a failed file for one key must not prevent other
// keys from flushing. The uncommitted offsets will cause reprocessing.
_logger.LogError(ex, "[DATA-LAKE] Failed to write file for key {Key}", key);
}
}
// Commit only after all files are uploaded.
// Events for any key that failed above will be re-read on next startup.
if (_highWatermarks.Any())
{
consumer.Commit(_highWatermarks.Values);
_logger.LogInformation(
"[DATA-LAKE] Committed offsets for {PartitionCount} partitions after flushing {FileCount} files",
_highWatermarks.Count, filesWritten);
}
_buffer.Clear();
_highWatermarks.Clear();
}
private async Task<byte[]> BuildParquetAsync(
string topic, List<BufferedEvent> events, int partition)
{
if (topic == _kafkaOptions.Topics.ObservationRecorded)
{
var rows = events.Select(e => ParseObservationRow(e, partition)).ToList();
return await ParquetFileBuilder.BuildObservationsAsync(rows);
}
if (topic == _kafkaOptions.Topics.AlertGenerated)
{
var rows = events.Select(e => ParseAlertRow(e, partition)).ToList();
return await ParquetFileBuilder.BuildAlertsAsync(rows);
}
if (topic == _kafkaOptions.Topics.EncounterStatusChanged)
{
var rows = events.Select(e => ParseEncounterRow(e, partition)).ToList();
return await ParquetFileBuilder.BuildEncountersAsync(rows);
}
throw new InvalidOperationException($"Unknown topic: {topic}");
}
// ---------------------------------------------------------------------------
// Object key and date partition helpers
// ---------------------------------------------------------------------------
// File path: observations/2025/01/15/partition-0-offset-0000001000.parquet
// The date comes from the event timestamp, not the wall clock.
// Events from the same encounter that cross midnight are written into the date
// bucket matching their recorded_at timestamp — consistent with how Athena and
// Spark partition-prune by event time, not ingest time.
private string BuildObjectKey(BufferKey key, long firstOffset)
{
var folder = key.Topic switch
{
var t when t == _kafkaOptions.Topics.ObservationRecorded => "observations",
var t when t == _kafkaOptions.Topics.AlertGenerated => "alerts",
var t when t == _kafkaOptions.Topics.EncounterStatusChanged => "encounters",
_ => "unknown",
};
return $"{folder}/{key.DatePath}/partition-{key.Partition}-offset-{firstOffset:D10}.parquet";
}
private string ExtractDatePath(string topic, string payload)
{
try
{
var doc = JsonDocument.Parse(payload);
var ts = topic switch
{
var t when t == _kafkaOptions.Topics.ObservationRecorded => doc.RootElement.GetProperty("recordedAt").GetDateTimeOffset(),
var t when t == _kafkaOptions.Topics.AlertGenerated => doc.RootElement.GetProperty("triggeredAt").GetDateTimeOffset(),
var t when t == _kafkaOptions.Topics.EncounterStatusChanged => doc.RootElement.GetProperty("changedAt").GetDateTimeOffset(),
_ => DateTimeOffset.UtcNow,
};
return $"{ts.Year:D4}/{ts.Month:D2}/{ts.Day:D2}";
}
catch
{
// Malformed payload: use today so the event is not lost.
var now = DateTimeOffset.UtcNow;
return $"{now.Year:D4}/{now.Month:D2}/{now.Day:D2}";
}
}
// ---------------------------------------------------------------------------
// Payload deserializers — each reads only the fields needed for the Parquet row.
// ---------------------------------------------------------------------------
private static ObservationRow ParseObservationRow(BufferedEvent e, int partition)
{
var d = JsonDocument.Parse(e.Payload).RootElement;
return new ObservationRow(
ObservationId : d.GetProperty("observationId").GetString() ?? "",
EncounterId : d.GetProperty("encounterId").GetString() ?? "",
PatientId : d.GetProperty("patientId").GetString() ?? "",
Mrn : d.TryGetProperty("mrn", out var mrn) ? mrn.GetString() ?? "" : "",
ObservationCode : d.GetProperty("observationCode").GetString() ?? "",
Value : d.GetProperty("value").GetDouble(),
Unit : d.GetProperty("unit").GetString() ?? "",
Source : d.GetProperty("source").GetString() ?? "",
RecordedAt : d.GetProperty("recordedAt").GetString() ?? "",
KafkaPartition : partition,
KafkaOffset : e.Offset
);
}
private static AlertRow ParseAlertRow(BufferedEvent e, int partition)
{
var d = JsonDocument.Parse(e.Payload).RootElement;
return new AlertRow(
AlertId : d.GetProperty("alertId").GetString() ?? "",
EncounterId : d.GetProperty("encounterId").GetString() ?? "",
PatientId : d.GetProperty("patientId").GetString() ?? "",
AlertType : d.GetProperty("alertType").GetString() ?? "",
Severity : d.GetProperty("severity").GetString() ?? "",
Details : d.GetProperty("details").GetString() ?? "",
TriggeredAt : d.GetProperty("triggeredAt").GetString() ?? "",
KafkaPartition : partition,
KafkaOffset : e.Offset
);
}
private static EncounterStatusRow ParseEncounterRow(BufferedEvent e, int partition)
{
var d = JsonDocument.Parse(e.Payload).RootElement;
return new EncounterStatusRow(
EncounterId : d.GetProperty("encounterId").GetString() ?? "",
PatientId : d.GetProperty("patientId").GetString() ?? "",
PreviousStatus : d.GetProperty("previousStatus").GetString() ?? "",
NewStatus : d.GetProperty("newStatus").GetString() ?? "",
ChangedAt : d.GetProperty("changedAt").GetString() ?? "",
KafkaPartition : partition,
KafkaOffset : e.Offset
);
}
// ---------------------------------------------------------------------------
// MinIO upload
// ---------------------------------------------------------------------------
private async Task UploadToMinioAsync(string objectKey, byte[] bytes, CancellationToken ct)
{
var client = MinioClientFactory.Build(_minioOpts);
var bucket = _opts.BucketName;
var exists = await client.BucketExistsAsync(
new BucketExistsArgs().WithBucket(bucket), ct);
if (!exists)
await client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), ct);
await client.PutObjectAsync(new PutObjectArgs()
.WithBucket(bucket)
.WithObject(objectKey)
.WithStreamData(new MemoryStream(bytes))
.WithObjectSize(bytes.Length)
.WithContentType("application/octet-stream"),
ct);
}
}
@@ -0,0 +1,102 @@
using Parquet;
using Parquet.Data;
using Parquet.Schema;
public static class ParquetFileBuilder
{
public static async Task<byte[]> BuildObservationsAsync(IReadOnlyList<ObservationRow> rows)
{
var schema = new ParquetSchema(
new DataField<string>("observation_id"),
new DataField<string>("encounter_id"),
new DataField<string>("patient_id"),
new DataField<string>("mrn"),
new DataField<string>("observation_code"),
new DataField<double>("value"),
new DataField<string>("unit"),
new DataField<string>("source"),
new DataField<string>("recorded_at"),
new DataField<int>("kafka_partition"),
new DataField<long>("kafka_offset")
);
using var ms = new MemoryStream();
using (var writer = await ParquetWriter.CreateAsync(schema, ms))
using (var rg = writer.CreateRowGroup())
{
var f = schema.DataFields;
await rg.WriteColumnAsync(new DataColumn(f[0], rows.Select(r => r.ObservationId).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[1], rows.Select(r => r.EncounterId).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[2], rows.Select(r => r.PatientId).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[3], rows.Select(r => r.Mrn).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[4], rows.Select(r => r.ObservationCode).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[5], rows.Select(r => r.Value).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[6], rows.Select(r => r.Unit).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[7], rows.Select(r => r.Source).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[8], rows.Select(r => r.RecordedAt).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[9], rows.Select(r => r.KafkaPartition).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[10], rows.Select(r => r.KafkaOffset).ToArray()));
}
return ms.ToArray();
}
public static async Task<byte[]> BuildAlertsAsync(IReadOnlyList<AlertRow> rows)
{
var schema = new ParquetSchema(
new DataField<string>("alert_id"),
new DataField<string>("encounter_id"),
new DataField<string>("patient_id"),
new DataField<string>("alert_type"),
new DataField<string>("severity"),
new DataField<string>("details"),
new DataField<string>("triggered_at"),
new DataField<int>("kafka_partition"),
new DataField<long>("kafka_offset")
);
using var ms = new MemoryStream();
using (var writer = await ParquetWriter.CreateAsync(schema, ms))
using (var rg = writer.CreateRowGroup())
{
var f = schema.DataFields;
await rg.WriteColumnAsync(new DataColumn(f[0], rows.Select(r => r.AlertId).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[1], rows.Select(r => r.EncounterId).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[2], rows.Select(r => r.PatientId).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[3], rows.Select(r => r.AlertType).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[4], rows.Select(r => r.Severity).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[5], rows.Select(r => r.Details).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[6], rows.Select(r => r.TriggeredAt).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[7], rows.Select(r => r.KafkaPartition).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[8], rows.Select(r => r.KafkaOffset).ToArray()));
}
return ms.ToArray();
}
public static async Task<byte[]> BuildEncountersAsync(IReadOnlyList<EncounterStatusRow> rows)
{
var schema = new ParquetSchema(
new DataField<string>("encounter_id"),
new DataField<string>("patient_id"),
new DataField<string>("previous_status"),
new DataField<string>("new_status"),
new DataField<string>("changed_at"),
new DataField<int>("kafka_partition"),
new DataField<long>("kafka_offset")
);
using var ms = new MemoryStream();
using (var writer = await ParquetWriter.CreateAsync(schema, ms))
using (var rg = writer.CreateRowGroup())
{
var f = schema.DataFields;
await rg.WriteColumnAsync(new DataColumn(f[0], rows.Select(r => r.EncounterId).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[1], rows.Select(r => r.PatientId).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[2], rows.Select(r => r.PreviousStatus).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[3], rows.Select(r => r.NewStatus).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[4], rows.Select(r => r.ChangedAt).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[5], rows.Select(r => r.KafkaPartition).ToArray()));
await rg.WriteColumnAsync(new DataColumn(f[6], rows.Select(r => r.KafkaOffset).ToArray()));
}
return ms.ToArray();
}
}
@@ -0,0 +1,11 @@
public sealed record AlertRow(
string AlertId,
string EncounterId,
string PatientId,
string AlertType,
string Severity,
string Details,
string TriggeredAt,
int KafkaPartition,
long KafkaOffset
);
@@ -0,0 +1,9 @@
public sealed record EncounterStatusRow(
string EncounterId,
string PatientId,
string PreviousStatus,
string NewStatus,
string ChangedAt,
int KafkaPartition,
long KafkaOffset
);
@@ -0,0 +1,13 @@
public sealed record ObservationRow(
string ObservationId,
string EncounterId,
string PatientId,
string Mrn,
string ObservationCode,
double Value,
string Unit,
string Source,
string RecordedAt,
int KafkaPartition,
long KafkaOffset
);
+4
View File
@@ -55,6 +55,9 @@ try
builder.Services.Configure<ReconciliationJobOptions>( builder.Services.Configure<ReconciliationJobOptions>(
builder.Configuration.GetSection(ReconciliationJobOptions.Section)); builder.Configuration.GetSection(ReconciliationJobOptions.Section));
builder.Services.Configure<DataLakeOptions>(
builder.Configuration.GetSection(DataLakeOptions.Section));
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>();
@@ -84,6 +87,7 @@ try
builder.Services.AddHostedService<AlertsUnacknowledgedCollector>(); builder.Services.AddHostedService<AlertsUnacknowledgedCollector>();
builder.Services.AddHostedService<OutboxPendingCollector>(); builder.Services.AddHostedService<OutboxPendingCollector>();
builder.Services.AddHostedService<KafkaConsumerLagCollector>(); builder.Services.AddHostedService<KafkaConsumerLagCollector>();
builder.Services.AddHostedService<DataLakeWriterService>();
builder.Services.AddControllers() builder.Services.AddControllers()
.AddJsonOptions(opts => .AddJsonOptions(opts =>
@@ -18,6 +18,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Minio" Version="6.0.3" /> <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="Parquet.Net" Version="4.24.0" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="RabbitMQ.Client" Version="6.8.1" /> <PackageReference Include="RabbitMQ.Client" Version="6.8.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
@@ -13,5 +13,9 @@
}, },
"RabbitMq": { "RabbitMq": {
"PagingAckTimeoutMs": 5000 "PagingAckTimeoutMs": 5000
},
"DataLake": {
"FlushCount": 3,
"FlushIntervalSeconds": 10
} }
} }
+4
View File
@@ -74,5 +74,9 @@
"UnacknowledgedAlertThresholdMinutes": 30, "UnacknowledgedAlertThresholdMinutes": 30,
"PendingOrderThresholdHours": 4, "PendingOrderThresholdHours": 4,
"NoObservationThresholdHours": 2 "NoObservationThresholdHours": 2
},
"DataLake": {
"FlushCount": 1000,
"FlushIntervalSeconds": 300
} }
} }
+87
View File
@@ -0,0 +1,87 @@
```markdown
# Decision: Data Lake Architecture — Parquet on MinIO
## Context
Healthcare regulations in most jurisdictions require medical records to be retained
for 725 years. A medium hospital with 200 concurrent inpatients generates roughly
17 observations per second at steady state. Over 10 years that is approximately
5 billion observation rows. Keeping this in PostgreSQL would require continuous
partitioning and archival work; running population health queries against it would
compete with live ingest writes.
## Decision
A separate data lake tier handles long-term retention. The Kafka `data-lake-writer`
consumer group reads all three topics and writes Parquet files to MinIO under
date-partitioned prefixes.
## Why Parquet over JSON or CSV
Parquet is columnar. A population health query — "give me all heart rate values for
ICU patients in 2024" — reads only the `observation_code` and `value` columns without
deserializing `encounter_id`, `patient_id`, `unit`, `source`, or `recorded_at`.
The repetition of `observation_code` values across millions of rows (thousands of rows
all with `HEART_RATE`) compresses at 510× vs JSON via dictionary encoding. At 10-year
scale this difference is measured in terabytes of storage cost.
CSV is row-oriented like JSON and does not support schema evolution — adding a new
column requires rewriting every historical file.
## Why MinIO (S3-compatible) over PostgreSQL or Elasticsearch for archive
PostgreSQL is the operational write layer. Running 10-year-scale queries on the same
instance that serves live ingest introduces contention. Adding partitioning and tiered
storage to PostgreSQL adds operational complexity without solving the fundamental
problem: it is still a row store.
Elasticsearch is optimized for search and aggregations, not for full-table scans or
columnar projections. Storing 5 billion observation rows in Elasticsearch would require
enormous index memory and produce no benefit for the analytics pattern that justifies
the archive (population health over multi-year windows).
MinIO is S3-compatible. The files it stores can be queried directly by DuckDB
(single analyst), Apache Spark (cluster analytics), and AWS Athena (serverless queries
over S3) without any data movement. Switching from MinIO to S3 in production requires
changing one endpoint URL.
## Why not Apache Flink or Spark Streaming for the lake writer
At a single-hospital scale — 50 obs/sec peak, 6 Kafka partitions — the consumer lag
from a simple .NET BackgroundService with in-memory buffering and a 5-minute flush is
negligible. The overhead of deploying and operating a Flink cluster adds operational
cost that is not justified by the throughput.
The scale inflection point: approximately 10,000+ events/sec sustained, or the need
for exactly-once semantics at the storage layer. Below that, the simple consumer is
correct. Above it, Flink's checkpoint-based exactly-once delivery to Parquet
(via its FileSystem sink) becomes worth the operational cost.
## Flush policy rationale
**Count-based flush (1,000 events):** Bounds memory. At 17 obs/sec steady state this
fires approximately once per minute. At 50 obs/sec peak it fires every 20 seconds.
**Time-based flush (5 minutes):** Bounds latency. At low load (night shift, few
monitors active), the count threshold might not fire for hours. A 5-minute ceiling
means the most recent data is always queryable within 5 minutes of arriving in Kafka.
**At-least-once delivery:** Kafka offsets are committed only after the Parquet file
is successfully uploaded. A process crash between buffering and uploading produces
duplicate rows on the next startup — the same observation appears in two files with
different `kafka_offset` values. For a regulatory archive this is acceptable.
Downstream queries can deduplicate on `observation_id`. Data loss is not acceptable;
duplicates are.
## Date partition structure
Files are partitioned by the **event timestamp** from the payload, not by wall clock
time at consumption. An observation `recorded_at 2025-01-14T23:59:59Z` consumed at
`2025-01-15T00:01:00Z` is written to `observations/2025/01/14/...`.
This matches how Athena and Spark apply partition pruning: a query with
`WHERE recorded_at BETWEEN '2025-01-14' AND '2025-01-14'` reads only the
`observations/2025/01/14/` prefix. Partitioning by ingest time instead would cause
the query to miss that row.
```