Files
Deal/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

176 lines
8.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Deal.Api.Configuration;
using Deal.Modules.Tenants.Application;
namespace Deal.Api.Hosting;
/// <summary>
/// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки
/// удаляет устаревшие записи аудита по retention, сбрасывает накопительные поля лимитов прошедших периодов
/// и убирает завершившиеся окна распределённых счётчиков rate limiting.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>аудит — <see cref="IAuditLogStore.PurgeOlderThanAsync"/> по границе
/// <c>now DataRetention:AuditRetentionDays</c> (дефолт 180 дней);</item>
/// <item>лимиты — <see cref="ITenantLimitStore.ResetExpiredPeriodsAsync"/>: отдельной истории периодов нет,
/// поэтому очищаются накопительные поля (UsedTokens/флаги) строк с завершившимся периодом;</item>
/// <item>счётчики — <see cref="IRateLimitCounterStore.DeleteExpiredAsync"/> удаляет строки с истёкшим
/// ExpiresAt (окна rate limiting и попыток входа).</item>
/// </list>
/// Идемпотентно: повторный проход на тех же данных не находит, что чистить. Ошибки логируются и наружу
/// не выбрасываются (сбой очистки не валит хост); перекрывающиеся проходы исключены in-flight guard
/// (Interlocked); при остановке хоста таймер останавливается и текущий проход отменяется.
/// </remarks>
public sealed class DataRetentionScheduler : IHostedService
{
/// <summary>
/// Период проходов цикла — 24 часа (авто-очистка редко меняющихся данных).
/// </summary>
public const int RunPeriodHours = 24;
/// <summary>
/// Задержка первого прохода — 60 с (после bootstrap/провижининга, без конкуренции со стартом).
/// </summary>
public const int InitialDelaySeconds = 60;
/// <summary>
/// Фолбэк срока хранения аудита в днях, если конфигурация задала неположительное значение.
/// </summary>
public const int DefaultAuditRetentionDays = 180;
private readonly IServiceScopeFactory _scopeFactory;
private readonly DataRetentionOptions _options;
private readonly ILogger<DataRetentionScheduler> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт фоновый цикл авто-очистки.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход — в собственном scope (scoped-хранилища).</param>
/// <param name="options">Настройки retention (секция DataRetention).</param>
/// <param name="logger">Логгер ошибок/итогов цикла.</param>
public DataRetentionScheduler(
IServiceScopeFactory scopeFactory,
DataRetentionOptions options,
ILogger<DataRetentionScheduler> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_options = options;
_logger = logger;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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.
}
}
}
/// <summary>
/// Один проход очистки (no-op, если проход уже идёт); публичен как точка запуска для тестов.
/// </summary>
/// <param name="ct">Токен отмены прохода.</param>
/// <returns>Задача прохода (завершается без исключений).</returns>
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<IAuditLogStore>();
int purgedAudit = await auditStore.PurgeOlderThanAsync(now.AddDays(-retentionDays), ct);
var limitStore = scope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
int resetLimits = await limitStore.ResetExpiredPeriodsAsync(now, ct);
var counterStore = scope.ServiceProvider.GetRequiredService<IRateLimitCounterStore>();
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);
}
}
}