Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл SSE-алертов ИИ-бюджета (Ruling 3, Task 9; эталон StorageTickScheduler): каждые 60 с
|
||||
/// обходит ВСЕ тенанты реестра и публикует в канал тенанта тост при пересечении порогов бюджета 80%/100%.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Пересечение порога детектируется CAS-установкой флагов Warned80/NotifiedExhausted
|
||||
/// (<see cref="ITenantLimitStore.TryMarkWarnedAsync"/> / <see cref="ITenantLimitStore.TryMarkNotifiedExhaustedAsync"/>, Task 8): метод возвращает
|
||||
/// true только в момент «флаг ещё не стоял и порог достигнут» — публикация выполняется ровно один раз на порог
|
||||
/// за период (флаги сбрасываются ленивым reset периода и сменой бюджета оператором, Task 8/10). Флаги выставляет
|
||||
/// ТОЛЬКО TryMark* (review-fix Task 9): AddUsage только инкрементирует UsedTokens, поэтому естественный расход,
|
||||
/// пересекший порог, планировщик видит на ближайшем проходе как непомеченный переход — TryMark* возвращает true
|
||||
/// ровно один раз, и тост не теряется и не задваивается.
|
||||
/// Тексты и иконка
|
||||
/// 1:1 с Ruling 3 («ИИ-бюджет израсходован на 80%» / «ИИ-бюджет исчерпан — обработка в локальном режиме», icon
|
||||
/// bell); SSE-тип — существующий 'toast' (Ruling 11: новых SSE-типов нет). Списывание usage выполняется в
|
||||
/// gRPC-адаптерах (TokenUsageRecorder) и флаги при пересечении уже могут стоять — TryMark*-CAS не даёт
|
||||
/// задвоить тост. Публикация в канал тенанта; без подписчиков — no-op, не падает (Ruling 5).
|
||||
/// <para>
|
||||
/// Лимиты живут в публичной схеме (ITenantLimitStore → DealDbContext) — в отличие от StorageTickScheduler
|
||||
/// tenant-контекст проходу не нужен (SetTenant не выполняется); проверка каждого тенанта — в собственном scope
|
||||
/// (scoped-хранилище лимитов). Первый проход — сразу после старта, далее по таймеру; перекрывающиеся проходы
|
||||
/// исключены in-flight guard (Interlocked, как RatesRefreshScheduler). Ошибки логируются и наружу не выбрасываются
|
||||
/// (проверка одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход
|
||||
/// отменяется (graceful).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class BudgetAlertScheduler : IHostedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Период проходов цикла — 60 с (план Task 9: фоновая проверка порогов бюджета).
|
||||
/// </summary>
|
||||
public const int AlertPeriodSeconds = 60;
|
||||
|
||||
// Тип SSE-события тоста (Ruling 5/11; api.js слушает 'toast', новых типов не вводим).
|
||||
private const string ToastEventType = "toast";
|
||||
|
||||
// Текст тоста пересечения порога 80% (Ruling 3).
|
||||
private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%";
|
||||
|
||||
// Текст тоста исчерпания бюджета (Ruling 3).
|
||||
private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме";
|
||||
|
||||
// Иконка тостов бюджета (Ruling 3: bell).
|
||||
private const string BellIcon = "bell";
|
||||
|
||||
private static readonly TimeSpan AlertPeriod = TimeSpan.FromSeconds(AlertPeriodSeconds);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly SseBroker _broker;
|
||||
private readonly ILogger<BudgetAlertScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фоновых алертов ИИ-бюджета.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и проверка каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="broker">SSE-брокер каналов тенантов (публикация тостов; без подписчиков — no-op).</param>
|
||||
/// <param name="logger">Логгер ошибок цикла.</param>
|
||||
public BudgetAlertScheduler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
SseBroker broker,
|
||||
ILogger<BudgetAlertScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(broker);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_broker = broker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (как StorageTickScheduler), далее каждые 60 с.
|
||||
_timer = new Timer(
|
||||
static state => ((BudgetAlertScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
AlertPeriod);
|
||||
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)
|
||||
{
|
||||
// Лимит остановки истёк — хост продолжает остановку; ошибки прохода проглочены внутри
|
||||
// RunCycleCoreAsync, незавершённый проход безопасно завершится на отменённом токене.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
// Тело прохода: проверка порогов каждого тенанта реестра; 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 CheckTenantAsync(tenant, ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста: проход прерван по токену — штатный выход, не ошибка.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Сбой всего прохода (реестр недоступен и т.п.): логируем, цикл продолжит со следующего тика.
|
||||
_logger.LogError(exception, "Цикл бюджет-алертов: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка порогов одного тенанта в собственном scope: TryMark* (CAS) → тост в канал тенанта.
|
||||
// Список тенантов и лимиты живут в публичной схеме — tenant-контекст не заполняется (в отличие от
|
||||
// тиков StorageTickScheduler, работающих со схемой тенанта). Порядок: сначала 80% (Warned80), затем 100%
|
||||
// (NotifiedExhausted) — при скачке через оба порога за период выходят оба тоста по одному разу. Сбой одного
|
||||
// тенанта не валит проход: ошибка логируется, остальные тенанты обрабатываются; отмена (OCE) пробрасывается
|
||||
// наверх — проход завершается.
|
||||
// tenant: Тенант реестра (Id в формате Guid).
|
||||
// ct: Токен отмены прохода.
|
||||
private async Task CheckTenantAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope();
|
||||
try
|
||||
{
|
||||
ITenantLimitStore limitStore = tenantScope.ServiceProvider.GetRequiredService<ITenantLimitStore>();
|
||||
|
||||
// TryMark* — атомарная установка флага: true только в момент первого наблюдения порога за период
|
||||
// (Task 8/9). Тост публикуется ровно один раз на порог; повторные проходы — false → no-op.
|
||||
if (await limitStore.TryMarkWarnedAsync(tenant.Id, ct))
|
||||
{
|
||||
_logger.LogDebug("Бюджет тенанта {TenantId}: порог 80% пересечён — SSE-тост", tenant.Id);
|
||||
_broker.Publish(tenant.Id, ToastEventType, new { text = Warned80ToastText, icon = BellIcon });
|
||||
}
|
||||
|
||||
if (await limitStore.TryMarkNotifiedExhaustedAsync(tenant.Id, ct))
|
||||
{
|
||||
_logger.LogDebug("Бюджет тенанта {TenantId}: исчерпан — SSE-тост", tenant.Id);
|
||||
_broker.Publish(tenant.Id, ToastEventType, new { text = ExhaustedToastText, icon = BellIcon });
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл бюджет-алертов: проверка тенанта {TenantId} не удалась", tenant.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Deal.Modules.Discovery.Application;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
|
||||
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 L444–484.
|
||||
/// Как 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_<N>).
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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 L56–82): каждые 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_<N>).
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Deal.Modules.Tenants.Application;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Hosted-шаг bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_* → OperatorBootstrapService.EnsureOperatorAsync.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Регистрируется после <see cref="TenantBootstrapService"/> и работает тем же паттерном: OperatorBootstrapService —
|
||||
/// scoped (его IOperatorAuthStore живёт на scoped DealDbContext), поэтому резолвится в собственном scope из
|
||||
/// IServiceScopeFactory (как TenantService в TenantBootstrapService). Идемпотентен: существующего оператора
|
||||
/// не пересоздаёт и пароль не перезаписывает. В Development без env-кред используется dev-дефолт
|
||||
/// operator/operator (зеркало dev-seed admin/admin, логируется как dev-режим); в Production без env-кред
|
||||
/// шаг пропускается с warning — оператора заводит админ позже через env и рестарт хоста (кода регистрации
|
||||
/// оператора нет). Частичная конфигурация (задана ровно одна из DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD)
|
||||
/// логируется warning — не молчаливый дефолт (решение ревью Task 2): в Development используются dev-дефолты,
|
||||
/// в Production шаг пропускается. Секреты (пароли) в логи не пишутся (правило этапов 1–6).
|
||||
/// </remarks>
|
||||
public sealed class OperatorBootstrapHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment,
|
||||
ILogger<OperatorBootstrapHostedService> logger) : IHostedService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var operatorBootstrapService = scope.ServiceProvider.GetRequiredService<OperatorBootstrapService>();
|
||||
|
||||
var login = configuration[OperatorBootstrapService.LoginEnvKey];
|
||||
var password = configuration[OperatorBootstrapService.PasswordEnvKey];
|
||||
bool hasLogin = !string.IsNullOrWhiteSpace(login);
|
||||
bool hasPassword = !string.IsNullOrWhiteSpace(password);
|
||||
bool allowDevelopmentDefaults = environment.IsDevelopment();
|
||||
|
||||
// Предупреждения о пропуске/частичной конфигурации логируются ДО вызова шага — сам шаг
|
||||
// в этих случаях ничего не создаёт (EnsureOperatorAsync возвращает null, хранилище не тронуто).
|
||||
if (!hasLogin || !hasPassword)
|
||||
{
|
||||
if (hasLogin != hasPassword)
|
||||
{
|
||||
LogPartialConfigurationWarning(hasLogin, allowDevelopmentDefaults);
|
||||
}
|
||||
else if (!allowDevelopmentDefaults)
|
||||
{
|
||||
// Production без кред: пропуск с warning (Ruling 1) — кода регистрации оператора нет.
|
||||
logger.LogWarning(
|
||||
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Production) — bootstrap оператора "
|
||||
+ "пропущен. Оператор заводится позже: задайте env DEAL_OPERATOR_* и перезапустите хост.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Development без кред: штатный dev-дефолт (зеркало dev-seed admin/admin).
|
||||
logger.LogInformation(
|
||||
"DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD не заданы (Development) — используется "
|
||||
+ "dev-дефолт оператора {DefaultLogin}.",
|
||||
OperatorBootstrapService.DefaultOperatorLogin);
|
||||
}
|
||||
}
|
||||
|
||||
var ensuredLogin = await operatorBootstrapService.EnsureOperatorAsync(
|
||||
login, password, allowDevelopmentDefaults, ct);
|
||||
if (ensuredLogin is not null)
|
||||
{
|
||||
logger.LogInformation("Bootstrap оператора: оператор {Login} присутствует.", ensuredLogin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
// Логирует warning о неполной env-конфигурации (задана одна из двух переменных).
|
||||
// hasLogin: Задан ли логин (пароль при этом пуст).
|
||||
// allowDevelopmentDefaults: Разрешены ли dev-дефолты (Development).
|
||||
private void LogPartialConfigurationWarning(bool hasLogin, bool allowDevelopmentDefaults)
|
||||
{
|
||||
string missingEnv = hasLogin
|
||||
? OperatorBootstrapService.PasswordEnvKey
|
||||
: OperatorBootstrapService.LoginEnvKey;
|
||||
if (allowDevelopmentDefaults)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Env-конфигурация оператора неполна: не задан {MissingEnv}. В Development используются "
|
||||
+ "dev-дефолты оператора {DefaultLogin}.",
|
||||
missingEnv, OperatorBootstrapService.DefaultOperatorLogin);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Env-конфигурация оператора неполна: не задан {MissingEnv}. Bootstrap оператора в Production "
|
||||
+ "пропущен — оператор заводится позже через env DEAL_OPERATOR_* и рестарт хоста.",
|
||||
missingEnv);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using Deal.Api.Events;
|
||||
using Deal.Modules.Kanban.Application;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Фоновый цикл правил хранения по всем тенантам (план Tasks 11–12, Rulings 3/8; аналог _storage_loop main.py L43–53).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Каждые 30 с (в прототипе — <c>asyncio.sleep(30)</c>, main.py L53) обходит ВСЕ тенанты системного
|
||||
/// реестра. Проход открывает собственный scope (реестр живёт в публичной схеме — ITenantRepository вне
|
||||
/// tenant-контекста, паттерн TenantBootstrapService), на каждый тенант — вложенный scope с
|
||||
/// ITenantContext.SetTenant (эталон SessionMiddleware/TenantBootstrapService) и StorageTickService.TickAsync,
|
||||
/// после чего — автоочистка отсева пайплайна (<see cref="PipelineProcessingService.PurgeExpiredAsync"/>, 3 суток;
|
||||
/// Task 11, Ruling 8/9: фоновый аналог тика AdminTickOrchestrator, tick_storage L485–493), SSE-тосты
|
||||
/// статистики в канал тенанта (StorageToastPublisher; без подписчиков — no-op, Ruling 5) и проверка
|
||||
/// наступивших напоминаний «Отложено» (CardsService.CheckDueRemindersAsync + SSE reminder_due, Task 12,
|
||||
/// Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator, check_reminders из _storage_loop main.py L49).
|
||||
/// Порядок тика тенанта 1:1 с _storage_loop main.py L47–53: тик → тосты → напоминания. Как в прототипе,
|
||||
/// первый проход выполняется сразу после старта (тик до первого sleep), далее — по таймеру. Параллельные
|
||||
/// проходы исключены in-flight guard (Interlocked, как RatesRefreshScheduler): если проход длится дольше
|
||||
/// периода, следующее срабатывание таймера пропускается. Ошибки логируются и наружу не выбрасываются
|
||||
/// (тик одного тенанта не валит проход); при остановке хоста таймер останавливается и текущий проход
|
||||
/// отменяется (graceful).
|
||||
/// </remarks>
|
||||
public sealed class StorageTickScheduler : IHostedService
|
||||
{
|
||||
// Период проходов цикла — 30 с, 1:1 с _storage_loop main.py L53 (asyncio.sleep(30)).
|
||||
private const int TickPeriodSeconds = 30;
|
||||
|
||||
// SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}).
|
||||
private const string ReminderDueEventType = "reminder_due";
|
||||
|
||||
private static readonly TimeSpan TickPeriod = TimeSpan.FromSeconds(TickPeriodSeconds);
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly StorageToastPublisher _toastPublisher;
|
||||
private readonly SseBroker _broker;
|
||||
private readonly ILogger<StorageTickScheduler> _logger;
|
||||
|
||||
// Отмена при остановке хоста: прерывает текущий проход (EF-запросы тика наблюдают токен).
|
||||
private readonly CancellationTokenSource _shutdownCts = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private Task? _currentIteration;
|
||||
private int _iterationInProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт планировщик фонового цикла правил хранения.
|
||||
/// </summary>
|
||||
/// <param name="scopeFactory">Фабрика scope: проход цикла и тик каждого тенанта — в собственных scope.</param>
|
||||
/// <param name="toastPublisher">Публикатор SSE-тостов статистики тика (общий с POST /api/admin/tick).</param>
|
||||
/// <param name="broker">SSE-брокер каналов тенантов: reminder_due «выстреливших» напоминаний в канал тенанта
|
||||
/// (как StorageToastPublisher; без подписчиков публикация — no-op, Ruling 5).</param>
|
||||
/// <param name="logger">Логгер ошибок цикла.</param>
|
||||
public StorageTickScheduler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
StorageToastPublisher toastPublisher,
|
||||
SseBroker broker,
|
||||
ILogger<StorageTickScheduler> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scopeFactory);
|
||||
ArgumentNullException.ThrowIfNull(toastPublisher);
|
||||
ArgumentNullException.ThrowIfNull(broker);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
_scopeFactory = scopeFactory;
|
||||
_toastPublisher = toastPublisher;
|
||||
_broker = broker;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
// Первый проход — сразу после старта (в прототипе тик выполняется до первого sleep); далее каждые 30 с.
|
||||
_timer = new Timer(
|
||||
static state => ((StorageTickScheduler)state!).RunIteration(),
|
||||
this,
|
||||
TimeSpan.Zero,
|
||||
TickPeriod);
|
||||
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);
|
||||
}
|
||||
|
||||
// Тело прохода: тик каждого тенанта реестра; 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, "Цикл правил хранения: проход по тенантам не удался");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _iterationInProgress, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Тик одного тенанта в собственном scope: SetTenant → Kanban-тик → purge отсева → SSE-тосты →
|
||||
// проверка напоминаний + SSE reminder_due; Reset в finally (1:1 с _storage_loop main.py L47–53: тик →
|
||||
// тосты → напоминания).
|
||||
// Контекст AsyncLocal сбрасывается в finally, чтобы не переживать scope тенанта (как
|
||||
// SessionMiddleware). Тик одного тенанта не валит проход: ошибка ветки/тенанта логируется, остальные
|
||||
// тенанты обрабатываются; отмена (OCE) пробрасывается наверх — проход завершается.
|
||||
// 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<ITenantContext>();
|
||||
try
|
||||
{
|
||||
tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N")));
|
||||
|
||||
// Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта.
|
||||
StorageTickService tickService = tenantScope.ServiceProvider.GetRequiredService<StorageTickService>();
|
||||
StorageTickStatsDto stats = await tickService.TickAsync(ct);
|
||||
|
||||
// Автоочистка отсева пайплайна: записи старше 3 суток — безвозвратно (Task 11, Ruling 8/9;
|
||||
// tick_storage L485–493). Счётчик вливается в storage.purgedRejected — как ручной тик
|
||||
// (AdminTickOrchestrator), тост «Отсев очищен: N записей (3 дн.)» публикуется этой же веткой.
|
||||
PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService<PipelineProcessingService>();
|
||||
int purgedRejected = await processing.PurgeExpiredAsync(ct);
|
||||
StorageTickStatsDto mergedStats = stats with { PurgedRejected = purgedRejected };
|
||||
|
||||
// Тосты — в канал тенанта (публикация из Api-слоя, Ruling 5; без подписчиков — no-op).
|
||||
_toastPublisher.PublishTickToasts(tenant.Id, mergedStats);
|
||||
|
||||
// Проверка наступивших напоминаний «Отложено» (Task 12, Ruling 3/8; proj_svc.check_reminders из
|
||||
// _storage_loop main.py L49): CheckDueRemindersAsync помечает due-строки hold-карточек fired и
|
||||
// возвращает их {id,title,containerId}. Сбой проверки НЕ роняет тик тенанта/проход: лог-предупреждение,
|
||||
// остальные тенанты обрабатываются (паттерн ветки AdminTickOrchestrator). SSE reminder_due по каждой
|
||||
// записи — в канал тенанта (Ruling 8: публикации только из Api; toast НЕ шлём, без подписчиков — no-op).
|
||||
IReadOnlyList<CardReminderDueDto> dueReminders;
|
||||
try
|
||||
{
|
||||
CardsService reminders = tenantScope.ServiceProvider.GetRequiredService<CardsService>();
|
||||
dueReminders = await reminders.CheckDueRemindersAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Остановка хоста — прерываем проход штатно (не «сбой проверки напоминаний»).
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл правил хранения: проверка напоминаний тенанта {TenantId} не удалась", tenant.Id);
|
||||
dueReminders = Array.Empty<CardReminderDueDto>();
|
||||
}
|
||||
|
||||
foreach (CardReminderDueDto due in dueReminders)
|
||||
{
|
||||
_broker.Publish(tenant.Id, ReminderDueEventType, due);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Цикл правил хранения: тик тенанта {TenantId} не удался", tenant.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tenantContext.Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Deal.Infrastructure.Tenancy;
|
||||
using Deal.Modules.Tenants.Application;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Api.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap при старте (Ruling 8): дефолтный тенант + admin (dev-only), провижининг схем всех тенантов.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Идемпотентен. Dev-seed дефолтного тенанта (фиксированный id) и пользователя admin из env
|
||||
/// DEAL_BOOTSTRAP_LOGIN/DEAL_BOOTSTRAP_PASSWORD (по умолчанию admin/admin) выполняется только в Development
|
||||
/// или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1 (Ruling 1 этапа 7); в Production тенантов заводит оператор.
|
||||
/// Провижининг схем ВСЕХ тенантов реестра выполняется всегда. Работает только через порты модулей и
|
||||
/// IPasswordHasher — без DealDbContext.
|
||||
/// </remarks>
|
||||
public sealed class TenantBootstrapService(IServiceScopeFactory scopeFactory) : IHostedService
|
||||
{
|
||||
// Имя дефолтного тенанта, если реестр пуст (Ruling 8).
|
||||
private const string DefaultTenantName = "Default";
|
||||
|
||||
// Фиксированный id дефолтного тенанта: схема tenant_000...0001 детерминирована (Ruling 8).
|
||||
private static readonly Guid DefaultTenantId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
private const string ActiveStatus = "active";
|
||||
private const string BootstrapLoginEnvKey = "DEAL_BOOTSTRAP_LOGIN";
|
||||
private const string BootstrapPasswordEnvKey = "DEAL_BOOTSTRAP_PASSWORD";
|
||||
private const string DefaultAdminLogin = "admin";
|
||||
private const string DefaultAdminPassword = "admin";
|
||||
|
||||
// Ключ env-флага принудительного dev-seed в не-Development окружениях (Ruling 1 этапа 7).
|
||||
private const string DefaultTenantBootstrapEnvKey = "DEAL_BOOTSTRAP_DEFAULT_TENANT";
|
||||
|
||||
// Значение «включено» env-флага (Ruling 1: =1).
|
||||
private const string DefaultTenantBootstrapEnabledValue = "1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var tenantService = scope.ServiceProvider.GetRequiredService<TenantService>();
|
||||
var tenantRepository = scope.ServiceProvider.GetRequiredService<ITenantRepository>();
|
||||
var authStore = scope.ServiceProvider.GetRequiredService<IAuthStore>();
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher>();
|
||||
|
||||
var login = NormalizeLogin(configuration[BootstrapLoginEnvKey] ?? DefaultAdminLogin);
|
||||
var password = configuration[BootstrapPasswordEnvKey] ?? DefaultAdminPassword;
|
||||
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
|
||||
|
||||
// Dev-seed дефолтного тенанта + admin становится dev-only (Ruling 1 этапа 7): создаётся только
|
||||
// в Development или при DEAL_BOOTSTRAP_DEFAULT_TENANT=1. В Production тенантов заводит оператор.
|
||||
var seedDefaultTenant = environment.IsDevelopment()
|
||||
|| configuration[DefaultTenantBootstrapEnvKey] == DefaultTenantBootstrapEnabledValue;
|
||||
if (seedDefaultTenant)
|
||||
{
|
||||
var tenants = await tenantRepository.ListAsync(ct);
|
||||
if (tenants.Count == 0)
|
||||
{
|
||||
// CreateTenantAsync сам провижинит схему дефолтного тенанта (TenantService → ITenantProvisioner).
|
||||
await tenantService.CreateTenantAsync(DefaultTenantName, DefaultTenantId, ct);
|
||||
}
|
||||
|
||||
// Идемпотентность: пользователь с таким логином уже есть — ничего не делаем.
|
||||
var existingUser = await authStore.FindUserByLoginAsync(login, ct);
|
||||
if (existingUser is null)
|
||||
{
|
||||
await authStore.CreateUserAsync(
|
||||
new StoredUserDto(
|
||||
Id: Guid.NewGuid(),
|
||||
Login: login,
|
||||
TenantId: DefaultTenantId,
|
||||
Status: ActiveStatus,
|
||||
PasswordHash: passwordHasher.Hash(password)),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
|
||||
// Провижининг схем ВСЕХ тенантов реестра выполняется всегда (Ruling 1 этапа 7), включая только
|
||||
// что созданного в dev: пакетная идемпотентная миграция (CREATE SCHEMA IF NOT EXISTS + Migrate,
|
||||
// применяющий только неприменённые миграции) с ограниченным параллелизмом и логированием прогресса —
|
||||
// масштабируется на сотни/тысячи схем (этап 12, пакет C). Повторный старт безопасен.
|
||||
var tenantSchemaMigrationService = scope.ServiceProvider.GetRequiredService<TenantSchemaMigrationService>();
|
||||
await tenantSchemaMigrationService.MigrateAllAsync(ct);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
// Нормализация логина: нижний регистр и обрезка пробелов (единая форма хранения).
|
||||
// login: Входной логин.
|
||||
// Возвращает: Нормализованный логин.
|
||||
private static string NormalizeLogin(string login) => login.Trim().ToLowerInvariant();
|
||||
}
|
||||
Reference in New Issue
Block a user