using Database.Database; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace Esim.SendMail; /// /// Background worker to clean up expired messages from MESSAGE_QUEUE. /// Messages older than configured expiration days are moved to MESSAGE_QUEUE_HIS with failed status and deleted. /// public class ExpiredMessageCleanupWorker : BackgroundService { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly int _intervalMinutes; private readonly int _expirationDays; // Message statuses private const int STATUS_PENDING = 0; private const int STATUS_PROCESSING = 1; private const int STATUS_FAILED = 3; public ExpiredMessageCleanupWorker( ILogger logger, IServiceProvider serviceProvider, IConfiguration configuration) { _logger = logger; _serviceProvider = serviceProvider; _intervalMinutes = int.Parse(configuration["Cleanup:IntervalMinutes"] ?? "60"); // Default: 1 hour _expirationDays = int.Parse(configuration["Cleanup:ExpirationDays"] ?? "1"); // Default: 1 day } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("ExpiredMessageCleanupWorker started. Interval: {Interval} minutes, Expiration: {Days} day(s)", _intervalMinutes, _expirationDays); // Wait a bit for initialization await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); while (!stoppingToken.IsCancellationRequested) { try { await CleanupExpiredMessagesAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { _logger.LogError(ex, "Error in cleanup loop"); } try { await Task.Delay(TimeSpan.FromMinutes(_intervalMinutes), stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } } _logger.LogInformation("ExpiredMessageCleanupWorker stopped."); } private async Task CleanupExpiredMessagesAsync(CancellationToken stoppingToken) { var startTime = DateTime.Now; using var scope = _serviceProvider.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); try { // Find messages older than expiration days that are still pending or processing var expirationDate = DateTime.Now.AddDays(-_expirationDays); var expiredMessages = await dbContext.MessageQueues .Where(m => m.CreatedDate <= expirationDate && (m.Status == STATUS_PENDING || m.Status == STATUS_PROCESSING)) .OrderBy(m => m.CreatedDate) .ToListAsync(stoppingToken); if (!expiredMessages.Any()) { _logger.LogDebug("No expired messages found"); return; } _logger.LogWarning("Found {Count} expired messages (older than {Days} day(s)). Moving to history and deleting...", expiredMessages.Count, _expirationDays); // Log sample of expired messages for audit var sampleSize = Math.Min(5, expiredMessages.Count); for (int i = 0; i < sampleSize; i++) { var msg = expiredMessages[i]; var age = DateTime.Now - (msg.CreatedDate ?? DateTime.Now); var subjectPreview = msg.Subject != null ? msg.Subject.Substring(0, Math.Min(50, msg.Subject.Length)) : "(no subject)"; _logger.LogInformation( "Expired message sample: ID={Id}, Recipient={Recipient}, Subject={Subject}, Age={Age:F1}h, Status={Status}, Retries={Retry}/{Max}", msg.Id, msg.Recipient, subjectPreview, age.TotalHours, msg.Status, msg.RetryCount ?? 0, msg.MaxRetry ?? 3); } if (expiredMessages.Count > sampleSize) { _logger.LogInformation("... and {More} more expired messages", expiredMessages.Count - sampleSize); } // Move to MESSAGE_QUEUE_HIS with failed status await MoveToHistoryAsync(dbContext, expiredMessages, stoppingToken); // Delete from MESSAGE_QUEUE var ids = expiredMessages.Select(m => m.Id).ToList(); var deleteSql = $"DELETE FROM MESSAGE_QUEUE WHERE ID IN ({string.Join(",", ids)})"; await dbContext.Database.ExecuteSqlRawAsync(deleteSql, stoppingToken); var elapsed = DateTime.Now - startTime; _logger.LogInformation("Successfully cleaned up {Count} expired messages in {Elapsed:F0}ms. Messages archived to MESSAGE_QUEUE_HIS with FAILED status.", expiredMessages.Count, elapsed.TotalMilliseconds); } catch (Exception ex) { _logger.LogError(ex, "Error cleaning up expired messages"); } } private async Task MoveToHistoryAsync( ModelContext dbContext, List messages, CancellationToken stoppingToken) { if (!messages.Any()) return; try { var ids = string.Join(",", messages.Select(m => m.Id)); // Insert to history with STATUS = 3 (FAILED - EXPIRED) var insertSql = $@" INSERT INTO MESSAGE_QUEUE_HIS (ID, MESSAGE_TYPE, RECIPIENT, SUBJECT, CONTENT, TEMPLATE_CODE, TEMPLATE_DATA, PRIORITY, STATUS, SCHEDULED_AT, PROCESSED_AT, RETRY_COUNT, MAX_RETRY, ERROR_MESSAGE, CREATED_BY, CREATED_DATE, MOVED_DATE) SELECT ID, MESSAGE_TYPE, RECIPIENT, SUBJECT, CONTENT, TEMPLATE_CODE, TEMPLATE_DATA, PRIORITY, {STATUS_FAILED} AS STATUS, SCHEDULED_AT, SYSDATE AS PROCESSED_AT, RETRY_COUNT, MAX_RETRY, 'EXPIRED: Message older than {_expirationDays} day(s) - Auto-deleted to prevent spam and table bloat. Created: ' || TO_CHAR(CREATED_DATE, 'YYYY-MM-DD HH24:MI:SS') AS ERROR_MESSAGE, CREATED_BY, CREATED_DATE, SYSDATE FROM MESSAGE_QUEUE WHERE ID IN ({ids})"; await dbContext.Database.ExecuteSqlRawAsync(insertSql, stoppingToken); _logger.LogDebug("Moved {Count} expired messages to MESSAGE_QUEUE_HIS with FAILED status", messages.Count); } catch (Exception ex) { _logger.LogError(ex, "Failed to move expired messages to history"); throw; // Re-throw to prevent deletion if archival fails } } public override async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("ExpiredMessageCleanupWorker stopping..."); await base.StopAsync(cancellationToken); _logger.LogInformation("ExpiredMessageCleanupWorker stopped gracefully"); } }