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

186 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.Api.Events;
using Deal.Modules.Kanban.Application;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Api;
/// <summary>
/// Оркестратор ручного тика POST /api/admin/tick (план Tasks 1011, Ruling 8/9; dashboard_routes.py admin_tick L327337).
/// </summary>
/// <remarks>
/// Api-слой объединяет сервисы модулей (Kanban тик правил хранения + Pipeline очистка отсева и pump +
/// Projects проверка напоминаний «Отложено») и публикует SSE (Ruling 5/8/9 — публикации только из Api;
/// модули остаются чистыми). Порядок 1:1 с прототипом:
/// (1) <see cref="StorageTickService.TickAsync"/> — автоархив и очистки архива/корзины;
/// (2) <see cref="PipelineProcessingService.PurgeExpiredAsync"/> — отсев старше 3 суток (tick_storage L485493),
/// результат вливается в storage.purgedRejected (Ruling 9);
/// (3) SSE-тосты статистики (<see cref="StorageToastPublisher"/>, notify_tick_stats L496504) — до pump, как в
/// прототипе (L333);
/// (4) проверка наступивших напоминаний <see cref="CardsService.CheckDueRemindersAsync"/> (admin_tick L334,
/// check_reminders L264282; план Task 11, Ruling 3): «выстрелившие» {id,title,containerId} помечены fired и
/// публикуются SSE <c>reminder_due</c> (Ruling 8 — toast НЕ шлём, у фронта модалка ReminderNotice); сбой
/// проверки НЕ роняет тик: лог + reminders ответа пуст;
/// (5) <see cref="PipelineWorkerService.PumpOnceAsync"/> под общим воркер-гейтом тенанта (Task 10/11): pump
/// одного тенанта выполняет либо ручной тик, либо фоновый цикл — при занятом гейте проход пропускается;
/// сбой pump НЕ роняет тик: исключение логируется, pipeline ответа пуст ({} как при занятом локе прототипа
/// L901–902), очередь остаётся до следующего тика/фонового цикла. Операция отмены (OCE) пробрасывается — запрос прерван;
/// (6) SSE new_card по каждой созданной карточке (Ruling 8/9; полный CardDto, как publish из Api);
/// (7) queue = строк очереди после pump (queue_len L337). Ответ — <see cref="AdminTickResultDto"/>.
/// </remarks>
/// <param name="storageTick">Тик правил хранения канбана (StorageTickService модуля Kanban).</param>
/// <param name="processing">Очистка отсева и счётчики очереди (модуль Pipeline).</param>
/// <param name="worker">Один проход pump по очереди входящих (модуль Pipeline).</param>
/// <param name="reminders">Проверка наступивших напоминаний «Отложено» (CardsService, Ruling 3).</param>
/// <param name="toastPublisher">Публикатор SSE-тостов статистики тика (общий с фоновым циклом Task 11).</param>
/// <param name="broker">SSE-брокер канала тенанта (публикация reminder_due/new_card).</param>
/// <param name="pumpGate">Общий воркер-гейт pump тенанта (singleton; общий с фоновым циклом Task 11).</param>
/// <param name="logger">Логгер сбоя проверки напоминаний/pump (тик продолжается без этих веток).</param>
public sealed class AdminTickOrchestrator(
StorageTickService storageTick,
PipelineProcessingService processing,
PipelineWorkerService worker,
CardsService reminders,
StorageToastPublisher toastPublisher,
SseBroker broker,
PipelinePumpGate pumpGate,
ILogger<AdminTickOrchestrator> logger)
{
// SSE-тип события новой карточки (Ruling 5; api.js слушает 'new_card').
private const string NewCardEventType = "new_card";
// SSE-тип события «выстрелившего» напоминания «Отложено» (Ruling 8, api-map §2: {id,title,containerId}).
private const string ReminderDueEventType = "reminder_due";
// Ключи счётчиков pump в pipeline-словаре ответа (1:1 со словарём _pump_unlocked python L921).
private static readonly string[] PipelineCounterKeys =
[
"staged", "rulesStored", "mlStored", "mlDrop", "typeDrop", "aiStored", "aiDrop", "aiFail", "noBudget",
];
/// <summary>
/// Выполняет один ручной тик тенанта: правила хранения + очистка отсева + напоминания + pump + SSE-публикации.
/// </summary>
/// <param name="tenantId">Тенант-получатель (сессия запроса; канал SSE-публикаций).</param>
/// <param name="ct">Токен отмены запроса.</param>
/// <returns>Ответ {storage, reminders, pipeline, queue}; сбой проверки напоминаний/pump не выбрасывается наружу.</returns>
public async Task<AdminTickResultDto> TickAsync(Guid tenantId, CancellationToken ct)
{
// (1) Тик правил хранения канбана (как этап 3; leads.py tick_storage L454484).
StorageTickStatsDto storage = await storageTick.TickAsync(ct);
// (2) Очистка отсева пайплайна: записи старше 3 суток — безвозвратно (tick_storage L485486); счётчик
// вливается в storage.purgedRejected (Ruling 9: ответ тика объединяет статистику, L488–493).
int purgedRejected = await processing.PurgeExpiredAsync(ct);
StorageTickStatsDto mergedStorage = storage with { PurgedRejected = purgedRejected };
// (3) Тосты статистики — до pump, как в прототипе (L333): очистка отсева видна, даже если pump упадёт.
toastPublisher.PublishTickToasts(tenantId, mergedStorage);
// (4) Проверка наступивших напоминаний «Отложено» (admin_tick L334 → check_reminders L264282; план
// Task 11, Ruling 3): CheckDueAsync помечает due-строки fired и возвращает их {id,title,stage}. Сбой
// проверки НЕ роняет тик: лог + reminders ответа пуст (очередь/хранение продолжают работать).
IReadOnlyList<CardReminderDueDto> dueReminders;
try
{
dueReminders = await reminders.CheckDueRemindersAsync(ct);
}
catch (OperationCanceledException)
{
// Запрос отменён — прерываем тик штатно (не «сбой проверки напоминаний»).
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "POST /api/admin/tick: проверка напоминаний не удалась — reminders ответа пуст");
dueReminders = Array.Empty<CardReminderDueDto>();
}
// SSE reminder_due по каждому «выстрелившему» напоминанию (Ruling 8: событие {id,title,stage}, toast НЕ
// шлём — у фронта модалка ReminderNotice; без подписчиков канала публикация — no-op). После MarkFired
// (внутри CheckDueAsync), как прототип L277–281 — публикуются уже «сработавшие» записи.
foreach (CardReminderDueDto due in dueReminders)
{
broker.Publish(tenantId, ReminderDueEventType, due);
}
// (5) Один проход pump под гейтом тенанта; сбой не роняет тик: pipeline={}, очередь дождётся
// следующего тика/фонового цикла (Ruling 10; Task 11 — гейт общий с фоновым циклом).
PipelinePumpResult? pump = await PumpOnceSafelyAsync(tenantId, ct);
// (6) SSE new_card по карточкам, созданным проходом (Ruling 8/9; без подписчиков — no-op).
if (pump is not null)
{
foreach (CardDto card in pump.CreatedCards)
{
broker.Publish(tenantId, NewCardEventType, card);
}
}
// (7) Строк очереди после pump (queue_len L337: total = new + ai).
QueueCountsDto queueCounts = await processing.QueueCountsAsync(ct);
return new AdminTickResultDto(
mergedStorage,
dueReminders,
pump is null ? new Dictionary<string, int>() : ToPipelineWireDict(pump),
queueCounts.Total);
}
// Один проход воркера под гейтом тенанта с изоляцией сбоя: исключения pump не роняют тик (Task 10).
// tenantId: Тенант тика (ключ гейта, общего с фоновым циклом Task 11).
// ct: Токен отмены запроса.
// Возвращает: Результат прохода либо null — гейт занят другим воркером/pump упал (pipeline ответа пуст).
private async Task<PipelinePumpResult?> PumpOnceSafelyAsync(Guid tenantId, CancellationToken ct)
{
// Общий воркер-гейт (Ruling 8, аналог asyncio.Lock pipeline.py L40): admin/tick и фоновый цикл не
// разбирают очередь тенанта одновременно. Гейт занят (фоновый цикл уже pump'ит) — проход пропускаем,
// как прототип при занятом локе (L901902): pipeline={}, очередь дождётся следующего срабатывания.
if (!pumpGate.TryEnter(tenantId))
{
return null;
}
try
{
return await worker.PumpOnceAsync(ct);
}
catch (OperationCanceledException)
{
// Запрос отменён — прерываем тик штатно (не «сбой pump»).
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "POST /api/admin/tick: проход pump не удался — тик возвращает storage без pipeline");
return null;
}
finally
{
pumpGate.Exit(tenantId);
}
}
// Счётчики результата pump → pipeline-словарь ответа (9 ключей словаря python L921; карточки в
// wire не выходят — они ушли отдельными SSE new_card).
// pump: Результат успешного прохода pump.
// Возвращает: Словарь счётчиков в wire-порядке прототипа.
private static IReadOnlyDictionary<string, int> ToPipelineWireDict(PipelinePumpResult pump)
{
int[] counters =
[
pump.Staged, pump.RulesStored, pump.MlStored, pump.MlDrop, pump.TypeDrop,
pump.AiStored, pump.AiDrop, pump.AiFail, pump.NoBudget,
];
var wire = new Dictionary<string, int>(PipelineCounterKeys.Length, StringComparer.Ordinal);
for (int index = 0; index < PipelineCounterKeys.Length; index++)
{
wire[PipelineCounterKeys[index]] = counters[index];
}
return wire;
}
}