Обновления

Что мы выкатили — простым языком · всего постов: 9

← Дашборд
исправление

Мониторинг молчал 2 дня — починили двухслойную защиту от подвисаний

инцидент 14–17 мая — пропущенные определения за ~64 часа подтянулись из backlogbash-обвязка cron теперь форс-килит зависший процесс через 2 часаPython-клиент браузера теперь не может зависнуть навсегда на закрытии

Что произошло

В четверг 14 мая в 18:00 MSK мониторинг подавился на kad.arbitr и замолчал на 2 дня 20 часов — до утра воскресенья 17 мая, когда ты прислал «pipeline has error, watch logs».

Снаружи это выглядело так:
- Telegram-уведомления о новых определениях перестали приходить
- В Telegram прилетел single alert «pipeline error»
- Дашборд работал, дискавери новых дел в watching тоже работал — то есть «половина системы» жила
- А именно проверка определений на kad.arbitr — стояла

Подтянулось всё, как только мы пнули руками: 645 дел в очереди, первое уведомление за 64 часа простоя — А73-6533/2026 «АГВУС» (ЦФОП в определении от 15 мая) — ушло через 1 минуту после починки. Остальной backlog ушёл в течение нескольких часов.

Что сломалось технически

Цепочка такая:

  1. 14 мая 18:00 MSK. kad.arbitr перестал отдавать страницы карточек — каждый запрос упирался в 90-секундный таймаут (антибот pravo.tech включил защиту на наш прокси-IP). Это нормальное состояние — раз в недели-две прокси сжигается, и мы переключаемся на новый.

  2. Python поймал фатальную ошибку и пошёл закрывать браузер. Это штатная процедура — на любой fatal сценарий мы корректно гасим Chromium, освобождаем прокси, пишем статистику в базу.

  3. Закрытие зависло. Браузер был в «отравленном» состоянии после антибот-блока. Команда «закрой страницу» уходила в Chromium через цепочку pipe → Node.js → CDP → Chrome, и где-то по дороге пайп оборвался без ответа. Python-вызов browser.close() тихо завис в ожидании ответа, который не придёт никогда.

  4. Bash-обёртка ждала Python. Python жив → не завершился → bash жив → лок-файл /tmp/bankruptcy-monitor.lock остался лежать.

  5. Каждый следующий cron-тик (каждые 15 минут) видел лок-файл, проверял жив ли держатель — да, жив! — и тихо выходил. Никаких ошибок, никаких алертов. Просто Monitor already running (PID 2936547), skipping. 192 раза подряд.

Итог: 2 дня 20 часов мониторинг был «как бы запущен», но реально ничего не делал. RAM съело 4.6 GB на зомби-процессах Chromium. Никаких признаков снаружи кроме отсутствия Telegram.

Что мы сделали

Защиту построили в два независимых слоя — если один пропустит, второй поймает.

