using Deal.Api.Configuration; using Deal.Modules.Tenants.Application; namespace Deal.Api.Hosting; /// /// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки /// удаляет устаревшие записи аудита по retention, сбрасывает накопительные поля лимитов прошедших периодов /// и убирает завершившиеся окна распределённых счётчиков rate limiting. /// /// /// /// аудит — по границе /// now − DataRetention:AuditRetentionDays (дефолт 180 дней); /// лимиты — : отдельной истории периодов нет, /// поэтому очищаются накопительные поля (UsedTokens/флаги) строк с завершившимся периодом; /// счётчики — удаляет строки с истёкшим /// ExpiresAt (окна rate limiting и попыток входа). /// /// Идемпотентно: повторный проход на тех же данных не находит, что чистить. Ошибки логируются и наружу /// не выбрасываются (сбой очистки не валит хост); перекрывающиеся проходы исключены in-flight guard /// (Interlocked); при остановке хоста таймер останавливается и текущий проход отменяется. /// public sealed class DataRetentionScheduler : IHostedService { /// /// Период проходов цикла — 24 часа (авто-очистка редко меняющихся данных). /// public const int RunPeriodHours = 24; /// /// Задержка первого прохода — 60 с (после bootstrap/провижининга, без конкуренции со стартом). /// public const int InitialDelaySeconds = 60; /// /// Фолбэк срока хранения аудита в днях, если конфигурация задала неположительное значение. /// public const int DefaultAuditRetentionDays = 180; private readonly IServiceScopeFactory _scopeFactory; private readonly DataRetentionOptions _options; private readonly ILogger _logger; // Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен). private readonly CancellationTokenSource _shutdownCts = new(); private Timer? _timer; private Task? _currentIteration; private int _iterationInProgress; /// /// Создаёт фоновый цикл авто-очистки. /// /// Фабрика scope: проход — в собственном scope (scoped-хранилища). /// Настройки retention (секция DataRetention). /// Логгер ошибок/итогов цикла. public DataRetentionScheduler( IServiceScopeFactory scopeFactory, DataRetentionOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(scopeFactory); ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); _scopeFactory = scopeFactory; _options = options; _logger = logger; } /// public Task StartAsync(CancellationToken ct) { if (!_options.Enabled) { _logger.LogInformation("Авто-очистка данных выключена (DataRetention:Enabled=false)"); return Task.CompletedTask; } _timer = new Timer( static state => ((DataRetentionScheduler)state!).RunIteration(), this, TimeSpan.FromSeconds(InitialDelaySeconds), TimeSpan.FromHours(RunPeriodHours)); return Task.CompletedTask; } /// public async Task StopAsync(CancellationToken ct) { _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); _timer?.Dispose(); _timer = null; _shutdownCts.Cancel(); Task? iteration = Volatile.Read(ref _currentIteration); if (iteration is not null) { try { await iteration.WaitAsync(ct); } catch (OperationCanceledException) { // Лимит остановки истёк — ошибки прохода проглочены внутри RunCycleCoreAsync. } } } /// /// Один проход очистки (no-op, если проход уже идёт); публичен как точка запуска для тестов. /// /// Токен отмены прохода. /// Задача прохода (завершается без исключений). public Task RunCycleAsync(CancellationToken ct) { if (Interlocked.CompareExchange(ref _iterationInProgress, 1, 0) != 0) { return Task.CompletedTask; } Task iteration = RunCycleCoreAsync(ct); Volatile.Write(ref _currentIteration, iteration); return iteration; } // Запускает проход из callback таймера (guard — внутри RunCycleAsync). private void RunIteration() { _ = RunCycleAsync(_shutdownCts.Token); } // Тело прохода: аудит по retention + сброс лимитов прошедших периодов + уборка счётчиков. // ct: Токен отмены (остановка хоста). private async Task RunCycleCoreAsync(CancellationToken ct) { try { await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope(); DateTimeOffset now = DateTimeOffset.UtcNow; int retentionDays = _options.AuditRetentionDays > 0 ? _options.AuditRetentionDays : DefaultAuditRetentionDays; var auditStore = scope.ServiceProvider.GetRequiredService(); int purgedAudit = await auditStore.PurgeOlderThanAsync(now.AddDays(-retentionDays), ct); var limitStore = scope.ServiceProvider.GetRequiredService(); int resetLimits = await limitStore.ResetExpiredPeriodsAsync(now, ct); var counterStore = scope.ServiceProvider.GetRequiredService(); int purgedCounters = await counterStore.DeleteExpiredAsync(now, ct); _logger.LogInformation( "Авто-очистка данных: аудит {Audit} записей старше {Days} дн., сброшено лимитов {Limits}, " + "убрано счётчиков {Counters}", purgedAudit, retentionDays, resetLimits, purgedCounters); } catch (OperationCanceledException) { // Остановка хоста: проход прерван по токену — штатный выход, не ошибка. } catch (Exception exception) { _logger.LogError(exception, "Авто-очистка данных: проход не удался"); } finally { Interlocked.Exchange(ref _iterationInProgress, 0); } } }