Топология подтверждена перед удалением (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.
89 lines
3.7 KiB
Python
89 lines
3.7 KiB
Python
"""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
|