Причина: 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().
106 lines
3.9 KiB
Python
106 lines
3.9 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() вызывается в начале каждого тика 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())
|