now() в PostgreSQL — синоним transaction_timestamp(): замерзает на СТАРТЕ
транзакции. Финализаторы (mark_done/mark_failed/mark_banned) выполняются той же
сессией, что и работа задачи, поэтому если её транзакция всё это время оставалась
открытой (коммитить было нечего: читающий батч, ноль сохранений, сохранение чужой
сессией), их UPDATE попадал ВНУТРЬ неё и получал время НАЧАЛА работы.
Прод 2026-08-06 (487 прогонов с finished_at и counters.duration_sec): у 153
заявленная длительность больше окна finished_at − started_at в >1.5 раза, у 133
окно меньше секунды при работе дольше 10 с. 126 из этих 133 окон лежат в 9-64 мс —
подпись механизма, а не разброс: столько проходит от коммита claim'а до первого
запроса рабочей транзакции. Прогон 346 (cian_history_backfill): 18 230 с работы,
окно 32 мс.
Дефект не сплошной ровно потому, что зависел от коммита перед финалом:
cadastral_geo_match / house_imv_backfill / avito_detail_backfill коммитят поштучно
(окно = работа), yandex_address_backfill (45 из 50) / newbuilding_enrich /
cian_history_backfill — нет.
Правка: clock_timestamp() вместо now() во всех финализаторах и в heartbeat, обе
копии runs-модуля (kit-копия входит в scraper-allowlist деплоя, app-копия — нет,
но исполняется она; расходиться им нельзя).
Побочно чинится критерий зависших прогонов: reap_zombies сравнивает записанный
heartbeat со «сейчас», и обе стороны обязаны быть настоящим временем. На проде у
всех 6 прогонов cian_history_backfill, помеченных 'zombie', записанный heartbeat
остался на отметке старта (max advance 0.0 с) при нормальной длительности до 5.06 ч.
started_at уже писался своей закоммиченной транзакцией (create_run коммитит INSERT
до возврата run_id) — требование #2702 п.1 выполнялось и раньше; тест это фиксирует.
Историю не переписываем: настоящий finished_at нигде не сохранился, но
counters.duration_sec измерялся монотонными часами процесса и верен. Миграция 223
проставляет это COMMENT'ами на колонках, чтобы аналитика брала длительность из
счётчика, а не из разности отметок.
Refs #2702