From 72a4c122c43780e204c5bd4f08743cc27f320362 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 12 Jun 2026 10:50:50 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(tradein):=20=D0=BE=D1=82=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20scraper-=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=20=E2=80=94=20scheduler=20?= =?UTF-8?q?=D0=B2=D0=BD=D0=B5=20API-=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B0=20(#1182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причина: 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(). --- .forgejo/workflows/deploy-tradein.yml | 21 +++- tradein-mvp/backend/app/scheduler_main.py | 106 ++++++++++++++++++ .../backend/tests/test_scheduler_main.py | 76 +++++++++++++ tradein-mvp/docker-compose.prod.yml | 38 ++++++- 4 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 tradein-mvp/backend/app/scheduler_main.py create mode 100644 tradein-mvp/backend/tests/test_scheduler_main.py diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 73a32c4a..79b98ae4 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -28,6 +28,7 @@ jobs: frontend: ${{ steps.filter.outputs.frontend }} browser: ${{ steps.filter.outputs.browser }} infra: ${{ steps.filter.outputs.infra }} + scraper: ${{ steps.filter.outputs.scraper }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -44,6 +45,11 @@ jobs: - 'tradein-mvp/docker-compose.prod.yml' - 'tradein-mvp/deploy/**' - '.forgejo/workflows/deploy-tradein.yml' + scraper: + - 'tradein-mvp/backend/app/services/scrapers/**' + - 'tradein-mvp/backend/app/services/scheduler.py' + - 'tradein-mvp/backend/app/scheduler_main.py' + - 'tradein-mvp/backend/app/tasks/**' # Quality gate: pytest MUST pass before any image is built/deployed (#666). # Runs the tradein-mvp/backend suite; a red test blocks build + deploy. @@ -203,12 +209,13 @@ jobs: env: IMAGE_TAG: latest GHCR_PAT: ${{ secrets.GHCR_PAT }} + SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,GHCR_PAT + envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED script: | set -euo pipefail cd /opt/gendesign @@ -238,7 +245,17 @@ jobs: export IMAGE_TAG="$IMAGE_TAG" docker compose -p gendesign-tradein -f docker-compose.prod.yml pull - docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d + + # Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) — + # один image :latest на backend+scraper, блочный up -d рестартовал бы scraper + # и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях. + docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend + if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then + echo "→ scraper paths changed — recreating tradein-scraper" + docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper + else + echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)" + fi # Применяем SQL миграции (если есть backend/data/sql/*.sql) # Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом diff --git a/tradein-mvp/backend/app/scheduler_main.py b/tradein-mvp/backend/app/scheduler_main.py new file mode 100644 index 00000000..807a7a5c --- /dev/null +++ b/tradein-mvp/backend/app/scheduler_main.py @@ -0,0 +1,106 @@ +"""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()) diff --git a/tradein-mvp/backend/tests/test_scheduler_main.py b/tradein-mvp/backend/tests/test_scheduler_main.py new file mode 100644 index 00000000..995b22b7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_scheduler_main.py @@ -0,0 +1,76 @@ +"""Тесты для scheduler_main.py — standalone scraper-контейнер (#1182). + +(a) модуль импортируется без реального DB/сети. +(b) _should_run() возвращает False при SCHEDULER_ENABLE=false. +(c) _run() отменяется чисто без unhandled exceptions. +""" + +from __future__ import annotations + +import asyncio +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +import app.scheduler_main as sm + + +# (a) Импорт модуля +def test_scheduler_main_importable() -> None: + """scheduler_main импортируется без ошибок.""" + assert sm is not None + assert hasattr(sm, "_should_run") + assert hasattr(sm, "_run") + + +# (b) Kill-switch SCHEDULER_ENABLE=false +def test_should_run_false_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + """_should_run() == False когда SCHEDULER_ENABLE отключён.""" + monkeypatch.setattr(sm.settings, "scheduler_enable", False) + assert sm._should_run() is False + + +def test_should_run_true_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + """_should_run() == True при включённом scheduler.""" + monkeypatch.setattr(sm.settings, "scheduler_enable", True) + assert sm._should_run() is True + + +# (c) _run() отменяется чисто +@pytest.mark.asyncio +async def test_run_cancels_cleanly(monkeypatch: pytest.MonkeyPatch) -> None: + """_run() завершается без unhandled exceptions при отмене задачи.""" + cancelled = asyncio.Event() + + async def _stub_loop() -> None: + """Короткая заглушка: ждёт отмены, имитируя бесконечный scheduler_loop.""" + cancelled.set() + await asyncio.sleep(999) + + monkeypatch.setattr("app.scheduler_main.scheduler_loop", _stub_loop, raising=False) + + # Патчим импорт внутри _run (он делает `from app.services.scheduler import scheduler_loop`) + import app.services.scheduler as _sched_mod + + monkeypatch.setattr(_sched_mod, "scheduler_loop", _stub_loop) + + # Также патчим внутри самого модуля если он уже закешировал ссылку + # (на случай если _run делает локальный import каждый раз — оба патча идемпотентны) + + task = asyncio.create_task(sm._run()) + + # Ждём пока _stub_loop запустится (флаг) + await asyncio.wait_for(cancelled.wait(), timeout=2.0) + + task.cancel() + + # Не должно бросить никакого исключения + try: + await task + except asyncio.CancelledError: + pass # CancelledError от самого task — допустимо + + # Тест прошёл если не было unhandled exception + assert task.done() diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 26a2ef8d..4642d90f 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -54,9 +54,9 @@ services: container_name: tradein-backend # Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload, # где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск - # reload в середине запроса. Single worker ОБЯЗАТЕЛЕН: in-app планировщик - # (app.services.scheduler, tick=60s) живёт в процессе backend — несколько uvicorn - # воркеров = дублирование scheduled-джоб (cian/rosreestr запустятся N раз). Без --workers. + # reload в середине запроса. Single worker сохраняется для предсказуемости + # (один process = единственный asyncio event loop, нет shared-state race). + # Scheduler переехал в tradein-scraper (#1182) — SCHEDULER_ENABLE=false ниже. command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] # .env.runtime пишется вручную на VPS (см. DEPLOY.md / scripts/README.md). # Контейнер читает YANDEX_GEOCODER_API_KEY, COOKIE_ENCRYPTION_KEY и пр. из @@ -81,6 +81,9 @@ services: # Valuation Calculator (Stage 9). Empty = Calculator scraper disabled + # cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`. COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}" + # Scheduler отключён в API-процессе — он живёт в tradein-scraper (#1182). + # Это предотвращает двойной запуск sweep'ов при наличии обоих контейнеров. + SCHEDULER_ENABLE: "false" depends_on: browser: condition: service_started @@ -96,6 +99,35 @@ services: - tradein-net - gendesign_shared # чтобы Caddy gendesign-стека достучался + # Отдельный scraper-контейнер (#1182): scheduler_loop() живёт здесь, а не в API. + # Мотивация: docker restart tradein-backend убивал бегущие sweep'ы (avito/cian/ + # rosreestr могут идти часами). Теперь backend-деплой не трогает scraper. + # Образ тот же что у backend (один Dockerfile), команда другая. + # НЕ пересоздаётся при каждом backend-деплое — селективный up в deploy-tradein.yml + # поднимает scraper только при изменениях scraper/infra путей (см. SCRAPER_CHANGED). + scraper: + image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest} + container_name: tradein-scraper + command: ["python", "-m", "app.scheduler_main"] + env_file: + - path: ./backend/.env.runtime + required: false + environment: + DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein" + ENVIRONMENT: "production" + GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}" + GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}" + COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}" + SCHEDULER_ENABLE: "true" + depends_on: + postgres: + condition: service_healthy + browser: + condition: service_started + restart: unless-stopped + networks: + - tradein-net + frontend: image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest} container_name: tradein-frontend -- 2.45.3 From 07b06617fdd5936ec298a09027629cefd52a9ae7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 12 Jun 2026 10:56:27 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(tradein):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BC=D1=91=D1=80=D1=82=D0=B2=D1=8B=D0=B9=20mon?= =?UTF-8?q?keypatch=20=D0=B2=20test=5Fscheduler=5Fmain=20(review=20fixup)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/tests/test_scheduler_main.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/backend/tests/test_scheduler_main.py b/tradein-mvp/backend/tests/test_scheduler_main.py index 995b22b7..439926f3 100644 --- a/tradein-mvp/backend/tests/test_scheduler_main.py +++ b/tradein-mvp/backend/tests/test_scheduler_main.py @@ -49,16 +49,12 @@ async def test_run_cancels_cleanly(monkeypatch: pytest.MonkeyPatch) -> None: cancelled.set() await asyncio.sleep(999) - monkeypatch.setattr("app.scheduler_main.scheduler_loop", _stub_loop, raising=False) - - # Патчим импорт внутри _run (он делает `from app.services.scheduler import scheduler_loop`) + # Патчим источник импорта: _run() делает `from app.services.scheduler import scheduler_loop` + # на каждый вызов, поэтому патч модуля-источника достаточен. import app.services.scheduler as _sched_mod monkeypatch.setattr(_sched_mod, "scheduler_loop", _stub_loop) - # Также патчим внутри самого модуля если он уже закешировал ссылку - # (на случай если _run делает локальный import каждый раз — оба патча идемпотентны) - task = asyncio.create_task(sm._run()) # Ждём пока _stub_loop запустится (флаг) -- 2.45.3