using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
namespace ZB.MOM.WW.ScadaBridge.AuditLog.Central;
///
/// Central that rolls
/// pf_AuditLog_Month forward once a day. Each tick opens a fresh DI
/// scope, resolves , and calls
/// to SPLIT any
/// missing future boundaries — the partition function must always cover at
/// least
/// future months, otherwise inserts past the highest boundary accumulate in
/// a single unbounded tail partition that SwitchOutPartitionAsync
/// cannot purge cleanly.
///
///
///
/// Why a hosted service, not an actor.
/// sits inside the central singleton
/// because it needs supervised lifecycle alongside the rest of the
/// reconciliation / ingest pipeline. Roll-forward is genuinely a once-a-day
/// chore with no cross-actor coordination, so we use the much simpler
/// hosted-service pattern: Task.Run on start, Task.Delay
/// between ticks, cancellation on stop. Reusing
/// from the central node-only DI graph
/// keeps the contract testable without any actor framework involvement.
///
///
/// Failure containment. The tick body wraps the maintenance call in
/// a try/catch so a transient SQL Server error never tears down the hosted
/// service — the next tick simply retries. The exception is logged with
/// the original stack trace at Error level; ops surfaces can subscribe to the logger to alert on
/// repeated failures.
///
///
/// Startup ordering. A first tick fires immediately at
/// so a fresh deployment doesn't need to wait
/// for
/// the partition function to come up to spec. This is also what the brief
/// asks for ("Run once on startup").
///
///
/// DI scope per tick. is scoped
/// (alongside the rest of the EF repositories) because the implementation
/// reuses the per-scope ScadaBridgeDbContext. A hosted service is a
/// singleton, so it must open and dispose a scope around each tick — the
/// same pattern uses.
///
///
public sealed class AuditLogPartitionMaintenanceService : IHostedService, IDisposable
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions _options;
private readonly ILogger _logger;
private CancellationTokenSource? _cts;
private Task? _loop;
///
/// Initializes the maintenance service with its required dependencies.
///
/// Scope factory used to open DI scopes for each maintenance run.
/// Partition maintenance options (retention period, purge interval, etc.).
/// Logger for this service.
public AuditLogPartitionMaintenanceService(
IServiceScopeFactory scopeFactory,
IOptions options,
ILogger logger)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_options = options ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
///
/// Starts the background maintenance loop, firing an immediate first tick and then
/// repeating every .
///
/// Cancellation token provided by the host.
/// A completed task; the loop runs independently on a background thread.
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.
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_cts = cts;
// Read Token here, on the caller's thread, rather than inside the lambda:
// the lambda runs whenever the thread pool gets to it, so a Dispose landing
// first would make the deferred _cts.Token read throw ObjectDisposedException
// and fault the loop task — which StopAsync hands to the host to await. The
// token struct stays usable once captured.
var token = cts.Token;
_loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
return Task.CompletedTask;
}
private async Task RunLoopAsync(CancellationToken ct)
{
// Run once on startup so a fresh deployment isn't gated on the
// IntervalSeconds initial wait — the brief calls this out explicitly.
await SafeMaintainAsync(ct).ConfigureAwait(false);
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(_options.Value.IntervalSeconds), ct)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
await SafeMaintainAsync(ct).ConfigureAwait(false);
}
}
private async Task SafeMaintainAsync(CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var maintenance = scope.ServiceProvider.GetRequiredService();
var added = await maintenance
.EnsureLookaheadAsync(_options.Value.LookaheadMonths, ct)
.ConfigureAwait(false);
if (added.Count > 0)
{
_logger.LogInformation(
"AuditLogPartitionMaintenance added {Count} boundaries: {Boundaries}",
added.Count,
string.Join(", ", added.Select(b => b.ToString("yyyy-MM-dd"))));
}
}
catch (Exception ex)
{
// Catch-all is deliberate: the hosted service must survive every
// class of tick failure (transient SQL, DI resolution, etc.) so
// the next tick gets a chance. The brief's contract is
// "exception logged, not propagated".
_logger.LogError(ex, "AuditLogPartitionMaintenance tick failed");
}
}
///
/// Signals the maintenance loop to stop by cancelling its linked token,
/// then returns the loop task so the host can await its completion.
///
/// Cancellation token provided by the host (unused — the internal CTS is cancelled directly).
/// The background loop task, or a completed task if the loop was never started.
public Task StopAsync(CancellationToken ct)
{
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Stop-after-Dispose is a legal ordering — WebApplicationFactory's
// teardown disposes the container before driving StopAsync. Dispose
// already cancelled the loop, so there is nothing left to signal, and
// letting this escape would abort the host's whole shutdown sequence.
}
return _loop ?? Task.CompletedTask;
}
///
/// Cancels and disposes the internal used to
/// stop the maintenance loop.
///
///
/// Cancels before disposing so the loop is always signalled, even when the host
/// disposes the container without having driven first.
/// It also keeps the loop's pending Task.Delay(interval, token) safe: an
/// already-cancelled token makes Delay complete as cancelled rather than register
/// a callback against a dead source and throw.
///
public void Dispose()
{
try
{
_cts?.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed — Dispose is idempotent.
}
_cts?.Dispose();
}
}