Clinical Sync Batch Engine: first commit
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
public sealed class ClinicalSyncBatchConsumer : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly RabbitMqOptions _rabbitOpts;
|
||||
private readonly ILogger<ClinicalSyncBatchConsumer> _logger;
|
||||
|
||||
public ClinicalSyncBatchConsumer(
|
||||
IServiceScopeFactory scopes,
|
||||
IOptions<RabbitMqOptions> rabbitOpts,
|
||||
ILogger<ClinicalSyncBatchConsumer> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_rabbitOpts = rabbitOpts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = _rabbitOpts.Host,
|
||||
Port = _rabbitOpts.Port,
|
||||
UserName = _rabbitOpts.Username,
|
||||
Password = _rabbitOpts.Password,
|
||||
DispatchConsumersAsync = true
|
||||
};
|
||||
|
||||
using var connection = factory.CreateConnection("clinical-sync-consumer");
|
||||
using var channel = connection.CreateModel();
|
||||
channel.BasicQos(0, prefetchCount: 5, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
consumer.Received += async (_, ea) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandleMessageAsync(channel, ea, stoppingToken);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Invalid sync batch message — NACK no requeue");
|
||||
channel.BasicNack(ea.DeliveryTag, false, requeue: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Sync batch consumer failed — NACK requeue");
|
||||
channel.BasicNack(ea.DeliveryTag, false, requeue: true);
|
||||
}
|
||||
};
|
||||
|
||||
channel.BasicConsume(RabbitMqTopologyProvisioner.SyncBatchQueue, autoAck: false, consumer);
|
||||
_logger.LogInformation("ClinicalSyncBatchConsumer consuming {Queue}", RabbitMqTopologyProvisioner.SyncBatchQueue);
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(IModel channel, BasicDeliverEventArgs ea, CancellationToken ct)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetString(ea.Body.Span);
|
||||
var doc = JsonDocument.Parse(payload);
|
||||
var batchId = Guid.Parse(doc.RootElement.GetProperty("batchId").GetString()!);
|
||||
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var processor = scope.ServiceProvider.GetRequiredService<ClinicalSyncBatchProcessor>();
|
||||
await processor.ProcessBatchAsync(batchId, ct);
|
||||
|
||||
channel.BasicAck(ea.DeliveryTag, false);
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -9,15 +9,18 @@ public sealed class NotificationPublisherService : BackgroundService
|
||||
private readonly IOptions<RabbitMqOptions> _rabbitOpts;
|
||||
private readonly KafkaOptions _kafkaOptions;
|
||||
private readonly ILogger<NotificationPublisherService> _logger;
|
||||
private readonly ClinicalSyncOptions _syncOptions;
|
||||
|
||||
public NotificationPublisherService(
|
||||
IOptions<RabbitMqOptions> rabbitOpts,
|
||||
IOptions<KafkaOptions> kafkaOptions,
|
||||
ILogger<NotificationPublisherService> logger)
|
||||
ILogger<NotificationPublisherService> logger,
|
||||
IOptions<ClinicalSyncOptions> syncOptions)
|
||||
{
|
||||
_rabbitOpts = rabbitOpts;
|
||||
_kafkaOptions = kafkaOptions.Value;
|
||||
_logger = logger;
|
||||
_syncOptions = syncOptions.Value;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
@@ -109,6 +112,19 @@ public sealed class NotificationPublisherService : BackgroundService
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var alertId = doc.RootElement.GetProperty("alertId").GetString();
|
||||
|
||||
// Skip paging for gateway-synced alerts when configured
|
||||
if (_syncOptions.SuppressPagingForSyncedAlerts
|
||||
&& doc.RootElement.TryGetProperty("syncedFromGateway", out var synced)
|
||||
&& synced.GetBoolean())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipping central paging for gateway-synced alert {AlertId} — ward already paged locally",
|
||||
alertId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var body = Encoding.UTF8.GetBytes(payload);
|
||||
chan.BasicPublish(
|
||||
exchange: RabbitMqTopologyProvisioner.Exchange,
|
||||
@@ -116,7 +132,7 @@ public sealed class NotificationPublisherService : BackgroundService
|
||||
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;
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
using System.Text;
|
||||
using Confluent.Kafka;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Exceptions;
|
||||
|
||||
public class OutboxRelayService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly KafkaOptions _options;
|
||||
private readonly RabbitMqOptions _rabbitOpts;
|
||||
private readonly ClinicalSyncOptions _syncOpts;
|
||||
private readonly ILogger<OutboxRelayService> _logger;
|
||||
private IProducer<string, string>? _producer;
|
||||
private IConnection? _rabbitConnection;
|
||||
private IModel? _rabbitChannel;
|
||||
private IBasicProperties? _rabbitProps;
|
||||
|
||||
public OutboxRelayService(
|
||||
IServiceProvider services,
|
||||
IOptions<KafkaOptions> options,
|
||||
IOptions<RabbitMqOptions> rabbitOpts,
|
||||
IOptions<ClinicalSyncOptions> syncOpts,
|
||||
ILogger<OutboxRelayService> logger)
|
||||
{
|
||||
_services = services;
|
||||
_options = options.Value;
|
||||
_rabbitOpts = rabbitOpts.Value;
|
||||
_syncOpts = syncOpts.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -33,6 +45,19 @@ public class OutboxRelayService : BackgroundService
|
||||
RetryBackoffMs = 100
|
||||
}).Build();
|
||||
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = _rabbitOpts.Host,
|
||||
Port = _rabbitOpts.Port,
|
||||
UserName = _rabbitOpts.Username,
|
||||
Password = _rabbitOpts.Password,
|
||||
DispatchConsumersAsync = true,
|
||||
};
|
||||
_rabbitConnection = factory.CreateConnection("outbox-relay");
|
||||
_rabbitChannel = _rabbitConnection.CreateModel();
|
||||
_rabbitProps = _rabbitChannel.CreateBasicProperties();
|
||||
_rabbitProps.Persistent = true;
|
||||
|
||||
return base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -91,26 +116,45 @@ public class OutboxRelayService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _producer!.ProduceAsync(
|
||||
ev.Topic,
|
||||
new Message<string, string>
|
||||
{
|
||||
Key = ev.PartitionKey ?? string.Empty,
|
||||
Value = ev.Payload
|
||||
},
|
||||
ct);
|
||||
if (ev.Topic == ClinicalSyncOptions.BatchReceivedOutboxTopic)
|
||||
{
|
||||
_rabbitChannel!.BasicPublish(
|
||||
exchange: _syncOpts.SyncExchange,
|
||||
routingKey: _syncOpts.SyncBatchReceivedRoutingKey,
|
||||
basicProperties: _rabbitProps,
|
||||
body: Encoding.UTF8.GetBytes(ev.Payload));
|
||||
|
||||
_logger.LogDebug(
|
||||
"Published sync batch to RabbitMQ exchange={Exchange} routingKey={RoutingKey}",
|
||||
_syncOpts.SyncExchange, _syncOpts.SyncBatchReceivedRoutingKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await _producer!.ProduceAsync(
|
||||
ev.Topic,
|
||||
new Message<string, string>
|
||||
{
|
||||
Key = ev.PartitionKey ?? string.Empty,
|
||||
Value = ev.Payload
|
||||
},
|
||||
ct);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Published {Topic} offset={Offset} partition={Partition} key={Key}",
|
||||
ev.Topic, result.Offset.Value, result.Partition.Value, ev.PartitionKey);
|
||||
}
|
||||
|
||||
published.Add(ev.Id);
|
||||
ev.ProcessedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
_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)
|
||||
catch (Exception ex) when (ex is ProduceException<string, string> or RabbitMQClientException)
|
||||
{
|
||||
ev.RetryCount++;
|
||||
ev.LastError = ex.Error.Reason;
|
||||
ev.LastError = ex switch
|
||||
{
|
||||
ProduceException<string, string> kex => kex.Error.Reason,
|
||||
_ => ex.Message
|
||||
};
|
||||
|
||||
if (ev.RetryCount >= _options.OutboxMaxRetries)
|
||||
{
|
||||
@@ -122,8 +166,8 @@ public class OutboxRelayService : BackgroundService
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Kafka produce failed for outbox event {Id} — retry {Retry}/{Max}",
|
||||
ev.Id, ev.RetryCount, _options.OutboxMaxRetries);
|
||||
"Outbox publish failed for event {Id} — retry {Retry}/{Max} topic={Topic}",
|
||||
ev.Id, ev.RetryCount, _options.OutboxMaxRetries, ev.Topic);
|
||||
}
|
||||
|
||||
hadFailure = true;
|
||||
@@ -142,7 +186,9 @@ public class OutboxRelayService : BackgroundService
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_rabbitChannel?.Dispose();
|
||||
_rabbitConnection?.Dispose();
|
||||
_producer?.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user