reap_zombies меряет scrape_runs.heartbeat_at с порогом 6 ч, а cian_history_backfill
слал heartbeat ровно один раз — ДО батча. Прогон, работающий дольше шести часов,
помечался 'zombie' независимо от того, жив он или висит.
Замер на проде 2026-08-06: 6 прогонов cian_history_backfill в статусе 'zombie', у всех
шести сдвиг heartbeat 16-32 мс (единственный стартовый вызов) и финал ровно на
started_at + 6.00 ч. Живыми они были: внутри окна пятерых писались строки
offer_price_history с source='cian' (98/523/38/60/83 — плановый писатель этих строк
только этот батч), у прогона 304 последняя строка легла через 5.40 ч после старта.
Штатная длительность источника доходит до 5.06 ч (прогон 346).
Чинится сигнал, а не критерий: пометка 'zombie' снимает running-блокировку источника
(has_running_run), и ослабление критерия заперло бы источник на зависшем прогоне
навсегда. Плюс mark_done апдейтит WHERE status='running' — после ложной пометки финал
прогона становится no-op, отсюда нулевые counters у всех шести строк.
backfill_cian_history и backfill_newbuilding_enrichment получают колбэк on_progress,
дёргаемый на каждой сущности; планировщик переливает его в update_heartbeat вместе с
текущими счётчиками (best-effort — сбой heartbeat не роняет уже идущую работу).
newbuilding_enrich лечится тем же шаблоном превентивно: тот же единственный стартовый
вызов, на проде пока max 1.08 ч при limit=25, но полный прогон — 318 домов по ~2.6 мин.
Шаблон уже был в репозитории (house_imv_backfill, #1363) — здесь он доехал до двух
оставшихся долгоживущих задач. listing_source_snapshot (34 зомби) в правку не входит:
там вся работа — два set-based statement'а, heartbeat невозможен по построению,
лечилось иначе в #2607 (statement_timeout).
Refs #2725