gendesign/tradein-mvp/backend/tests/test_scheduler_main.py
bot-backend 848e6d3bdd feat(tradein/scraper): cooperative SIGTERM-drain for graceful deploy (#1182 Phase 2)
Деплой (docker recreate tradein-scraper) шлёт SIGTERM с stop_grace_period=120s
(Phase 1). Раньше scheduler_main отвечал hard task.cancel() → бегущий scrape-unit
получал CancelledError посреди браузер-карточки → карточка гибла без COMMIT.

Теперь SIGTERM/SIGINT лишь выставляют кооперативный флаг; tick-loop и длинные
задачи опрашивают его на СВОИХ существующих between-unit checkpoint'ах,
докоммичивают текущий unit и выходят сами.

- app/core/shutdown.py: новый standalone-модуль (asyncio.Event + request_shutdown
  / shutdown_requested / wait_for_shutdown), без app-зависимостей → нет циклов.
- scheduler_main._run: SIGTERM → request_shutdown() вместо task.cancel(); ждём
  добровольного drain'а, safety-net wait_for(100s < docker grace) с fallback на
  hard-cancel для некооперирующей задачи.
- scheduler_loop: проверка флага в начале тика и после каждого dispatch — не
  reap/claim новые run'ы во время shutdown, выходим из loop'а.
- avito_detail_backfill: break на границе карточки рядом с budget-guard; snapshot
  pending-query идемпотентен → следующий run сам резюмит остаток.
- import_rosreestr_dkp: расширен is_cancelled checkpoint — drain делает mark_done
  (partial), не mark_cancelled; user-cancel семантика без изменений.

Без SIGTERM поведение идентично прежнему. reap_zombies не трогает drained-run'ы
(они mark_done, не 'running').

Tests: tests/core/test_shutdown.py, scheduler_main drain+timeout-fallback,
avito_detail_backfill partial-drain. 26 + 29 scheduler passed.
2026-06-28 19:52:09 +03:00

154 lines
5.8 KiB
Python
Raw 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.

"""Тесты для scheduler_main.py — standalone scraper-контейнер (#1182).
(a) модуль импортируется без реального DB/сети.
(b) _should_run() возвращает False при SCHEDULER_ENABLE=false.
(c) _run() отменяется чисто без unhandled exceptions.
(d) #1182 Phase 2: SIGTERM → request_shutdown() (не task.cancel); задача
кооперативно drain'ится; safety-net wait_for-timeout добивает зависшую.
"""
from __future__ import annotations
import asyncio
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
import app.scheduler_main as sm
from app.core import shutdown as sd
@pytest.fixture(autouse=True)
def _reset_shutdown() -> None:
"""shutdown — module-global Event: сбрасываем вокруг каждого теста для изоляции."""
sd.reset_shutdown()
yield
sd.reset_shutdown()
# (a) Импорт модуля
def test_scheduler_main_importable() -> None:
"""scheduler_main импортируется без ошибок."""
assert sm is not None
assert hasattr(sm, "_should_run")
assert hasattr(sm, "_run")
# (b) Kill-switch SCHEDULER_ENABLE=false
def test_should_run_false_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""_should_run() == False когда SCHEDULER_ENABLE отключён."""
monkeypatch.setattr(sm.settings, "scheduler_enable", False)
assert sm._should_run() is False
def test_should_run_true_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
"""_should_run() == True при включённом scheduler."""
monkeypatch.setattr(sm.settings, "scheduler_enable", True)
assert sm._should_run() is True
# (c) _run() отменяется чисто
@pytest.mark.asyncio
async def test_run_cancels_cleanly(monkeypatch: pytest.MonkeyPatch) -> None:
"""_run() завершается без unhandled exceptions при отмене задачи."""
cancelled = asyncio.Event()
async def _stub_loop() -> None:
"""Короткая заглушка: ждёт отмены, имитируя бесконечный scheduler_loop."""
cancelled.set()
await asyncio.sleep(999)
# Патчим источник импорта: _run() делает `from app.services.scheduler import scheduler_loop`
# на каждый вызов, поэтому патч модуля-источника достаточен.
import app.services.scheduler as _sched_mod
monkeypatch.setattr(_sched_mod, "scheduler_loop", _stub_loop)
task = asyncio.create_task(sm._run())
# Ждём пока _stub_loop запустится (флаг)
await asyncio.wait_for(cancelled.wait(), timeout=2.0)
task.cancel()
# Не должно бросить никакого исключения
try:
await task
except asyncio.CancelledError:
pass # CancelledError от самого task — допустимо
# Тест прошёл если не было unhandled exception
assert task.done()
# (d) #1182 Phase 2 — кооперативный SIGTERM-drain
@pytest.mark.asyncio
async def test_await_scheduler_drains_on_shutdown() -> None:
"""_await_scheduler: кооперативная задача видит флаг и выходит сама — без cancel."""
drained = False
async def _coop_loop() -> None:
nonlocal drained
while not sd.shutdown_requested():
await asyncio.sleep(0.01)
drained = True # докоммитил unit и вышел
task = asyncio.create_task(_coop_loop())
runner = asyncio.create_task(sm._await_scheduler(task))
await asyncio.sleep(0.03)
assert not runner.done(), "без shutdown _await_scheduler не завершается"
sd.request_shutdown()
await asyncio.wait_for(runner, timeout=2.0)
assert drained is True
assert task.done()
assert not task.cancelled(), "кооперативный выход — НЕ hard-cancel"
@pytest.mark.asyncio
async def test_await_scheduler_timeout_hard_cancels(monkeypatch: pytest.MonkeyPatch) -> None:
"""_await_scheduler: некооперативная задача (игнорит флаг) добивается cancel'ом
по истечении safety-net grace (_DRAIN_TIMEOUT_S)."""
monkeypatch.setattr(sm, "_DRAIN_TIMEOUT_S", 0.05)
async def _stuck_loop() -> None:
# Игнорирует shutdown_requested() — висит вечно.
await asyncio.Event().wait()
task = asyncio.create_task(_stuck_loop())
sd.request_shutdown() # drain уже запрошен
await asyncio.wait_for(sm._await_scheduler(task), timeout=2.0)
assert task.done()
assert task.cancelled(), "превысила grace → hard-cancel"
@pytest.mark.asyncio
async def test_run_uses_request_shutdown_not_cancel(monkeypatch: pytest.MonkeyPatch) -> None:
"""_run(): когда выставлен shutdown, scheduler_loop сам drain'ится и _run выходит,
БЕЗ обращения к task.cancel() (кооперативный путь, не hard-cancel)."""
started = asyncio.Event()
async def _coop_loop() -> None:
started.set()
while not sd.shutdown_requested():
await asyncio.sleep(0.01)
import app.services.scheduler as _sched_mod
monkeypatch.setattr(_sched_mod, "scheduler_loop", _coop_loop)
run_task = asyncio.create_task(sm._run())
await asyncio.wait_for(started.wait(), timeout=2.0)
# Имитируем то, что делает SIGTERM-хендлер (на Windows add_signal_handler нет).
sm.request_shutdown()
await asyncio.wait_for(run_task, timeout=2.0)
assert run_task.done()
assert run_task.exception() is None