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);
}
}
}