Compare commits

..

No commits in common. "d3d746426789cadb0f0ab03721578540f5ef293e" and "eb98852ddf1f3eeb366f3bb42b71284475d09d80" have entirely different histories.

View file

@ -28,8 +28,7 @@ jobs:
frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }} frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }}
browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }} browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }}
infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }} infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }}
# Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE scraper: ${{ steps.set-all.outputs.scraper || steps.filter.outputs.scraper }}
# в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend.
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@ -92,6 +91,7 @@ jobs:
echo "frontend=true" >> "$GITHUB_OUTPUT" echo "frontend=true" >> "$GITHUB_OUTPUT"
echo "browser=true" >> "$GITHUB_OUTPUT" echo "browser=true" >> "$GITHUB_OUTPUT"
echo "infra=true" >> "$GITHUB_OUTPUT" echo "infra=true" >> "$GITHUB_OUTPUT"
echo "scraper=true" >> "$GITHUB_OUTPUT"
# Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges # Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges
# (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes. # (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes.
@ -115,20 +115,20 @@ 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` (#2679, 2026-08-05). Он был allowlist'ом scraper:
# «файлов, которые исполняет планировщик», и перечислял только то, - 'tradein-mvp/backend/app/services/scrapers/**'
# что вспомнили. Дважды выстрелило одинаково: - 'tradein-mvp/backend/app/services/scrape_pipeline.py'
# 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не - 'tradein-mvp/backend/app/services/scheduler.py'
# до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**, - 'tradein-mvp/backend/app/scheduler_main.py'
# house_dedup_merge.py) — залатали случай, не механизм; - 'tradein-mvp/backend/app/tasks/**'
# 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py # #2188: scheduler исполняет matching/dedup при каждом scrape-тике —
# в списке не значились → планировщик час крутил старый код, # без этих путей scraper-контейнер оставался на старом коде
# деплой при этом отчитался успехом. # (2026-07-02: fias-dedup доехал до tradein-backend, но не до
# За июнь-август 48% (193 из 402) backend-мержей не попадали ни в # tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть
# один из путей списка, т.е. половина правок доезжала до scraper'а # scheduler — его правки тоже обязаны пересоздавать контейнер.
# только со следующим «удачным» деплоем. Теперь пересоздание - 'tradein-mvp/backend/app/services/matching/**'
# привязано не к списку файлов, а к факту пересборки образа — - 'tradein-mvp/backend/app/services/house_dedup_merge.py'
# см. SCRAPER_RECREATE в job deploy. - 'tradein-mvp/packages/scraper-kit/**'
# 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.
@ -294,32 +294,21 @@ jobs:
uses: appleboy/ssh-action@v1.0.3 uses: appleboy/ssh-action@v1.0.3
env: env:
IMAGE_TAG: latest IMAGE_TAG: latest
# Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не
# уходить в drain, когда пересоздавать нечего (см. ниже, #2679).
IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }}
GHCR_PAT: ${{ secrets.GHCR_PAT }} GHCR_PAT: ${{ secrets.GHCR_PAT }}
# #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ # Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT
# gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса, # recreate the scraper and SIGKILL a running multi-hour job. Only genuine
# одна строка image, разный command). Значит вопрос «пересоздавать ли # scraper-code paths (the `scraper` paths-filter already covers
# scraper» — это не «трогали ли его файлы», а «мог ли пересобраться # app/services/scrapers/**, scrape_pipeline.py, scheduler.py,
# образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend: # scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch —
# backend || infra || workflow_dispatch. Ровно тогда в реестре мог # should trigger a scraper recreate. (infra term intentionally dropped.)
# появиться новый :latest, и оставить scraper на старом — значит SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }}
# оставить планировщик на старом коде (инцидент #2679).
#
# Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ
# пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс
# больше не нужен — с #1951 перед recreate'ом идёт graceful drain
# (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам
# `compose up -d` на неизменившемся образе — no-op.
SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }}
GITHUB_SHA: ${{ github.sha }} GITHUB_SHA: ${{ github.sha }}
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,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED,GITHUB_SHA
script: | script: |
set -euo pipefail set -euo pipefail
cd /opt/gendesign cd /opt/gendesign
@ -500,43 +489,8 @@ jobs:
# с browser/backend/frontend, отдельного graceful-drain не требует. # с browser/backend/frontend, отдельного graceful-drain не требует.
SERVICES="browser backend frontend tgbot" SERVICES="browser backend frontend tgbot"
SCRAPER_STOP_TS="" SCRAPER_STOP_TS=""
scraper_stale="" if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
# Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE
# истинно и на infra-правках (compose / workflow / deploy/**), а те
# почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется,
# `up -d` выходит no-op — и платить за него пятиминутным drain'ом,
# прерывая многочасовой сбор, не за что. Сравниваем, на том ли
# образе бежит scraper, что уже лежит в локальном демоне.
# ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) —
# до pull'а под тегом :latest ещё старый образ, сравнение всегда
# «совпало» и drain пропускался бы как раз тогда, когда он нужен.
# Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на
# ЭТОТ же признак и больше не выполняются, когда recreate'а не было
# (иначе живой прогон с heartbeat старше чекпоинта помечался бы
# 'cancelled', продолжая работать).
pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "")
running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "")
if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then
echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего,"
echo " drain пропускаем, in-flight прогоны не трогаем"
else
scraper_stale="yes"
fi
# scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d`
# — no-op, но правка самого compose (env/лимиты сервиса) так всё же
# доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а
# страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку
# прогона подчистит периодический 6h zombie-reaper.
SERVICES="$SERVICES scraper"
else
echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем"
echo " (сверка образов ниже всё равно проверит, что он не отстал)"
fi
if [ -n "$scraper_stale" ]; then
echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);"
echo " ждём слива in-flight scrape_runs (до 5 мин)"
drained="" drained=""
for i in $(seq 1 30); do for i in $(seq 1 30); do
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный # NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
@ -573,11 +527,15 @@ jobs:
SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS="" psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS=""
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}" echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
SERVICES="$SERVICES scraper"
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi fi
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до # NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD) # сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
@ -695,7 +653,7 @@ jobs:
# снижает шанс поймать контейнер ровно в момент between-restarts # снижает шанс поймать контейнер ровно в момент between-restarts
# промежуточного "running" внутри crash-loop. # промежуточного "running" внутри crash-loop.
# tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES); # tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES);
# scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому # scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому
# проверяем только то, что реально входит в текущий $SERVICES. # проверяем только то, что реально входит в текущий $SERVICES.
for svc in tgbot scraper; do for svc in tgbot scraper; do
case " $SERVICES " in case " $SERVICES " in
@ -725,49 +683,6 @@ jobs:
echo "→ tradein-$svc running." echo "→ tradein-$svc running."
done done
# Сверка образов backend-семейства (#2679) — последняя проверка перед
# маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ
# gendesign-tradein-backend; backend пересоздаётся на каждом деплое
# (безусловно в $SERVICES) и потому всегда несёт свежий :latest —
# он и есть эталон. Если у scraper или tgbot image ID другой, значит
# контейнер остался на старом коде, а деплой без этой проверки
# отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до
# tradein-backend, ff98603ba3cc; tradein-scraper остался на
# da26154c64a6 часовой давности — а планировщик, единственный
# исполнитель домовой оценки, живёт именно там).
# Падаем, а не warning'уем: расхождение = правка не работает, и
# узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт
# ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же
# базу и пересоберёт всё накопленное (тот же приём, что в health-check).
# «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся
# по-разному, поэтому сообщения различаются явно.
backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "")
image_mismatch=""
if [ -z "$backend_image" ]; then
echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем."
image_mismatch="yes"
fi
for svc in scraper tgbot; do
svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "")
if [ -z "$svc_image" ]; then
echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший"
echo " образ, а неполный стек: сервис не работает вообще."
image_mismatch="yes"
elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then
echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image"
image_mismatch="yes"
fi
done
if [ -n "$image_mismatch" ]; then
echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)."
echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):"
echo " docker compose -p gendesign-tradein \\"
echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\"
echo " up -d --force-recreate --no-deps backend scraper tgbot"
exit 1
fi
echo "→ образы совпадают: backend/scraper/tgbot на $backend_image."
# Cleanup старых образов # Cleanup старых образов
for repo in ghcr.io/lekss361/gendesign-tradein-backend \ for repo in ghcr.io/lekss361/gendesign-tradein-backend \
ghcr.io/lekss361/gendesign-tradein-frontend; do ghcr.io/lekss361/gendesign-tradein-frontend; do