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() { }
}
}