Слой 1 — bash-обвязка (PR #276)

В scripts/cron_monitor.sh добавилась проверка возраста лок-файла. Логика:

  • Лок есть и держатель жив → проверь сколько лок-файлу часов
  • Меньше 2 часов — нормальная ситуация, штатная работа, выходим тихо
  • Больше 2 часов — это аномалия, никакой штатный запуск столько не длится. Принудительно убиваем держателя со всем поддеревом процессов, чистим осиротевшие Xvfb-дисплеи старше 2 часов, удаляем лок, продолжаем нормально

Это первая страховка. Даже если Python завис «насмерть», cron на следующем тике после 2-часовой отметки сам себя починит — без вмешательства руками.

Слой 2 — Python-клиент браузера (PR #279)

В src/scraper/client.py каждый вызов «закрой» теперь обёрнут жёстким таймаутом:

  • page.close() — 10 секунд
  • context.close() — 10 секунд
  • browser.close() — 15 секунд
  • playwright.stop() — 15 секунд

Если Chromium не отвечает за это время — Python не ждёт бесконечно, а пишет warning в лог и идёт дальше. Процесс завершается штатно → лок-файл снимается → cron на следующем тике запускается чисто.

В худшем сценарии (Chromium совсем не отвечает): Python теряет до 15 секунд на каждый из 4 шагов закрытия = максимум минута. Это не «зависание на 2 дня».

Что покрыто двумя слоями

Сценарий Слой 1 (bash) Слой 2 (Python)
Штатный fatal с быстрым закрытием не активируется не активируется
Закрытие зависло на pravo.tech-блоке сработает через 2 ч сработает через ≤1 мин
Python завис вне процедуры закрытия (бесконечный цикл, deadlock) сработает через 2 ч не сработает
Bash-обвязка сломалась (порча скрипта, сбой xvfb-run) не сработает поможет если успеет

Любая комбинация — максимум 2 часа простоя вместо «пока ты не напишешь "watch logs"».

Что ты увидишь

В нормальной работе — ничего. Это защита, она не должна срабатывать на штатных запусках.

Если в логах когда-то появятся строки вида:

WARN: monitor PID 12345 alive 7250s > 7200s — force-killing hung tree

или

browser.close() timed out / errored: ...

— значит защита сработала. Один из двух слоёв поймал зависание, система продолжает работать. Можно ничего не делать — для информации.

Конкретно сейчас:
- Backlog за 14–17 мая полностью обработан, все пропущенные определения подтянуты
- Telegram-уведомления приходят как обычно
- 16 открытых задач в проектной доске (после очистки от 41) — продолжаем по плану

Что дальше

  • Наблюдаем 7 дней. Если за неделю не было ни одного срабатывания force-kill — значит штатно (хорошо: бан-окно у нас редкое). Если было 1–2 — отлично, защита работает по делу.
  • Если зависания участятся — поднимаем приоритет на более глубокий fix в самой обвязке Chromium (отдельный апстрим-issue в patchright). Сейчас это пока единичный случай за месяц.
  • Хроническая утечка Xvfb-дисплеев (8 штук висели с 4–12 мая ещё до этого инцидента — там Python падал чисто, но Xvfb-обёртка не убирала за собой). Это отдельный мелкий баг, тикет на чистку повешен в backlog.

Технические детали (для интересующихся)

PR'ы и коммиты:

  • PR #276 — bash 2h stale-lockfile force-kill (12f2dfe)
  • Issue #278 — диагноз Python-зависания
  • PR #279asyncio.wait_for на 4 close-вызова в BrowserClient (2483205)

Связанные инциденты в памяти системы:
- 2026-04-29/30 — антибот pravo.tech на kad.arbitr впервые поймали и научились ретраить через new_session() (PR #269)
- 2026-05-08 — каскадные определения не терялись после первого без СРО (T1 эпик)

Корневая причина: patchright/Chromium IPC может молчать неопределённо долго, когда страница в pravo.tech-«отравленном» состоянии или сломан node-pipe (EPIPE). Без жёстких таймаутов на этой стороне любой close-вызов потенциально бесконечный. До PR #279 это была единственная незащищённая поверхность в shutdown-пути.

новая возможность

«Рулетка»: дела с несколькими СРО теперь видно на дашборде + фильтр

на 91 историческом деле появились данные о множественных СРОв карточке дела на дашборде новый бейдж 🎰в фильтре по статусу `notified` новая опция «🎰 Несколько СРО (≥2)»

Что было раньше

4 мая мы зашипили мульти-СРО («рулетка») — на определении суд иногда перечисляет 2–8 кандидатур СРО, и наша СРО может стоять не первой. Раньше система брала только первую СРО и молча теряла случаи, где наша на позиции 2–5.

Это работало для всех новых дел с 4 мая. Но для старых уведомлённых дел (до 4 мая) — мы видели в старом плоском поле sro_name только одну строку. Технически в PDF могло лежать 5 СРО, а ты видел одну. Никакого индикатора «здесь рулетка».

Ты это заметил 14 мая на деле А40-111932/2026 АО «РВС» (наше дело от 28 апреля) — там в PDF 5 СРО (ААУ ЦФОП АПК на позиции 2), а на дашборде показывалось только «ААУ ЦФОП АПК» — никакого сигнала, что это рулетка.

Что мы сделали

1. Backfill — заполнили мульти-СРО для исторических дел

Прошлись по всем 489 определениям в базе. Прочитали кэшированный PDF, прогнали через тот же экстрактор СРО, который работает в живом пайплайне с 4 мая (словарь + регекс), и записали все найденные СРО в таблицу ruling_sros с правильными позициями.

Результат:
- 361 определение уже имело мульти-СРО данные (это всё что после 4 мая — система их пишет автоматически)
- 91 определение не имело — и при пересборке нашлись СРО (от 1 до 8 на дело)
- 166 строк мульти-СРО добавилось в базу
- 35 определений прочитались, но СРО там действительно нет (отказы / возвраты без перечисления СРО — это правильное поведение)

Бэкфилл идемпотентный — повторный запуск ничего не дублирует.

2. Бейдж 🎰 в карточке дела

В колонке СРО рядом со старым тегом теперь появляется amber-бейдж, когда определение содержит ≥2 СРО:

  • 🎰 ЦФОП — №2 из 5 — если наша СРО (ААУ ЦФОП АПК) в списке. Цифра — её позиция, вторая — общее количество. Это самый сильный сигнал: «здесь несколько кандидатур, и мы среди них».
  • 🎰 8 СРО — если в списке нашей нет, но кандидатур несколько. Тоже полезный сигнал — рулетка крутится, можно зайти и побороться.

3. Фильтр «🎰 Несколько СРО (≥2)»

В выпадайке по статусу notified добавилась четвёртая опция (рядом с «Наши / без СРО» и «Чужие СРО»):

🎰 Несколько СРО (≥2)

Кликаешь — таблица сужается до дел, где в определении 2 и более СРО. На текущий момент таких дел 72.

Что ты увидишь

Открываешь denis.ownmail.dev → в шапке таблицы кликаешь Statusnotified🎰 Несколько СРО (≥2).

Или сразу по прямой ссылке: https://denis.ownmail.dev/?status=notified&sro_filter=multi_sro

72 дела, отсортированные по дате — наверху самые свежие. У каждого видно полный список СРО (наведи курсор на бейдж — увидишь tooltip) и позицию нашей среди них.

Конкретные дела, на которые посмотри в первую очередь:

Дело СРО в PDF Бейдж
А40-111932/2026 АО «РВС» (то самое, которое ты флагнул) 5 🎰 ЦФОП — №2 из 5
А56-29352/2026 3 🎰 ЦФОП — №1 из 3
А40-118403/2026 3 🎰 ЦФОП — №2 из 3
А70-7550/2026 8 🎰 8 СРО (без ЦФОП)
А60-22218/2026 5 🎰 5 СРО (без ЦФОП)
А60-21866/2026 (твой прежний «опорный пример») 4 🎰 4 СРО (без ЦФОП)

«Без ЦФОП» дела — это контекст. Мы там борьбу не ведём, но если хочешь ручную ревизию закрома — теперь можешь.

Что дальше

  • Watchpoint на 24-48 часов: новые дела продолжают писать мульти-СРО в реальном времени (так уже работает с 4 мая, не меняется). Если за неделю новые multi-SRO кейсы перестанут появляться — это сигнал, что экстрактор что-то поломал и надо смотреть. Цифра «72» на фильтре должна потихоньку расти.
  • Telegram-уведомление с позицией («(позиция X из N)») приходит только на дела, обработанные после 4 мая. Старые дела бэкфилл не ре-уведомляет — старые сообщения в Telegram остаются как были. Это намеренно: не хочу спамить тебя дублями.
  • Если в карточке дела видишь, что СРО в ruling_sros неполный (например, в PDF реально 5 СРО, а у нас в базе только 3) — это или словарь не знает редкую СРО, или регекс не справился. Скинь номер дела — добавим в словарь, бэкфилл можно прогнать повторно.

Технические детали

Раскрыть
  • Два PR'а, оба squash-merged в main 14-15 мая:
    • PR #271 «feat(sro): multi-SRO «рулетка» backfill + dashboard badge» — scripts/backfill_ruling_sros.py + SQL-подзапросы в case-list query (sros_total, target_sro_position) + amber-бейдж в dashboard.html.
    • PR #272 «feat(sro): «🎰 Несколько СРО (≥2)» filter on dashboard» — расширение sro_filter параметра до значения multi_sro.
  • Экстракция при бэкфилле — те же два слоя, что в живом пайплайне:
    • Словарь (match_all_sros) — точное совпадение по реестру известных СРО, включая орфографические варианты. Возвращает каноническое короткое имя. Источник source='dictionary'.
    • Регекс fallback (extract_all_sros_from_text) — для СРО, которых нет в словаре. Ищет паттерны «Ассоциация ...», «ААУ ...», «Союз СРО ...» и т.п. Источник source='regex'. Если та же СРО уже найдена словарём — регекс-хит игнорируется.
  • PDF-парсерpypdfium2, не pdfplumber. Pdfplumber на проде падает в этом контексте (Python 3.12.3 + importlib.metadata + inspect.getmro — поломка после миграции на 3.12). Это уже отдельная история, фикс был в PR #268 (12 мая, переключили scripts/audit_stalled_misclassified.py).
  • Идемпотентность бэкфилла: перед извлечением проверяется ruling_sros — если уже есть хоть одна строка для этого определения, бэкфилл пропускает (не хочет переписать данные, которые мог накатать живой пайплайн).
  • Перфоманс бейджа: case-list query теперь делает два дополнительных коррелированных подзапроса на каждую строку (COUNT(*) для sros_total + MIN(position) для target_sro_position). На 5500 делах это даёт +0.05с к latency, не критично. Если когда-то нужно будет сократить — можно перевести на materialized view или агрегат в самой таблице rulings.
  • Тесты: 10 на сам бэкфилл-скрипт (TDD red → green), 5 на новый фильтр, плюс пришлось патчить 4 тестовых файла с inline CREATE TABLE rulings — добавили туда CREATE TABLE ruling_sros, потому что новые подзапросы к ней обращаются. Файлы, использующие канонический init_db(), поднялись автоматически — там схема уже актуальная.
  • Codex review chain: PR #271 — r1 REVISE (gap в тестовых фикстурах) → r2 APPROVE. PR #272 — r1 APPROVE.
  • Запуск на проде: дамп cases.sqlite.backup-pre-multi-sro-backfill-20260515-040134 (62 МБ) — на случай если что-то не понравится в бэкфилле. На 7 дней оставлю на сервере.
новая возможность

OUT — email outreach pipeline V0 собран от штыка до отписки (9 из 10 тикетов)

штык 100 — двухкампанийный outbound `рулетка_v1` + `обездвижка_v1` готов запуститьSmartlead.ai Basic ($35/мес) на mailbox `denis@127-fz.com` — пивот с Coldy после блока на ₽9 400/мес тарифевся цепочка: триггер → одобрение в `/outreach/queue` → отправка → подавление → ответ → классификацияочередь PATCH-override на ручную перетегировку — когда regex ошибся на ответеосталось OUT-10 (smoke-харнесс) — последний шаг до flip'а на «по-настоящему отправляем»

Что заказывали

На звонке 5 мая ты сказал:

«давай попробуем, две штыки»

≈ 100 писем × 2 батча, чтобы посмотреть отдачу. Никакого SaaS, никакой автоматизации до сигнала — просто понять, конвертится ли холодный outbound в кейсы.

Под это собирался OUT — отдельная подсистема внутри проекта: 10 тикетов (OUT-01..OUT-10), которые превращают «есть телефон лида в outreach_contacts» в «письмо ушло, ответ классифицирован, кейс в воронке».

Что мы сделали

Pivot Coldy → Smartlead

Изначальный план был на Coldy.ai — российский cold-email-первичный сервис. На неделе они заблокировали API за самым дорогим тарифом (₽9 400/мес) + у саппорта неудобные часы. Архитектура у нас специально была сделана с swap'абельным ESP-адаптером, поэтому смена провайдера прошла без боли.

Пивотнули на Smartlead.ai Basic ($35/мес ≈ ₽3 300) — те же возможности, Inbox Rotation «из коробки» (несколько ящиков на одну кампанию), cold-permissive policy. Свой собственный mailbox denis@127-fz.com на Yandex 360 «Бизнесе» — провели полную DNS-настройку (MX/SPF/DKIM/DMARC, всё green по mxtoolbox), создали custom tracking-домен emailtracking.127-fz.com, две кампании-заготовки уже там.

9 тикетов выкачены отдельными ветками на GitHub

Каждый тикет проехал по протоколу Codex review → fixes → APPROVE → commit + push. Ни одна ветка ещё не вмержена в main — это для твоего ревью на GitHub.

Тикет Ветка на GitHub Что внутри
OUT-01 feat/out-01-schema-migration Схема: 3 новые таблицы (outreach_candidates, outreach_suppression, outreach_send_attempts) + ALTER на старых
OUT-02 feat/out-02-smartlead-esp Smartlead ESP-адаптер: send, get_lead_status, error taxonomy + контроль OutreachConfig
OUT-03 feat/out-03-candidate-builder YAML-конфиг триггеров config/outreach_sources.yaml + рендер кандидатов
OUT-04 feat/out-04-queue-ui Страница /outreach/queue для тебя: 5 действий (Approve/Reject/Edit/Snooze/Bulk approve до 100 за раз)
OUT-05 feat/out-05-sender-cron Sender cron: gate подавления → daily cap → pacing → idempotency → отправка через ESP
OUT-06 feat/out-06-unsubscribe-stats RFC 8058 one-click unsubscribe /u/{key}/{token} + поллинг bounce/блок-статусов из Smartlead
OUT-07 feat/out-07-reply-poller Yandex IMAP-поллер: тянет UNSEEN ответы, классифицирует regexом (positive/negative/unclear), пишет в outreach_messages.reply_classification
OUT-08 feat/out-08-funnel-ui Воронка /outreach/funnel — когорты × source × template × variant за 30 дней
OUT-09 (doc-only) DNS-runbook + warmup-чеклист

Что нового на дашборде

Когда смержим ветки в main и задеплоим:

  • /outreach/queue — твоя главная страница для outbound. Видишь pending-кандидатов, читаешь черновики, одобряешь либо отклоняешь. Bulk-approve кнопка («штык 100» одним кликом).
  • /outreach/funnel — built → approved → sent → delivered → opened → replied → positive — с фильтрами по source/template/variant. Видишь, какой шаблон работает.
  • /u/<key>/<token> — публичная страница отписки. Когда лид жмёт «отписаться» в письме, попадает сюда → суппресс-роу + русская страница «Отписка зарегистрирована» с упоминанием ст. 18 ФЗ-152.
  • PATCH /api/outreach/messages/{id}/reply_classification — когда regex угадал неправильно, ты в очереди жмёшь «изменить ярлык» (positive ↔ negative ↔ unclear).

Защитные слои

В OUT-05 sender реализованы семь стадий перед каждой отправкой:

  1. Malformed-guard — если в кандидате нет email или тело пустое → failed, без вызова ESP.
  2. Suppression gate (первая) — если email/domain/INN уже в outreach_suppressionsuppressed, без вызова ESP. Это срабатывает ПЕРЕД дневным капом и pacing'ом — отписки приоритетнее.
  3. Daily cap — если за UTC-сутки уже отправлено OutreachConfig.daily_global_cap (по умолчанию 200) → кандидат остаётся approved (отложен на следующий тик), без отправки.
  4. Pacing — задержка между кандидатами (30 сек по умолчанию). В рамках лимита Smartlead Basic 50 req/min.
  5. Idempotency claim — INSERT в outreach_send_attempts с ключом outreach-c<id>-a<retry>. UNIQUE-индекс ловит дубликаты, если cron перезапустится в неудачный момент.
  6. Resolve campaign_id — берёт OutreachConfig.smartlead_campaign_id_<source>. Если None и адаптер не manual_copyfailed.
  7. ESP send — Smartlead. На 2xx → пишем outreach_messages, статус кандидата → sent. На отказ → бамп retry_count. На третьем фейле → failed (terminal).

Плюс kill-switch: OutreachConfig.auto_send_globally_enabled=false коротко замыкает всё ещё до фетча кандидатов — на случай ЧП.

Reply-классификатор знает русские падежи

Тонкость: «не интересно» содержит подстроку «интересно» — regex без правил отнёс бы это к positive. Поэтому отрицательный regex проверяется первым:

  • не\s*(интересн|пиш|нуж|хоч) или отпи[сш] / удалит / спам / стопnegative
  • Если негатив не сработал, интересн / давайте обсуд / свяж / готов(ы|а)? / подробнpositive
  • Иначе → unclear

Класс отпи[сш] покрывает обе формы — инфинитивный корень («отписаться») и повелительный («отпишите меня»). Слова отпис/удалит дополнительно триггерят авто-suppress — суппресс-роу прилетает сразу, без твоего вмешательства. спам/стоп относятся к negative, но автомат не давит — это спорные сигналы, ты решаешь руками через PATCH-override.

Smartlead — что прямо сейчас

Smartlead Basic         ✅ оплачен с корп-карты Ivinco
mailbox denis@127-fz.com ✅ SMTP+IMAP green, warmup ACTIVE
DNS                      ✅ MX/SPF/DKIM/DMARC verified mxtoolbox
tracking domain          ✅ emailtracking.127-fz.com → Smartlead
campaign rouletka_v1     ✅ id 3325689, DRAFTED, mailbox linked
campaign obezdvizhka_v1  ✅ id 3325690, DRAFTED, mailbox linked
warmup-фаза              🟡 14 дней (стартовала 2026-05-12)

14-day warmup — Smartlead будет нагревать ящик от ~5 писем/день до ~50/день. Это нужно чтобы Gmail/Yandex/mail.ru видели рост репутации постепенно, не пометили как спам. Через 14 дней (около 2026-05-26) — flip на active sends.

Что осталось до запуска

OUT-10 (последний тикет)

Smoke-харнесс: end-to-end проверка по всем 10 шагам спеки. Прогон в shadow-mode (send_adapter='manual_copy') на dev-БД, потом один тестовый send живьём на твой адрес, чтобы убедиться что цепочка не разорвалась. Шипну в течение недели.

Твои шаги, когда warmup закончится

  1. Залить SQL триггеров в config/outreach_sources.yaml — какие именно кейсы должны попадать в рулетка_v1 (no-SRO leads) и обездвижка_v1 (stalled cases). Сейчас там stubs.
  2. Заполнить тело шаблонов в БД — outreach_templates для cession_offer@v2 и т.п. Голос — твой; я могу помочь черновиком.
  3. Дать команду flip-кнопкеOutreachConfig.auto_send_globally_enabled=true в config.yaml на Hetzner + рестарт web-сервиса. После этого Approve в очереди начинает реально отправлять.
  4. Первая «штык 100» — ты заходишь в /outreach/queue?status=pending, читаешь черновики, жмёшь Bulk Approve. Sender cron в течение 5-10 минут (pacing=30s × 100 = ~50 мин) отправит всё через Smartlead.

Технические соображения

Multi-branch вместо одного epic-merge

Все 9 тикетов лежат отдельными ветками на origin. Можно мержить по одной в порядке OUT-01 → OUT-02 → ... → OUT-08 (или одной большой PR'кой с тага OUT-07, который содержит все предыдущие в истории).

IMAP пароль уже на Hetzner

YANDEX_IMAP_PASSWORD положен в /opt/scrapers/bankruptcy-monitor/.env сегодня. Залогинились — OK LOGIN Completed, 5 сообщений в INBOX (вероятно, тестовые от Smartlead warmup). Когда OUT-07 cron активируется, эти 5 классифицируются как unclear (нет матча в outreach_messages — мы пока ничего не отправляли) → orphan-skip, marked SEEN, без записей в БД. Чистая разминка.

Метрики проекта

Тикетов отгружено 9 из 10
Веток на GitHub 8 (плюс OUT-09 — doc-only)
Коммитов на ветках ~50 (с учётом промежуточных Codex-итераций)
Тестов написано ~300+ (suppression, classifier, poller, route, sender, smartlead-esp, unsubscribe)
Внешних сервисов Smartlead Basic + Yandex 360 Бизнес + GoDaddy (домен)
Прямых трат проекта $35/мес Smartlead + $1/мес домен 127-fz.com
Доделать до запуска OUT-10 smoke + 14-day warmup
новая возможность

CP-5: единый контракт для всех источников данных + replay-кнопка в /ops

Все 4 источника (kad.arbitr, fedresurs, pb.nalog, egrul) теперь живут под одним контрактом BaseSourceЦветные бейджи здоровья на /ops/sources — видно сразу, кто работает, кто упал, кто молчитКнопка «Replay» на каждом событии в /ops/sources/{name} — если что-то странно сработало, можно пересчитать одним кликом без операторских скриптовCanary на kad.arbitr в shadow-режиме параллельно с прод-монитором — 24 часа наблюдения, потом решаем

Что было раньше

У нас 4 источника данных: kad.arbitr (определения суда), fedresurs (намерения о банкротстве), pb.nalog (налоговая задолженность через «Прозрачный Бизнес»), egrul (ЕГРЮЛ). Каждый когда-то писался отдельно — kad самый старый и боевой, egrul самый новый.

Проблема, которую ты сам не видишь, но которая ест время:

  • У каждого источника свой стиль ошибок. kad может «лечь» из-за DDoS-Guard, fedresurs из-за Qrator, pb.nalog из-за CAPTCHA, egrul из-за лимита запросов. Каждый раз когда что-то падает, я лезу в логи и должен помнить, как именно этот конкретный источник обозначает «упал».
  • Если по конкретному определению пришёл странный результат (например, СРО не та или ничего не нашли), нет простого способа «пересчитать с нуля». Нужно лезть в БД и крутить SQL.
  • Не было видно, что вообще происходит. Все 4 шли через cron-скрипты, и единственный показатель здоровья — «есть ли свежие записи в БД». Если источник молчит — это потому что он сломался или потому что просто новых дел нет?

Это всё операционка, которая копится. Каждый раз когда мы добавляли новую функцию (вроде EN-cascade недавно), приходилось решать вопросы заново для каждого источника.

Что мы сделали

Большой рефакторинг — внутренний, тебя в день-в-день он не должен задевать. Но базу под следующие фичи положили.

1. Единый контракт для всех источников

Теперь все 4 источника живут под одним «договором»:

Метод Что делает Когда срабатывает
pull() Тащит сырые события из внешнего API Каждый раз когда cron запускается
parse() Превращает сырое событие в нашу структуру Сразу после pull, без I/O
persist() Записывает в БД Если parse не отбросил событие
replay() Пересчитывает уже сохранённое событие через текущую логику По кнопке в UI или вручную
health_check() Проверяет, жив ли источник По расписанию или по запросу

Это значит — когда мы в следующий раз будем менять, например, регекспы для извлечения СРО, я не лезу в 4 разных файла, а правлю один. И тесты автоматически прогоняются через все 4 адаптера.

2. Replay-кнопка на /ops/sources/{name}

На страницах /ops/sources/kad.arbitr, /ops/sources/fedresurs_spa, /ops/sources/pb.nalog, /ops/sources/egrul.nalog теперь видны последние 50 событий — что было сделано, когда, с каким результатом. Рядом с каждым событием — кнопка «Replay».

Зачем это нужно: если ты когда-нибудь заметишь, что по конкретному определению пришёл странный результат (например, парсер посчитал что СРО ЦФОП АПК, а ты вручную посмотрел — там МСРО Содействие), нажми Replay. Система пересчитает событие с текущей версией логики. Если регекспы или dict обновились — увидишь новый результат сразу. Без меня и без SQL.

3. Цветные бейджи здоровья на /ops/sources

На общей странице /ops/sources теперь у каждого источника видна цветная плашка:

  • 🟢 зелёный (ok) — источник отвечает, латентность нормальная
  • 🟡 амбер (degraded) — отвечает, но медленно или с ошибками (часто = CAPTCHA, временный rate-limit)
  • 🔴 красный (failed) — упал, нужен оператор
  • серый (unknown) — нет данных о состоянии (например, мы ещё не делали health-check, или у источника нет дешёвого ping-эндпоинта как у EGRUL)

Это полезно когда я не у компьютера: ты сам можешь зайти и увидеть, что красное — это уже сигнал «звони Юджину».

4. Canary на kad.arbitr в shadow-режиме

Самая аккуратная часть. Старый монитор по kad.arbitr продолжает работать как обычно — ничего не меняется. Параллельно с ним мы запустили новый адаптер в shadow-режиме: он каждые 15 минут делает ровно то же самое, что старый монитор, но в БД ничего не пишет. Только сравниваем результаты.

Цель: 24 часа наблюдать, не разъезжается ли новый адаптер со старым. Если разъезжается больше 5% циклов — откатываемся, разбираемся, чиним. Если в пределах нормы — следующим шагом промоутим новый адаптер на место старого. Это уже отдельная задача после 24-часового окна.

Этот же подход применим к fedresurs/pb.nalog/egrul, но мы их пока не canary'им — у них тестовое покрытие через сравнения и контракт-харнесс, а risk-budget для shadow-сравнения на проде мы тратим аккуратно, по одному.

Что ты увидишь

Сегодня вечером и завтра

  • На /ops/sources появятся цветные бейджи у kad.arbitr / fedresurs_spa / pb.nalog / egrul.nalog. На старте часть может быть серой — это норма, после первого health-цикла обновится.
  • Зайдёшь на /ops/sources/kad.arbitr — увидишь таблицу последних 50 событий и кнопки Replay. Можно тыкать — это безопасно, не сломает прод. На каждый клик в БД остаётся след «replay started → success/failed», я смогу разобрать ретроспективно если что-то пойдёт не так.

В ближайшие 24 часа

Каждые 15 минут срабатывает canary по kad.arbitr — пишет одну запись в pipeline_events с пометкой step='canary'. Через сутки я гляну логи и:

  • Если расхождений со старым монитором < 5% → планируем замену старого монитора новым адаптером (это будет следующий пост).
  • Если ≥ 5% → откатываю canary, разбираюсь почему.

По остальным трём источникам

fedresurs / pb.nalog / egrul пока работают по-старому — старые скриптовые цепочки никто не трогал. Новые адаптеры готовы и протестированы, но включим их в прод следующим этапом, по одному, через тот же shadow-режим, чтобы не рисковать.

Что дальше

Через 24 часа: ретро по kad.arbitr canary, решение по flip-to-primary.

Следующая неделя: если kad.arbitr canary прошёл — повторяем для fedresurs / pb.nalog / egrul в той же последовательности.

Параллельно: теперь когда платформа есть, проще будет добавлять новые источники когда они нам понадобятся (например, ЕФРСБ через платный API, если решим, что оно того стоит — отдельный разговор).

Технические детали

Что включает CP-5 эпик 10 sub-issues, итог: - **CP-5-01:** BaseSource ABC + Pydantic v2 types + replay taxonomy preflight - **CP-5-02:** Source registry auto-discovery + adapter-presence badge - **CP-5-03:** Conformance test harness (общий тестовый контракт для всех адаптеров) - **CP-5-04:** kad.arbitr adapter migration — самый pain-prone источник, под него ABC окончательно зафиксировали - **CP-5-05:** Replay dispatcher endpoint с content negotiation (HTTP API + UI fallback) - **CP-5-06:** fedresurs_spa adapter migration (SPA publications stream) - **CP-5-07:** pb.nalog + egrul.nalog adapter migrations split (две лёгкие миграции одним тикетом) - **CP-5-08:** Replay button UI на /ops/sources/{name} detail page - **CP-5-09:** Health display + cross-adapter replay taxonomy validation - **CP-5-10:** Canary cron + scope-boundary regression gate — закрытие эпика **Тесты:** `pytest -m sources` 117 → 294 (+177 net new). `pytest -m sources_conformance` 31 → 199 (+168 — это conformance-харнесс, прогоняет общий контракт через все 4 адаптера). mypy --strict + ruff clean на всех CP-5 путях. **Review chain:** 4 из 10 тикетов прошли через Codex review с REVISE → APPROVE циклом (5 substantive findings caught и зафиксированы pre-merge: run_step status semantics, частные методы провайдера, EGRUL health probe quota concern, taxonomy test vacuity, persist exception swallowing). Остальные 6 прошли APPROVE с первого захода. **Architecture decisions locked:** - Per-adapter drop-reason ownership: CP-5-01 владеет только `replay.started/.success/.failed`; каждый адаптер сам добавляет свои `.` коды в CP-2 taxonomy. - CP-2/CP-3 schemas — LOCKED, новых ALTER нет. - _CatalogScraperBase preserved untouched — fedresurs адаптер обёртывает FedresursPublicProvider + PublicationStreamScraper, не _CatalogScraperBase. - Opt-in health probe pattern (EGRUL): default `unknown` со status detail вместо реального fetch, чтобы не палить квоту/CAPTCHA на cold-start. Оператор может явно включить live probe через `enable_live_health_probe=True`.
Push state + GitHub close-out Все 10 commits на `origin/main`:
1361795 cp5-10  canary cron + scope-boundary gate
52c5d66 cp5-09  health-status display + replay taxonomy validation
0b4d3da cp5-07  pb.nalog + egrul.nalog adapter migrations
a90e984 cp5-06  fedresurs_spa adapter migration
e6cb42c cp5-08  replay button UI
b42a409 cp5-05  replay dispatcher endpoint
82b1976 cp5-04  kad.arbitr adapter migration
7f87772 cp5-03  conformance test harness
8f59031 cp5-02  source registry auto-discovery + badge
d1b72fb cp5-01  BaseSource ABC + Pydantic v2 types
GitHub Project #6: epic #188 + sub-issues #238-#247 закрыты как Done.
новая возможность

Восстановили мониторинг + новый слой проверки «жирных» через DaData (EN)

Telegram-алерты возобновились — мониторинг был в простое 14 часовновый источник для классификации должников — DaData identity layer152 кейса с пометкой `inn_not_in_db` пересобраны: 109 — живые ООО, которые мы зря пропускалипосле 24-часового наблюдения за стабильностью кэша — операторская команда поднимет 109 кейсов в `watching`

Что было раньше

Ты заметил, что с раннего утра 8 мая Telegram молчал — никаких новых уведомлений. Это не потому что нечего слать. Мониторинг упал в 06:23 UTC и 14 часов подряд каждый 15-минутный тик crash-ился на старте — браузер не мог открыть kad.arbitr.ru.

Когда я начал разбираться, всё выглядело как «прокси умер»: сайт Evomi у тебя не открывался, мне казалось — провайдер лёг. Но проверки показали другое:

  • Evomi отвечал HTTP 200 за 122 мс на запрос их собственного сайта
  • curl через тот же Evomi-прокси открывал kad.arbitr.ru без проблем (TLS handshake, 200 OK)
  • А Chrome через тот же прокси падал с ERR_TUNNEL_CONNECTION_FAILED

Прокси работал. Падал именно браузер.

Параллельно у нас была другая задача — новый слой проверки должников через DaData (EN-cascade). Когда наша основная база bo.nalog возвращает 404 на ИНН (а это бывает у банков, страховых, недавно открытых ООО), мы помечали кейс как inn_not_in_db и забывали. Получалось, что жирные ACTIVE-юрлица тихо проваливались мимо, и ты их не видел в воронке. Этот слой был готов, но не «прожжён» в проде до сегодня.

Что мы сделали

1. Починили мониторинг (P0)

Корень проблемы оказался хирургическим: библиотека playwright-stealth, которую мы накладывали поверх patchright (наш undetected-движок). Patchright инжектит скрипт через свой внутренний URL patchright-init-script-inject.internal. С прокси на уровне браузерного контекста этот internal-запрос шёл через Evomi — а Evomi (как и любая residential-сеть) не умеет резолвить такие нерутируемые *.internal хосты. Каждый запрос ронялся, а с ним — вся загрузка страницы.

Patchright и так stealthed — двойной патчинг был антипаттерном. Убрали playwright-stealth из обоих клиентов (kad-monitor и casebook-discovery). Воспроизвели на проде минимальным репро и подтвердили — без stealth Chrome через Evomi проходит DDoS-Guard и открывает kad.arbitr.ru за 7 секунд.

Результат: прод восстановился в 03:37 UTC. Накопившиеся 488 кейсов сейчас догоняются. К утру 9 мая по Москве Telegram опять должен зашевелиться.

2. Активировали DaData identity layer

Логика проста: если bo.nalog не нашёл ИНН — спроси у DaData. Их ответ говорит:

  • ИНН относится к ACTIVE юрлицу (ООО/АО/ПАО, не ИП и не физлицо) → мы зря его пропускали, надо мониторить
  • ИНН найден, но юрлицо в LIQUIDATED / BANKRUPTCY / UNKNOWN → это уже не наша целевая аудитория, исключаем явно
  • ИНН не найден вообще → оставляем как было, на будущий слой (web-search, PDF-parse)

3. Прогнали репетицию по существующим 152 кейсам

Это всё кейсы, помеченные inn_not_in_db за последние 4 месяца — из тех, что когда-то прошли первичный фильтр, но провалились на финансовой проверке.

Что нашли Сколько Что значит
Живое ACTIVE юрлицо 109 (71,7%) Мы зря их откладывали — это потенциально твои клиенты
Юрлицо в ликвидации/банкротстве/архиве 35 (23%) Корректно отфильтрованы — теперь явно помечены
Не найдено вообще 8 (5%) Оставили как было — задел на следующие слои
Ошибки запросов 0

Подчеркну: 109 кейсов — это потенциальные сделки, которые ты бы не увидел старой логикой. Прогон сделан в режиме «только посмотреть» (dry-run) — пока никаких изменений в базе нет.

Что ты увидишь

Сегодня вечером и завтра утром (МСК)

  • В Telegram пойдут уведомления, которые накопились за 14 часов простоя — это backlog мониторинга, а не новые кейсы. Ничего не теряется, всё просто отложилось.
  • На дашборде «watching» цифра колыхнётся вниз: за 14 часов часть кейсов ушла в notified/returned/rejected без Telegram, и теперь обработается пакетно.

Через ~24 часа (после стабильного наблюдения)

Я запущу второй прогон по тем же 152 ИНН в режиме «применить» — и 109 «жирных» кейсов перейдут в watching с отметкой «найдено через DaData identity». Дальше они работают как обычные watching-кейсы: monitor каждые 15 минут проверяет, не появилось ли определение, и шлёт тебе Telegram если СРО подходит.

На дашборде

  • В «Сkipped» появится новая под-категория inn_not_in_db_dadata_inactive (35 кейсов) — это явно мертвые юрлица, чтобы ты не путал их с настоящими «не нашли»
  • В «Watching» — новые 109 кейсов с пометкой qualified_by_dadata_identity

Что дальше

Ход 1 (через 24h): запуск backfill в режиме --apply. Тебя дёрну в Telegram когда буду готов — глянешь dry-run output (109 имён) на разумность.

Ход 2 (через 7 дней): замер реального recovery rate. Если из 109 в watching-статусе у нас будет приличная конверсия в notified (например, ≥30%) — слой DaData оправдал себя, движемся к следующему: web-search для тех 8% которые DaData не нашла.

Если recovery rate окажется низким (< 30%) — добавим следующий слой (Tavily / Perplexity web-search) для этих 8 «оставшихся». Это уже отдельный эпик.

Технические детали (для тех, кому интересно)

Stealth-фикс:

  • 2 коммита: e608093 (kad-monitor + casebook-discovery), fced0e4 (UX-фиксы recascade-скрипта)
  • Минимальный репро прошёл через 3 вариации: только browser-level proxy, context-level proxy без stealth, context-level proxy + stealth — последний воспроизвёл ERR_TUNNEL точь-в-точь как прод
  • Codex (cross-family review) APPROVE r1 на stealth-fix, REVISE r1 → APPROVE r2 на recascade-UX-фиксы
  • В follow-up: _on_request_finished callback в client.py пишет coroutine 'Request.sizes' was never awaited warning — patchright API стал async, callback нужно asyncronize-ировать. Не блокер.

EN-cascade architecture:

  • 5 слоёв: bo_nalog_cachedtax_dbtax_db_namebo_nalog_livedadata (новый)
  • Колонка cases.latest_enrichment_layer пишется на каждом ходе пайплайна — теперь видно через какой слой решение принято
  • Кэш DaData в dadata_enrichment_cache (TTL: hit 7d / not_found 30d / rate-limited 1h)
  • Дневная квота 900 запросов (платный тариф $50/мес)
  • Anti-spoof: проверяем что DaData вернула тот же INN что мы спросили (защита от случая, когда они «исправили опечатку» и вернули соседнее юрлицо)

Скрипт scripts/recascade_inn_not_in_db.py:

  • Опции: --dry-run (по умолчанию), --apply, --resume-from <инн> (+ --resume-from-kad-id для точного crash-resume), --wait-for-monitor, --confirm-large-batch для > 200 кейсов
  • Pre-check: блокирует запуск если monitor cron занят (через pipeline_runs в observability DB)
  • Контрольная точка: data/backfill_state/recascade_<run_id>.json — composite cursor (debtor_inn, kad_id) чтобы при крэше не пропустить same-INN siblings
  • Fail-loud если DADATA_API_KEY не загружен — иначе все 152 кейса «дренировали бы тихо», как в первом прогоне сегодня

Тесты: 23 кейса для recascade + 95 EN-suite full coverage. ruff + mypy --strict clean.

Чего НЕТ в этом релизе (отложено):

  • Web-search слой (Tavily/Perplexity) — после замера recovery rate через 7 дней
  • PDF deep-parse слой — после web-search
  • UI: пока новые qualified_by_dadata_identity появятся в Watching обычным фильтром, отдельной плашки нет
новая возможность

Event sink + Sentry: каждый шаг каждой задачи теперь виден (CP-2)

новая таблица `pipeline_events` в `data/observability.sqlite`5 чокпоинтов инструментованы (catalog scraper, intention sweep, LLM, provider health, refresh-оркестраторы)Sentry SaaS подключён (DSN на Hetzner, PII-скраббер локально)суточная ретенция — 90 дней событий, 365 дней пробеговстраница `/ops/runs/{id}` готова к расширенному виду из CP-3

Что было раньше

Предыдущие шаги дали два слоя видимости:

  • CP-1 — реестр запусков. Таблица pipeline_runs, страница Ops · Runs. Видно когда стартанул cron_monitor или intention_stream, сколько шёл, упал или нет.
  • CP-3 — админ-страницы поверх реестра. Расписание (Ops · Jobs), реестр источников и расходов (Ops · Sources), заглушка под детальный пробег /ops/runs/{id} с amber-плашкой «Per-event drill-down requires CP-2 ship».

Дыра в середине: «а что задача внутри себя сделала?» Прошёл ли intention-sweep по 700 компаниям? Сколько раз LLM вызывался и зачем? На каком шаге catalog-скрейпер уперся в Qrator-блок? Был ли это уже третий fetch_timeout подряд? — Всё это жило в логах в /var/log/bankruptcy-monitor/*.log. Чтобы что-то найти — grep-ать на сервере. Структурированного «а сколько у нас было intention.fetch_fail за вчера» — не было.

Плюс ошибки: если cron упал — в pipeline_runs.status='failed', exit_code какой-то, и всё. Стектрейс — найди его в логе. Никаких алертов.

Что мы сделали

Восемь подзадач, все на проде. Эпик #185 закрыт.

1. Таблица событий

Новая pipeline_events в data/observability.sqlite (отдельный файл от cases.sqlite — чтобы запись событий не конкурировала за блокировку с основной БД). 16 колонок: run_id (linkage с pipeline_runs), source/step/action (saved/dropped/errored/started/finished), reason_code (catalog.qrator_block, llm.over_budget, etc.), payload_hash + payload_size, error_msg, created_at, seq для упорядочивания внутри пробега.

Реестр валидных reason_codes — отдельный файл src/observability/event_reasons.py с 20 кодами, по семантическим доменам: catalog.* (6 кодов), intention.* (2), llm.* (8), provider.* (2), run.* (2). Любой неизвестный код в dev-режиме крашит ассерт; в проде — логируется в stderr и пропускается (fail-open).

2. Hot-path API

Функция track_event(...) синхронная, async-safe by construction: на горячем пути только in-memory append + микросекундный лок, ноль I/O. Воркер-тред каждые 200мс / 100 событий / на severity=high сливает буфер в SQLite одним батч-инсертом. Если процесс падает — atexit-хук сливает оставшееся.

run_step — контекстный менеджер обёртка вокруг произвольного блока кода — автоматически парные started/finished или started/errored события, замеряет latency_ms, ловит исключения.

3. Sentry init с PII-скраббером

init_sentry() в src/observability/sentry_init.py. Lazy import — sentry-sdk не подгружается если DSN не настроен. На инициализации: дефолтные интеграции отключены (никакие фреймворковые хуки не успеют схватить тело request'а до нашего скраббера); единственный хук — before_send, который:

  • проходит по всему event (extra / contexts / tags / request / user / breadcrumbs / stacktrace.vars) и заменяет значения по ключам, матчящим PII-регекс — phone|email|inn|ogrn|kpp|passport|address|fio|otchestvo|surname|familia плюс кириллические эквиваленты фио|отчество|адрес|инн|кпп|огрн|телефон|почта|паспорт|счет|фамилия — на [REDACTED]
  • считает стабильный fingerprint = SHA-256 от (exception_type, message[:200], top-3 stack frames «module::function») и режет до 10 событий per-fingerprint per-час. Sentry-шный event_id — UUID4 на каждое срабатывание, для группировки бесполезен; наш fingerprint склеивает повторы из одного места кода

Результат: даже если кто-то случайно сунет ИНН в logger.info(...) — в Sentry улетит [REDACTED]. И если runaway-цикл будет генерить одно и то же исключение 1000 раз в минуту — Sentry получит ровно 10 за час.

4. Ретенция

Суточный cron 35 4 * * * запускает scripts/cron_observability_retention.sh → удаляет события старше 90 дней и пробеги старше 365 дней. Пишется log в /var/log/bankruptcy-monitor/observability_retention.log. Если data/observability.sqlite залочен — таймаут 60с (busy_timeout PRAGMA), потом лог + следующий тик попробует снова.

5. Инструментация: 5 чокпоинтов

Места, где пишется событие на каждое решение — расставлены по «горячим» точкам пайплайна:

  • catalog-скрейпер (_CatalogScraperBase): на каждой странице — started; на каждой строке — saved (записалось) или dropped catalog.freshness_unchanged (запись не изменилась); на ошибках — errored catalog.qrator_block / catalog.fetch_timeout / catalog.parse_error; при попадании в watermark — finished catalog.watermark_caught_up; при date_from-cutoff — dropped catalog.before_date_from
  • intention-sweep: started / finished бэндят сам пробег; errored intention.fetch_fail на каждый failed snapshot fetch или per-guid publication fetch; dropped intention.filter_miss если discovery-filter отказал кейсу
  • LLM-обёртка (call_with_audit): 11 точек — errored llm.api_key_missing / llm.over_budget / llm.prompt_template_error / llm.openrouter_exception / llm.parse_failed; dropped llm.confidence_low / llm.captcha_unsupported. Успешный вызов НЕ пишет в pipeline_events — для этого есть llm_extractions (cost, latency, raw_quote)
  • provider health: errored provider.degraded только в момент пересечения порога (когда threshold впервые крестится за час). Single-probe failure → пишется только в provider_health_log, в pipeline_events не дублируется
  • refresh-оркестраторы (refresh_catalog, refresh_cession_matches): started / finished на каждый пробег; errored run.early_abort если что-то упало до основной работы (например, BrowserSession.__aenter__ не смог поднять браузер)

6. Бенчмарк под нагрузкой

Перед деплоем — scripts/benchmark_observability_writes.py: на копии observability.sqlite запускается синтетическая нагрузка: Worker A симулирует cron-tick раз в 5 секунд, Worker B пишет 4 потока × 250 событий в секунду, Worker C читает. Замеряется p50/p95/p99 latency для pipeline_runs И pipeline_events. Verdict-функция: ratio > 1.5 → BLOCK; > 1.0 → APPROVE_WITH_CAVEATS; ≤ 1.0 → APPROVE. На бейслайне: APPROVE.

Что ты увидишь

В верхнем меню как было — три Ops-кнопки. Визуально на дашборде ничего не поменялось.

Под капотом — на текущий момент уже 137+ событий в pipeline_events за первый час после деплоя (события набегают на каждый cron_intention_stream, cron_p5_bl_* и cron_monitor тик). Каждое событие имеет run_id, который джойнится с pipeline_runs.id для того же пробега — это означает, что когда CP-3-страница /ops/runs/{id} подключит этот источник, на ней будет live-таймлайн.

Sentry уже подключён. Тестовое событие проверено — пришло в дашборд с правильным release (git-SHA), environment=production, тегом smoke_test=true чтобы фильтровать «не это», и со скрабленным контентом. Если что-то реально упадёт в проде — увидишь его в Sentry как Issue с email-нотификацией.

Что дальше

  • CP-3-страница /ops/runs/{id} уже шипалась с amber-плашкой «Per-event drill-down requires CP-2 ship». Эта плашка теперь устарела — следующий шаг (отдельный мини-эпик) подключает источник pipeline_events к шаблону страницы и рисует гистограмму типов событий + raw-таймлайн. Спека готова, в очереди.
  • Бейслайн lock-contention на cases.sqlite снят перед деплоем: p95=0ms, max=8ms, 0 таймаутов на 100К попыток за 30 секунд. Через 7-14 дней под реальной нагрузкой пересниму и сравню — если p95 уйдёт за 50% бейслайна, значит пишущий воркер событий бьётся за блок с основной БД (хотя они в разных файлах — поводов для контеншна не должно быть).
  • Бэкапы data/observability.sqlite.backup-pre-cp2-20260508T182456Z (184КБ) + cases.sqlite.backup-pre-cp2-... (52МБ) лежат на Hetzner на случай отката. Снять можно после 15 мая.
  • Sentry квота — Developer-план, 5К событий/мес. С нашим per-fingerprint rate-limit (10/час/класс) этого хватит на ~50 разных классов ошибок постоянно работающих. Если упрёмся — апгрейд на Team-план $26/мес.

Технические детали (для интересующихся)

Раскрыть
  • 8 sub-issues в Project #6: #199 (schema + reason registry) → #200 (track_event API + buffered writer) → #201 (Sentry init + PII-скраббер) → #202 (retention cron) → #203 (catalog scraper instrumentation) → #204 (chokepoints #2-5) → #205 (write-pressure benchmark) → #206 (Hetzner deploy)

  • Эпик #185 закрыт. Около 16 коммитов на main, ~5800 строк диффа.

  • 3624 теста в полном наборе (с 3461 до старта инструментации — ~+160 за весь эпик: 26 boundary-тестов на CP-2-06, 12 contract-тестов на CP-2-05, плюс параллельная работа T1/EN-каскада которая мерджилась в тот же день)

  • Архитектурные решения:

    • Отдельный файл data/observability.sqlite — чтобы события не конкурировали за write-lock с cases.sqlite (где монитор и discovery работают каждые 15 минут)
    • Sync track_event() async-safe by construction: лок только на in-memory deque append (микросекунды), I/O — в отдельном демон-треде. Кейс «awaitable из sync» решается через asyncio.run_in_executor если нужно — но в горячем пути не нужно, append — 200ns
    • payload НЕ хранится — только SHA-256 хэш + размер + truncated-флаг. Если когда-нибудь понадобится посмотреть содержимое — есть hash для перекрёстной ссылки на лог. Это решение под GDPR/272.1 УК — даже если в pipeline_events случайно попадёт ИНН в payload, он туда не запишется
    • severity='high' форсит флэш — для errored событий, после которых процесс может упасть. Иначе буфер мог бы потеряться при крэше. На обычных saved/dropped — обычная буферизация
    • Default integrations OFF в Sentry: default_integrations=False, integrations=[]. Это значит excepthook НЕ установлен — необработанные исключения НЕ попадают в Sentry автоматически. Решение: ловить руками через try/except → capture_exception(). Surfaced в smoke-тесте (первая версия sentry_smoke.py ожидала автокапчуа — не работало)
    • source_event_id как идентичность для catalog-событий — позволяет CP-3-странице делать event_id-based drill-down («покажи всю историю этого этп-объявления»). Не уникальный (одно объявление может пройти через сейв и потом зафейлить parse) — но удобный для группировки
    • Locked Decision 2: per-row hot-path emission OKsaved событие на каждый rc>0 upsert. На бэкфилле 1000 страниц × 50 строк = 50К событий за пробег. Лок-микросекундный append + батч-фласх каждые 200мс — выдерживает без проблем. Verified бенчмарком CP-2-07
  • Per-task review chain (Codex r1 → revise/APPROVE → Gemini SHIP):

    • CP-2-01..05, 07: Codex APPROVE на r1 (с одним-двумя NIT-фиксами)
    • CP-2-03: Codex REVISE на r1 — предложил Cyrillic PII-регекс отдельно; на r2 APPROVE
    • CP-2-06: 4 раунда (r1 REVISE → r2 REVISE → r3 REVISE → r4 APPROVE) — каждый раунд Codex поднимал планку покрытия тестов: «эти тесты вызывают track_event() напрямую и не проверяют что production-код вообще зовёт track_event()». Финальный набор — 26 boundary-тестов с реальным driving кода
    • CP-2-08: Codex r1 APPROVE; runtime — surfaced 1 баг в smoke-скрипте + 1 баг в env var bridge (см. ниже)
  • Surfaced+fixed mid-deploy: после миграции на проде 137 событий за первый час, но run_id у всех NULL. Cause: CP-1-обёртка _run_step.sh экспортит _RUN_STEP_ID, CP-2-функция track_event() ищет PIPELINE_RUN_ID. Имена не координировались между спеками. Фикс — двустрочный bridge в _run_step.sh: export PIPELINE_RUN_ID="$_RUN_STEP_ID"; export PIPELINE_JOB_NAME="$_RUN_STEP_JOB_NAME". После пуша + ручного триггера intention_stream — 2 новых события с run_id=52f03f757d74..., джойнящимся с pipeline_runs.id. Lesson для будущих эпиков: env var names — это контракт между специками, валидировать явно

  • Smoke-контракт (7 итемов из спеки § CP-2-08, все ✅):

    • pipeline_events count > 0 после первого тика — да, 137
    • run_idpipeline_runs.id — да, после env-bridge фикса
    • Sentry дашборд показывает test exception — да, event_id 15b0755a3c2244c5...
    • Retention cron status='ok' — да, ручной --apply (d10651e8..., 335ms, 0 удалений)
    • Routes 200 OK — да, кроме pre-existing /costs/sources 503 (pending ProviderHealthMonitor cron, не относится к CP-2)
    • Lock contention p95 в пределах 50% бейслайна — да, бейслайн p95=0ms 0 таймаутов
    • NARROW pytest subset (pytest -m smoke) — пропущено как опциональное, локальный mechanical green держался все 4 дня
  • Зависимость: sentry-sdk>=2.0.0 (актуально 2.59.0)

  • Deploy-runbook в 2026-05-08_{denis}{plan}_cp3_deploy_runbook.md (CP-2 + CP-3 шипались бандлом — runbook общий). Pre-flight checklist: backup observability.sqlite через .backup API, snapshot crontab -l, applied миграция через migrate_observability.py --apply (идемпотентна), установка retention-cron, smoke-проверки. Rollback path: backup-файлы лежат до 15 мая, crontab snapshot тоже — восстановление одной командой если что

исправление

Каскадные определения: дела перестают теряться после первого определения без СРО (T1)

375 уже-уведомлённых дел вернулись в мониторингтаблица `rulings` — новая схема (несколько определений на одно дело)новая суточная метрика на /ops/sources

Что было раньше

Алгоритм отбора (документ от 7 мая) описывает реалистичный сценарий:

  1. Несколько кредиторов подают на банкротство одного должника.
  2. Суд принимает к производству одно заявление, остальные оставляет «без движения» — даёт срок устранить недостатки.
  3. Если первое определение с целевой СРО — мы шлём Telegram. Хорошо.
  4. Если первое определение по «обездвиженному» заявлению или вступлению в дело без СРО — мы тоже шлём Telegram (дисциплина «нет СРО — пишем»). Дело уходит в статус notified.
  5. Через 7-30 дней заявитель №1 устраняет недостатки. Появляется второе определение по тому же делу — теперь с целевой СРО.
  6. Это второе определение мы пропускали. Система видела notified и больше к делу не возвращалась.

Корень: на таблице rulings стоял UNIQUE INDEX (case_id) — на одно дело могло быть только одно определение в базе. Второе определение технически не могло записаться.

Эмпирика на 7 мая: из 394 уведомлённых дел 22 кейса были notified без СРО — и 15 из них (≈68%) на самом деле имели позже выпущенное определение с целевой СРО, которое мы потеряли.

Что мы сделали

Перестроили слой хранения определений с нуля. Семь подзадач, все на проде.

1. Несколько определений на одно дело

Сняли UNIQUE на case_id. Идентичность теперь не «дело», а «документ суда» — каждое определение хранится по уникальному ключу из URL kad-арбитража. Цепочка определений по одному делу теперь честно живёт в базе как несколько строк, а не затирается.

2. Cascade-watching — дела notified без целевой СРО возвращаются в мониторинг

Главная страница мониторинга раньше смотрела только на дела со статусом watching. Теперь дополнительно подбирает дела:

  • статус notified
  • определение есть, но целевой СРО нет (ни в каноническом виде «ААУ ЦФОП АПК», ни в legacy-поле sro_name — проверка через регистро-нечувствительное сравнение поверх кириллицы)
  • дело не в архиве (архивация без СРО — issue #180, шипалось 6 мая)
  • от последнего обновления прошло меньше 60 дней
  • последняя проверка — больше часа назад (cascade-перепроверки в 4 раза реже чем основной тир, потому что новые определения по уже-уведомлённым делам — редкое событие)

Логика: пока на деле не появилась целевая СРО, мы продолжаем смотреть его раз в час следующие 60 дней. Появилось — отрабатываем как обычно. Не появилось за 60 дней — отпускаем (дело либо умерло, либо это уже не наш сегмент).

3. Атомарная запись определений

Раньше определение и связанные с ним СРО писались двумя отдельными SQL-вызовами. Если первый прошёл, а второй упал — определение лежало в базе без списка СРО, и логика «есть ли целевая СРО» падала на legacy-проверку по подстроке. Теперь обе таблицы пишутся в одной транзакции: либо обе, либо ни одной.

4. Защита от повторного Telegram

Когда то же определение (по тому же URL kad-арбитража) попадает в систему второй раз — что бывает на cascade-перепроверках — система видит «у этого дела уже есть целевое определение» и молча пишет в аудит без отправки Telegram. Никаких дублирующих уведомлений.

5. Корректные счётчики на дашборде

Подсчёт «дел с СРО», топ-5 СРО, и список дел в /buyers раньше считал строки определений, а не дела. Если у одного дела теперь может быть несколько определений с одной и той же СРО — топ-5 раздуется в два раза без переделки. Все три места переведены на «считать уникальные дела».

6. Суточная метрика «подозрение на переиздание»

Возможен ещё один сценарий: kad-арбитраж публикует то же самое логическое определение под новым document_uuid (исправительное определение, CDN-перепубликация). Это создаст новую строку в rulings, хотя по сути это та же запись — просто переоформленная.

Сейчас на проде таких случаев 0 из 5082 дел. Но если их станет больше 1% — значит надо запиливать ещё один уровень дедупликации (по тексту определения, не по URL). Поэтому каждую ночь в 03:50 UTC автоматически считается метрика и пишется в логи. Если перевалит порог — увидим в логах сразу.

Что ты увидишь

На текущий момент:

  • 375 дел уже сейчас вернулись в мониторинг через cascade-watching. Это те самые notified без целевой СРО, которые система раньше теряла. Каждое из них теперь будет перепроверяться раз в час в течение 60 дней с момента последнего обновления.
  • На странице Покупатели топ-5 СРО и счётчик «дел с СРО» теперь показывают честные числа в делах, а не в определениях. На текущий момент with_sro = 373 (а не «X с дублями»).
  • Если по одному из этих 375 дел появится определение с «ААУ ЦФОП АПК» в ближайшие 60 дней — придёт Telegram как обычно. Это и есть основной возврат от этого фикса.

Что дальше

  • 24-72 часа наблюдения. Cascade-watching добавил 375 дел в мониторинг — это в плюс к 209 делам в watching. Каждый cron-тик мониторинга теперь будет проверять чуть больше кейсов. Расход прокси-трафика должен вырасти максимум на 20-30% — за этим следим на странице Ops · Sources.
  • Появятся ли реальные «переиздания» — увидим через ту самую ночную метрику. Если строка в логах начнёт показывать ненулевое число и расти — запиливаем дедупликацию по тексту (это уже отдельный эпик, спека к нему лежит готовая).
  • Старые архивированные дела (18 кейсов из issue #180) cascade-watching не задевает — они в архиве и должны там остаться. Если потом захочешь их аудитнуть руками — /cases?archive_view=archived это отдельный фильтр, добавлен 6 мая.

Технические детали (для интересующихся)

Раскрыть
  • Эпик #222 (Bankruptcy Monitor — Project #5), 7 sub-issues #223-#229. Закрылся за один день: discovery + спека + 4 раунда Codex review + 7 коммитов на main. Все sub-issues прошли цикл Claude → Codex (low reasoning + split prompts) → revise/APPROVE → Gemini SHIP → коммит → push.

  • Спека под капотомpersonal/denis/2026-05-08_{denis}{spec}_t1_unique_index_migration_v2.md. Версия v2.1 после двух раундов Codex review (v1 получил REVISE с 5 Tier-1 блокерами, v2 — 4 концретных бага в split-prompt раунде). Главные архитектурные решения:

    • Идентичность определения = source_document_key (нормализованный URL kad-арбитража: lowercase scheme/netloc, percent-encoding round-trip, strip trailing slash). На проде все 396 строк имели уникальный непустой document_url, так что миграция прошла без коллизий.
    • pylower как SQLite custom function: встроенный lower() в SQLite ASCII-only — для русских СРО-имён надо было явно зарегистрировать обёртку над Python str.lower(). Иначе legacy-substring-match по sro_name молча проваливался на смешанном регистре.
    • changed_to_target как notify-gate: возвращается из атомарного upsert_ruling_with_sros и означает «это определение перевело дело из «нет целевой СРО» в «есть целевая СРО»». Логика «слать ли Telegram» теперь сводится к одной строчке.
    • logical_act_key отложен в v3 — гейтится метрикой re-issue (см. п. 6 выше). На текущем поведении функционально корректно: даже если будет 2 строки на одно логическое определение, Telegram отправится ровно один раз (потому что вторая запись увидит «у дела уже есть target» → silent).
  • Атомарная миграция в src/db/t1_migration.py — отдельный transaction wrapping ALTER + backfill + DROP/CREATE индексов. Pre-flight проверяет инварианты (uniqueness + non-null document_url) — если падает, миграция вообще не стартует. Post-check проверяет финальное состояние — если legacy-индекс не дропнулся, миграция откатывается. На проде применилась чисто, post-check passed.

  • Решение «cascade-watching ограничен status='notified', а не также rejected/returned/stale/skipped. Денисов алгоритм формально допускает каскад через rejected-цепочки, но эмпирически это редкий и шумный случай — лучше явное ограничение и потом расширить, если понадобится. Зафиксировано в спеке § 9 Q4.

  • Тесты: 97 новых T1-специфичных тестов (16 на schema migration, 26 на document identity helper, 14 на atomic upsert, 20 на cascade-watching monitor, 8 на dashboard SQL, 13 на E2E + monitoring metric). Полный набор: 3677 / 3677 pass. mypy --strict baseline-стабильный, ruff clean на всех изменённых файлах.

  • Per-task review chain поймал:

    • Codex r1 на v1 спеке — 5 Tier-1 блокеров (monitor skip pre-DB / pdf_path unsafe identity / non-atomic ruling+sros / silent migration error / inline SQL multiplies rows)
    • Codex r2 split-prompts на v2 — 4 концретных бага (case-sensitivity на пустом target_set, falsy bytes check, 4 migration safety bugs)
    • Codex r1 на T1-04 — APPROVE
    • Codex r1 на T1-05 — APPROVE с подтверждением semantic equivalence
    • Codex r1 на T1-06 — APPROVE
    • Codex r1 на T1-07 — APPROVE с одной NIT (>= vs > для строгого порога — пофиксили)
  • Hetzner deploy 8 мая: код уже прилетел на прод во время волны параллельных коммитов (другая сессия рестартанула web — миграция автозапустилась через init_db). Когда дошла очередь до ручного deploy-чек-листа — миграция уже была применена, оставалось только установить cron на ночную метрику + smoke-checks. Запасной DB-снапшот не требовался.

  • Бэйслайн метрики переиздания: count=0, case_total=5082, pct=0.0 на 8 мая. Cron активирован: 50 3 * * * UTC.

  • Deploy runbook с pre-flight + rollback options (revert + DB restore) — personal/denis/2026-05-08_{denis}{plan}_t1_deploy_runbook.md.

новая возможность

Админ-страницы: расписание, источники, детальный пробег (CP-3)

новые страницы /ops/jobs, /ops/sources, /ops/runs/{id}две новые кнопки в верхнем менюреестр из 24 источников данных и инфра-провайдеров

Что было раньше

Прошлый шаг (CP-1) дал страницу Ops · Runs — список запусков всех 32 фоновых задач. Видно, что когда стартовало, сколько шло, упало или нет.

Но оставались дыры:

  • «А когда они запустятся в следующий раз?» — расписания крона жили в crontab -l на сервере, не в UI. Чтобы посмотреть когда следующий пробег monitor, нужно было лезть по SSH.
  • «Сколько мы тратим на каждый источник?» — Evomi-прокси, AstroProxy, 2captcha, OpenRouter, ЕГРЮЛ, Контур — расходы рассыпаны по разным таблицам. Не было единого ответа на вопрос «что у нас вообще есть и что почём».
  • «Что именно сделал этот конкретный запуск?» — клик по строке в Ops · Runs ничего не давал. Только имя + длительность + статус.

Что мы сделали

Три новые страницы плюс реестр источников под капотом.

Ops · Jobs — расписание всех 32 задач

Читает cron_manifest.yaml (декларативный реестр всех cron-задач, заведённый в CP-1) и для каждой строки показывает:

  • имя + русское описание
  • расписание (cron-выражение */15 * * * * и т.п.)
  • когда следующий запуск в UTC — посчитано через библиотеку croniter
  • источники данных — какие именно сайты/API задача дёргает (например monitorkad.arbitr)
  • тип лока (single-instance / parallel)
  • путь к bash-обёртке для отладки

Если cron-выражение битое или cron_manifest.yaml не парсится — страница не падает 500-кой, а показывает плашку с ошибкой и рендерит остальные строки.

Ops · Sources — реестр источников и расходы

Самая «жирная» страница. Группирует 24 источника по категориям:

  • Источники данных (17) — kad.arbitr, casebook.ru, fedresurs.ru/backend, pb.nalog.gov.ru, ЕГРЮЛ, ФНП, Контур.Фокус и т.д.
  • Инфра-провайдеры (7) — Evomi (residential proxy), AstroProxy, Hetzner, Selectel, 2captcha, OpenRouter (LLM fallback), Sentry

По каждой строке: тип (бесплатный скрейп / платный API / гибрид), статус (активный / устарел / отключён), коммит-бюджет в месяц, фактический расход с 1-го числа, дата последнего обновления записи.

Особый случай — три прокси-провайдера: их трафик пишется в общую таблицу без атрибуции по провайдеру (так исторически устроено), поэтому сверху рендерится строка «Unallocated proxy MTD: $X», а в каждой из трёх отдельных строк — заглушка (included above). Это не баг, это честное отображение данных.

2captcha считается отдельно — там MTD это сумма положительных дельт баланса (фильтрует пополнения, оставляет только реальный сжог). Если снапшотов меньше двух — показывается «balance only — burn calc requires 2 snapshots» вместо мусора.

Если cases.sqlite залочена долгим cron-ом — таймаут 5 секунд, потом ячейки MTD рендерятся как «Cross-DB read unavailable». Страница всё равно открывается.

Ops · Runs/{id} — детальная страница по одному запуску

Клик по любой строке в Ops · Runs теперь открывает детальную страницу. Рендерит все метаданные конкретного пробега: имя, статус, время старта/финиша, сколько шёл, сколько ждал блокировку основной БД, какой git-SHA крутился, путь к лог-файлу, код выхода.

Если CP-2 (следующая фаза, расширение телеметрии — детализация по обработанным записям) ещё не докатилась — страница показывает амбер-плашку «Per-event drill-down requires CP-2 ship». Когда CP-2 приедет, на той же странице сверху появится гистограмма по типам событий, снизу — пагинированный raw-таймлайн.

Реестр источников под капотом

Заведена новая таблица source_registry в data/observability.sqlite. 24 строки засеяны: имя, отображаемое имя, тип, категория, кост-модель (JSON), путь к Python-модулю-владельцу, статус. Имена нормализованы — twocaptcha (а не 2captcha), kontur_basic (а не kontur_focus), proxy_seller помечен deprecated (мигрировали на Evomi 30 апреля).

Cross-validation между реестром и cron_manifest.yaml: если cron-задача упоминает source_keys: ["kad.arbitr"], но в реестре такого имени нет — линтер ругается. Это защита от очепяток.

Что ты увидишь

В верхнем меню дашборда теперь три кнопки на префиксе Ops:

  • Ops · Runs (с CP-1) — список запусков
  • Ops · Jobs (новое) — расписание
  • Ops · Sources (новое) — источники и расходы

И клик по любой строке в Ops · Runs теперь ведёт на детальную страницу /ops/runs/{id} — раньше это была пустая ссылка.

На текущий момент Ops · Sources уже показывает живые цифры:

  • Evomi-прокси — $49.99/мес коммит, фактический расход с 1 мая в строке Unallocated proxy MTD
  • AstroProxy — $20/мес коммит
  • 2captcha — текущий баланс + сжог за месяц через дельты
  • OpenRouter (LLM fallback) — фактический MTD из таблицы llm_extractions WHERE accepted=1
  • Hetzner — $8/мес
  • Selectel (RU-фасад) — $9/мес

И 17 строк источников данных — kad.arbitr, casebook, fedresurs, pb.nalog и остальные — у них $0/мес коммит (бесплатный скрейп), но видна дата последнего обновления записи и владелец модуля для отладки.

Что дальше

  • 24-часовой watch до 9 мая ~07:00 МСК — следим чтобы /ops/sources не таймаутил на cases.sqlite под нагрузкой (там сейчас ~50 МБ и параллельные мониторинговые писатели)
  • CP-2 (следующая фаза) уже частично замёрджена — пишет события на каждый шаг внутри запуска. Когда докатится полностью, страница /ops/runs/{id} оживёт: гистограмма событий + raw-таймлайн вместо текущей амбер-плашки
  • Бэкапы /var/backups/bankruptcy-monitor/{observability,cases}_cp3_pre_*.sqlite лежат на Hetzner до 15 мая на случай отката, потом удалю вручную

Технические детали (для интересующихся)

Раскрыть
  • 9 sub-issues в Project #6 (Bankruptcy Monitor — Control Plane): #207 (schema) → #208 (Pydantic + repo) → #209 (manifest source_keys) → #210 (seed script + governance) → #211 (drill-down) → #212 (jobs page + croniter) → #213 (sources + cross-DB MTD) → #214 (top-nav links) → #215 (deploy runbook + p95 smoke)

  • Эпик #186 закрыт. ~21 коммит на main за 2 дня.

  • ~98 новых тестов + 4 smoke-бенчмарка (@pytest.mark.smoke — исключены из дефолтного pytest, прогоняются явно через pytest -m smoke)

  • Pre-deploy server-side p95 (после рестарта run_web.py на Hetzner, 50 итераций per route с прогревом):

    • /ops/runs — median 35ms, p95 40ms
    • /ops/jobs — median 36ms, p95 43ms
    • /ops/sources — median 4ms, p95 6ms (cross-DB read через cases.sqlite?mode=ro с 5s timeout — на холодной маленькой cases.sqlite рендер быстрый)
    • /ops/runs/{id} — median 33ms, p95 40ms
    • Все ниже 200ms — порога из AC
  • Архитектурные решения:

    • source_registry в observability.sqlite, не в cases.sqlite — чтобы реестр не зависел от write-contention на основной БД
    • TypedDict + union для MtdSpend: float | TwocaptchaMtd | None — каждое значение ячейки явно типизировано (mypy --strict clean)
    • Cross-DB read pattern: sqlite3.connect("file:cases.sqlite?mode=ro", uri=True, timeout=5.0) — read-only URI с 5-секундным таймаутом; если БД залочена дольше — graceful fallback на placeholder
    • Multi-pass template injection prevention: handler /ops/runs/{id} использует single-pass regex substitution вместо template.replace() × N — последовательные replace могут переписывать ранее отрисованные фрагменты HTML, если те случайно содержат литерал плейсхолдера. Codex r2 поймал это до мерджа
    • /ops/jobs не кэширует croniter-вычисления — на 32 задачах меряет ~5ms total, но если manifest перевалит за 100 — добавим LRU-кэш на 5 минут
    • Decision 8 fallback: страницы готовы к до-CP-2 состоянию — если таблица pipeline_events отсутствует, рендерится amber-плашка вместо 500
  • Новая зависимость: croniter>=1.4.0 (для вычисления next-fire времени)

  • Per-task review chain: каждый sub-issue прошёл через цикл Claude → Codex (medium reasoning) → revise/APPROVE → Gemini SHIP → коммит. Codex раз поймал по 3-5 реальных багов на тикет (multi-pass template injection, twocaptcha pre-month anchor SQL allowing duplicate ties, dashboard nav grid overflow, CSS class vs data-attribute mismatch, и т.д.) — за пять волн ноль регрессий долетело до прода

  • Deploy-runbook с rollback-процедурой лежит в 2026-05-08_{denis}{plan}_cp3_deploy_runbook.md — 19 шагов, протестирован на этом самом деплое

новая возможность

Запущена обсервабилити пайплайнов (CP-1)

все 32 cron-задачиновая страница /ops/runs

Что было раньше

У нас 32 фоновых процесса, которые крутятся по расписанию: одни каждые 15 минут (мониторинг kad.arbitr), другие раз в день (поиск новых дел на casebook.ru, парсинг определений), третьи раз в неделю (обновление "Прозрачного бизнеса"), четвёртые раз в месяц (ЕГРЮЛ, ФНП).

Если что-то ломалось — узнавали либо из Telegram-алерта (если алерт настроен), либо случайно при просмотре логов в tail -f data/logs/*.log. Не было общего ответа на вопрос «всё ли запустилось сегодня?» — приходилось проверять каждый лог-файл отдельно.

Что мы сделали

Каждый раз, когда запускается любая из 32 задач, теперь записывается отдельная строка в журнал запусков:

  • имя пайплайна (например monitor, discovery, p5_bl_biddings)
  • когда запустился (с миллисекундной точностью)
  • сколько шёл (длительность)
  • статус: ok (отработал чисто) / failed (упал с ошибкой) / running (ещё идёт)
  • код выхода (0 = успех, 1 = ошибка, 130 = прерван по Ctrl+C, 143 = убит сигналом TERM)
  • git SHA — какая версия кода крутилась
  • путь к лог-файлу для детальной отладки

Журнал хранится в отдельном файле базы (data/observability.sqlite), чтобы не мешать основной бизнес-БД (data/cases.sqlite). Это важно: даже если журнал сломается, сами задачи продолжат работать как обычно — телеметрия никогда не блокирует прод.

При сбое задачи (исключение в коде, kill -9, Ctrl+C) трап-обработчик в bash успевает записать failed + код ошибки прежде чем процесс завершится. Если же существующая задача уже имела свой обработчик выхода (например, cron_monitor.sh снимает lockfile при выходе) — наш обработчик корректно вызывает её первой и только потом пишет в журнал.

Что ты увидишь

Новая кнопка Ops · Runs в верхнем меню дашборда (https://denis.ownmail.dev) → откроет страницу со списком всех запусков:

  • На каждой строке: что за пайплайн (русское описание простым языком), когда запустился (московское время), сколько шёл, успешно или нет (цветной индикатор)
  • Фильтры: только running / только failed / по имени / лимит количества
  • Сортировка: новые сверху или старые сверху
  • Под капотом — это реалтайм из новой таблицы pipeline_runs

Например, на текущий момент уже видно:
- monitor → "kad.arbitr.ru мониторинг каждые 15 минут — новые определения по watching делам", запустился 03:52 МСК, шёл 1 мин 29 сек, статус ok
- archive_old_no_sro → "Архивация notified-без-СРО старше 14 дней", запустился 03:39, шёл 1 сек, ok (заархивировал 1 кейс)

Что дальше

  • 24-часовой watch-период до 8 мая ~04:00 МСК — все 32 задачи должны зарегистрироваться хотя бы раз
  • После — финальная проверка: не выросло ли время блокировок основной БД из-за новой телеметрии. Pre-deploy замер показал p95 = 1 мс, 0 таймаутов на 950 тыс попытках — должно остаться так же
  • В планах CP-2 (расширение телеметрии — детализация по обработанным записям внутри каждого запуска) и CP-3 (детальная страница по конкретному запуску — drill-down + replay button)

Технические детали (для интересующихся)

Раскрыть
  • 10 коммитов на main: daa7535..6c58b94
  • 8 sub-issues в Project #6 (Bankruptcy Monitor — Control Plane): #191..#198
  • Ключевые файлы:
    • scripts/_run_step.sh — bash-хелпер, который sourcing-ом подключается каждым cron-обёрткой
    • src/observability/cli.py — Python CLI для записи start/finish строк
    • src/observability/run_step.py — context manager для прямого использования из Python
    • src/web/ops/ — модуль с handler'ом /ops/runs
    • scripts/cron_manifest.yaml — декларативный реестр всех 32 задач (имя, расписание, описание, лог)
  • 84 новых теста (3075 → 3159), включая интеграционные bash-тесты сигналов SIGINT/SIGTERM, fail-open пути, idempotent double-source и трап-цепочку для существующих lockfile-обработчиков
  • Pre-deploy baseline на проде: p95 lock wait 1 мс, max 78 мс, 0 таймаутов на 950к попыток (10 параллельных писателей × 120 сек)
  • Архитектурные инварианты:
    • observability.sqlite — отдельный файл от cases.sqlite (mitigates write contention)
    • Fail-open semantics: телеметрия не блокирует прод (timeout 5s, swallow errors)
    • Idempotent: двойной source = no-op
    • Trap chaining: предыдущий EXIT trap (lockfile cleanup) сохраняется