Топология подтверждена перед удалением (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.
219 lines
10 KiB
Python
219 lines
10 KiB
Python
"""Standalone entrypoint для tradein-scraper контейнера (#1182).
|
||
|
||
Зачем: API-деплой (docker restart tradein-backend) не должен прерывать
|
||
бегущие scraper sweep'ы (avito/cian/rosreestr). Вынесен в отдельный
|
||
контейнер tradein-scraper с тем же backend-образом, но другой командой.
|
||
|
||
Запуск: python -m app.scheduler_main
|
||
|
||
Zombie-reap: reap_zombies() вызывается в начале каждого тика kit scheduler_loop
|
||
(см. scraper_kit/orchestration/scheduler.py), дублировать здесь не нужно.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import signal
|
||
import sys
|
||
from contextlib import suppress
|
||
|
||
from app.core.config import settings
|
||
from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Safety net: после запроса drain'а ждём добровольного выхода задачи не дольше
|
||
# этого порога, который ОБЯЗАН быть < docker stop_grace_period (120s). Если
|
||
# некооперативная задача не вышла за это время — hard-cancel, чтобы процесс не
|
||
# подвис до SIGKILL за пределами grace. Module-level → monkeypatch'абельно в тестах.
|
||
_DRAIN_TIMEOUT_S = 100.0
|
||
|
||
# Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396).
|
||
# Только integrations без Starlette/FastAPI (у нас нет ASGI-приложения здесь).
|
||
# SqlalchemyIntegration + HttpxIntegration + LoggingIntegration покрывают scraper-слой.
|
||
if settings.glitchtip_dsn:
|
||
import sentry_sdk
|
||
from sentry_sdk.integrations.httpx import HttpxIntegration
|
||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||
|
||
from app.observability.sentry_scrub import scrub_pii_event
|
||
|
||
sentry_sdk.init(
|
||
dsn=settings.glitchtip_dsn,
|
||
environment=settings.environment,
|
||
release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown",
|
||
traces_sample_rate=0.0,
|
||
send_default_pii=False,
|
||
before_send=scrub_pii_event,
|
||
integrations=[
|
||
SqlalchemyIntegration(),
|
||
HttpxIntegration(),
|
||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||
],
|
||
)
|
||
logger.info("GlitchTip monitoring enabled (scheduler_main)")
|
||
|
||
|
||
def _should_run() -> bool:
|
||
"""Проверить kill-switch SCHEDULER_ENABLE перед запуском asyncio loop."""
|
||
return settings.scheduler_enable
|
||
|
||
|
||
async def _await_scheduler(task: asyncio.Task[None]) -> None:
|
||
"""Дождаться завершения scheduler-задачи с кооперативным SIGTERM-drain'ом.
|
||
|
||
- Нет shutdown: scheduler_loop бесконечен → задача никогда не завершается, ждём её
|
||
как есть (процесс просто работает).
|
||
- shutdown запрошен, задача ещё бежит: bounded `wait_for(_DRAIN_TIMEOUT_S)` —
|
||
кооперативная задача докоммитит текущий unit на ближайшем checkpoint'е и выйдет
|
||
сама. Если превысила grace → hard-cancel + suppress CancelledError, чтобы
|
||
некооперативная задача не подвесила процесс за пределами docker stop_grace_period.
|
||
"""
|
||
# Гонка «задача завершилась сама» против «пришёл SIGTERM»: отсчёт safety-net'а
|
||
# должен стартовать от МОМЕНТА запроса drain'а, а не от старта процесса.
|
||
shutdown_waiter = asyncio.create_task(wait_for_shutdown())
|
||
try:
|
||
await asyncio.wait({task, shutdown_waiter}, return_when=asyncio.FIRST_COMPLETED)
|
||
finally:
|
||
shutdown_waiter.cancel()
|
||
with suppress(asyncio.CancelledError):
|
||
await shutdown_waiter
|
||
|
||
if task.done():
|
||
# Задача вышла сама (drained на checkpoint'е; без сигнала сюда не попадаем).
|
||
# .result() пробрасывает реальное исключение, если scheduler_loop неожиданно
|
||
# упал (сохраняем прежнюю loud-crash семантику `await task`), а не глушит его.
|
||
task.result()
|
||
logger.info("scheduler_main: scheduler task exited cleanly")
|
||
return
|
||
|
||
logger.info(
|
||
"scheduler_main: SIGTERM-drain — waiting up to %.0fs for in-flight unit to commit",
|
||
_DRAIN_TIMEOUT_S,
|
||
)
|
||
try:
|
||
await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S)
|
||
logger.info("scheduler_main: scheduler drained and exited cleanly")
|
||
except TimeoutError:
|
||
logger.warning(
|
||
"scheduler_main: drain exceeded %.0fs grace — hard-cancelling scheduler task",
|
||
_DRAIN_TIMEOUT_S,
|
||
)
|
||
task.cancel()
|
||
with suppress(asyncio.CancelledError):
|
||
await task
|
||
|
||
|
||
async def _run_kit_scheduler() -> None:
|
||
"""Kit-путь (#2192): собрать SchedulerContext + registry и крутить kit scheduler_loop.
|
||
|
||
Инжектируем продуктовые адаптеры (RealScraperConfig/Matcher/Enrichment/SessionFactory) +
|
||
боевой runs-модуль (app.services.scrape_runs) + текущий shutdown_requested callable, чтобы
|
||
kit-loop делил ту же concurrency/lifecycle-семантику с боевым scheduler'ом. Продуктовые
|
||
НЕ-sweep source'ы приходят из build_product_handlers; kit-native sweeps — из build_registry.
|
||
|
||
SIGTERM-drain: kit scheduler_loop сам следит за ctx.shutdown_requested() и дренит
|
||
in-flight задачи (ctx.drain_inflight) — та же кооперативная семантика, что у боевого.
|
||
"""
|
||
from scraper_kit.orchestration.scheduler import (
|
||
SchedulerContext,
|
||
build_registry,
|
||
)
|
||
from scraper_kit.orchestration.scheduler import (
|
||
scheduler_loop as kit_scheduler_loop,
|
||
)
|
||
|
||
from app.services import scrape_runs as runs_mod
|
||
from app.services.product_handlers import build_product_handlers
|
||
from app.services.scraper_adapters import (
|
||
RealEnrichmentJobs,
|
||
RealMatcherAdapter,
|
||
RealProxyProvider,
|
||
RealScraperConfig,
|
||
RealSessionFactory,
|
||
)
|
||
|
||
# Proxy-пул (#2163 curl / #2164 P4 browser) инжектируется ТОЛЬКО когда включён любой
|
||
# из флагов. Оба дефолтно False (ship-dark) → proxy_provider=None → curl/browser берут
|
||
# прокси из env, прод не меняется. Один RealProxyProvider обслуживает оба пути (curl
|
||
# через curl_proxy_url, browser через BrowserFetcher → POST /fetch{proxy}).
|
||
use_pool = settings.use_proxy_pool_curl or settings.use_proxy_pool_browser
|
||
proxy_provider = RealProxyProvider() if use_pool else None
|
||
|
||
ctx = SchedulerContext(
|
||
config=RealScraperConfig(),
|
||
matcher=RealMatcherAdapter(),
|
||
enrichment=RealEnrichmentJobs(),
|
||
session_factory=RealSessionFactory(),
|
||
shutdown_requested=shutdown_requested,
|
||
runs=runs_mod,
|
||
proxy_provider=proxy_provider,
|
||
)
|
||
registry = build_registry(product_handlers=build_product_handlers(ctx))
|
||
logger.info("scheduler_main: kit-scheduler registry built (%d source handlers)", len(registry))
|
||
await kit_scheduler_loop(ctx, registry)
|
||
|
||
|
||
async def _run() -> None:
|
||
"""Async entrypoint: kit-scheduler с кооперативным SIGTERM/SIGINT-drain'ом.
|
||
|
||
SIGTERM/SIGINT → request_shutdown() (НЕ task.cancel()): бегущий scrape-unit
|
||
докоммитит текущую карточку и выйдет сам на ближайшем between-unit checkpoint'е.
|
||
Safety-net в _await_scheduler гарантирует выход в пределах docker grace.
|
||
|
||
#2397 Part C: legacy app.services.scheduler.scheduler_loop fallback (USE_KIT_SCHEDULER=
|
||
false ship-dark путь из #2192) удалён — kit (_run_kit_scheduler) теперь единственный
|
||
путь. Прод уже давно на kit (USE_KIT_SCHEDULER=true), legacy был мёртвым грузом.
|
||
settings.use_kit_scheduler остаётся в конфиге (extra="ignore" защищает от startup-краха
|
||
на leftover env var), но на ветвление больше не влияет.
|
||
"""
|
||
task = asyncio.create_task(_run_kit_scheduler())
|
||
|
||
loop = asyncio.get_running_loop()
|
||
|
||
def _on_signal(signum: int) -> None:
|
||
logger.info("scheduler_main: signal %d received — requesting cooperative drain", signum)
|
||
request_shutdown()
|
||
|
||
try:
|
||
loop.add_signal_handler(signal.SIGTERM, lambda: _on_signal(signal.SIGTERM))
|
||
loop.add_signal_handler(signal.SIGINT, lambda: _on_signal(signal.SIGINT))
|
||
except NotImplementedError:
|
||
# Windows dev: signal handlers через loop не поддерживаются
|
||
logger.warning("scheduler_main: loop.add_signal_handler not supported (Windows dev)")
|
||
|
||
await _await_scheduler(task)
|
||
|
||
if shutdown_requested():
|
||
logger.info("scheduler_main: scheduler drained cleanly (SIGTERM)")
|
||
else:
|
||
logger.info("scheduler_main: scheduler task exited")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if not _should_run():
|
||
logger.warning("scheduler_main: scheduler disabled via SCHEDULER_ENABLE — exiting")
|
||
sys.exit(0)
|
||
|
||
# Best-effort FDW bootstrap: чтобы ensure_fdw_user_mapping отработал при старте
|
||
# scraper-контейнера (postgres_fdw нужен для rosreestr_dkp_import).
|
||
try:
|
||
from app.core.db import SessionLocal
|
||
from app.core.fdw import ensure_fdw_user_mapping
|
||
|
||
with SessionLocal() as db:
|
||
ensure_fdw_user_mapping(db)
|
||
except Exception:
|
||
logger.exception(
|
||
"scheduler_main: FDW user mapping bootstrap failed — cadastral queries may fail"
|
||
)
|
||
|
||
asyncio.run(_run())
|