"""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())