Топология подтверждена перед удалением (docker-compose.prod.yml): tradein-backend (uvicorn app.main:app) — SCHEDULER_ENABLE=false; tradein-scraper (python -m app.scheduler_main) — SCHEDULER_ENABLE=true + USE_KIT_SCHEDULER=true. Kit-путь (_run_kit_scheduler → scraper_kit.orchestration.scheduler + product_handlers) самодостаточен: не импортирует ничего из app.services.scheduler.scheduler_loop или app.services.scrape_pipeline. Все НЕ-sweep джобы, которые kit-scheduler диспетчерит через build_product_handlers, идут напрямую в app.tasks.*/ app.services.* (либо lazy-импортят import_rosreestr_dkp/_execute_cian_backfill из scheduler.py) — мимо удаляемой legacy-машинерии. app/services/scheduler.py: 2098 → 418 строк. Удалено: scheduler_loop, get_due_schedules, reap_zombies, _claim_run, _defer_next_run_at, _spawn_tracked/ _drain_inflight/_inflight_tasks, все 27 trigger_*_run-функций, импорт app.services.scrape_pipeline, константы SCHEDULER_TICK_SEC/ZOMBIE_THRESHOLD_HOURS (достижимы были только через удалённый scheduler_loop-путь). Оставлено (живые импортёры вне удалённого): compute_next_run_at + has_running_run (admin.py), import_rosreestr_dkp + _execute_cian_backfill (lazy-импорты в product_handlers.py — job-тела kit-handler'ов). main.py: убран `from app.services.scheduler import scheduler_loop` + lifespan-блок запуска (`if settings.scheduler_enable: asyncio.create_task(scheduler_loop())`); прод-backend всегда шёл с SCHEDULER_ENABLE=false, так что это был мёртвый код. scheduler_main.py: убрана ship-dark развилка #2192 (USE_KIT_SCHEDULER=false → legacy scheduler_loop fallback) — _run_kit_scheduler() теперь безусловный путь. Поле settings.use_kit_scheduler оставлено в конфиге (Settings extra="ignore" защищает от startup-краха на leftover env var), но на ветвление не влияет. app.services.scrape_pipeline: 0 runtime-импортёров в app/+scripts/+packages/ после этого PR (только тесты, которые Part E удалит вместе с самим файлом) — подтверждено grep. scrape_pipeline.py не тронут (Part E). Тесты: удалены test_house_imv_backfill_scheduler.py (100% legacy-триггер, backfill_house_imv сервис покрыт в test_house_imv_backfill_browser_flag.py / test_backfill_wave2.py) и test_kit_registry_completeness.py (parity-инвариант против удалённого dispatch, дублирует test_scraper_kit_scheduler_parity.py). Точечно вырезаны "Scheduler wiring" секции (trigger_fn_exists/dispatch_branch_ wired/runs_in_executor) из ~10 файлов, тестирующих сами task-функции — сами task-тесты (SQL-shape, миграции, fake-db поведение) оставлены нетронутыми. test_scheduler.py: 825 → ~90 строк (остались только compute_next_run_at-тесты). test_scraper_kit_scheduler_parity.py: убрана golden-parity секция против удалённого scheduler_loop (SOURCE_TO_OLD_TRIGGER/_drive_old_one_tick/ test_routing_parity_per_source), остальное (claim/reap_zombies/dispatch/ registry-shape тесты kit-модуля) сохранено — источник этих инвариантов не app.services.scheduler, а сам scraper_kit.orchestration.scheduler. test_scheduler_main.py: 2 теста, патчившие app.services.scheduler.scheduler_loop, переведены на монкипатч sm._run_kit_scheduler (единственный путь после этого PR). test_sweep_imv_phase.py:171-371 (6 прямых импортов run_avito_city_sweep из scrape_pipeline) намеренно НЕ тронуты — Part E. Verify: полный pytest 3179 passed / 6 skipped / 1 known-unrelated fail (test_search_cache_hit, #2208, не связан с этим PR); ruff 0.7.4 чист на всех изменённых файлах; `python -c "import app.main; import app.scheduler_main"` OK.
151 lines
5.7 KiB
Python
151 lines
5.7 KiB
Python
"""Тесты для 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:
|
||
"""Короткая заглушка: ждёт отмены, имитируя бесконечный kit scheduler_loop."""
|
||
cancelled.set()
|
||
await asyncio.sleep(999)
|
||
|
||
# #2397 Part C: _run() безусловно зовёт _run_kit_scheduler() (legacy fallback удалён) —
|
||
# патчим саму функцию на модуле scheduler_main.
|
||
monkeypatch.setattr(sm, "_run_kit_scheduler", _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)
|
||
|
||
# #2397 Part C: _run() безусловно зовёт _run_kit_scheduler() (legacy fallback удалён).
|
||
monkeypatch.setattr(sm, "_run_kit_scheduler", _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
|