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.");
}
}
}
}