"""#3074: чекпоинт предшественника наследуется в counters НОВОГО прогона при claim. Прод-факт (Beget, 23.08): прогон 4707 подхватил чекпоинт 4117 (42 корзины, resume_reason='ok'), был убит деплоем на 26-й минуте — ДО завершения первой НОВОЙ корзины — и не успел ни разу написать heartbeat с done_buckets. Его собственный чекпоинт остался пуст: следующий кандидат увидел бы no_checkpoint, и 42 корзины пропали бы, хотя цепочка (resume_chain=2) ещё позволяла подхват. Фикс: _resume_decision при вердикте 'ok' кладёт done_buckets предшественника в counters-заготовку нового прогона — она пишется в БД прямо при claim (_pick_resume → update_heartbeat), до старта пайплайна. Heartbeat мержит jsonb, первый настоящий bucket-heartbeat перезапишет ключ надмножеством. Красный на origin/main по ЗНАЧЕНИЮ: 'done_buckets' отсутствует в вердикте. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from types import SimpleNamespace from typing import Any from scraper_kit.orchestration import scheduler as sched def _candidate(**over: Any) -> SimpleNamespace: """Кандидат из _RESUME_CANDIDATE_SQL: прогон 4117 как он лежал на проде 23.08.""" base = { "prev_id": 4117, "prev_status": "banned", "prev_counters": { "resume_chain": 1, "done_buckets": [f"room_1_komn:{i}:0" for i in range(42)], }, "same_params": True, "age_h": 164.8, "interval_days": "7", } base.update(over) return SimpleNamespace(**base) def test_ok_verdict_carries_predecessor_checkpoint() -> None: """Вердикт 'ok' несёт done_buckets предшественника — чекпоинт персистится при claim и переживает обрыв до первой новой корзины (кейс 4707).""" resume_id, verdict = sched._resume_decision(_candidate()) assert resume_id == 4117 assert verdict["resume_reason"] == "ok" assert verdict.get("done_buckets") == sorted(f"room_1_komn:{i}:0" for i in range(42)), ( "counters-заготовка нового прогона не содержит чекпоинт предшественника — " "обрыв до первой новой корзины снова потеряет всю цепочку" ) def test_refusal_verdict_carries_no_checkpoint() -> None: """Отказ от подхвата чекпоинт не наследует: прогон честно идёт с нуля, и его counters не должны врать, будто корзины предшественника уже собраны.""" _id, verdict = sched._resume_decision(_candidate(same_params=False)) assert verdict["resume_reason"] == "params_changed" assert "done_buckets" not in verdict