using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance; /// /// Central hosted service that purges AuditLogEntries (the configuration audit trail) /// older than . A no-op while /// RetentionDays is 0 (the default — retain indefinitely). /// /// /// Deletes in bounded batches (, at most /// per tick) so a first purge over a large /// backlog is time-sliced across ticks instead of one long-running DELETE. Both central nodes may /// run it; deleting an already-deleted row is harmless, so no singleton is needed. A failed tick /// is logged and retried on the next. /// public sealed class ConfigAuditPurgeService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly IOptions _options; private readonly TimeProvider _time; private readonly ILogger _logger; /// Initializes the purge service. /// Creates a DI scope per tick (the DbContext is scoped). /// Retention options. /// Clock. /// Logger. public ConfigAuditPurgeService( IServiceScopeFactory scopeFactory, IOptions options, TimeProvider time, ILogger logger) { _scopeFactory = scopeFactory; _options = options; _time = time; _logger = logger; } /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var opts = _options.Value; if (opts.RetentionDays <= 0) { _logger.LogInformation("Config audit retention is off (RetentionDays=0); entries are kept indefinitely."); return; } using var timer = new PeriodicTimer(opts.PurgeInterval, _time); do { try { await PurgeOnceAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; } catch (Exception ex) { _logger.LogError(ex, "Config audit purge tick failed; will retry next interval."); } } while (await WaitNextAsync(timer, stoppingToken)); } private static async Task WaitNextAsync(PeriodicTimer timer, CancellationToken ct) { try { return await timer.WaitForNextTickAsync(ct); } catch (OperationCanceledException) { return false; } } /// /// Runs one purge pass. Returns the number of rows deleted. Exposed for tests. /// /// Cancellation token. /// Total rows deleted this pass. public async Task PurgeOnceAsync(CancellationToken cancellationToken = default) { var opts = _options.Value; if (opts.RetentionDays <= 0) return 0; var cutoff = _time.GetUtcNow().AddDays(-opts.RetentionDays); await using var scope = _scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var total = 0; for (var batch = 0; batch < opts.MaxBatchesPerTick; batch++) { // Oldest-first, bounded: select the batch's keys then delete by key so the DELETE // stays a short, index-ordered operation on SQL Server. var ids = await db.AuditLogEntries .AsNoTracking() .Where(a => a.Timestamp < cutoff) .OrderBy(a => a.Timestamp) .Select(a => a.Id) .Take(opts.PurgeBatchSize) .ToListAsync(cancellationToken); if (ids.Count == 0) break; total += await db.AuditLogEntries .Where(a => ids.Contains(a.Id)) .ExecuteDeleteAsync(cancellationToken); if (ids.Count < opts.PurgeBatchSize) break; } if (total > 0) _logger.LogInformation("Config audit purge removed {Count} entries older than {Cutoff:o}.", total, cutoff); return total; } }