using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Observability; namespace ZB.MOM.WW.ScadaBridge.Transport.Import; /// /// T-007: periodic background sweep that drives /// so abandoned import sessions clear from memory on their own, without needing a /// new to trigger lazy eviction. Each session /// owns the decrypted bundle content (potentially up to ~100 MB of secrets — DB /// connection strings, SMTP credentials, external-system auth configs), and the /// design contract is "bundles are not retained server-side after ApplyAsync /// commits". This service keeps abandoned / failed sessions from pinning that /// plaintext for the full 30-minute TTL when no other traffic flows. /// /// Register row 65: it also ticks the bundle-unlock attempt prune. That retention /// sweep used to ride the unlock path alone, on the reasoning that Transport owned no /// central scheduler — this service is that scheduler, so a cluster that stops importing /// entirely no longer leaves its last BundleUnlockAttempt rows in place /// indefinitely. The window keeps its own 15-minute cadence claim, so ticking it on the /// 1-minute session sweep costs at most one bounded DELETE per quarter hour, and the two /// drivers can never prune concurrently. /// /// internal sealed class BundleSessionEvictionService : BackgroundService { private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(1); private readonly IBundleSessionStore _sessionStore; private readonly IBundleUnlockAttemptWindow _unlockAttemptWindow; private readonly ILogger _logger; /// /// Register row 53: the open-session gauge callback, held in a field so /// can pass the SAME delegate reference to /// — the clear is /// an identity-checked compare-and-clear, so a stale stop cannot unregister a newer /// instance's provider. Mirrors StoreAndForwardService's queue-depth registration. /// private readonly Func _openSessionCountProvider; /// /// Initializes a new instance of . /// /// The bundle session store to sweep for expired sessions. /// Shared unlock-attempt window whose retention prune this sweep also ticks (register row 65). /// Logger for sweep diagnostics. public BundleSessionEvictionService( IBundleSessionStore sessionStore, IBundleUnlockAttemptWindow unlockAttemptWindow, ILogger logger) { _sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore)); _unlockAttemptWindow = unlockAttemptWindow ?? throw new ArgumentNullException(nameof(unlockAttemptWindow)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _openSessionCountProvider = () => _sessionStore.OpenSessionCount; } /// /// Register row 53: registers the open-import-sessions gauge provider before the /// sweep loop starts. This service is the Transport component's only lifecycle seam /// (the session store itself is a plain singleton), and it is registered by /// AddTransport, which only the Central branch of the host calls — so the /// gauge reports 0 on a site node, where there are no import sessions to count. /// /// Host startup cancellation token. /// A task that completes when the base has started. public override Task StartAsync(CancellationToken cancellationToken) { ScadaBridgeTelemetry.SetImportSessionCountProvider(_openSessionCountProvider); return base.StartAsync(cancellationToken); } /// /// Deregisters this instance's gauge provider on graceful stop so the gauge falls /// back to 0 rather than reading through a dead store. The clear is identity-checked, /// so a late stop cannot clobber a successor that already re-registered. /// /// Host shutdown cancellation token. /// A task that completes when the base has stopped. public override async Task StopAsync(CancellationToken cancellationToken) { ScadaBridgeTelemetry.ClearImportSessionCountProvider(_openSessionCountProvider); await base.StopAsync(cancellationToken).ConfigureAwait(false); } /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { await Task.Delay(SweepInterval, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } try { _sessionStore.EvictExpired(); } catch (Exception ex) { _logger.LogWarning(ex, "Bundle session sweep failed; will retry on next interval."); } // Register row 65: retention tick for the fleet-wide unlock-attempt window. // PruneAsync is contractually non-throwing and self-limits to its own // 15-minute cadence, so this is a cheap no-op on most sweeps. Kept separate // from the session sweep's try so neither maintenance duty can mask the other. try { await _unlockAttemptWindow .PruneAsync(BundleUnlockRateLimiter.DefaultWindow, stoppingToken) .ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } catch (Exception ex) { _logger.LogWarning( ex, "Bundle-unlock attempt prune tick failed; will retry on next interval."); } } } }