using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.HealthMonitoring; namespace ScadaLink.AuditLog.Site; /// /// Audit Log (#23) M6 Bundle E (T6) — site-side hosted service that /// periodically pulls a backlog snapshot from /// and pushes it into so the next /// emits a fresh /// SiteAuditBacklog field on the site health report. /// /// /// /// Why a hosted service, not the report sender. Querying SQLite for the /// backlog requires the queue's write lock; doing it inline in /// would couple the collector /// to and turn an in-memory snapshot read into /// a synchronous I/O call on the report path. The hosted-service pattern keeps /// the report path pure and the SQL probe off the report timing budget. /// /// /// Cadence. 30 s by default — coarse enough to amortise the SQL probe /// across many reports, fine enough that the central dashboard never lags by /// more than one health-report interval. Tunable via /// in a follow-up /// if ops needs a different cadence; for M6 we hard-code the value because the /// brief calls it out explicitly. /// /// /// Failure containment. The probe call is wrapped in a try/catch so a /// transient SQLite error never tears down the hosted service — the next tick /// retries. Mirrors 's /// "exception logged, not propagated" contract. /// /// public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable { /// /// Default poll cadence. Half a typical 60 s health-report interval keeps /// the snapshot fresh without spinning the SQL probe more often than /// necessary. /// internal static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(30); private readonly ISiteAuditQueue _queue; private readonly ISiteHealthCollector _collector; private readonly ILogger _logger; private readonly TimeSpan _refreshInterval; private CancellationTokenSource? _cts; private Task? _loop; /// Initializes a new instance of . /// The site audit queue used to probe the backlog count. /// The site health collector that receives the backlog snapshot. /// Logger instance. /// Poll interval override; defaults to (30 s). public SiteAuditBacklogReporter( ISiteAuditQueue queue, ISiteHealthCollector collector, ILogger logger, TimeSpan? refreshInterval = null) { _queue = queue ?? throw new ArgumentNullException(nameof(queue)); _collector = collector ?? throw new ArgumentNullException(nameof(collector)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _refreshInterval = refreshInterval ?? DefaultRefreshInterval; } /// public Task StartAsync(CancellationToken ct) { // Linked CTS lets StopAsync's cancellation AND the host's shutdown // token both terminate the loop; either side firing aborts the // pending Task.Delay. _cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _loop = Task.Run(() => RunLoopAsync(_cts.Token)); return Task.CompletedTask; } private async Task RunLoopAsync(CancellationToken ct) { // First tick runs immediately so the very first health report after // process start carries a real backlog snapshot — without this the // dashboard would show null for the first 30 s after a deploy. await SafeProbeAsync(ct).ConfigureAwait(false); while (!ct.IsCancellationRequested) { try { await Task.Delay(_refreshInterval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } await SafeProbeAsync(ct).ConfigureAwait(false); } } private async Task SafeProbeAsync(CancellationToken ct) { try { var snapshot = await _queue.GetBacklogStatsAsync(ct).ConfigureAwait(false); _collector.UpdateSiteAuditBacklog(snapshot); } catch (OperationCanceledException) { // Shutdown — let the outer loop exit cleanly. throw; } catch (Exception ex) { // Catch-all is deliberate: the hosted service must survive every // class of probe failure (transient SQLite lock contention, disk // I/O hiccup, …) so the next tick gets a chance. _logger.LogWarning(ex, "SiteAuditBacklogReporter probe failed; next tick will retry."); } } /// public Task StopAsync(CancellationToken ct) { _cts?.Cancel(); return _loop ?? Task.CompletedTask; } /// public void Dispose() { _cts?.Dispose(); } }