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