gendesign/tradein-mvp/backend/tests/test_scheduler.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

89 lines
3.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.

"""Offline smoke for compute_next_run_at (app/services/scheduler.py).
#2397 Part C removed the legacy scheduler_loop + trigger_*/get_due_schedules/_claim_run/
reap_zombies/_spawn_tracked/_drain_inflight machinery from app.services.scheduler — the kit
scheduler (scraper_kit.orchestration.scheduler, covered by test_scraper_kit_scheduler_parity.py)
is now the only scheduling path. compute_next_run_at survives as a shared helper (still used
by admin.py for the operator-facing "next run" preview), so this file now covers only it.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from datetime import UTC, datetime
from app.services.scheduler import compute_next_run_at
def test_compute_next_run_in_normal_window() -> None:
now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC)
next_at = compute_next_run_at(2, 5, now=now)
assert next_at.date() == datetime(2026, 5, 24).date()
assert 2 <= next_at.hour <= 4 # window [2, 5) UTC
def test_compute_next_run_cross_midnight() -> None:
now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC)
# Window 22..3 = 22:00-23:59 + 00:00-02:59
next_at = compute_next_run_at(22, 3, now=now)
# Должен попасть либо в [22,23] либо в [0,3) range
hour = next_at.hour
assert hour >= 22 or hour < 3
def test_compute_next_run_window_1_hour() -> None:
now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC)
# Минимальное окно [3, 4) → ровно 3-й час
next_at = compute_next_run_at(3, 4, now=now)
assert next_at.hour == 3
def test_compute_next_run_is_future() -> None:
"""next_run_at всегда в будущем (завтра) — не в прошлом."""
now = datetime(2026, 5, 23, 3, 30, tzinfo=UTC)
next_at = compute_next_run_at(2, 5, now=now)
assert next_at > now
def test_compute_next_run_timezone_aware() -> None:
"""Результат всегда timezone-aware (UTC)."""
now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC)
next_at = compute_next_run_at(2, 5, now=now)
assert next_at.tzinfo is not None
# ── per-source interval_days cadence (weekly exhaustive avito) ──────────────
def test_compute_next_run_default_interval_is_daily() -> None:
"""Без interval_days → +1 сутки (back-compat, как раньше)."""
now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC)
next_at = compute_next_run_at(13, 15, now=now)
assert next_at.date() == datetime(2026, 5, 24).date()
assert 13 <= next_at.hour <= 14 # window [13, 15) UTC
def test_compute_next_run_interval_days_1_explicit() -> None:
"""interval_days=1 явно → +1 сутки (идентично default)."""
now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC)
next_at = compute_next_run_at(13, 15, now=now, interval_days=1)
assert next_at.date() == datetime(2026, 5, 24).date()
assert 13 <= next_at.hour <= 14
def test_compute_next_run_interval_days_7_weekly() -> None:
"""interval_days=7 → ~7 суток вперёд, всё ещё в окне."""
now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC)
next_at = compute_next_run_at(13, 15, now=now, interval_days=7)
assert next_at.date() == datetime(2026, 5, 30).date() # 23 + 7
assert 13 <= next_at.hour <= 14 # всё ещё внутри окна [13, 15) UTC
assert next_at > now
def test_compute_next_run_interval_days_cross_midnight_weekly() -> None:
"""interval_days с cross-midnight окном → целевой день +N, час в окне."""
now = datetime(2026, 5, 23, 12, 0, tzinfo=UTC)
next_at = compute_next_run_at(22, 3, now=now, interval_days=7)
assert next_at > now
assert next_at.hour >= 22 or next_at.hour < 3