using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace ScadaLink.SiteEventLogging; /// /// Background service that periodically purges old events from the SQLite event log. /// Enforces both time-based retention (default 30 days) and storage cap (default 1GB). /// Runs on a background thread and does not block event recording. /// public class EventLogPurgeService : BackgroundService { /// Number of events deleted per cap-purge batch. private const int CapPurgeBatchSize = 1000; private readonly SiteEventLogger _eventLogger; private readonly SiteEventLogOptions _options; private readonly ILogger _logger; public EventLogPurgeService( SiteEventLogger eventLogger, IOptions options, ILogger logger) { // Depend on the concrete recorder directly: purge must funnel database access // through its lock-guarded WithConnection. Taking ISiteEventLogger and // downcasting would throw InvalidCastException for any other implementation. _eventLogger = eventLogger; _options = options.Value; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation( "Event log purge service started — retention: {Days} days, cap: {Cap} MB, interval: {Interval}", _options.RetentionDays, _options.MaxStorageMb, _options.PurgeInterval); using var timer = new PeriodicTimer(_options.PurgeInterval); // Run an initial purge on startup RunPurge(); while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) { RunPurge(); } } internal void RunPurge() { try { PurgeByRetention(); PurgeByStorageCap(); } catch (Exception ex) { _logger.LogError(ex, "Error during event log purge"); } } private void PurgeByRetention() { var cutoff = DateTimeOffset.UtcNow.AddDays(-_options.RetentionDays).ToString("o"); var deleted = _eventLogger.WithConnection(connection => { using var cmd = connection.CreateCommand(); cmd.CommandText = "DELETE FROM site_events WHERE timestamp < $cutoff"; cmd.Parameters.AddWithValue("$cutoff", cutoff); return cmd.ExecuteNonQuery(); }); if (deleted > 0) { _logger.LogInformation("Purged {Count} events older than {Days} days", deleted, _options.RetentionDays); } } private void PurgeByStorageCap() { var capBytes = (long)_options.MaxStorageMb * 1024 * 1024; var currentSizeBytes = GetDatabaseSizeBytes(); if (currentSizeBytes <= capBytes) return; _logger.LogWarning( "Event log size {Size:F1} MB exceeds cap {Cap} MB — purging oldest events", currentSizeBytes / (1024.0 * 1024.0), _options.MaxStorageMb); // Delete the oldest events in batches until the database is under the cap. // The loop also stops if the on-disk size fails to decrease across an // iteration (e.g. if vacuum cannot reclaim space), so a cap that can never // be met does not silently empty the entire table. while (currentSizeBytes > capBytes) { var previousSizeBytes = currentSizeBytes; var deleted = _eventLogger.WithConnection(connection => { using var cmd = connection.CreateCommand(); cmd.CommandText = $""" DELETE FROM site_events WHERE id IN ( SELECT id FROM site_events ORDER BY id ASC LIMIT {CapPurgeBatchSize} ) """; var rows = cmd.ExecuteNonQuery(); // Reclaim free pages so page_count/freelist measurement reflects the // delete. Effective because auto_vacuum = INCREMENTAL is set at schema // creation; harmless otherwise. using var vacuumCmd = connection.CreateCommand(); vacuumCmd.CommandText = "PRAGMA incremental_vacuum"; vacuumCmd.ExecuteNonQuery(); return rows; }); if (deleted == 0) break; currentSizeBytes = GetDatabaseSizeBytes(); if (currentSizeBytes >= previousSizeBytes) { // Size is not shrinking despite deletes — stop rather than wipe the // whole table. This should not happen now that logical size is // measured, but guards against any future regression. _logger.LogWarning( "Event log size did not decrease after a cap-purge batch ({Size:F1} MB); " + "stopping to avoid emptying the log", currentSizeBytes / (1024.0 * 1024.0)); break; } } } /// /// Returns the logical size of the database in bytes — only pages that hold live /// data, excluding free pages on the freelist. Measuring logical size (rather than /// the raw file size from page_count) means the storage-cap loop observes /// space being reclaimed even if free pages have not yet been returned to the OS. /// internal long GetDatabaseSizeBytes() { return _eventLogger.WithConnection(connection => { using var pageCountCmd = connection.CreateCommand(); pageCountCmd.CommandText = "PRAGMA page_count"; var pageCount = (long)pageCountCmd.ExecuteScalar()!; using var freeListCmd = connection.CreateCommand(); freeListCmd.CommandText = "PRAGMA freelist_count"; var freeListCount = (long)freeListCmd.ExecuteScalar()!; using var pageSizeCmd = connection.CreateCommand(); pageSizeCmd.CommandText = "PRAGMA page_size"; var pageSize = (long)pageSizeCmd.ExecuteScalar()!; var usedPages = Math.Max(0, pageCount - freeListCount); return usedPages * pageSize; }); } }