using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Signals.Options; using StellaOps.Signals.Persistence; namespace StellaOps.Signals.Services; /// /// Background service that periodically cleans up expired runtime facts /// based on the configured retention policy. /// public sealed class RuntimeFactsRetentionService : BackgroundService { private readonly IReachabilityFactRepository _factRepository; private readonly IOptions _options; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public RuntimeFactsRetentionService( IReachabilityFactRepository factRepository, IOptions options, TimeProvider timeProvider, ILogger logger) { _factRepository = factRepository ?? throw new ArgumentNullException(nameof(factRepository)); _options = options ?? throw new ArgumentNullException(nameof(options)); _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var retention = _options.Value.Retention; if (!retention.EnableAutoCleanup) { _logger.LogInformation("Runtime facts auto-cleanup is disabled."); return; } _logger.LogInformation( "Runtime facts retention service started. TTL={TtlHours}h, Interval={IntervalMinutes}m, MaxPerSubject={MaxPerSubject}", retention.RuntimeFactsTtlHours, retention.CleanupIntervalMinutes, retention.MaxRuntimeFactsPerSubject); var interval = TimeSpan.FromMinutes(retention.CleanupIntervalMinutes); while (!stoppingToken.IsCancellationRequested) { try { await Task.Delay(interval, _timeProvider, stoppingToken).ConfigureAwait(false); await CleanupExpiredFactsAsync(retention, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { // Normal shutdown break; } catch (Exception ex) { _logger.LogError(ex, "Error during runtime facts cleanup cycle."); // Continue to next cycle } } _logger.LogInformation("Runtime facts retention service stopped."); } private async Task CleanupExpiredFactsAsync(SignalsRetentionOptions retention, CancellationToken cancellationToken) { if (retention.RuntimeFactsTtlHours <= 0) { _logger.LogDebug("RuntimeFactsTtlHours is 0, skipping expiration cleanup."); return; } var cutoff = _timeProvider.GetUtcNow().AddHours(-retention.RuntimeFactsTtlHours); var expiredDocs = await _factRepository.GetExpiredAsync(cutoff, 100, cancellationToken).ConfigureAwait(false); if (expiredDocs.Count == 0) { _logger.LogDebug("No expired runtime facts documents found."); return; } _logger.LogInformation("Found {Count} expired runtime facts documents to clean up.", expiredDocs.Count); var deletedCount = 0; var archivedCount = 0; foreach (var doc in expiredDocs) { try { if (retention.ArchiveBeforeDelete) { await ArchiveDocumentAsync(doc, retention, cancellationToken).ConfigureAwait(false); archivedCount++; } var deleted = await _factRepository.DeleteAsync(doc.SubjectKey, cancellationToken).ConfigureAwait(false); if (deleted) { deletedCount++; } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to cleanup expired document for subject {SubjectKey}.", doc.SubjectKey); } } _logger.LogInformation( "Cleanup complete: deleted={DeletedCount}, archived={ArchivedCount}", deletedCount, archivedCount); } private Task ArchiveDocumentAsync( StellaOps.Signals.Models.ReachabilityFactDocument document, SignalsRetentionOptions retention, CancellationToken cancellationToken) { // Archive to CAS is a placeholder - actual implementation would write to CAS storage // using the configured ArchiveCasPath _logger.LogDebug( "Archiving document for subject {SubjectKey} to {CasPath}", document.SubjectKey, retention.ArchiveCasPath); // TODO: Implement actual CAS archival via ICasStore when available return Task.CompletedTask; } }