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
Прод-факт 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>
91 lines
4.4 KiB
Python
91 lines
4.4 KiB
Python
"""#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"
|