feature: Ward Gateway Service (Local-First Clinical Path)
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using VigilCare.ClinicalContracts.Sync;
|
||||
|
||||
public sealed class SyncUploaderService : BackgroundService
|
||||
{
|
||||
private static readonly JsonSerializerOptions PayloadJson = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly IServiceScopeFactory _scopes;
|
||||
private readonly CentralReachabilityService _reachability;
|
||||
private readonly GatewayOptions _gateway;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<SyncUploaderService> _logger;
|
||||
private int _backoffSeconds = 30;
|
||||
|
||||
public SyncUploaderService(
|
||||
IServiceScopeFactory scopes,
|
||||
CentralReachabilityService reachability,
|
||||
IOptions<GatewayOptions> gateway,
|
||||
IConfiguration config,
|
||||
ILogger<SyncUploaderService> logger)
|
||||
{
|
||||
_scopes = scopes;
|
||||
_reachability = reachability;
|
||||
_gateway = gateway.Value;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
if (!_reachability.IsCentralReachable) continue;
|
||||
try
|
||||
{
|
||||
await UploadBatchAsync(ct);
|
||||
_backoffSeconds = 30;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Sync upload failed — backoff {Seconds}s", _backoffSeconds);
|
||||
await Task.Delay(TimeSpan.FromSeconds(_backoffSeconds), ct);
|
||||
_backoffSeconds = Math.Min(_backoffSeconds * 2, 600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadBatchAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = _scopes.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<GatewayDbContext>();
|
||||
|
||||
var pending = await db.BufferedSyncItems
|
||||
.Where(b => !b.Synced)
|
||||
.OrderBy(b => b.RecordedAt)
|
||||
.Take(_gateway.SyncBatchSize)
|
||||
.ToListAsync(ct);
|
||||
if (pending.Count == 0) return;
|
||||
|
||||
var batchRef = Guid.NewGuid();
|
||||
var request = BuildBatchRequest(batchRef, pending);
|
||||
|
||||
var client = scope.ServiceProvider.GetRequiredService<IHttpClientFactory>().CreateClient("central");
|
||||
client.BaseAddress = new Uri(
|
||||
scope.ServiceProvider.GetRequiredService<IOptions<CentralApiOptions>>().Value.BaseUrl);
|
||||
client.DefaultRequestHeaders.Add("X-Api-Key", _config["ApiKey:Gateway"]!);
|
||||
client.DefaultRequestHeaders.Add("X-Gateway-Id", _gateway.GatewayId.ToString());
|
||||
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/sync/batches", request, ct);
|
||||
submit.EnsureSuccessStatusCode();
|
||||
var batchId = (await submit.Content.ReadFromJsonAsync<ApiResponse<SyncBatchStatus>>(ct))!.Data!.BatchId;
|
||||
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
await Task.Delay(2000, ct);
|
||||
var status = await client.GetFromJsonAsync<ApiResponse<SyncBatchStatus>>(
|
||||
$"/api/v1/sync/batches/{batchId}", ct);
|
||||
if (status?.Data?.Status is "APPLIED")
|
||||
{
|
||||
foreach (var item in pending)
|
||||
{
|
||||
item.Synced = true;
|
||||
item.SyncedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
_logger.LogInformation(
|
||||
"Sync batch {BatchId} applied — marked {Count} buffered items synced",
|
||||
batchId, pending.Count);
|
||||
return;
|
||||
}
|
||||
if (status?.Data?.Status is "CONFLICT" or "REJECTED")
|
||||
{
|
||||
_logger.LogError(
|
||||
"Sync batch {BatchId} {Status} — items remain buffered",
|
||||
batchId, status.Data.Status);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("Sync batch {BatchId} still processing after poll window", batchId);
|
||||
}
|
||||
|
||||
private ClinicalSyncBatchRequest BuildBatchRequest(Guid batchRef, List<BufferedSyncItem> items)
|
||||
{
|
||||
var observations = new List<SyncedObservation>();
|
||||
var alertEvents = new List<SyncedAlertEvent>();
|
||||
var acks = new List<SyncedAlertAcknowledgment>();
|
||||
var resolves = new List<SyncedAlertResolution>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
switch (item.ItemType)
|
||||
{
|
||||
case BufferedSyncItemType.Observation:
|
||||
observations.Add(MapObservation(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Alert:
|
||||
alertEvents.Add(MapAlert(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Ack:
|
||||
acks.Add(MapAck(item));
|
||||
break;
|
||||
case BufferedSyncItemType.Resolve:
|
||||
resolves.Add(MapResolve(item));
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Unknown buffered sync item type '{item.ItemType}' for item {item.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
return new ClinicalSyncBatchRequest(
|
||||
batchRef,
|
||||
_gateway.GatewayId,
|
||||
_gateway.SiteId,
|
||||
DateTimeOffset.UtcNow,
|
||||
observations,
|
||||
alertEvents,
|
||||
acks,
|
||||
resolves);
|
||||
}
|
||||
|
||||
private static SyncedObservation MapObservation(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedObservationPayload>(item.Payload);
|
||||
return new SyncedObservation(
|
||||
p.ClientRef,
|
||||
p.IdempotencyKey ?? item.IdempotencyKey,
|
||||
p.EncounterId,
|
||||
p.ObservationCode,
|
||||
p.Value,
|
||||
p.Unit,
|
||||
p.Source,
|
||||
p.RecordedAt);
|
||||
}
|
||||
|
||||
private static SyncedAlertEvent MapAlert(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedAlertPayload>(item.Payload);
|
||||
return new SyncedAlertEvent(
|
||||
p.ClientAlertId,
|
||||
p.EncounterId,
|
||||
p.AlertType,
|
||||
p.Severity,
|
||||
p.Details,
|
||||
p.GeneratedAt);
|
||||
}
|
||||
|
||||
private static SyncedAlertAcknowledgment MapAck(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedAckPayload>(item.Payload);
|
||||
return new SyncedAlertAcknowledgment(
|
||||
p.ClientRef,
|
||||
p.ClientAlertId,
|
||||
p.ClinicianId,
|
||||
p.AcknowledgedAt,
|
||||
p.Note);
|
||||
}
|
||||
|
||||
private static SyncedAlertResolution MapResolve(BufferedSyncItem item)
|
||||
{
|
||||
var p = Deserialize<BufferedResolvePayload>(item.Payload);
|
||||
return new SyncedAlertResolution(
|
||||
p.ClientRef,
|
||||
p.ClientAlertId,
|
||||
p.ClinicianId,
|
||||
p.ResolvedAt,
|
||||
p.Note);
|
||||
}
|
||||
|
||||
private static T Deserialize<T>(string json) =>
|
||||
JsonSerializer.Deserialize<T>(json, PayloadJson)
|
||||
?? throw new JsonException($"Failed to deserialize buffered sync payload as {typeof(T).Name}");
|
||||
|
||||
private sealed record BufferedObservationPayload(
|
||||
Guid ClientRef,
|
||||
string? IdempotencyKey,
|
||||
Guid EncounterId,
|
||||
string ObservationCode,
|
||||
decimal Value,
|
||||
string? Unit,
|
||||
string Source,
|
||||
DateTimeOffset RecordedAt);
|
||||
|
||||
private sealed record BufferedAlertPayload(
|
||||
Guid ClientAlertId,
|
||||
Guid EncounterId,
|
||||
string AlertType,
|
||||
string Severity,
|
||||
string Details,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
private sealed record BufferedAckPayload(
|
||||
Guid ClientRef,
|
||||
Guid ClientAlertId,
|
||||
string ClinicianId,
|
||||
DateTimeOffset AcknowledgedAt,
|
||||
string? Note);
|
||||
|
||||
private sealed record BufferedResolvePayload(
|
||||
Guid ClientRef,
|
||||
Guid ClientAlertId,
|
||||
string ClinicianId,
|
||||
DateTimeOffset ResolvedAt,
|
||||
string? Note);
|
||||
|
||||
private sealed record SyncBatchStatus(Guid BatchId, string Status);
|
||||
}
|
||||
Reference in New Issue
Block a user