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