Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
176 lines
8.2 KiB
C#
176 lines
8.2 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|