Осиротевший прогон 6 часов числится «идёт»: деплой убил процесс, а status='running' остался — гейт деплоя врёт #2848

Closed
opened 2026-08-12 18:51:58 +00:00 by bot-backend · 2 comments
Collaborator

Что произошло сегодня

16:30:51  старт  yandex_city_sweep (прогон 3798)
16:49:08  деплой #2843/#2844 пересоздал контейнеры — прогон убит на 18-й минуте
18:51     прогон ВСЁ ЕЩЁ status='running', пульс не обновлялся 7160 с (≈2 ч)
          процессов свипа: 0 во ВСЕХ шести контейнерах (backend, scraper, tgbot,
          frontend, browser, postgres)
          счётчики застыли: lots_fetched 1615 — то же число, что и 46 минут назад

Строка провисит running до 22:49 UTCZOMBIE_THRESHOLD_HOURS = 6 (scraper_kit/orchestration/scheduler.py:74), а reap_zombies зовётся в начале тика планировщика.

Два отдельных дефекта, не путать

1. Мягкий слив не спас прогон. #1951 ждёт scrape_runs до 5 минут, после чего пересоздаёт контейнер. Свип шёл 18 минут и был убит. Пять минут — это не «ждём окончания», это «даём дописать хвост»; для многочасовых свипов слив не защищает вообще. Само по себе это может быть осознанным разменом — но тогда он должен быть назван, потому что читается как защита.

2. Осиротевшая строка живёт 6 часов, и всё это время она врёт. Это дороже первого. status='running' — общепринятый признак «идёт работа»: по нему смотрят перед деплоем (в том числе я, и это записано в правилах), по нему _claim_run не берёт вторую работу на ту же площадку, по нему считается занятость. Шесть часов подряд признак говорит «работает» про процесс, которого нет.

Следствия, которые видны уже сегодня:

  • деплой блокируется впустую: два готовых PR ждали окончания прогона, которого не существовало;
  • если бы я поверил признаку до конца, ждать пришлось бы до 22:49;
  • и наоборот — привычка «ну наверное опять сирота» обесценивает признак в тот раз, когда работа настоящая.

Честный признак под рукой

heartbeat_at уже пишется и уже точен: у 3798 он отстал ровно на столько, сколько прошло с убийства контейнера (16:49 → 18:49, 7160 с). То есть различающий вопрос — не «какой статус», а «когда был последний пульс».

Предложение, по убыванию дешевизны:

  1. Подбирать сирот при старте контейнера, а не только по 6-часовому порогу: процесс, который вёл прогон, при пересоздании гарантированно мёртв. У прогонов, чей пульс старше времени старта контейнера, running заведомо ложь.
  2. Гейт деплоя и проверку «идут ли сборы» перевести на пульс, а не на статус: status='running' AND heartbeat_at > now() - interval '<2 такта пульса>'.
  3. Порог 6 часов оставить как последнюю сетку для настоящих зависаний — но он не должен быть единственной.

Связь с только что смерженным #2845

Там zombie намеренно исключён из подхвата контрольной точки: «reap_zombies снимает пометку, но процесс не убивает, поэтому подхват читал бы движущуюся точку». Рассуждение верное для настоящего зависания — но сегодняшний случай показывает вторую популяцию внутри того же статуса: процесс не «может быть жив», его гарантированно нет, потому что контейнер пересоздан.

Различать их можно тем же пульсом: пульс старше времени старта контейнера ⇒ процесса нет ⇒ точка неподвижна и подхват безопасен. Это не правка #2845, а возможное расширение — 147 корзин в 10 прогонах, которые он сегодня оставляет несобранными, частично лежат именно в этой популяции.

Проверять надо тем же путём

Я едва не подождал четыре часа, поверив статусу. Поймалось только тем, что пульс и счётчик собранного стояли, а прямая проверка процессов дала ноль во всех контейнерах. Признак «работа идёт» стоит проверять тем же способом, каким его проверяет тот, кто на него полагается — то есть смотреть на пульс и на процесс, а не на строку статуса.

