"""#3122: boot-reap — прогоны предыдущего контейнера снимаются на старте, а не через 6ч. Прод-факт (27.08, после ночного офлайна #3119): 4 прогона 'running' со стартами 03:30–07:08 при старте контейнера 07:57 — процессы доказуемо мертвы, но has_running_run блокировал их источники до 6-часового порогового reap'а: до пяти часов слепоты на источник ровно после простоя. Критерий boot-reap — started_at < старт процесса (минус минута на дрейф), пульс не участвует: ложные срабатывания класса #2702 (редкий пульс у длинных прогонов) невозможны по построению. Маркер counters.boot_reaped=true открывает таким зомби подхват чекпоинта (_resume_decision): у порогового zombie процесс может быть жив (движущаяся точка), у boot-зомби — гарантированно мёртв. Красные на main по значению: reap-SQL не существует (capability), а resume-вердикт для boot-зомби — status_zombie вместо ok (по значению). """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from datetime import UTC, datetime from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock from scraper_kit.orchestration import scheduler as sched # ── 1. Сам boot-reap: SQL-критерий по границе старта ───────────────────────── class _ReapDb: def __init__(self, rows: list[Any]) -> None: self.rows = rows self.params: dict[str, Any] | None = None self.sql: str = "" def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any: self.sql = str(stmt) self.params = dict(params or {}) return MagicMock(fetchall=lambda: self.rows) def commit(self) -> None: pass def test_boot_reap_criterion_is_started_at_not_heartbeat() -> None: """Критерий — started_at против границы старта процесса с минутой запаса; heartbeat в SQL не участвует (иначе вернулись бы ложные срабатывания #2702).""" db = _ReapDb([SimpleNamespace(id=5018, source="avito_newbuilding_sweep")]) boot = datetime(2026, 8, 27, 7, 57, tzinfo=UTC) n = sched.reap_boot_zombies(db, boot) assert n == 1 assert "started_at <" in db.sql and "60 seconds" in db.sql assert "heartbeat" not in db.sql assert "boot_reaped" in db.sql # маркер безопасного подхвата assert db.params and db.params["boot"] == boot # ── 2. Resume: boot-зомби подхватывается, пороговый — нет ──────────────────── def _candidate(status: str, counters: dict[str, Any]) -> SimpleNamespace: return SimpleNamespace( prev_id=5040, prev_status=status, prev_counters=counters, same_params=True, age_h=2.0, interval_days="1", ) def test_boot_reaped_zombie_is_resumable() -> None: """Зомби С маркером boot_reaped — процесс гарантированно мёртв, чекпоинт безопасен → подхват. На main: status_zombie (красный по значению).""" rid, verdict = sched._resume_decision( _candidate("zombie", {"boot_reaped": True, "done_buckets": ["a", "b"]}) ) assert verdict["resume_reason"] == "ok", verdict assert rid == 5040 def test_threshold_zombie_stays_rejected() -> None: """Пороговый зомби БЕЗ маркера — процесс может быть жив (движущаяся точка, см. _RESUME_STATUSES) → по-прежнему отказ. Инвариант обеих эр.""" rid, verdict = sched._resume_decision(_candidate("zombie", {"done_buckets": ["a", "b"]})) assert rid is None assert verdict["resume_reason"] == "status_zombie"