gendesign/tradein-mvp/backend/tests/test_scheduler_main.py
bot-backend 85059aeb1b refactor(tradein/scheduler): удалить legacy scheduler_loop + scraper-scheduling, kit единственный путь (#2397 Part C)
Топология подтверждена перед удалением (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.
2026-07-04 13:00:30 +03:00

151 lines
5.7 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:
"""Короткая заглушка: ждёт отмены, имитируя бесконечный 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