using Microsoft.Data.Sqlite; 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 { private readonly SiteEventLogger _eventLogger; private readonly SiteEventLogOptions _options; private readonly ILogger _logger; public EventLogPurgeService( ISiteEventLogger eventLogger, IOptions options, ILogger logger) { // We need the concrete type to access the connection _eventLogger = (SiteEventLogger)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"); using var cmd = _eventLogger.Connection.CreateCommand(); cmd.CommandText = "DELETE FROM site_events WHERE timestamp < $cutoff"; cmd.Parameters.AddWithValue("$cutoff", cutoff); var deleted = cmd.ExecuteNonQuery(); if (deleted > 0) { _logger.LogInformation("Purged {Count} events older than {Days} days", deleted, _options.RetentionDays); } } private void PurgeByStorageCap() { var currentSizeBytes = GetDatabaseSizeBytes(); var capBytes = (long)_options.MaxStorageMb * 1024 * 1024; 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 oldest events in batches until under the cap while (currentSizeBytes > capBytes) { using var cmd = _eventLogger.Connection.CreateCommand(); cmd.CommandText = """ DELETE FROM site_events WHERE id IN ( SELECT id FROM site_events ORDER BY id ASC LIMIT 1000 ) """; var deleted = cmd.ExecuteNonQuery(); if (deleted == 0) break; // Reclaim space using var vacuumCmd = _eventLogger.Connection.CreateCommand(); vacuumCmd.CommandText = "PRAGMA incremental_vacuum"; vacuumCmd.ExecuteNonQuery(); currentSizeBytes = GetDatabaseSizeBytes(); } } internal long GetDatabaseSizeBytes() { using var pageCountCmd = _eventLogger.Connection.CreateCommand(); pageCountCmd.CommandText = "PRAGMA page_count"; var pageCount = (long)pageCountCmd.ExecuteScalar()!; using var pageSizeCmd = _eventLogger.Connection.CreateCommand(); pageSizeCmd.CommandText = "PRAGMA page_size"; var pageSize = (long)pageSizeCmd.ExecuteScalar()!; return pageCount * pageSize; } }