fix(scraper-kit/scheduler): SIGTERM-drain помечает in-flight прогоны interrupted=1 при hard-cancel; честный лог drain'а (#3391) #3392
Merged
bot-backend
merged 2 commits from 2026-09-06 04:14:07 +00:00
fix/drain-marks-inflight-app-tasks into main
2 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| a7362bc5fa |
fix(#3391): пульс не пишет по финализированной строке; отмена в теле тика тоже помечает; rollback/try/честный лог
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / 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 / 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 4m56s
Пять замечаний deep-ревью к PR #3392, ровно они.
1. Пульс стирал метку дрейна. `update_heartbeat` обеих копий бил `WHERE id = :run_id`
без гейта по статусу, а app-копия counters ЗАМЕНЯЕТ (#3390): задача, помеченная
`interrupted`, но ещё живая (ветка таймаута drain_inflight отдаёт её внешнему
hard-cancel'у — несколько итераций спустя, пульс на каждый батч —
app/services/scheduler.py:141), следующим же ударом стирала метку, и оборванный
прогон снова читался как полный проход. Гейт — `IN ('running', 'cancelled')`, а не
`= 'running'`: 'cancelled' финализирует строку, но задача встаёт лишь на ближайшей
границе якоря, и её последний пульс — ЕДИНСТВЕННЫЙ писатель чекпоинта в этот момент
(pipeline.py:1308/2368/2986/4488, mark_done там уже no-op по своему гейту), а
'cancelled' входит в _RESUME_STATUSES — сужение до 'running' молча съело бы точку
возобновления у каждой отмены. Возвращаемое значение update_heartbeat не читает
никто (обе копии -> None, ни одного присваивания на 130 сайтах вызова), так что
«0 строк обновлено» ломать нечего; no-op логируется WARNING'ом, как у mark_done.
2. Отмена вне drain_inflight. Hard-cancel приходит по расписанию grace'а
scheduler_main, а не по нашему, и может застать ТЕЛО тика (reap / stale-digest /
`_dispatch` с сетевым pre_claim). `except Exception` тика CancelledError не ловит,
до `await ctx.drain_inflight()` дело не доходит — строки оставались 'running'.
Тело вынесено в `_tick_loop`, `scheduler_loop` ловит CancelledError, помечает
in-flight и пробрасывает отмену.
3. rollback в except пометки: отказавший statement оставляет сессию в aborted-tx, и
первый же непроходимый run_id утаскивал все следующие (образец — defensive rollback
в mark_failed/mark_banned).
4. session_factory()/db.close() втянуты в try: исключение оттуда ЗАМЕНИЛО бы собой
CancelledError, а suppress(CancelledError) в scheduler_main его не глушит — процесс
уходил бы с трейсбеком вместо чистого drain-выхода.
5. WARNING перечисляет marked_ids, а не весь run_ids (там были и пропущенные по
статусу). В докстринге назван потолок: SELECT синхронный, у движка нет ни connect-,
ни statement-таймаута (app/core/db.py:8-19) — недоступная БД блокирует луп до
SIGKILL'а через 20 с docker-grace; данные при этом не хуже прежних (строки остаются
'running' → boot-reap).
Тесты — по значению, не по факту вызова; на исходниках
|
|||
| 30e3bacc5e |
fix(tradein/scheduler): SIGTERM-drain снимает с 'running' in-flight app-task'и (#3391)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m55s
Прод 07.09 02:36 UTC, первый настоящий drain после #3363: hard-cancel из scheduler_main оборвал дрейн, и два бэкфилла (cian_detail_backfill 6167, cian_history_backfill 6173) остались в scrape_runs со статусом 'running' — boot-reap следующего контейнера сделал их 'zombie' (boot_reaped=true), метки interrupted не было. interrupted=1 при дрейне писали только kit-пайплайны и DKP-импорт: у задач, чьё тело живёт в app, ставить её было некому. Метка ставится в единственной точке, через которую проходит любая detached run-задача — SchedulerContext.drain_inflight: и по истечении _CHILD_DRAIN_TIMEOUT_S, и в обработчике CancelledError (тот самый прод-путь). run_id берётся из нового реестра {task: run_id}, который заполняет _dispatch сразу после claim'а; claim-логика не тронута. Статус строки перечитывается перед записью, поэтому успевший финализироваться сам прогон не перезаписывается, а counters читаются из строки и дописываются — app-копия mark_done их ЗАМЕНЯЕТ (#3390), голая {"interrupted": 1} стёрла бы чекпоинт. scheduler_main: _await_scheduler возвращает признак hard-cancel'а, и строка «scheduler drained cleanly (SIGTERM)» больше не печатается сразу за WARNING'ом о превышении grace — на проде эти две строки стояли подряд и противоречили друг другу. Запас времени на запись: docker stop_grace_period 120s − _DRAIN_TIMEOUT_S 100s = 20 с после hard-cancel'а, запись синхронная (несколько statement'ов). |