## Что произошло сегодня ``` 16:30:51 старт yandex_city_sweep (прогон 3798) 16:49:08 деплой #2843/#2844 пересоздал контейнеры — прогон убит на 18-й минуте 18:51 прогон ВСЁ ЕЩЁ status='running', пульс не обновлялся 7160 с (≈2 ч) процессов свипа: 0 во ВСЕХ шести контейнерах (backend, scraper, tgbot, frontend, browser, postgres) счётчики застыли: lots_fetched 1615 — то же число, что и 46 минут назад ``` Строка провисит `running` до **22:49 UTC** — `ZOMBIE_THRESHOLD_HOURS = 6` (`scraper_kit/orchestration/scheduler.py:74`), а `reap_zombies` зовётся в начале тика планировщика. ## Два отдельных дефекта, не путать **1. Мягкий слив не спас прогон.** #1951 ждёт `scrape_runs` **до 5 минут**, после чего пересоздаёт контейнер. Свип шёл 18 минут и был убит. Пять минут — это не «ждём окончания», это «даём дописать хвост»; для многочасовых свипов слив не защищает вообще. Само по себе это может быть осознанным разменом — но тогда он должен быть назван, потому что читается как защита. **2. Осиротевшая строка живёт 6 часов, и всё это время она врёт.** Это дороже первого. `status='running'` — общепринятый признак «идёт работа»: по нему смотрят перед деплоем (в том числе я, и это записано в правилах), по нему `_claim_run` не берёт вторую работу на ту же площадку, по нему считается занятость. Шесть часов подряд признак говорит «работает» про процесс, которого нет. Следствия, которые видны уже сегодня: - **деплой блокируется впустую**: два готовых PR ждали окончания прогона, которого не существовало; - если бы я поверил признаку до конца, ждать пришлось бы до 22:49; - и наоборот — привычка «ну наверное опять сирота» обесценивает признак в тот раз, когда работа настоящая. ## Честный признак под рукой `heartbeat_at` уже пишется и уже точен: у 3798 он отстал ровно на столько, сколько прошло с убийства контейнера (16:49 → 18:49, 7160 с). То есть различающий вопрос — не «какой статус», а **«когда был последний пульс»**. Предложение, по убыванию дешевизны: 1. **Подбирать сирот при старте контейнера**, а не только по 6-часовому порогу: процесс, который вёл прогон, при пересоздании гарантированно мёртв. У прогонов, чей пульс старше времени старта контейнера, `running` заведомо ложь. 2. **Гейт деплоя и проверку «идут ли сборы» перевести на пульс**, а не на статус: `status='running' AND heartbeat_at > now() - interval '<2 такта пульса>'`. 3. Порог 6 часов оставить как последнюю сетку для настоящих зависаний — но он не должен быть единственной. ## Связь с только что смерженным #2845 Там `zombie` намеренно исключён из подхвата контрольной точки: «`reap_zombies` снимает пометку, но процесс не убивает, поэтому подхват читал бы движущуюся точку». Рассуждение верное для настоящего зависания — но сегодняшний случай показывает **вторую популяцию внутри того же статуса**: процесс не «может быть жив», его гарантированно нет, потому что контейнер пересоздан. Различать их можно тем же пульсом: пульс старше времени старта контейнера ⇒ процесса нет ⇒ точка неподвижна и подхват безопасен. Это не правка #2845, а возможное расширение — 147 корзин в 10 прогонах, которые он сегодня оставляет несобранными, частично лежат именно в этой популяции. ## Проверять надо тем же путём Я едва не подождал четыре часа, поверив статусу. Поймалось только тем, что пульс и счётчик собранного стояли, а прямая проверка процессов дала ноль во всех контейнерах. **Признак «работа идёт» стоит проверять тем же способом, каким его проверяет тот, кто на него полагается** — то есть смотреть на пульс и на процесс, а не на строку статуса.
lekss361 added the
bug
observability
priority/p2
scope/backend
scrapers
tradein
labels 2026-08-16 10:25:24 +00:00
Author
Collaborator

Статус на 21.08: деплойный случай закрыт, не-деплойный не воспроизводится 19 дней — вот числа

Проверил на проде, прежде чем что-то чинить.

