gendesign/tradein-mvp/backend/app/scheduler_main.py
bot-backend 72a4c122c4 feat(tradein): отдельный scraper-контейнер — scheduler вне API-процесса (#1182)
Причина: docker restart tradein-backend (при каждом API-деплое) убивал бегущие
sweep'ы avito/cian/rosreestr — они могут идти часами, и их прерывание середине
батча создавало zombie-runs и пропуски данных.

Что сделано:
- scheduler_main.py — standalone entrypoint для tradein-scraper; SIGTERM/SIGINT
  отменяют scheduler_loop чисто; GlitchTip init без Starlette/FastAPI интеграций.
- docker-compose.prod.yml — новый сервис scraper (тот же image, команда python -m
  app.scheduler_main, только tradein-net); SCHEDULER_ENABLE=false в backend.
- deploy-tradein.yml — селективный up -d --no-deps: scraper пересоздаётся только
  при изменениях scraper/infra путей (SCRAPER_CHANGED), не при каждом backend-деплое.
- tests/test_scheduler_main.py — импорт, kill-switch _should_run(), чистая отмена _run().
2026-06-12 10:50:50 +03:00

106 lines
3.9 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.

"""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() вызывается в начале каждого тика scheduler_loop()
(см. app/services/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
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
# Мониторинг ошибок — 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 _run() -> None:
"""Async entrypoint: запустить scheduler_loop() с чистой отменой по SIGTERM/SIGINT."""
from app.services.scheduler import scheduler_loop
task = asyncio.create_task(scheduler_loop())
loop = asyncio.get_running_loop()
def _cancel(_signum: int, _frame: object = None) -> None:
logger.info("scheduler_main: signal received — cancelling scheduler task")
task.cancel()
try:
loop.add_signal_handler(signal.SIGTERM, lambda: _cancel(signal.SIGTERM))
loop.add_signal_handler(signal.SIGINT, lambda: _cancel(signal.SIGINT))
except NotImplementedError:
# Windows dev: signal handlers через loop не поддерживаются
logger.warning("scheduler_main: loop.add_signal_handler not supported (Windows dev)")
with suppress(asyncio.CancelledError):
await task
logger.info("scheduler_main: scheduler cancelled cleanly")
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())