Merge pull request 'feat(tradein): отдельный scraper-контейнер — scheduler вне API-процесса' (#1183) from feat/tradein-scraper-container into main
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 2m14s
Deploy Trade-In / build-browser (push) Successful in 4m45s
Deploy Trade-In / deploy (push) Successful in 1m30s
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / test (push) Successful in 35s
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 2m14s
Deploy Trade-In / build-browser (push) Successful in 4m45s
Deploy Trade-In / deploy (push) Successful in 1m30s
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Successful in 30s
Deploy Trade-In / test (push) Successful in 35s
Reviewed-on: #1183
This commit is contained in:
commit
7a06839a0d
4 changed files with 232 additions and 5 deletions
|
|
@ -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 ТОЛЬКО при первом
|
||||
|
|
|
|||
106
tradein-mvp/backend/app/scheduler_main.py
Normal file
106
tradein-mvp/backend/app/scheduler_main.py
Normal file
|
|
@ -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())
|
||||
72
tradein-mvp/backend/tests/test_scheduler_main.py
Normal file
72
tradein-mvp/backend/tests/test_scheduler_main.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""Тесты для 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)
|
||||
|
||||
# Патчим источник импорта: _run() делает `from app.services.scheduler import scheduler_loop`
|
||||
# на каждый вызов, поэтому патч модуля-источника достаточен.
|
||||
import app.services.scheduler as _sched_mod
|
||||
|
||||
monkeypatch.setattr(_sched_mod, "scheduler_loop", _stub_loop)
|
||||
|
||||
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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue