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 }}
|
frontend: ${{ steps.filter.outputs.frontend }}
|
||||||
browser: ${{ steps.filter.outputs.browser }}
|
browser: ${{ steps.filter.outputs.browser }}
|
||||||
infra: ${{ steps.filter.outputs.infra }}
|
infra: ${{ steps.filter.outputs.infra }}
|
||||||
|
scraper: ${{ steps.filter.outputs.scraper }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dorny/paths-filter@v3
|
- uses: dorny/paths-filter@v3
|
||||||
|
|
@ -44,6 +45,11 @@ jobs:
|
||||||
- 'tradein-mvp/docker-compose.prod.yml'
|
- 'tradein-mvp/docker-compose.prod.yml'
|
||||||
- 'tradein-mvp/deploy/**'
|
- 'tradein-mvp/deploy/**'
|
||||||
- '.forgejo/workflows/deploy-tradein.yml'
|
- '.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).
|
# Quality gate: pytest MUST pass before any image is built/deployed (#666).
|
||||||
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
# Runs the tradein-mvp/backend suite; a red test blocks build + deploy.
|
||||||
|
|
@ -203,12 +209,13 @@ jobs:
|
||||||
env:
|
env:
|
||||||
IMAGE_TAG: latest
|
IMAGE_TAG: latest
|
||||||
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
GHCR_PAT: ${{ secrets.GHCR_PAT }}
|
||||||
|
SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.DEPLOY_HOST }}
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
username: ${{ secrets.DEPLOY_USER }}
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
port: ${{ secrets.DEPLOY_PORT }}
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
envs: IMAGE_TAG,GHCR_PAT
|
envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED
|
||||||
script: |
|
script: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd /opt/gendesign
|
cd /opt/gendesign
|
||||||
|
|
@ -238,7 +245,17 @@ jobs:
|
||||||
|
|
||||||
export IMAGE_TAG="$IMAGE_TAG"
|
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 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)
|
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
|
||||||
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
|
# 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
|
container_name: tradein-backend
|
||||||
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
|
# Prod: uvicorn БЕЗ --reload (Dockerfile CMD несёт --reload только для dev hot-reload,
|
||||||
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
|
# где app/ bind-mount'ится). В prod --reload = лишний WatchFiles-наблюдатель + риск
|
||||||
# reload в середине запроса. Single worker ОБЯЗАТЕЛЕН: in-app планировщик
|
# reload в середине запроса. Single worker сохраняется для предсказуемости
|
||||||
# (app.services.scheduler, tick=60s) живёт в процессе backend — несколько uvicorn
|
# (один process = единственный asyncio event loop, нет shared-state race).
|
||||||
# воркеров = дублирование scheduled-джоб (cian/rosreestr запустятся N раз). Без --workers.
|
# Scheduler переехал в tradein-scraper (#1182) — SCHEDULER_ENABLE=false ниже.
|
||||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
# .env.runtime пишется вручную на VPS (см. DEPLOY.md / scripts/README.md).
|
# .env.runtime пишется вручную на VPS (см. DEPLOY.md / scripts/README.md).
|
||||||
# Контейнер читает YANDEX_GEOCODER_API_KEY, COOKIE_ENCRYPTION_KEY и пр. из
|
# Контейнер читает YANDEX_GEOCODER_API_KEY, COOKIE_ENCRYPTION_KEY и пр. из
|
||||||
|
|
@ -81,6 +81,9 @@ services:
|
||||||
# Valuation Calculator (Stage 9). Empty = Calculator scraper disabled +
|
# Valuation Calculator (Stage 9). Empty = Calculator scraper disabled +
|
||||||
# cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`.
|
# cookies upload endpoint returns 503. Generate with `openssl rand -hex 32`.
|
||||||
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
|
COOKIE_ENCRYPTION_KEY: "${COOKIE_ENCRYPTION_KEY:-}"
|
||||||
|
# Scheduler отключён в API-процессе — он живёт в tradein-scraper (#1182).
|
||||||
|
# Это предотвращает двойной запуск sweep'ов при наличии обоих контейнеров.
|
||||||
|
SCHEDULER_ENABLE: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
browser:
|
browser:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|
@ -96,6 +99,35 @@ services:
|
||||||
- tradein-net
|
- tradein-net
|
||||||
- gendesign_shared # чтобы Caddy gendesign-стека достучался
|
- 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:
|
frontend:
|
||||||
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
|
image: ghcr.io/lekss361/gendesign-tradein-frontend:${IMAGE_TAG:-latest}
|
||||||
container_name: tradein-frontend
|
container_name: tradein-frontend
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue