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

232 lines
12 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.Infrastructure.Integrations;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Tenants.Application;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants;
namespace Deal.Api.Hosting;
/// <summary>
/// Фоновый флашер очереди обучения ML — выгрузка MlOutbox в ml-service батчами (план Task 16, Ruling 6).
/// </summary>
/// <remarks>
/// Аналог <c>_ml_sync_loop</c> python-прототипа и <c>flush_outbox</c> (ml_client.py L5682): каждые 10 с
/// обходит ВСЕ тенанты реестра и в собственном scope с <c>ITenantContext.SetTenant</c> (эталон
/// PipelineWorkerScheduler/StorageTickScheduler) отправляет накопленное обучение RPC TrainBatch порциями по
/// 10 строк, ≤100 за цикл. Строки удаляются ТОЛЬКО после успешного батча (python L78–79); при недоступности
/// ml-service порция остаётся и уходит в следующий цикл (ретрай на каждом тике, «строки остаются» — Ruling 6).
/// <para>
/// Регистрируется в Deal.Api только при <c>Services:Ml:UseLocal=false</c> (gRPC-режим): Local-режиму
/// ml-service не нужен — очередь копится (этап 3), а при «поднятом сервисе» флашер выгружает её сразу.
/// Первый проход — сразу после старта (как PipelineWorkerScheduler); перекрывающиеся проходы исключены
/// in-flight guard (Interlocked). Ошибки логируются и наружу не выбрасываются (флаш одного тенанта не
/// валит цикл — остальные тенанты обрабатываются); при остановке хоста таймер останавливается и текущий
/// проход отменяется (graceful). Пустая очередь — тихий no-op.
/// </para>
/// </remarks>
public sealed class MlOutboxFlushScheduler : IHostedService
{
/// <summary>
/// Период циклов выгрузки — 10 с (Ruling 6).
/// </summary>
public const int FlushPeriodSeconds = 10;
/// <summary>
/// Размер порции за один TrainBatch — 10 строк (ml_client.flush_outbox L63: chunk=10).
/// </summary>
public const int BatchSize = 10;
/// <summary>
/// Потолок выгрузки за один цикл тенанта — 100 строк (ml_client.flush_outbox L56: batch=100).
/// </summary>
public const int MaxPerCycle = 100;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<MlOutboxFlushScheduler> _logger;
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
private readonly CancellationTokenSource _shutdownCts = new();
private Timer? _timer;
private Task? _currentIteration;
private int _iterationInProgress;
/// <summary>
/// Создаёт планировщик фоновой выгрузки очереди обучения ML.
/// </summary>
/// <param name="scopeFactory">Фабрика scope: проход цикла и флаш каждого тенанта — в собственных scope.</param>
/// <param name="logger">Логгер сбоев цикла.</param>
public MlOutboxFlushScheduler(IServiceScopeFactory scopeFactory, ILogger<MlOutboxFlushScheduler> logger)
{
ArgumentNullException.ThrowIfNull(scopeFactory);
ArgumentNullException.ThrowIfNull(logger);
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken ct)
{
// Первый проход — сразу после старта (как PipelineWorkerScheduler), далее каждые 10 с.
_timer = new Timer(
static state => ((MlOutboxFlushScheduler)state!).RunIteration(),
this,
TimeSpan.Zero,
TimeSpan.FromSeconds(FlushPeriodSeconds));
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)
{
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
// FlushCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене.
}
}
}
/// <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 = FlushCycleCoreAsync(ct);
Volatile.Write(ref _currentIteration, iteration);
return iteration;
}
// Запускает проход из callback таймера (guard — внутри RunCycleAsync).
private void RunIteration()
{
_ = RunCycleAsync(_shutdownCts.Token);
}
// Тело прохода: выгрузка очереди каждого тенанта реестра; guard сбрасывается в finally.
// ct: Токен отмены (остановка хоста).
private async Task FlushCycleCoreAsync(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 FlushTenantAsync(tenant, ct);
}
}
catch (OperationCanceledException)
{
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
}
catch (Exception exception)
{
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
_logger.LogError(exception, "Флашер ML-outbox: проход по тенантам не удался");
}
finally
{
Interlocked.Exchange(ref _iterationInProgress, 0);
}
}
// Выгрузка очереди одного тенанта в собственном scope: SetTenant → порции по 10 до ≤100/цикл.
// Строки удаляются только после успешного TrainBatch (Ruling 6); сбой батча — порция остаётся,
// цикл тенанта завершается (следующая попытка — следующий тик). Сбой хранилища тенанта не валит проход:
// ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается наверх.
// tenant: Тенант реестра (Id в формате Guid; схема — tenant_&lt;N&gt;).
// ct: Токен отмены прохода.
private async Task FlushTenantAsync(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: IMlLearningStore строится от схемы текущего тенанта (как в цикле pump).
IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService<IMlLearningStore>();
IMlTrainClient trainClient = tenantScope.ServiceProvider.GetRequiredService<IMlTrainClient>();
int total = 0;
while (total < MaxPerCycle)
{
IReadOnlyList<MlOutboxEntryDto> rows = await learningStore.TakeOutboxBatchAsync(BatchSize, ct);
if (rows.Count == 0)
{
break;
}
try
{
await trainClient.TrainBatchAsync(rows, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
// Недоступность/сбой ml-service: порция остаётся в очереди (python L75–77), следующая
// попытка — на следующем тике; флашер не роняет проход цикла.
_logger.LogWarning(
exception,
"Флашер ML-outbox: отправка {RowCount} строк тенанта {TenantId} не удалась — строки остались",
rows.Count,
tenant.Id);
break;
}
// Удаление только после успеха (python L78–79): отправленные строки больше не нужны.
await learningStore.DeleteOutboxAsync(rows.Select(row => row.Id).ToList(), ct);
total += rows.Count;
}
if (total > 0)
{
_logger.LogInformation("Флашер ML-outbox: выгружено {RowCount} строк тенанта {TenantId}", total, tenant.Id);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Флашер ML-outbox: выгрузка тенанта {TenantId} не удалась", tenant.Id);
}
finally
{
tenantContext.Reset();
}
}
}