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; /// /// Фоновый цикл Discovery-воркера по всем тенантам (план Task 18, Ruling 10; эталон PipelineWorkerScheduler/StorageTickScheduler). /// /// /// Каждые 5 с (в прототипе — _discovery_loop main.py: tick раз в ~5 секунд) обходит ВСЕ тенанты /// системного реестра и для каждого выполняет один тик в /// собственном scope с ITenantContext.SetTenant (эталон StorageTickScheduler). Тик делает ОДНО действие /// (поиск/оценка/вступление/done) для самой старой running-задачи тенанта — 1:1 с discovery_worker.tick L444–484. /// Как StorageTickScheduler: первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы /// исключены in-flight guard (Interlocked) — следующее срабатывание пропускается, если проход длится дольше /// периода (в частности, при паузах авто-вступлений 50–70 с внутри тика). Ошибки логируются и наружу не /// выбрасываются (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий /// проход отменяется (graceful). Пустой проход (нет running-задач/пауза/flood) — тихий no-op (action none). /// 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 _logger; // Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен). private readonly CancellationTokenSource _shutdownCts = new(); private Timer? _timer; private Task? _currentIteration; private int _iterationInProgress; /// /// Создаёт планировщик фонового цикла Discovery. /// /// Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope. /// Логгер ошибок цикла. public DiscoveryWorkerScheduler(IServiceScopeFactory scopeFactory, ILogger logger) { ArgumentNullException.ThrowIfNull(scopeFactory); ArgumentNullException.ThrowIfNull(logger); _scopeFactory = scopeFactory; _logger = logger; } /// public Task StartAsync(CancellationToken ct) { // Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 5 с. _timer = new Timer( static state => ((DiscoveryWorkerScheduler)state!).RunIteration(), this, TimeSpan.Zero, DiscoveryPeriod); return Task.CompletedTask; } /// 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, незавершённый проход безопасно завершится на отменённом токене. } } } /// /// Один проход цикла: список тенантов реестра и тик каждого (no-op, если проход уже идёт). /// /// Публичен как точка запуска прохода для unit-тестов (тайминги цикла не тестируются) и /// ручного вызова при отладке; таймер вызывает этот же метод. Ошибки и отмена токена наружу не /// выбрасываются: сбои логируются (цикл живёт), отмена по токену останова завершает проход штатно. /// Токен отмены прохода (в проде — токен остановки хоста). /// Задача прохода (завершается без исключений). 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(); IReadOnlyList 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_<N>). // ct: Токен отмены прохода. private async Task TickTenantAsync(TenantRecordDto tenant, CancellationToken ct) { await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); try { tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. DiscoveryWorkerService worker = tenantScope.ServiceProvider.GetRequiredService(); 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(); } } }