diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 2aabd680..abf4893e 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-07-07T09:14:18.118Z +Last updated: 2026-07-07T15:03:58.003Z | Контролёр | Состояние | Детали | |---|---|---| @@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 2132 | Code | 7.58ч | 0.0ч | -| 3276 | MsMpEng | 6.25ч | 0.0ч | -| 10168 | chrome | 4.19ч | 0.0ч | +| 2132 | Code | 8.53ч | NaNч | +| 3276 | MsMpEng | 7.41ч | NaNч | +| 4 | System | 3.35ч | 14476889.6ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий. diff --git a/docs/superpowers/specs/2026-07-07-supplier-alert-mode-aware-design.md b/docs/superpowers/specs/2026-07-07-supplier-alert-mode-aware-design.md new file mode 100644 index 00000000..c97651fc --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-supplier-alert-mode-aware-design.md @@ -0,0 +1,138 @@ +# Спека: сторож поставщиковых тревог, понимающий режим + +**Дата:** 2026-07-07 +**Автор:** Claude (по задаче владельца) +**Статус:** утверждён дизайн, ожидает ревью спеки → план + +--- + +## 1. Проблема (что случилось 07.07.2026) + +На `ops@liderra.ru` пришло **~116 писем** «`[Лидерра CRITICAL] Supplier sync: manual_required / failover_to_form`» +за считанные минуты (пик — 17:53 МСК). + +**Разбор по факту (боевой сервер, только чтение):** + +- Режим выгрузки к поставщику — **`batch`** (пакетный). Строка `system_settings.supplier_export_mode='batch'`, + выставлена 03.07.2026 10:03 МСК (единственная запись в аудите; по умолчанию код тоже = `batch`). + **Владелец подтвердил: `batch` — рабочий режим, менять НЕ надо.** Онлайн дёргал бы поставщика на каждую правку. +- В `batch`-режиме правка/создание проекта идёт через `SyncSupplierProjectJob::handleBatch → FailoverProjectChannel::createProjectForLiderra`. +- Во время сегодняшнего переезда (портал → `lk.liderra.ru`, апгрейд VM, рестарт очереди в 17:33 МСК) кабинет `crm.lead.store` + ненадолго отвалился. Воркер, придя в себя, за 12 минут прогнал ~57 накопившихся синхронизаций — все упали. +- Каждый непроливший проект → `FailoverProjectChannel` шлёт **отдельное** письмо `SupplierCriticalAlertMail` + (`manual_required` при уходе в ручную очередь; `failover_to_form` даже при **успехе** через запасной браузер-канал). + **Троттлинга/агрегации нет** → 57 строк в ручной очереди + запасные = ~116 писем разом. + +**Корень:** сторож не различает «штатную досинхронизацию» (1–2 проекта в ручную очередь — норма для `batch`) +и «кабинет реально упал» (пачка сразу). Любой сбой = письмо на каждый проект. + +Кабинет уже восстановился: ночной прогон 18:05 прошёл чисто (19 групп, 0 ошибок); новых эскалаций нет. +В ручной очереди осталось **57 pending** (обрабатываются штатным `supplier:retry-failed`). + +## 2. Цель + +Научить сторожа **понимать режим/ситуацию**: + +- **штатная мелочь** (1–2 проекта ушли в ручную очередь) → **молчок** (запись есть в ручной очереди, видна в админке); +- **реальная авария** (пачка проектов не залилась за короткое окно = кабинет, похоже, упал) → + **громкий сигнал**, повторяющийся пока авария длится, но **одним осмысленным сводным письмом**, а не N одинаковыми. + +Принцип владельца: «шуметь по минимуму, чтобы важное не потерялось; а реальную аварию — пусть сигналит как ненормальный». + +## 3. Не в объёме (YAGNI) + +- Смена режима выгрузки (`batch` остаётся). +- Массовое создание/правка проектов клиентом пачкой — отдельная задача, не трогаем. +- Ночные тревоги `sticky_auth` / `mass_transient` в `SyncSupplierProjectsJob` — **не трогаем** (они и так одиночные, + шлются один раз с abort'ом). +- `CsvReconcileJob` алерты (`csv_reconcile_failed` троттл 6ч, `CsvDriftAlertMail`, `TenantBusinessDriftAlertMail`) — + вне объёма. *(Замечание на будущее: у `TenantBusinessDriftAlertMail` тоже нет троттла — потенциальный отдельный тикет.)* +- Разбор 57 зависших проектов — обрабатывает существующий `supplier:retry-failed` (hourly); отдельно проверить по запросу. + +## 4. Дизайн + +### 4.1. Глушим шум — правка `FailoverProjectChannel` + +Файл: `app/app/Services/Supplier/Channel/FailoverProjectChannel.php` + +- **`escalateToTier3()`**: убрать отправку `SupplierCriticalAlertMail('manual_required', …)`. + Строку в `SupplierManualSyncQueue` **оставить** (это функциональная запись + источник для аварийного детектора) + и `throw TierEscalatedException` — без изменений. +- **`alertFailoverToForm()`**: удалить целиком (её единственная задача — послать письмо о том, что запасной + браузер-канал **сработал успешно**; сигналить «CRITICAL» об успехе — неверно). Удалить оба вызова + (в `createProjectForLiderra` и `updateProjectForLiderra`) — после успеха tier-2 просто возвращаем результат. +- Зависимость `Mailer $mailer` в конструкторе становится неиспользуемой → удалить из конструктора и из привязки + в `AppServiceProvider` (где `FailoverProjectChannel` собирается с `Mailer`). Убрать `use App\Mail\SupplierCriticalAlertMail;`. + +Итог: в `batch`-режиме уход проектов в ручную очередь и срабатывание запасного канала — **без писем**. + +### 4.2. Аварийный сигнал — новое правило в `IncidentsWatchFailures` + +Файл: `app/app/Console/Commands/IncidentsWatchFailures.php` (крутится `everyTenMinutes` — см. `routes/console.php`). + +Добавить **Блок 6: supplier-manual-queue spike** (по образцу существующих блоков): + +- Считаем строки `supplier_manual_sync_queue` (connection `pgsql_supplier`) с `created_at >= now()-window` + (`window` = существующая опция `--window`, дефолт 10 мин). +- Если `count >= threshold` (новая опция `--threshold-manual-queue`, дефолт **10**) → это «пачка» = вероятное падение кабинета: + - создать `incidents_log` (severity=`high`, type=`other`, `root_cause='supplier-manual-queue-spike'`), + **с дедупом 60 мин** (как у прочих правил — чтобы журнал инцидентов не засорять); + - **послать письмо каждый прогон, пока условие держится** (то есть НЕ гейтить письмо дедупом инцидента) — + это и есть «сигналит как ненормальный»: при длящейся аварии ~1 сводное письмо в 10 мин, но **одно**, со сводкой + «Похоже, кабинет поставщика упал: N проектов ушло в ручную очередь за {window} мин». +- Ниже порога (1–2 проекта) → тишина. + +**Разъяснение по агрегации/громкости:** текущий `createIncident()` связывает создание инцидента и отправку письма +(письмо только когда инцидент создан). Для этого правила их **расцепляем**: инцидент дедупится (1 открытый на 60 мин), +а письмо шлётся на каждом прогоне при активном спайке. Реализация — отдельная ветка отправки внутри нового блока +(не через общий `createIncident`), либо параметр «email-always» — выбрать в плане. + +### 4.3. Адрес аварийного письма — оба + +Письмо аварийного правила шлём на **оба** адреса: `kdv1@bk.ru` (куда идут прочие инциденты сторожа) +**и** `ops@liderra.ru` (где был потоп) — владелец: «чтобы точно не прозевать». + +Переиспользуем существующий `IncidentDetectedMail(summary, severity)`; recipients = `['kdv1@bk.ru', 'ops@liderra.ru']`. +(Список адресов вынести в константу/конфиг блока — уточнить в плане.) + +## 5. Компоненты и границы + +| Юнит | Что делает | Меняем | +|---|---|---| +| `FailoverProjectChannel` | Ярусная заливка проекта поставщику | Убрать per-project письма (§4.1) | +| `AppServiceProvider` | DI-привязка `FailoverProjectChannel` | Убрать `Mailer` из сборки | +| `IncidentsWatchFailures` | Скользящий сторож failed_jobs/webhook | +Блок 6: spike ручной очереди (§4.2) | +| `IncidentDetectedMail` | Письмо об инциденте | Переиспользуем; шлём на 2 адреса | +| `SupplierCriticalAlertMail` | Критические поставщиковые письма | НЕ трогаем (остаётся у ночного джоба и CSV) | + +## 6. Обработка ошибок / крайние случаи + +- Нет активного `saas_admin_users` → сторож в warn-only (уже есть) → аварийное правило тоже пропускает (FK). +- Пустая ручная очередь / < порога → блок ничего не делает. +- Дедуп инцидента (60 мин) защищает `incidents_log` от разрастания при длящейся аварии; письмо при этом продолжает идти. +- Онлайн-режим: путь `createProjectForLiderra/updateProjectForLiderra` в онлайне не используется → удаление писем + на него не влияет; аварийный детектор работает от таблицы очереди, режим-агностичен. + +## 7. Тестирование (TDD) + +Правка поведения → сначала падающие тесты (Pest 4), затем реализация. + +1. **`FailoverProjectChannel`** (`Mail::fake()`): + - escalate-to-tier3: при tier-1 transient и недоступном tier-2 → создаётся строка `supplier_manual_sync_queue`, + бросается `TierEscalatedException`, **писем 0**. + - failover-to-form: tier-1 упал, tier-2 успешен → возвращается external_id, **писем 0**. +2. **`IncidentsWatchFailures` Блок 6:** + - seed `≥ threshold` строк очереди в окне → создан `incidents_log(high, root_cause=supplier-manual-queue-spike)` + + письмо ушло на оба адреса. + - seed `< threshold` → инцидента нет, писем 0. + - дедуп инцидента: второй прогон в пределах 60 мин → нового инцидента нет, **но письмо снова ушло** (громкость). + - нет активного админа → warn-only, без инцидента. + +Прогон: тест-БД `liderra_testing` (НЕ прод), последовательный (не `--parallel` на свежей базе), запись под ролью +поставщика — `uses(SharesSupplierPdo::class)` где нужен `pgsql_supplier`. + +## 8. Выкат + +- Правка кода (dev/worktree) → TDD-зелёно → ревью → выкат на боевой **только по команде владельца** (ручной ssh-рунбук). +- Обратимость: правка «глушения» безопасна (перестаём слать письма); аварийное правило — новое, по умолчанию порог 10. +- После выката: перезапустить `queue:work` и пересобрать расписание (`schedule` подхватит новую опцию команды).