fix(ptica): осиротевшие прогоны Объектива закрываются, а не висят вечно (#2464) #2978

Merged
bot-backend merged 1 commit from fix/2464-objective-zombie-sweep into main 2026-08-20 12:58:54 +00:00
Collaborator

Находка

objective_scrape_runs не подметалась ничем: worker_ready знал только про kn_scrape_runs и nspd_geo_jobs. На проде 20.08.2026:

status    | строк | последний started_at
----------+-------+---------------------
done      |    71 | 2026-08-19
running   |     6 | 2026-05-17   ← 94 суток
failed    |     0 |

Шесть прогонов висят «running» 94 дня. У пяти из шести heartbeat_at замер в первую минуту, у одного его нет вовсе.

Ноль failed за всю историю — это не удача, а симптом: _finish_run(status='failed') не мог записаться, потому что сессия была отравлена упавшим запросом (причина починена #2972). То есть каждый сбой Объектива исторически превращался не в failed, а в вечный running.

Причина устранена, но жёсткое убийство воркера (редеплой, OOM) по-прежнему оставляет running навсегда: у Объектива нет ни своего cleanup_zombies, ни снапшота для resume.

Правка

Третий блок в worker_ready, по образцу kn: любая строка running на старте воркера осиротела — активных воркеров в этот момент нет. Resume не ставим, возобновлять нечего.

finished_at ставится не NOW(), а COALESCE(heartbeat_at, started_at). Прогон, умерший 94 дня назад, не должен читаться как «завершён только что».

Проверка ловушки, которая не подтвердилась

Первым делом я заподозрил, что пометка зомби разгонит монитор свежести — он читает ту же таблицу. Прочитал _FRESHNESS_SOURCES в admin_scrape.py, а не предположил:

  • last_success_at, upd_24h/7d, recent_output — все под FILTER (WHERE status = 'done');
  • last_attempt_at = MAX(started_at), last_status — по ORDER BY started_at DESC.

finished_at зомби-строк не входит ни в один столбец монитора, а started_at у шести сирот (17.05) старше последнего done (19.08). Монитор не сдвинется ни на йоту. Ловушка была реальной по форме и пустой по существу — это и стоило проверить до правки.

Как проверено

  • Двусторонне: против origin/main три теста красные по существу — «worker_ready не трогает objective_scrape_runs», причём функция при этом отрабатывает 4 запроса. То есть краснота от отсутствующего поведения, а не от отсутствующего символа.
  • Мутационно, оба контроля:
мутация ожидали получили
снять WHERE status = 'running' краснеет test_only_running_rows_are_touched краснеет он один
заменить на finished_at = NOW() краснеет test_finished_at_is_last_sign_of_life_not_now краснеет он один

(первый прогон мутации не применился — якорь WHERE status = 'running' встречается в файле трижды; скрипт упал, а pytest прогнался по неизменённому файлу и напечатал зелёное. Переделал с уникальным якорём.)

  • test_kn_sweep_still_runs — контроль от регресса, зелёный с обеих сторон.
  • pytest backend/tests/workers/ — 234 passed.

Что проверю на проде после мержа

В отличие от #2975, у этой правки есть наблюдаемый эффект: после деплоя воркер стартует и шесть строк обязаны перейти в zombie с finished_at = их собственный последний heartbeat (17.05), а не сегодняшняя дата. Отчитаюсь числами.

Часть эпика #2464.

## Находка `objective_scrape_runs` не подметалась **ничем**: `worker_ready` знал только про `kn_scrape_runs` и `nspd_geo_jobs`. На проде 20.08.2026: ``` status | строк | последний started_at ----------+-------+--------------------- done | 71 | 2026-08-19 running | 6 | 2026-05-17 ← 94 суток failed | 0 | ``` Шесть прогонов висят «running» 94 дня. У пяти из шести `heartbeat_at` замер в первую минуту, у одного его нет вовсе. **Ноль `failed` за всю историю** — это не удача, а симптом: `_finish_run(status='failed')` не мог записаться, потому что сессия была отравлена упавшим запросом (причина починена #2972). То есть каждый сбой Объектива исторически превращался не в `failed`, а в вечный `running`. Причина устранена, но жёсткое убийство воркера (редеплой, OOM) по-прежнему оставляет `running` навсегда: у Объектива нет ни своего `cleanup_zombies`, ни снапшота для resume. ## Правка Третий блок в `worker_ready`, по образцу kn: любая строка `running` на старте воркера осиротела — активных воркеров в этот момент нет. Resume не ставим, возобновлять нечего. `finished_at` ставится **не** `NOW()`, а `COALESCE(heartbeat_at, started_at)`. Прогон, умерший 94 дня назад, не должен читаться как «завершён только что». ## Проверка ловушки, которая не подтвердилась Первым делом я заподозрил, что пометка зомби разгонит монитор свежести — он читает ту же таблицу. Прочитал `_FRESHNESS_SOURCES` в `admin_scrape.py`, а не предположил: - `last_success_at`, `upd_24h/7d`, `recent_output` — все под `FILTER (WHERE status = 'done')`; - `last_attempt_at = MAX(started_at)`, `last_status` — по `ORDER BY started_at DESC`. `finished_at` зомби-строк не входит **ни в один** столбец монитора, а `started_at` у шести сирот (17.05) старше последнего `done` (19.08). Монитор не сдвинется ни на йоту. Ловушка была реальной по форме и пустой по существу — это и стоило проверить до правки. ## Как проверено - **Двусторонне:** против `origin/main` три теста красные по существу — «worker_ready не трогает objective_scrape_runs», причём функция при этом отрабатывает 4 запроса. То есть краснота от отсутствующего *поведения*, а не от отсутствующего символа. - **Мутационно, оба контроля:** | мутация | ожидали | получили | |---|---|---| | снять `WHERE status = 'running'` | краснеет `test_only_running_rows_are_touched` | краснеет он один | | заменить на `finished_at = NOW()` | краснеет `test_finished_at_is_last_sign_of_life_not_now` | краснеет он один | (первый прогон мутации не применился — якорь `WHERE status = 'running'` встречается в файле трижды; скрипт упал, а pytest прогнался по неизменённому файлу и напечатал зелёное. Переделал с уникальным якорём.) - `test_kn_sweep_still_runs` — контроль от регресса, зелёный с обеих сторон. - `pytest backend/tests/workers/` — 234 passed. ## Что проверю на проде после мержа В отличие от #2975, у этой правки есть **наблюдаемый** эффект: после деплоя воркер стартует и шесть строк обязаны перейти в `zombie` с `finished_at` = их собственный последний heartbeat (17.05), а не сегодняшняя дата. Отчитаюсь числами. Часть эпика #2464.
bot-backend added 1 commit 2026-08-20 12:39:00 +00:00
fix(ptica): осиротевшие прогоны Объектива закрываются, а не висят вечно (#2464)
All checks were successful
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 17m2s
392cc2ffb8
`objective_scrape_runs` не подметалась ничем: `worker_ready` знал только
про `kn_scrape_runs` и `nspd_geo_jobs`. На проде 20.08.2026 в ней висело
6 строк `status='running'` с 17.05 — 94 суток, при 71 `done` и НИ ОДНОМ
`failed`. Отсутствие `failed` — след отравления сессии, из-за которого
`_finish_run(status='failed')` не мог записаться (причина починена
#2972). Причина устранена, но жёсткое убийство воркера (редеплой, OOM)
по-прежнему оставляет `running` навсегда: у Объектива нет ни своего
cleanup_zombies, ни снапшота для resume.

Тот же инвариант, что у kn: на worker_ready активных воркеров нет,
значит любая строка `running` осиротела. Resume не ставим —
возобновлять нечего.

`finished_at` ставится НЕ NOW(), а `COALESCE(heartbeat_at, started_at)`:
прогон, умерший 94 дня назад, не должен читаться как «завершён только
что». Монитору свежести это безразлично в обе стороны — `last_success_at`
и `recent_output` считаются только по `status='done'`, а
`last_attempt_at`/`last_status` — по `started_at`, так что зомби-строки
не попадают в него ни одним столбцом (проверено по коду
_FRESHNESS_SOURCES, а не предположено).

Двусторонне: против origin/main три теста красные по существу («не
трогает objective_scrape_runs», функция при этом отрабатывает 4 запроса
— то есть краснота не от отсутствующего символа). Мутационно проверены
оба контроля: снятие `WHERE status='running'` роняет
test_only_running_rows_are_touched, замена на `finished_at = NOW()`
роняет test_finished_at_is_last_sign_of_life_not_now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bot-backend merged commit 8ad1e9f5ef into main 2026-08-20 12:58:54 +00:00
Author
Collaborator

Проверено на проде

Деплой 8ad1e9f5 зелёный. В отличие от #2975, здесь эффект наблюдаемый — и он наступил:

до:    done 71 · running 6   (running с 17.05, 94 суток)
после: done 71 · zombie  6   (running 0)

Причём finished_at — их собственный последний признак жизни, а не сегодняшняя дата:

run 1  zombie  finished_at = 2026-05-17 16:18:57   (его heartbeat)
run 2  zombie  finished_at = 2026-05-17 16:39:05   (heartbeat не было → started_at)
run 3  zombie  finished_at = 2026-05-17 16:40:14
run 4  zombie  finished_at = 2026-05-17 16:43:36
run 5  zombie  finished_at = 2026-05-17 16:43:39
run 6  zombie  finished_at = 2026-05-17 16:45:40
error: «auto-zombie at worker_ready: воркер перезапущен во время прогона…»

Прогон, умерший 94 дня назад, не читается как «завершён только что» — ровно то, ради чего NOW() здесь не годился.

Ловушка проверена и на живых данных

Перед правкой я утверждал по коду, что зомби-строки не попадут в монитор свежести ни одним столбцом. Проверил теперь по факту, уже после пометки:

last_success_at : 2026-08-19 21:30:14   (вчерашний настоящий прогон)
last_attempt_at : 2026-08-19 21:30:00
last_status     : done
recent_output 7d: 407 251

Шесть зомби от 17.05 невидимы монитору полностью. Утверждение из описания PR подтвердилось измерением, а не осталось доводом.

## Проверено на проде Деплой `8ad1e9f5` зелёный. В отличие от #2975, здесь эффект **наблюдаемый** — и он наступил: ``` до: done 71 · running 6 (running с 17.05, 94 суток) после: done 71 · zombie 6 (running 0) ``` Причём `finished_at` — их собственный последний признак жизни, а не сегодняшняя дата: ``` run 1 zombie finished_at = 2026-05-17 16:18:57 (его heartbeat) run 2 zombie finished_at = 2026-05-17 16:39:05 (heartbeat не было → started_at) run 3 zombie finished_at = 2026-05-17 16:40:14 run 4 zombie finished_at = 2026-05-17 16:43:36 run 5 zombie finished_at = 2026-05-17 16:43:39 run 6 zombie finished_at = 2026-05-17 16:45:40 error: «auto-zombie at worker_ready: воркер перезапущен во время прогона…» ``` Прогон, умерший 94 дня назад, не читается как «завершён только что» — ровно то, ради чего `NOW()` здесь не годился. ## Ловушка проверена и на живых данных Перед правкой я утверждал по коду, что зомби-строки не попадут в монитор свежести ни одним столбцом. Проверил теперь по факту, уже после пометки: ``` last_success_at : 2026-08-19 21:30:14 (вчерашний настоящий прогон) last_attempt_at : 2026-08-19 21:30:00 last_status : done recent_output 7d: 407 251 ``` Шесть зомби от 17.05 невидимы монитору полностью. Утверждение из описания PR подтвердилось измерением, а не осталось доводом.
Author
Collaborator

Проверено на проде — эффект наблюдаемый, в отличие от #2975

После деплоя воркер стартовал и подмёл все шесть сирот:

objective_scrape_runs:  done 72 · zombie 6 · running 0

 run_id  status   started_at   finished_at   error
   1..6  zombie   2026-05-17   2026-05-17    auto-zombie at worker_ready: воркер перезапущен во…

Ключевое — finished_at = 17.05, а не сегодня. Именно то, ради чего в правке стоит
COALESCE(heartbeat_at, started_at) вместо NOW(): прогон, умерший 94 дня назад, не читается
как «завершён только что». Оператор в списке прогонов видит настоящую дату смерти.

Монитор свежести, как и предсказывалось по коду, не сдвинулся: last_success_at и
recent_output считаются только по status='done', а last_attempt_at/last_status — по
started_at, поэтому зомби-строки в него не попадают ни одним столбцом.

## Проверено на проде — эффект наблюдаемый, в отличие от #2975 После деплоя воркер стартовал и подмёл все шесть сирот: ``` objective_scrape_runs: done 72 · zombie 6 · running 0 run_id status started_at finished_at error 1..6 zombie 2026-05-17 2026-05-17 auto-zombie at worker_ready: воркер перезапущен во… ``` Ключевое — **`finished_at` = 17.05, а не сегодня**. Именно то, ради чего в правке стоит `COALESCE(heartbeat_at, started_at)` вместо `NOW()`: прогон, умерший 94 дня назад, не читается как «завершён только что». Оператор в списке прогонов видит настоящую дату смерти. Монитор свежести, как и предсказывалось по коду, не сдвинулся: `last_success_at` и `recent_output` считаются только по `status='done'`, а `last_attempt_at`/`last_status` — по `started_at`, поэтому зомби-строки в него не попадают ни одним столбцом.
Sign in to join this conversation.
No reviewers
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#2978
No description provided.