Files
Deal/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs
T
Rustam Khalimov e8b9fab860 Убрать неиспользуемые using по код-стайлу
Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using,
оставшиеся после миграции namespace (676 файлов).
2026-09-11 13:30:57 +03:00

188 lines
10 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.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Api.Hosting;
/// <summary>
/// Фоновый цикл Discovery-воркера по всем тенантам (план Task 18, Ruling 10; эталон PipelineWorkerScheduler/StorageTickScheduler).
/// </summary>
/// <remarks>
/// Каждые 5 с (в прототипе — <c>_discovery_loop</c> main.py: tick раз в ~5 секунд) обходит ВСЕ тенанты
/// системного реестра и для каждого выполняет один тик <see cref="DiscoveryWorkerService.TickOnceAsync"/> в
/// собственном scope с <c>ITenantContext.SetTenant</c> (эталон StorageTickScheduler). Тик делает ОДНО действие
/// (поиск/оценка/вступление/done) для самой старой running-задачи тенанта — 1:1 с discovery_worker.tick L444484.
/// Как StorageTickScheduler: первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы
/// исключены in-flight guard (Interlocked) — следующее срабатывание пропускается, если проход длится дольше
/// периода (в частности, при паузах авто-вступлений 50–70 с внутри тика). Ошибки логируются и наружу не
/// выбрасываются (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий
/// проход отменяется (graceful). Пустой проход (нет running-задач/пауза/flood) — тихий no-op (action none).
/// </remarks>
public sealed class DiscoveryWorkerScheduler : IHostedService
{
// Период тиков цикла — 5 с (в прототипе _discovery_loop: tick каждые ~5 секунд).
private const int DiscoveryPeriodSeconds = 5;
private static readonly TimeSpan DiscoveryPeriod = TimeSpan.FromSeconds(DiscoveryPeriodSeconds);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<DiscoveryWorkerScheduler> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт планировщик фонового цикла Discovery.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope.</param>
/// <param name="logger">Логгер ошибок цикла.</param>
public DiscoveryWorkerScheduler(IServiceScopeFactory scopeFactory, ILogger<DiscoveryWorkerScheduler> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken ct)
{
// Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 5 с.
_timer = new Timer(
static state => ((DiscoveryWorkerScheduler)state!).RunIteration(),
this,
TimeSpan.Zero,
DiscoveryPeriod);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken ct)
{
// Новые проходы не запускаем; текущий отменяем и ждём его завершения — не дольше лимита
// остановки хоста (HostOptions.ShutdownTimeout).
_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)
{
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
// RunCycleAsync, незавершённый проход безопасно завершится на отменённом токене.
}
}
}
/// <summary>
/// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт).
/// </summary>
/// <remarks>Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и
/// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не
/// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно.</remarks>
/// <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);
}
// Тело прохода: тик Discovery каждого тенанта реестра; guard сбрасывается в finally.
// ct: Токен отмены (остановка хоста).
private async Task RunCycleCoreAsync(CancellationToken ct)
{
try
{
await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope();
ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService<ITenantRepository>();
IReadOnlyList<TenantRecordDto> tenants = await tenantRepository.ListAsync(ct);
foreach (TenantRecordDto tenant in tenants)
{
await TickTenantAsync(tenant, ct);
}
}
catch (OperationCanceledException)
{
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
}
catch (Exception exception)
{
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
_logger.LogError(exception, "Цикл Discovery: проход по тенантам не удался");
}
finally
{
Interlocked.Exchange(ref _iterationInProgress, 0);
}
}
// Тик одного тенанта в собственном scope: SetTenant → один тик воркера; Reset в finally.
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка логируется, остальные тенанты
// обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается. Результат «none» — тихий
// no-op (пауза/нет работы); прочие действия логируются в Debug (рабочие события — в DiscLog задачи).
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
// ct: Токен отмены прохода.
private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct)
{
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService<ITenantContext>();
try
{
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
DiscoveryWorkerService worker = tenantScope.ServiceProvider.GetRequiredService<DiscoveryWorkerService>();
DiscoveryWorkerOutcome outcome = await worker.TickOnceAsync(ct);
if (outcome.Action != DiscoveryWorkerService.ActionNone)
{
_logger.LogDebug(
"Discovery-тик {TenantId}: {Action} (задача {TaskId})",
tenant.Id,
outcome.Action,
outcome.TaskId ?? "-");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Цикл Discovery: тик тенанта {TenantId} не удался", tenant.Id);
}
finally
{
tenantContext.Reset();
}
}
}