docs: спека сторожа поставщиковых тревог, понимающего режим

Устраняет потоп ~116 писем на ops@ 07.07: убираем per-project письма
FailoverProjectChannel, добавляем аварийный spike-детектор ручной очереди
в incidents:watch-failures. batch остаётся рабочим режимом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-07 18:36:06 +03:00
parent 5755ad5046
commit 87962d0e0b
2 changed files with 142 additions and 4 deletions
+4 -4
View File
@@ -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-сессий.
@@ -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` подхватит новую опцию команды).