Сбор МЕРА: упавший прогон не висит zombie 6 часов, провалившаяся фаза не попадает в чекпоинт, проверка отмены не держит транзакцию часами #3561

Merged
bot-backend merged 8 commits from fix/kit-orchestration into main 2026-09-17 09:23:44 +00:00
Collaborator

Группа задач по оркестрации scraper-kit: планировщик, чекпоинты свипов, транзакции. Семь коммитов (четыре исходных и три по ревью) и merge origin/main. Миграций нет.

Правки по ревью (17.09)

Исправлено

  • Блокер, #2406: тест закреплял потерю данных как контракт. test_domclick_phase_timeout_keeps_completed_buckets_and_is_not_done требовал done_buckets == ['st'] после таймаута фазы. Лоты ДомКлика пишутся одним save_listings после всех корзин, поэтому снятая watchdog'ом фаза не сохраняет ничего, но чекпоинт получал completed_buckets живого скрейпера. Исправил код, а не только тест: чекпоинт пополняется, только если фаза дошла до конца, то есть после save_listings (флаг _saved). Тест развёрнут: test_domclick_unsaved_buckets_are_not_checkpointed[fetch_timeout|save_failed] проверяет done_buckets == [] и статус failed. Контроль «полный проход несёт унаследованное ∪ пройденное» по-прежнему даёт test_3369. Сами лоты при таймауте теряются как и раньше: для этого нужно инкрементальное сохранение (п.1 миграции 308), в этот PR оно не входит.
  • #1940: ручные запуски из админки шли мимо планировщика. Финализация переехала из scheduler._dispatch в runs.mark_crashed(db, run_id, exc). Её зовут планировщик и все пять ручек: avito-city-sweep, cian-city-sweep, cian-full-load, yandex-full-load, yandex-city-sweep. Заявление «общая точка для всех хендлеров» из первой версии было верно только для планировщика. Корень (вход BrowserFetcher до try финализатора пайплайна) не трогал: он в каждом пайплайне свой, а внешняя финализация покрывает все пути.
  • #1940: дубль алерта. Обычный путь пайплайна — mark_failed и raise, после чего планировщик звал mark_failed ещё раз. UPDATE ничего не менял, но _alert_on_run_id срабатывал с тем же стриком: на вехе лестницы в Sentry уходил дубль и писался WARNING «no-op». Теперь mark_crashed сначала читает статус (после rollback, транзакцию не держит) и финализирует только running.
  • #3415: три мутации гейта оставались зелёными. Добавлены тесты по чекпоинту: частичный отказ (1 из 2 домов в каждом якоре, оба якоря в чекпоинте) ловит жадный гейт failed >= 1; единственный отказавший дом якоря ловит порог >= 2; упавший запрос домов ловит снятый houses_attempted += len(nb_id_list).
  • #1940 и #3415: Closes заменены на Refs. Приёмка у обоих на проде после деплоя.
  • Приёмка #1940 и #3480 переписана (ниже).

