using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; public sealed class GatewayStaleDetectorService : BackgroundService { private readonly IServiceScopeFactory _scopes; private readonly GatewayMonitoringOptions _opts; private readonly ClinicalMetrics _metrics; private readonly ILogger _logger; public GatewayStaleDetectorService( IServiceScopeFactory scopes, IOptions opts, ClinicalMetrics metrics, ILogger logger) { _scopes = scopes; _opts = opts.Value; _metrics = metrics; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken ct) { using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_opts.PollIntervalMinutes)); while (await timer.WaitForNextTickAsync(ct)) await DetectStaleAsync(ct); } private async Task DetectStaleAsync(CancellationToken ct) { await using var scope = _scopes.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var cutoff = DateTimeOffset.UtcNow.AddMinutes(-_opts.StaleThresholdMinutes); var stale = await db.WardGateways .Include(g => g.Site) .Where(g => g.Status != GatewayStatus.Offline && (g.LastHeartbeatAt == null || g.LastHeartbeatAt < cutoff)) .ToListAsync(ct); foreach (var gateway in stale) { gateway.MarkOffline(); _logger.LogWarning( "Gateway {Code} ({Department}) marked OFFLINE — last heartbeat {LastHeartbeat}", gateway.GatewayCode, gateway.Department, gateway.LastHeartbeatAt); _metrics.WardGatewaysOffline .WithLabels(gateway.Site.SiteCode) .Inc(); } if (stale.Count > 0) await db.SaveChangesAsync(ct); } }