feature: Outbox Relay and Kafka Pipeline

This commit is contained in:
voltsrage
2026-06-16 22:12:46 +08:00
parent de603df151
commit 84d259d10c
18 changed files with 1277 additions and 6 deletions
@@ -0,0 +1,55 @@
using Confluent.Kafka;
using Confluent.Kafka.Admin;
using Microsoft.Extensions.Options;
public class KafkaTopicProvisioner : IHostedService
{
private readonly KafkaOptions _options;
private readonly ILogger<KafkaTopicProvisioner> _logger;
public KafkaTopicProvisioner(IOptions<KafkaOptions> options, ILogger<KafkaTopicProvisioner> logger)
{
_options = options.Value;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var admin = new AdminClientBuilder(new AdminClientConfig
{
BootstrapServers = _options.BootstrapServers
}).Build();
var topicNames = new[]
{
_options.Topics.ObservationRecorded,
_options.Topics.AlertGenerated,
_options.Topics.AlertAcknowledged,
_options.Topics.EncounterStatusChanged
};
var specs = topicNames.Select(name => new TopicSpecification
{
Name = name,
NumPartitions = _options.NumPartitions,
ReplicationFactor = 1
}).ToList();
try
{
await admin.CreateTopicsAsync(specs);
_logger.LogInformation("Kafka topics provisioned: {Topics}", string.Join(", ", topicNames));
}
catch (CreateTopicsException ex)
{
var errors = ex.Results.Where(r => r.Error.Code != ErrorCode.TopicAlreadyExists).ToList();
if (errors.Count > 0)
throw new InvalidOperationException(
$"Failed to create Kafka topics: {string.Join(", ", errors.Select(e => e.Error.Reason))}");
_logger.LogInformation("Kafka topics already exist — skipping creation");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,146 @@
using Confluent.Kafka;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
public class OutboxRelayService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly KafkaOptions _options;
private readonly ILogger<OutboxRelayService> _logger;
private IProducer<string, string>? _producer;
public OutboxRelayService(
IServiceProvider services,
IOptions<KafkaOptions> options,
ILogger<OutboxRelayService> logger)
{
_services = services;
_options = options.Value;
_logger = logger;
}
public override Task StartAsync(CancellationToken cancellationToken)
{
_producer = new ProducerBuilder<string, string>(new ProducerConfig
{
BootstrapServers = _options.BootstrapServers,
Acks = Acks.All,
// Idempotent producer: the broker deduplicates in-flight retries using
// the producer ID + sequence number. Prevents duplicates from network
// timeouts that cause the client to retry a message the broker already accepted.
EnableIdempotence = true,
MessageSendMaxRetries = 3,
RetryBackoffMs = 100
}).Build();
return base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Outbox relay started. PollInterval={Interval}ms", _options.OutboxPollIntervalMs);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessBatchAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Outbox relay error — will retry on next poll cycle");
}
await Task.Delay(_options.OutboxPollIntervalMs, stoppingToken);
}
}
private async Task ProcessBatchAsync(CancellationToken ct)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// FOR UPDATE SKIP LOCKED: if a second relay instance is running concurrently,
// it skips rows already locked by this instance. Both instances make progress
// without blocking each other. A plain SELECT without locking would cause both
// to read the same rows and publish duplicates.
await using var tx = await db.Database.BeginTransactionAsync(ct);
var events = await db.OutboxEvents
.FromSqlRaw("""
SELECT id, topic, payload, partition_key, created_at, processed_at
FROM outbox_events
WHERE processed_at IS NULL
ORDER BY created_at ASC
LIMIT {0}
FOR UPDATE SKIP LOCKED
""", _options.OutboxBatchSize)
.ToListAsync(ct);
if (events.Count == 0)
{
await tx.RollbackAsync(ct);
return;
}
var published = new List<Guid>();
var failed = false;
foreach (var ev in events)
{
try
{
var result = await _producer!.ProduceAsync(
ev.Topic,
new Message<string, string>
{
// The partition key ensures all events for one encounter land on
// the same partition. The sepsis engine depends on this — if two
// observations for the same patient arrive on different partitions,
// a single-partition consumer misses one and SIRS evaluation is wrong.
Key = ev.PartitionKey ?? string.Empty,
Value = ev.Payload
},
ct);
published.Add(ev.Id);
_logger.LogDebug(
"Published {Topic} offset={Offset} partition={Partition} key={Key}",
ev.Topic, result.Offset.Value, result.Partition.Value, ev.PartitionKey);
}
catch (ProduceException<string, string> ex)
{
_logger.LogWarning(ex,
"Kafka produce failed for outbox event {Id} — leaving unprocessed for next cycle",
ev.Id);
failed = true;
break; // stop processing this batch; retry from this row next cycle
}
}
if (published.Count > 0)
{
var now = DateTimeOffset.UtcNow;
foreach (var id in published)
{
var ev = events.First(e => e.Id == id);
ev.ProcessedAt = now;
}
await db.SaveChangesAsync(ct);
}
await tx.CommitAsync(ct);
if (published.Count > 0)
_logger.LogInformation(
"Outbox relay published {Count} events. Failed={Failed}",
published.Count, failed);
}
public override void Dispose()
{
_producer?.Dispose();
base.Dispose();
}
}
@@ -0,0 +1,9 @@
public class KafkaOptions
{
public const string Section = "Kafka";
public string BootstrapServers { get; set; } = null!;
public KafkaTopicOptions Topics { get; set; } = null!;
public int NumPartitions { get; set; } = 6;
public int OutboxBatchSize { get; set; } = 100;
public int OutboxPollIntervalMs { get; set; } = 500;
}
@@ -0,0 +1,7 @@
public class KafkaTopicOptions
{
public string ObservationRecorded { get; set; } = "observation.recorded";
public string AlertGenerated { get; set; } = "alert.generated";
public string AlertAcknowledged { get; set; } = "alert.acknowledged";
public string EncounterStatusChanged { get; set; } = "encounter.status.changed";
}
@@ -10,6 +10,7 @@ public class OutboxEventConfiguration : IEntityTypeConfiguration<OutboxEvent>
builder.Property(o => o.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
builder.Property(o => o.Topic).HasColumnName("topic").HasMaxLength(200).IsRequired();
builder.Property(o => o.Payload).HasColumnName("payload").HasColumnType("jsonb").IsRequired();
builder.Property(o => o.PartitionKey).HasColumnName("partition_key").HasMaxLength(36);
builder.Property(o => o.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
builder.Property(o => o.ProcessedAt).HasColumnName("processed_at");
@@ -3,6 +3,7 @@ public class OutboxEvent
public Guid Id { get; set; }
public string Topic { get; set; } = null!;
public string Payload { get; set; } = null!; // JSON string
public string? PartitionKey { get; set; } // encounter_id for all clinical events
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? ProcessedAt { get; set; }
}
@@ -0,0 +1,568 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260616132054_AddOutboxPartitionKey")]
partial class AddOutboxPartitionKey
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlertThreshold", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<decimal?>("CriticalHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_high");
b.Property<decimal?>("CriticalLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("critical_low");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display_name");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal?>("WarningHigh")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_high");
b.Property<decimal?>("WarningLow")
.HasColumnType("decimal(10,3)")
.HasColumnName("warning_low");
b.HasKey("Id");
b.HasIndex("ObservationCode")
.IsUnique();
b.ToTable("alert_thresholds", (string)null);
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("AcknowledgedBy")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("acknowledged_by");
b.Property<string>("AlertType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("alert_type");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("ObservationId")
.HasColumnType("uuid")
.HasColumnName("observation_id");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("severity");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'OPEN'");
b.Property<DateTimeOffset>("TriggeredAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("triggered_at")
.HasDefaultValueSql("NOW()");
b.HasKey("Id");
b.HasIndex("EncounterId", "TriggeredAt");
b.HasIndex("PatientId", "TriggeredAt");
b.HasIndex("Severity", "TriggeredAt")
.HasFilter("status = 'OPEN'");
b.ToTable("clinical_alerts", null, t =>
{
t.HasCheckConstraint("chk_clinical_alerts_alert_type", "alert_type IN ('SEPSIS_WARNING', 'CRITICAL_HEART_RATE', 'CRITICAL_TEMP_C', 'CRITICAL_POTASSIUM_MEQ_L', 'CRITICAL_SPO2', 'CRITICAL_RESP_RATE', 'CRITICAL_WBC_K_UL')");
t.HasCheckConstraint("chk_clinical_alerts_severity", "severity IN ('WARNING', 'CRITICAL')");
t.HasCheckConstraint("chk_clinical_alerts_status", "status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ESCALATED')");
});
});
modelBuilder.Entity("Encounter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("AdmittedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("admitted_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("AttendingPhysician")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("attending_physician");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Department")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("department");
b.Property<DateTimeOffset?>("DischargedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("discharged_at");
b.Property<string>("EncounterType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("encounter_type");
b.Property<Guid>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("status")
.HasDefaultValueSql("'SCHEDULED'");
b.HasKey("Id");
b.HasIndex("PatientId", "AdmittedAt");
b.HasIndex("Status", "AdmittedAt")
.HasFilter("status = 'ACTIVE'");
b.ToTable("encounters", null, t =>
{
t.HasCheckConstraint("chk_encounters_encounter_type", "encounter_type IN ('INPATIENT', 'OUTPATIENT', 'EMERGENCY')");
t.HasCheckConstraint("chk_encounters_status", "status IN ('SCHEDULED', 'ACTIVE', 'DISCHARGED', 'CANCELLED')");
});
});
modelBuilder.Entity("Observation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("IdempotencyKey")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("idempotency_key");
b.Property<string>("ObservationCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("observation_code");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<string>("Source")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("source")
.HasDefaultValueSql("'MANUAL'");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("unit");
b.Property<decimal>("Value")
.HasColumnType("decimal(10,3)")
.HasColumnName("value");
b.HasKey("Id");
b.HasIndex("IdempotencyKey")
.IsUnique()
.HasFilter("idempotency_key IS NOT NULL");
b.HasIndex("EncounterId", "ObservationCode", "RecordedAt");
b.ToTable("observations", null, t =>
{
t.HasCheckConstraint("chk_observations_source", "source IN ('MANUAL', 'DEVICE', 'LAB')");
});
});
modelBuilder.Entity("Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<Guid>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<string>("OrderType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("order_type");
b.Property<DateTimeOffset>("OrderedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("ordered_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("OrderedBy")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("ordered_by");
b.Property<DateTimeOffset?>("ResultedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resulted_at");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("pending")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("EncounterId", "OrderedAt");
b.HasIndex("Status", "OrderedAt")
.HasFilter("status IN ('pending', 'in_progress')");
b.ToTable("orders", null, t =>
{
t.HasCheckConstraint("chk_orders_order_type", "order_type IN ('LAB', 'IMAGING', 'MEDICATION', 'PROCEDURE')");
});
});
modelBuilder.Entity("OutboxEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payload");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Topic")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("topic");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasFilter("processed_at IS NULL");
b.ToTable("outbox_events", (string)null);
});
modelBuilder.Entity("Patient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<DateOnly>("DateOfBirth")
.HasColumnType("date")
.HasColumnName("date_of_birth");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("first_name");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("gender");
b.Property<string>("LastName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("last_name");
b.Property<string>("Mrn")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("mrn");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("active")
.HasColumnName("status");
b.HasKey("Id");
b.HasIndex("Mrn")
.IsUnique();
b.ToTable("patients", (string)null);
});
modelBuilder.Entity("ReconciliationAlert", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("CheckType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("check_type");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Details")
.IsRequired()
.HasColumnType("text")
.HasColumnName("details");
b.Property<Guid?>("EncounterId")
.HasColumnType("uuid")
.HasColumnName("encounter_id");
b.Property<Guid?>("PatientId")
.HasColumnType("uuid")
.HasColumnName("patient_id");
b.Property<DateTimeOffset?>("ResolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("resolved_at");
b.HasKey("Id");
b.HasIndex("CheckType", "EncounterId")
.HasFilter("resolved_at IS NULL");
b.ToTable("reconciliation_alerts", null, t =>
{
t.HasCheckConstraint("chk_reconciliation_alerts_check_type", "check_type IN ('UNACKNOWLEDGED_CRITICAL_ALERT', 'PENDING_ORDER_NO_RESULT', 'ACTIVE_INPATIENT_NO_OBSERVATION')");
});
});
modelBuilder.Entity("ClinicalAlert", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Alerts")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.HasOne("Patient", "Patient")
.WithMany("Encounters")
.HasForeignKey("PatientId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Patient");
});
modelBuilder.Entity("Observation", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Observations")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Order", b =>
{
b.HasOne("Encounter", "Encounter")
.WithMany("Orders")
.HasForeignKey("EncounterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Encounter");
});
modelBuilder.Entity("Encounter", b =>
{
b.Navigation("Alerts");
b.Navigation("Observations");
b.Navigation("Orders");
});
modelBuilder.Entity("Patient", b =>
{
b.Navigation("Encounters");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VigilCareClinicalAPI.Migrations
{
/// <inheritdoc />
public partial class AddOutboxPartitionKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "partition_key",
table: "outbox_events",
type: "character varying(36)",
maxLength: 36,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "partition_key",
table: "outbox_events");
}
}
}
@@ -368,6 +368,11 @@ namespace VigilCareClinicalAPI.Migrations
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("PartitionKey")
.HasMaxLength(36)
.HasColumnType("character varying(36)")
.HasColumnName("partition_key");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb")
+5
View File
@@ -22,6 +22,9 @@ try
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(sp.GetRequiredService<IConfiguration>()["Redis:ConnectionString"]!));
builder.Services.Configure<KafkaOptions>(
builder.Configuration.GetSection(KafkaOptions.Section));
builder.Services.AddScoped<IPatientService, PatientService>();
builder.Services.AddScoped<IEncounterService, EncounterService>();
builder.Services.AddScoped<IAlertThresholdService, AlertThresholdService>();
@@ -30,6 +33,8 @@ try
builder.Services.AddScoped<IAlertService, AlertService>();
builder.Services.AddHostedService<ThresholdCacheLoader>();
builder.Services.AddHostedService<KafkaTopicProvisioner>();
builder.Services.AddHostedService<OutboxRelayService>();
builder.Services.AddControllers()
.AddJsonOptions(opts =>
@@ -96,6 +96,7 @@ public class AlertService : IAlertService
acknowledgedAt = alert.AcknowledgedAt,
note = req.Note
}),
PartitionKey = alert.EncounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
public class EncounterService : IEncounterService
@@ -41,11 +42,29 @@ public class EncounterService : IEncounterService
throw new ConflictException(
$"Transition to '{targetStatus}' is not permitted from the current status.",
"ILLEGAL_STATUS_TRANSITION");
var previousStatus = encounter.Status;
encounter.Status = targetStatus;
if (targetStatus == EncounterStatus.Discharged)
encounter.DischargedAt = DateTimeOffset.UtcNow;
_db.OutboxEvents.Add(new OutboxEvent
{
Id = Guid.NewGuid(),
Topic = "encounter.status.changed",
Payload = JsonSerializer.Serialize(new
{
encounterId,
patientId = encounter.PatientId,
previousStatus = previousStatus.ToDbString(),
newStatus = targetStatus.ToDbString(),
changedAt = DateTimeOffset.UtcNow
}),
PartitionKey = encounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
});
await _db.SaveChangesAsync();
return new EncounterStatusTransitionResult(encounterId, targetStatus);
}
@@ -116,7 +116,7 @@ public class ObservationService : IObservationService
severity = alert.Severity.ToDbString(),
triggeredAt = alert.TriggeredAt,
partitionKey = encounterId.ToString()
}));
}, encounterId.ToString()));
}
// Step 7 — outbox event for the observation (always; Kafka consumer handles warnings)
@@ -131,7 +131,7 @@ public class ObservationService : IObservationService
source = req.Source.ToDbString(),
recordedAt = req.RecordedAt,
partitionKey = encounterId.ToString()
}));
}, encounterId.ToString()));
// Step 8 — COMMIT
await _db.SaveChangesAsync();
@@ -202,11 +202,12 @@ public class ObservationService : IObservationService
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) => new()
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
};
@@ -9,6 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Confluent.Kafka" Version="2.14.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+12 -1
View File
@@ -35,5 +35,16 @@
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"Kafka": {
"BootstrapServers": "localhost:9092",
"Topics": {
"ObservationRecorded": "observation.recorded",
"AlertGenerated": "alert.generated",
"EncounterStatusChanged": "encounter.status.changed"
},
"NumPartitions": 6,
"OutboxBatchSize": 100,
"OutboxPollIntervalMs": 500
}
}