Проверено и уточнено (не опровергнуто)

  • Не-мгновенные zombie за 21 сутки. Их 13, последний 06.09, но не все из app-задач: среди них yandex_city_sweep 5289 (29.08) и cian_history_backfill 6173. Все 13 сняты через 32 с – 2 мин после последнего пульса, пары 5289/5290, 5610/5614 и 6167/6173 сняты в одну секунду. Это reap при пересоздании контейнера (#3391), а не 6-часовой reaper. Zombie с пульсом, умершим больше чем за час до снятия, за 21 сутки 0. Это обоснование гипотезы «зависания в данных нет» по данным, а не по построению запроса. Все 18 мгновенных zombie сняты ровно через 6:00–6:01 после старта.
  • Мёртвая _anchor_timed_out в yandex-свипе (эквивалентная мутация) была и в origin/main (стр. 2694/3000). В этом PR не трогал.
  • IP: сейчас tradein-scraper = 172.19.0.4, tradein-tgbot = 172.19.0.3 (docker inspect, контейнеры стартовали 08:36 UTC). На 172.19.0.3 висит idle in transaction тг-бота с SELECT value FROM tg_support_state, возраст 16 с в 08:58.
  • Для владельца, к миграции 308: у 7333 и 7344 в counters нет done_buckets вообще (оба done с interrupted=1), поэтому сброс по её п.4 у этих строк сбрасывать нечего.

#1940 — упавший до финализатора прогон висел «running» 6 часов, потом zombie (частично)

Что было. За 21 сутки на проде 18 avito-свипов ушли в zombie: 7× avito_newbuilding_sweep, 3× _serov, 3× _nizhniy_tagil, 2× avito_city_sweep и др. У всех heartbeat_at - started_at < 5 с, а finished_at стоит ровно через 6 ч.

Почему. Лог tradein-scraper по 7349 (17.09): через 10 мс после claim BrowserFetcher.__aenter___acquire_lease бросает NoProxyAvailableError(avito). В run_avito_city_sweep вызов stack.enter_async_context(BrowserFetcher(...)) стоит до try с mark_failed. Запускающий код (планировщик и ручки админки) исключение только логировал.

Что сделано. runs.mark_crashed: если строка ещё running и в цепочке причин есть пустой пул (caused_by_no_proxy), ставится banned с ban_kind=infra, иначе failed с crashed: <тип>: <текст>. Уже финализированную строку не трогает и повторно mark_* не зовёт. Вызывается из scheduler._dispatch и пяти ручных запусков в admin.py. Причина пустого пула avito в правку не входит.

Не сделано: п.2 issue, жёсткий таймаут на запуск браузера и вызов сайдкара. По данным за 21 сутки зависаний с умирающим пульсом нет (см. выше), но без таймаута такой случай снова уйдёт в zombie через 6 ч. Поэтому Refs.

#3415 — якорь попадал в done_buckets, даже если фаза в нём отказала полностью

Что было. cian_city_sweep 6179 (06.09) получил failed («houses 30 из 30»), но в done_buckets записаны все 5 якорей. Прогон 6276 резюмировался от него, пропустил все якоря и получил done с houses_attempted=0.

Почему. Отметку «якорь пройден» ставил только поток управления, а фаза отказывает и без исключения: fetch_newbuilding возвращает None, растёт только счётчик.

Что сделано. Гейт _bucket_phase_totally_failed сверяет прирост пар X_attempted/X_failed за якорь, порог — одна попытка. Стоит в cian- и avito-свипах. В ветке «houses DB query failed» houses_attempted растёт вместе с houses_failed. Форма чекпоинта прежняя.

#3480 — транзакция в БД tradein жила дольше часа (частично)

Виновник по живому снимку. pg_stat_activity 17.09 07:48 UTC: pid 1933154, client 172.19.0.3 (тогда это был tradein-scraper, сверено через docker inspect), idle in transaction, возраст транзакции 1:35:41, последний запрос SELECT status FROM scrape_runs WHERE id = $1 (runs.is_cancelled). Прогон domclick_city_sweep_moskva 7344 стартовал в 06:12:48.598, транзакция — в 06:12:48.628.

Совпадения по истории. Prometheus pg_activity_horizon_oldest_xact_age_s{db="tradein"}, 03.09–17.09: 61 точка из 4127 больше 3600 с, 12 окон. 10 из них совпадают со свипами ДомКлика, 2 окна (07.09 04:05–04:30, 10.09 22:30–23:35) не атрибутированы.

Что сделано. В runs.is_cancelled после чтения добавлен db.commit(), общая точка для всех 12 вызовов.

Не закрыто: остаются другие места «чтение, потом сеть» без commit. Например, SELECT priority_rows перед detail-циклом и чтения в якорях avito/cian. Поэтому Refs.

#2406 — крайние случаи city-свипов kit'а

Проверки по значению в строке прогона (фейковая БД мержит counters, как jsonb-мерж):

  • таймаут якоря у cian и yandex: якорь не в чекпоинте, следующий обработан, errors_count растёт;
  • у domclick фаза, снятая watchdog'ом или упавшая на save_listings, не отмечает корзины: done_buckets == [], статус failed (исправлено по ревью, см. выше);
  • SIGTERM-дрейн после первого якоря у cian и yandex: interrupted=1, в чекпоинте ровно первый якорь;
  • пользовательская отмена после первого якоря у avito, cian и yandex: второй якорь не посещён, чекпоинт на месте, mark_done не вызывается.

Таймаут и дрейн у avito уже покрывал test_3319, дрейн до первого якоря — test_3333, отмену у domclick — test_3369.

Не сделано: #3043 (geoCoords Авито)

В issue сказано: «решение по форме — за владельцем». Замена якорного цикла на citywide-проход меняет единицу чекпоинта (#3074/#3319/#3415) и логику подхвата, а в #3541 (16.09) только что завели сетку из 25 якорей по Москве. Без решения владельца не трогаю. Для решения: прогон 7283 (avito_city_sweep_moskva) — lots_fetched=982, inserted+updated=237.

Тесты

  • tradein-mvp/backend, весь сьют: 6254 passed, 42 skipped, rc=0 (DATABASE_URL=… uv run python -m pytest tests/ -q -p no:cacheprovider, rc снят у pytest). До правок по ревью было 6244.
  • Новые файлы: test_1940_crashed_run_is_finalized.py (10: планировщик 5, ручки админки 5), test_3415_bucket_done_only_if_phases_ok.py (8), test_3480_no_open_transaction_over_http.py (2, настоящая SQLAlchemy-сессия на SQLite), test_2406_sweep_edge_cases.py (9).
  • ruff check app tests ../packages/scraper-kit/src чисто, ruff format --check по 8 изменённым файлам чисто.
  • В packages/scraper-kit своих тестов нет, тесты kit'а живут в backend/tests.

Фальсификация (фикс ломался руками, копия в scratchpad, потом восстановление и diff -q)

Первая версия:

  • #1940, снят вызов финализатора: 3 failedassert 'running' == 'banned', assert 'running' == 'failed'.
  • #3415, pipeline.py из origin/main: 3 failed, 2 passedякорь с полностью отказавшей houses-фазой помечен пройденным — чекпоинт ['ekb-center', 'ekb-south'].
  • #3480, снят db.commit(): 2 failedassert True is False (in_transaction()).
  • #2406, 6 мутаций (снятый continue в таймауте cian, return в таймауте yandex, метка interrupted дрейна, return отмены у cian/yandex/avito): каждая роняет свой тест, например assert [56.83, 56.79] == [56.83].

По ревью:

  • #2406, гейт if _saved: заменён на if True:: 2 failed, 7 passedAssertionError: корзины без единой сохранённой строки в чекпоинте: ['st'] (оба параметра). То же при _saved = True в начале фазы.
  • #1940, снята проверка статуса в mark_crashed: 1 failed, 9 passedassert 2 == 1 (capture_message.call_count), в логе дважды sentry alert sent: source=avito_city_sweep has 3 consecutive failed/banned runs и mark_failed no-op.
  • #1940, в пяти ручках mark_crashed заменён на pass: 5 failed, 5 passedassert ('running', None) == ('banned', 'infra').
  • #1940, вызов в планировщике заменён на pass: 3 failed, 7 passed. Ветка no-proxy заменена на if False: 7 failedassert ('failed', None) == ('banned', 'infra').
  • #3415, attempted >= 2: 2 failedякорь с 1 из 1 отказавшим домом помечен пройденным: ['ekb-center', 'ekb-south']; failed >= 1: 1 failedякорь с частичным отказом houses не помечен пройденным: []; снят houses_attempted += len(nb_id_list): 1 failedякорь с упавшим запросом домов помечен пройденным: ['ekb-center', 'ekb-south'].

Деплой

Пересоздаются tradein-backend, tradein-tgbot и tradein-scraper (один образ, в него вкомпилирован scraper-kit). Миграций нет. Перед мержем в том же блоке, что и мерж: SELECT id, source FROM scrape_runs WHERE status='running'. На 17.09 08:58 UTC там пусто: 7349/7350 сняты boot-reap как cancelled в 08:36. Окна: ЕКБ domclick_city_sweep 03:00–06:00 UTC (следующий запуск 18.09 03:50), ночью cian_full_load/cian_detail_backfill.

Приёмка на проде (после деплоя)

  • #1940, до 25.09.
    1. SELECT count(*) FROM scrape_runs WHERE status='zombie' AND started_at > '<деплой>' — все zombie, по классам:
      • heartbeat_at - started_at < 5 s (мгновенные) = 0;
      • finished_at - heartbeat_at > 1 h (зависание, снятое 6-часовым reaper'ом) = 0. Если такие есть, это п.2 issue, который здесь не делался;
      • снятые при пересоздании контейнера (finished_at - heartbeat_at < 5 min) относятся к #3391 и отдельны.
    2. Каждая строка crashed run_id=N в логе tradein-scraper или tradein-backend (ручные запуски) — это N со статусом banned/failed в пределах секунды.
    3. На каждое failed-завершение свипа в Sentry не больше одного сообщения consecutive failed/banned runs.
  • #3415: первый cian_city_sweep* с полностью отказавшей фазой не несёт этот якорь в counters->'done_buckets'. Следующий прогон с resume_reason=ok показывает houses_attempted>0 (или detail_attempted>0). Прогон, пропустивший фазу по резюму, приёмкой не считается.
  • #3480. Доказывать по механизму, а не только по метрике: #3567 выключил domclick_city_sweep_moskva/_moskovskaya_oblast (самые длинные окна, 7344 — 1.6 ч), поэтому метрика улучшится и без этой правки.
    1. Во время ЕКБ domclick_city_sweep (первый — 18.09 с 03:50 UTC) сначала взять IP скрейпера: docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' tradein-scraper (на 17.09 это 172.19.0.4; 172.19.0.3 сейчас у тг-бота, у него своя короткая idle in transaction).
    2. Затем SELECT pid, client_addr, state, now()-xact_start, query FROM pg_stat_activity WHERE datname='tradein' AND state LIKE 'idle in transaction%' AND client_addr='<IP скрейпера>': нет сессии старше минуты при running свипе.
    3. Критерий issue: неделя без точек pg_activity_horizon_oldest_xact_age_s{db="tradein"} > 3600. Если окно повторится, снять pg_stat_activity в момент тревоги: два окна вне ДомКлика (07.09, 10.09) не атрибутированы, а «чтение, потом сеть» остаётся в avito/cian.

Closes #2406
Refs #1940 (п.2 issue — таймаут запуска браузера — не делался; приёмка до 25.09)
Refs #3415 (приёмка — первый cian_city_sweep с отказавшей фазой)
Refs #3480 (закрыть после недели без окон > 1 ч)
Refs #3043

🤖 Generated with Claude Code

Группа задач по оркестрации scraper-kit: планировщик, чекпоинты свипов, транзакции. Семь коммитов (четыре исходных и три по ревью) и merge `origin/main`. Миграций нет. ## Правки по ревью (17.09) **Исправлено** - **Блокер, #2406: тест закреплял потерю данных как контракт.** `test_domclick_phase_timeout_keeps_completed_buckets_and_is_not_done` требовал `done_buckets == ['st']` после таймаута фазы. Лоты ДомКлика пишутся одним `save_listings` после всех корзин, поэтому снятая watchdog'ом фаза не сохраняет ничего, но чекпоинт получал `completed_buckets` живого скрейпера. Исправил код, а не только тест: чекпоинт пополняется, только если фаза дошла до конца, то есть после `save_listings` (флаг `_saved`). Тест развёрнут: `test_domclick_unsaved_buckets_are_not_checkpointed[fetch_timeout|save_failed]` проверяет `done_buckets == []` и статус `failed`. Контроль «полный проход несёт унаследованное ∪ пройденное» по-прежнему даёт test_3369. Сами лоты при таймауте теряются как и раньше: для этого нужно инкрементальное сохранение (п.1 миграции 308), в этот PR оно не входит. - **#1940: ручные запуски из админки шли мимо планировщика.** Финализация переехала из `scheduler._dispatch` в `runs.mark_crashed(db, run_id, exc)`. Её зовут планировщик и все пять ручек: `avito-city-sweep`, `cian-city-sweep`, `cian-full-load`, `yandex-full-load`, `yandex-city-sweep`. Заявление «общая точка для всех хендлеров» из первой версии было верно только для планировщика. Корень (вход `BrowserFetcher` до `try` финализатора пайплайна) не трогал: он в каждом пайплайне свой, а внешняя финализация покрывает все пути. - **#1940: дубль алерта.** Обычный путь пайплайна — `mark_failed` и `raise`, после чего планировщик звал `mark_failed` ещё раз. UPDATE ничего не менял, но `_alert_on_run_id` срабатывал с тем же стриком: на вехе лестницы в Sentry уходил дубль и писался WARNING «no-op». Теперь `mark_crashed` сначала читает статус (после `rollback`, транзакцию не держит) и финализирует только `running`. - **#3415: три мутации гейта оставались зелёными.** Добавлены тесты по чекпоинту: частичный отказ (1 из 2 домов в каждом якоре, оба якоря в чекпоинте) ловит жадный гейт `failed >= 1`; единственный отказавший дом якоря ловит порог `>= 2`; упавший запрос домов ловит снятый `houses_attempted += len(nb_id_list)`. - **#1940 и #3415: `Closes` заменены на `Refs`.** Приёмка у обоих на проде после деплоя. - **Приёмка #1940 и #3480 переписана** (ниже). **Проверено и уточнено (не опровергнуто)** - Не-мгновенные zombie за 21 сутки. Их 13, последний 06.09, но не все из app-задач: среди них `yandex_city_sweep` 5289 (29.08) и `cian_history_backfill` 6173. Все 13 сняты через 32 с – 2 мин после последнего пульса, пары 5289/5290, 5610/5614 и 6167/6173 сняты в одну секунду. Это reap при пересоздании контейнера (#3391), а не 6-часовой reaper. Zombie с пульсом, умершим больше чем за час до снятия, за 21 сутки 0. Это обоснование гипотезы «зависания в данных нет» по данным, а не по построению запроса. Все 18 мгновенных zombie сняты ровно через 6:00–6:01 после старта. - Мёртвая `_anchor_timed_out` в yandex-свипе (эквивалентная мутация) была и в `origin/main` (стр. 2694/3000). В этом PR не трогал. - IP: сейчас `tradein-scraper` = 172.19.0.4, `tradein-tgbot` = 172.19.0.3 (`docker inspect`, контейнеры стартовали 08:36 UTC). На 172.19.0.3 висит `idle in transaction` тг-бота с `SELECT value FROM tg_support_state`, возраст 16 с в 08:58. - Для владельца, к миграции 308: у 7333 и 7344 в `counters` нет `done_buckets` вообще (оба `done` с `interrupted=1`), поэтому сброс по её п.4 у этих строк сбрасывать нечего. ## #1940 — упавший до финализатора прогон висел «running» 6 часов, потом zombie (частично) **Что было.** За 21 сутки на проде 18 avito-свипов ушли в `zombie`: 7× `avito_newbuilding_sweep`, 3× `_serov`, 3× `_nizhniy_tagil`, 2× `avito_city_sweep` и др. У всех `heartbeat_at - started_at < 5 с`, а `finished_at` стоит ровно через 6 ч. **Почему.** Лог `tradein-scraper` по 7349 (17.09): через 10 мс после claim `BrowserFetcher.__aenter__` → `_acquire_lease` бросает `NoProxyAvailableError(avito)`. В `run_avito_city_sweep` вызов `stack.enter_async_context(BrowserFetcher(...))` стоит до `try` с `mark_failed`. Запускающий код (планировщик и ручки админки) исключение только логировал. **Что сделано.** `runs.mark_crashed`: если строка ещё `running` и в цепочке причин есть пустой пул (`caused_by_no_proxy`), ставится `banned` с `ban_kind=infra`, иначе `failed` с `crashed: <тип>: <текст>`. Уже финализированную строку не трогает и повторно `mark_*` не зовёт. Вызывается из `scheduler._dispatch` и пяти ручных запусков в `admin.py`. Причина пустого пула avito в правку не входит. **Не сделано:** п.2 issue, жёсткий таймаут на запуск браузера и вызов сайдкара. По данным за 21 сутки зависаний с умирающим пульсом нет (см. выше), но без таймаута такой случай снова уйдёт в zombie через 6 ч. Поэтому `Refs`. ## #3415 — якорь попадал в done_buckets, даже если фаза в нём отказала полностью **Что было.** `cian_city_sweep` 6179 (06.09) получил `failed` («houses 30 из 30»), но в `done_buckets` записаны все 5 якорей. Прогон 6276 резюмировался от него, пропустил все якоря и получил `done` с `houses_attempted=0`. **Почему.** Отметку «якорь пройден» ставил только поток управления, а фаза отказывает и без исключения: `fetch_newbuilding` возвращает None, растёт только счётчик. **Что сделано.** Гейт `_bucket_phase_totally_failed` сверяет прирост пар `X_attempted/X_failed` за якорь, порог — одна попытка. Стоит в cian- и avito-свипах. В ветке «houses DB query failed» `houses_attempted` растёт вместе с `houses_failed`. Форма чекпоинта прежняя. ## #3480 — транзакция в БД tradein жила дольше часа (частично) **Виновник по живому снимку.** `pg_stat_activity` 17.09 07:48 UTC: pid 1933154, client 172.19.0.3 (тогда это был `tradein-scraper`, сверено через `docker inspect`), `idle in transaction`, возраст транзакции 1:35:41, последний запрос `SELECT status FROM scrape_runs WHERE id = $1` (`runs.is_cancelled`). Прогон `domclick_city_sweep_moskva` 7344 стартовал в 06:12:48.598, транзакция — в 06:12:48.628. **Совпадения по истории.** Prometheus `pg_activity_horizon_oldest_xact_age_s{db="tradein"}`, 03.09–17.09: 61 точка из 4127 больше 3600 с, 12 окон. 10 из них совпадают со свипами ДомКлика, 2 окна (07.09 04:05–04:30, 10.09 22:30–23:35) не атрибутированы. **Что сделано.** В `runs.is_cancelled` после чтения добавлен `db.commit()`, общая точка для всех 12 вызовов. **Не закрыто:** остаются другие места «чтение, потом сеть» без commit. Например, `SELECT priority_rows` перед detail-циклом и чтения в якорях avito/cian. Поэтому `Refs`. ## #2406 — крайние случаи city-свипов kit'а Проверки по значению в строке прогона (фейковая БД мержит counters, как jsonb-мерж): - таймаут якоря у cian и yandex: якорь не в чекпоинте, следующий обработан, `errors_count` растёт; - у domclick фаза, снятая watchdog'ом или упавшая на `save_listings`, не отмечает корзины: `done_buckets == []`, статус `failed` (исправлено по ревью, см. выше); - SIGTERM-дрейн после первого якоря у cian и yandex: `interrupted=1`, в чекпоинте ровно первый якорь; - пользовательская отмена после первого якоря у avito, cian и yandex: второй якорь не посещён, чекпоинт на месте, `mark_done` не вызывается. Таймаут и дрейн у avito уже покрывал test_3319, дрейн до первого якоря — test_3333, отмену у domclick — test_3369. ## Не сделано: #3043 (geoCoords Авито) В issue сказано: «решение по форме — за владельцем». Замена якорного цикла на citywide-проход меняет единицу чекпоинта (#3074/#3319/#3415) и логику подхвата, а в #3541 (16.09) только что завели сетку из 25 якорей по Москве. Без решения владельца не трогаю. Для решения: прогон 7283 (`avito_city_sweep_moskva`) — `lots_fetched=982`, `inserted+updated=237`. ## Тесты - `tradein-mvp/backend`, весь сьют: `6254 passed, 42 skipped`, rc=0 (`DATABASE_URL=… uv run python -m pytest tests/ -q -p no:cacheprovider`, rc снят у pytest). До правок по ревью было 6244. - Новые файлы: `test_1940_crashed_run_is_finalized.py` (10: планировщик 5, ручки админки 5), `test_3415_bucket_done_only_if_phases_ok.py` (8), `test_3480_no_open_transaction_over_http.py` (2, настоящая SQLAlchemy-сессия на SQLite), `test_2406_sweep_edge_cases.py` (9). - `ruff check app tests ../packages/scraper-kit/src` чисто, `ruff format --check` по 8 изменённым файлам чисто. - В `packages/scraper-kit` своих тестов нет, тесты kit'а живут в `backend/tests`. ## Фальсификация (фикс ломался руками, копия в scratchpad, потом восстановление и `diff -q`) Первая версия: - #1940, снят вызов финализатора: `3 failed` — `assert 'running' == 'banned'`, `assert 'running' == 'failed'`. - #3415, pipeline.py из origin/main: `3 failed, 2 passed` — `якорь с полностью отказавшей houses-фазой помечен пройденным — чекпоинт ['ekb-center', 'ekb-south']`. - #3480, снят `db.commit()`: `2 failed` — `assert True is False` (`in_transaction()`). - #2406, 6 мутаций (снятый `continue` в таймауте cian, `return` в таймауте yandex, метка `interrupted` дрейна, `return` отмены у cian/yandex/avito): каждая роняет свой тест, например `assert [56.83, 56.79] == [56.83]`. По ревью: - #2406, гейт `if _saved:` заменён на `if True:`: `2 failed, 7 passed` — `AssertionError: корзины без единой сохранённой строки в чекпоинте: ['st']` (оба параметра). То же при `_saved = True` в начале фазы. - #1940, снята проверка статуса в `mark_crashed`: `1 failed, 9 passed` — `assert 2 == 1` (`capture_message.call_count`), в логе дважды `sentry alert sent: source=avito_city_sweep has 3 consecutive failed/banned runs` и `mark_failed no-op`. - #1940, в пяти ручках `mark_crashed` заменён на `pass`: `5 failed, 5 passed` — `assert ('running', None) == ('banned', 'infra')`. - #1940, вызов в планировщике заменён на `pass`: `3 failed, 7 passed`. Ветка no-proxy заменена на `if False`: `7 failed` — `assert ('failed', None) == ('banned', 'infra')`. - #3415, `attempted >= 2`: `2 failed` — `якорь с 1 из 1 отказавшим домом помечен пройденным: ['ekb-center', 'ekb-south']`; `failed >= 1`: `1 failed` — `якорь с частичным отказом houses не помечен пройденным: []`; снят `houses_attempted += len(nb_id_list)`: `1 failed` — `якорь с упавшим запросом домов помечен пройденным: ['ekb-center', 'ekb-south']`. ## Деплой Пересоздаются `tradein-backend`, `tradein-tgbot` и `tradein-scraper` (один образ, в него вкомпилирован scraper-kit). Миграций нет. Перед мержем в том же блоке, что и мерж: `SELECT id, source FROM scrape_runs WHERE status='running'`. На 17.09 08:58 UTC там пусто: 7349/7350 сняты boot-reap как `cancelled` в 08:36. Окна: ЕКБ `domclick_city_sweep` 03:00–06:00 UTC (следующий запуск 18.09 03:50), ночью `cian_full_load`/`cian_detail_backfill`. ## Приёмка на проде (после деплоя) - **#1940, до 25.09.** 1. `SELECT count(*) FROM scrape_runs WHERE status='zombie' AND started_at > '<деплой>'` — все zombie, по классам: - `heartbeat_at - started_at < 5 s` (мгновенные) = 0; - `finished_at - heartbeat_at > 1 h` (зависание, снятое 6-часовым reaper'ом) = 0. Если такие есть, это п.2 issue, который здесь не делался; - снятые при пересоздании контейнера (`finished_at - heartbeat_at < 5 min`) относятся к #3391 и отдельны. 2. Каждая строка `crashed run_id=N` в логе `tradein-scraper` или `tradein-backend` (ручные запуски) — это N со статусом `banned`/`failed` в пределах секунды. 3. На каждое `failed`-завершение свипа в Sentry не больше одного сообщения `consecutive failed/banned runs`. - **#3415:** первый `cian_city_sweep*` с полностью отказавшей фазой не несёт этот якорь в `counters->'done_buckets'`. Следующий прогон с `resume_reason=ok` показывает `houses_attempted>0` (или `detail_attempted>0`). Прогон, пропустивший фазу по резюму, приёмкой не считается. - **#3480.** Доказывать по механизму, а не только по метрике: #3567 выключил `domclick_city_sweep_moskva/_moskovskaya_oblast` (самые длинные окна, 7344 — 1.6 ч), поэтому метрика улучшится и без этой правки. 1. Во время ЕКБ `domclick_city_sweep` (первый — 18.09 с 03:50 UTC) сначала взять IP скрейпера: `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' tradein-scraper` (на 17.09 это 172.19.0.4; 172.19.0.3 сейчас у тг-бота, у него своя короткая `idle in transaction`). 2. Затем `SELECT pid, client_addr, state, now()-xact_start, query FROM pg_stat_activity WHERE datname='tradein' AND state LIKE 'idle in transaction%' AND client_addr='<IP скрейпера>'`: нет сессии старше минуты при `running` свипе. 3. Критерий issue: неделя без точек `pg_activity_horizon_oldest_xact_age_s{db="tradein"} > 3600`. Если окно повторится, снять `pg_stat_activity` в момент тревоги: два окна вне ДомКлика (07.09, 10.09) не атрибутированы, а «чтение, потом сеть» остаётся в avito/cian. Closes #2406 Refs #1940 (п.2 issue — таймаут запуска браузера — не делался; приёмка до 25.09) Refs #3415 (приёмка — первый cian_city_sweep с отказавшей фазой) Refs #3480 (закрыть после недели без окон > 1 ч) Refs #3043 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 4 commits 2026-09-17 08:00:23 +00:00
Исключение, вылетевшее из хендлера до его try с mark_failed (прод: пустой пул
прокси в BrowserFetcher.__aenter__ через 10 мс после claim), планировщик только
логировал. Строка оставалась 'running' с heartbeat_at == started_at, reaper через
6 ч ставил 'zombie' — за 21 сутки так 18 avito-свипов (7349 и 7350 висят сейчас).

_dispatch._run теперь финализирует прогон сам: пустой пул прокси в цепочке
причин -> banned/ban_kind=infra (как в пайплайнах), остальное -> failed.
Уже финализированную хендлером строку не трогает (WHERE status='running'),
пустые counters мержатся и чекпоинт не стирают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cian_city_sweep 6179 (06.09) — failed по «фаза houses отказала полностью, 30 из
30», но в done_buckets все пять якорей. 6276 резюмировался от него, пропустил
все якоря и отчитался done с houses_attempted=0, lots_fetched=0.

Отметка «якорь пройден» опиралась только на поток управления, а фаза отказывает
и без исключения (fetch_newbuilding -> None, растёт счётчик). Гейт
_bucket_phase_totally_failed сверяет прирост пар X_attempted/X_failed за якорь;
порог одна попытка. Тот же гейт в run_avito_city_sweep (detail). Ветка «houses
DB query failed» теперь растит houses_attempted вместе с houses_failed.
Форма чекпоинта прежняя (плоский список имён).

Перенос ветки fix/3415-bucket-done-only-if-phases-ok (19a335f4) на свежий main,
применилась без конфликтов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Виновник по живому снимку pg_stat_activity 17.09 07:48 UTC: pid 1933154 из
tradein-scraper, 'idle in transaction' 1 ч 35 мин, последний запрос
`SELECT status FROM scrape_runs WHERE id = $1` (runs.is_cancelled),
xact_start через 30 мс после старта domclick_city_sweep_moskva 7344.
По Prometheus за 03-17.09 10 из 12 окон «транзакция > 1 ч» совпадают по
началу и концу со свипами ДомКлика.

is_cancelled делал SELECT без commit, а вызывающие сразу уходят в сетевую
фазу (у ДомКлика — весь fetch_city, до 3 ч). Теперь commit после чтения —
в общей точке для всех 12 вызовов; незакоммиченных записей, рассчитанных на
откат, перед вызовами нет (перед каждым — update_heartbeat/save_listings с
commit или чтения).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Тесты kit-свипов: таймаут якоря, дрейн и отмена посреди обхода у всех свипов, а не только у Авито (#2406)
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 5m21s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
d2a31dff08
После удаления legacy-пайплайна эти сценарии были заассерчены только у avito
(test_3319) и дрейн до первого якоря у yandex/cian (test_3333). Добавлено:
- таймаут якоря у cian и yandex: якорь не в чекпоинте, следующий обработан,
  errors_count растёт; таймаут фазы у domclick: завершённые корзины в чекпоинте,
  статус failed, а не done;
- SIGTERM-дрейн после первого якоря у cian и yandex: interrupted=1, в
  чекпоинте ровно первый якорь, второй не посещён;
- пользовательская отмена (is_cancelled=True) после первого якоря у avito,
  cian и yandex: второй якорь не посещён, чекпоинт на месте, без mark_done.

Проверка по строке прогона: фейковая БД мержит counters всех записей, как
jsonb-мерж в runs.py. Мутации (снятый continue/return/interrupted/errors_count
в pipeline.py) роняют ровно свой тест, 6 из 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 4 commits 2026-09-17 09:02:24 +00:00
Ревью PR #3561: тест таймаута фазы закреплял как контракт done_buckets == ['st'].
Лоты ДомКлика копятся в памяти и пишутся одним save_listings после всех корзин,
поэтому снятая watchdog'ом (или упавшая на save) фаза не сохраняет ничего, а
чекпоинт всё равно получал completed_buckets живого скрейпера — следующий прогон
пропускал корзину навсегда (механизм миграции 308).

Чекпоинт пополняется только если фаза дошла до конца (флаг _saved после save).
Тест развёрнут: при таймауте fetch_city и при падении save_listings
done_buckets == [], статус failed. Контроль «полный проход несёт
унаследованное ∪ пройденное» — test_3369.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью PR #3561, две находки.

1. Ручные запуски из админки (avito/cian city sweep, cian_full_load,
   yandex_full_load, yandex_city_sweep) идут мимо scheduler._dispatch: в except
   был только logger.exception, и отказ «пул пуст» до try-финализатора пайплайна
   оставлял строку running до zombie. Логика финализации переехала из
   планировщика в runs.mark_crashed; её зовут планировщик и все пять ручек.

2. Обычный путь пайплайна — mark_failed и raise; планировщик звал mark_failed
   второй раз. UPDATE — no-op, но _alert_on_run_id срабатывал снова с тем же
   стриком, и на вехе лестницы в Sentry уходил дубль (+ WARNING «no-op»).
   mark_crashed сначала читает статус и финализирует только running.

Тесты по значению над двойником строки scrape_runs и боевыми mark_*:
статус banned/infra или failed после планировщика и после каждой из пяти ручек,
одна отправка в Sentry на неудаче пайплайна.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью PR #3561: три мутации гейта оставались зелёными — порог `attempted >= 2`,
жадный гейт `failed >= 1` и снятый `houses_attempted += len(nb_id_list)` в ветке
«houses DB query failed». Добавлены проверки по чекпоинту:
- 1 из 2 домов отказал в каждом якоре — оба якоря в чекпоинте (контроль жадности);
- единственный дом якоря отказал — якорь не в чекпоинте (порог);
- упал запрос домов — якоря не в чекпоинте, houses_attempted == houses_failed == 2.

Код не менялся.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into fix/kit-orchestration
All checks were successful
CI Trade-In / changes (pull_request) Successful in 20s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 25s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 6m11s
2639d2a7e7
bot-backend merged commit 1e99d91afe into main 2026-09-17 09:23:44 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3561
No description provided.