Дефект №2 («осиротевшая строка живёт 6 часов») для деплоя — уже закрыт. В deploy-tradein.yml (#1951) после recreate идёт startup-reap: UPDATE scrape_runs SET status='cancelled', error='deploy #1951: tradein-scraper recreated mid-run (startup-reap, checkpoint …)' WHERE status='running' AND heartbeat_at < <метка stop по часам БД>. За 30 суток так закрыто 9 прогонов — в том числе сегодняшний cian_full_load (#4464), который убил мой мерж #3008; строка стала cancelled через минуту, а не через 6 часов.

Не-деплойный случай (OOM, падение, docker restart) — за 30 суток не случился ни разу: tradein-scraper restarts=0, OOMKilled=false, реплика одна, SCHEDULER_ENABLE только у scraper. Все 11 zombie за 30 суток — один источник listing_source_snapshot, каждую ночь 23.07–02.08, ровно 6 ч сиротой — системная поломка зависающего запроса снапшотов, починенная #2618 (02.08, «починить зависающий запрос снапшотов и бюджет времени»). После 02.08 — ноль. Сейчас running — ноль.

Дефект №1 (мягкий слив 5 минут не защищает многочасовой свип) — по-прежнему так и задуман как «дать дописать хвост», и в deploy-tradein.yml это теперь названо словами (комментарий к #1951: «таймаут не блокирует деплой навсегда — SIGTERM-drain + stop_grace_period остаются финальной страховкой»). Правило «проверять running в правильной базе перед мержем» я сегодня на себе и отработал (#3016/#3017 ждали конца свипов Серова/Тагила по пять тиков).

Что остаётся по существу: одна дыра по построению — после не-деплойного рестарта scraper'а сирота живёт до 6 ч, потому что reap_zombies на первом тике использует тот же 6-часовой порог. Правка тривиальна (на первом тике после старта любой running — сирота: планировщик один и только что поднялся, процесса нет) и ровно повторяет то, что я сегодня сделал для ПТИЦЫ (#2978). Но её эффект на сегодняшнем проде — ноль (событие не происходило 30 суток), поэтому пишу её только если вы считаете, что дыру стоит закрыть по построению. Сам бы закрыл — она дешёвая и честная.

## Статус на 21.08: деплойный случай закрыт, не-деплойный не воспроизводится 19 дней — вот числа Проверил на проде, прежде чем что-то чинить. **Дефект №2 («осиротевшая строка живёт 6 часов») для деплоя — уже закрыт.** В `deploy-tradein.yml` (#1951) после recreate идёт startup-reap: `UPDATE scrape_runs SET status='cancelled', error='deploy #1951: tradein-scraper recreated mid-run (startup-reap, checkpoint …)' WHERE status='running' AND heartbeat_at < <метка stop по часам БД>`. За 30 суток так закрыто **9** прогонов — в том числе сегодняшний `cian_full_load` (#4464), который убил мой мерж #3008; строка стала `cancelled` через минуту, а не через 6 часов. **Не-деплойный случай (OOM, падение, `docker restart`) — за 30 суток не случился ни разу:** `tradein-scraper` `restarts=0`, `OOMKilled=false`, реплика одна, `SCHEDULER_ENABLE` только у scraper. Все 11 `zombie` за 30 суток — **один источник `listing_source_snapshot`, каждую ночь 23.07–02.08, ровно 6 ч сиротой** — системная поломка зависающего запроса снапшотов, починенная #2618 (02.08, «починить зависающий запрос снапшотов и бюджет времени»). После 02.08 — ноль. Сейчас `running` — ноль. **Дефект №1 (мягкий слив 5 минут не защищает многочасовой свип)** — по-прежнему так и задуман как «дать дописать хвост», и в `deploy-tradein.yml` это теперь **названо словами** (комментарий к #1951: «таймаут не блокирует деплой навсегда — SIGTERM-drain + stop_grace_period остаются финальной страховкой»). Правило «проверять `running` в правильной базе перед мержем» я сегодня на себе и отработал (#3016/#3017 ждали конца свипов Серова/Тагила по пять тиков). **Что остаётся по существу:** одна дыра по построению — после не-деплойного рестарта scraper'а сирота живёт до 6 ч, потому что `reap_zombies` на первом тике использует тот же 6-часовой порог. Правка тривиальна (на первом тике после старта любой `running` — сирота: планировщик один и только что поднялся, процесса нет) и ровно повторяет то, что я сегодня сделал для ПТИЦЫ (#2978). Но её **эффект на сегодняшнем проде — ноль** (событие не происходило 30 суток), поэтому пишу её только если вы считаете, что дыру стоит закрыть по построению. Сам бы закрыл — она дешёвая и честная.
Author
Collaborator

Дефект 2 закрыт механизмом #3122. Проверено вживую сегодня.

Что появилось

reap_boot_zombies (orchestration/scheduler.py:413) вызывается однократно на первом тике планировщика после старта контейнера (:1109) и снимает прогоны, оставшиеся от предыдущего контейнера. Пороговый reap_zombies работает как раньше, следом (:1112).

Исходы различимы: boot-зомби помечается counters.boot_reaped = true. Это не косметика — от маркера зависит право на подхват: _RESUME_STATUSES не содержит zombie (:570), и только boot-зомби допускается к возобновлению (:571, :640), потому что у него процесс гарантированно мёртв, а у порогового может быть жив.

Живое подтверждение 27.08

Прогон 5124 (yandex_detail_backfill) шёл нормально: за минуту до деплоя status = running, пульс свежий, возраст 45 минут. Мерж PR #3169 пересоздал контейнер. На первом же тике нового планировщика прогон помечен zombie с boot_reaped = true.

Шести часов лжи не было — признак стал честным сразу. Это ровно то, чего требовал тикет.

Что осталось и где живёт

Дефект 1 (мягкий слив не спасает длинные прогоны) — по-прежнему верен и по-прежнему является разменом, а не поломкой: ждать 400-минутный cian_full_load деплой не может и не должен. Правильный ответ на него оказался не «ждать дольше», а «продолжать с места обрыва» — это #3074, где чекпоинты сегодня доведены до всех длинных свипов, и #3168 для курсорных backfill-циклов.

Показательно, что убитый сегодня прогон 5124 — как раз yandex_detail_backfill, то есть один из тех пяти циклов, которые чекпоинтов пока не имеют и начнутся заново. Цена дефекта 1 теперь измерима и адресована.

Закрываю: заявленный в заголовке признак больше не врёт.

**Дефект 2 закрыт механизмом #3122. Проверено вживую сегодня.** ## Что появилось `reap_boot_zombies` (`orchestration/scheduler.py:413`) вызывается однократно на первом тике планировщика после старта контейнера (`:1109`) и снимает прогоны, оставшиеся от предыдущего контейнера. Пороговый `reap_zombies` работает как раньше, следом (`:1112`). Исходы различимы: boot-зомби помечается `counters.boot_reaped = true`. Это не косметика — от маркера зависит право на подхват: `_RESUME_STATUSES` не содержит `zombie` (`:570`), и только boot-зомби допускается к возобновлению (`:571`, `:640`), потому что у него процесс гарантированно мёртв, а у порогового может быть жив. ## Живое подтверждение 27.08 Прогон **5124** (`yandex_detail_backfill`) шёл нормально: за минуту до деплоя `status = running`, пульс свежий, возраст 45 минут. Мерж PR #3169 пересоздал контейнер. На первом же тике нового планировщика прогон помечен `zombie` с `boot_reaped = true`. Шести часов лжи не было — признак стал честным сразу. Это ровно то, чего требовал тикет. ## Что осталось и где живёт **Дефект 1 (мягкий слив не спасает длинные прогоны)** — по-прежнему верен и по-прежнему является разменом, а не поломкой: ждать 400-минутный `cian_full_load` деплой не может и не должен. Правильный ответ на него оказался не «ждать дольше», а «продолжать с места обрыва» — это #3074, где чекпоинты сегодня доведены до всех длинных свипов, и #3168 для курсорных backfill-циклов. Показательно, что убитый сегодня прогон 5124 — как раз `yandex_detail_backfill`, то есть один из тех пяти циклов, которые чекпоинтов пока не имеют и начнутся заново. Цена дефекта 1 теперь измерима и адресована. Закрываю: заявленный в заголовке признак больше не врёт.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
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#2848
No description provided.