using Akka.Actor; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; namespace ZB.MOM.WW.ScadaBridge.Communication.Actors; /// /// Central cluster singleton that periodically reclaims expired (TTL-elapsed) /// PendingDeployment staging rows from the central configuration database — /// the notify-and-fetch deploy transport stages each deploy's flattened config in a /// PendingDeployment row that the site fetches over HTTP using a per-deployment /// token, and this actor is the maintenance cadence that sweeps rows whose TTL has /// elapsed without a re-deploy superseding them. /// /// /// /// Best-effort, not readiness-gated. This purge is pure hygiene: supersession /// already bounds the table to ≤1 pending row per instance, and the config-fetch /// endpoint enforces the TTL on read (expired rows return 404), so correctness never /// depends on this purge running. It exists only to reclaim rows left behind by /// instances that are deployed once and never re-deployed. Like /// KpiHistoryRecorderActor, it is deliberately absent from /// RequiredSingletonsHealthCheck — it must never gate /health/ready. /// /// /// Continue-on-error. The tick handler swallows every exception and logs it; a /// transient SQL failure on one tick must not crash the singleton — the next tick /// retries. The per-tick try/catch (not the supervisor) is what keeps the actor alive. /// /// /// DI scopes. is a scoped EF Core /// service registered by AddConfigurationDatabase. The singleton opens one DI /// scope per tick and resolves the repository there, mirroring the /// AuditLogPurgeActor / KpiHistoryRecorderActor pattern. /// /// public class PendingDeploymentPurgeActor : ReceiveActor { private readonly IServiceProvider _services; private readonly TimeSpan _interval; private readonly ILogger _logger; private ICancelable? _timer; /// Initializes a new instance of and registers the tick handler. /// Root DI provider used to create a scoped repository per tick. /// Communication options supplying the purge interval. /// Logger instance. public PendingDeploymentPurgeActor( IServiceProvider services, IOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); _services = services; _interval = options.Value.PendingDeploymentPurgeInterval; _logger = logger; ReceiveAsync(_ => OnTickAsync()); } /// protected override void PreStart() { base.PreStart(); _timer = Context.System.Scheduler.ScheduleTellRepeatedlyCancelable( initialDelay: _interval, interval: _interval, receiver: Self, message: PurgeTick.Instance, sender: Self); } /// protected override void PostStop() { _timer?.Cancel(); base.PostStop(); } private async Task OnTickAsync() { // CreateAsyncScope + await using so the scoped EF Core DbContext (IAsyncDisposable) // disposes asynchronously without blocking on connection cleanup. await using var scope = _services.CreateAsyncScope(); IDeploymentManagerRepository repository; try { repository = scope.ServiceProvider.GetRequiredService(); } catch (Exception ex) { _logger.LogError(ex, "Failed to resolve IDeploymentManagerRepository for PendingDeployment purge tick."); return; } try { var purged = await repository .PurgeExpiredPendingDeploymentsAsync(DateTimeOffset.UtcNow) .ConfigureAwait(false); if (purged > 0) { _logger.LogInformation( "Purged {Count} expired PendingDeployment staging row(s).", purged); } } catch (Exception ex) { // Best-effort: a failed tick must not crash the singleton. The next // interval retries; correctness does not depend on any single tick. _logger.LogError(ex, "PendingDeployment purge tick failed; retrying on the next interval."); } } /// Self-tick triggering one expired-row purge pass. internal sealed class PurgeTick { public static readonly PurgeTick Instance = new(); private PurgeTick() { } } }