gendesign/tradein-mvp/backend/tests/test_3122_boot_reap.py
bot-backend 63956f0891
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
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 4m40s
feat(tradein/scheduler): boot-reap — прогоны предыдущего контейнера снимаются на старте (#3122)
Прод-факт 27.08 (после ночного офлайна #3119): 4 прогона 'running' со
стартами до старта контейнера блокировали свои источники через
has_running_run до 6-часового порогового reap'а — до пяти часов слепоты
на источник ровно после простоя, когда догон нужнее всего.

Критерий — started_at < старт процесса планировщика (минус минута на
дрейф), пульс не участвует: ложные срабатывания класса #2702 (редкий
пульс длинных прогонов) невозможны по построению — живой прогон этого
процесса не может быть старше самого процесса.

Маркер counters.boot_reaped=true открывает boot-зомби подхват чекпоинта
(_resume_decision): у порогового zombie процесс может быть жив
(движущаяся точка — причина исключения 'zombie' из _RESUME_STATUSES),
у boot-зомби — гарантированно мёртв. Пороговый zombie без маркера
по-прежнему отвергается (закреплено тестом-инвариантом).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:17:42 +05:00

91 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#3122: boot-reap — прогоны предыдущего контейнера снимаются на старте, а не через 6ч.
Прод-факт (27.08, после ночного офлайна #3119): 4 прогона 'running' со стартами
03:3007: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"