236 lines
7.9 KiB
C#
236 lines
7.9 KiB
C#
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);
|
|
}
|