fix(tradein/deploy): atomic recreate + graceful scraper drain + startup-reap (#1951) (#2388)
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-browser (push) Successful in 30s
Deploy Trade-In / build-frontend (push) Successful in 31s
Deploy Trade-In / test (push) Successful in 1m39s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 55s

This commit is contained in:
lekss361 2026-07-04 06:49:06 +00:00
parent 2580158d8c
commit 9850bbde14
2 changed files with 97 additions and 6 deletions

View file

@ -451,17 +451,103 @@ jobs:
fi
# (4) Теперь — новый app-код: схема уже актуальна.
# Селективный 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 browser backend frontend
#
# #1951: раньше scraper пересоздавался ВТОРОЙ отдельной командой `up -d`,
# уже ПОСЛЕ browser/backend/frontend. Если это происходило посреди
# in-flight sweep'а (avito/cian/rosreestr full-load), процесс убивался на
# лету, а осиротевшая scrape_runs-строка сидела status='running' с
# замёрзшим heartbeat до периодического 6h zombie-reaper'а — false "hang"
# investigation вместо честного deploy-артефакта (см. cian_full_load #404).
# Три меры ниже — все devops-only (shell в deploy-скрипте), БЕЗ правок
# Python в scraper-стартап-пути (scheduler_main.py / scraper-kit /
# app/services/scrapers — намеренно не тронуты, см. PR-описание):
#
# 1) Атомарный recreate — browser/backend/frontend[+scraper] поднимаются
# ОДНИМ `docker compose up -d` инвокейшном (список сервисов собирается
# заранее в $SERVICES), а не двумя последовательными командами.
# 2) Graceful drain — если scraper будет пересоздан, ждём (до 5 мин, poll
# каждые 10s) пока scrape_runs.status='running' не станет 0, ПРЕЖДЕ чем
# инициировать recreate. Таймаут не блокирует деплой навсегда —
# SIGTERM-drain (#1182 Phase 2/3a) + stop_grace_period 120s остаются
# финальной страховкой для того, что не успело дойти до checkpoint'а.
# 3) Startup-reap — сразу после recreate помечаем 'cancelled' любые
# 'running'-строки, чей heartbeat не обновлялся с МОМЕНТА (по часам
# самой БД — SELECT NOW(), без risk clock-skew раннера), взятого
# непосредственно перед stop. Такие строки заведомо осиротели ЭТИМ
# recreate'ом (старый контейнер физически не может писать heartbeat
# после своей остановки) — не ждём 6h периодического reap_zombies().
# 'cancelled' (не 'zombie') — честно отличает «убит деплоем» от
# «непонятно завис» (последнее по-прежнему ловит только 6h-reaper).
# Порог — по метке времени конкретного recreate, а не по фиксированному
# интервалу: не зависит от heartbeat-каденса разных источников и не
# рискует ложно отменить НЕ относящийся к этому recreate run (напр.
# admin-triggered scrape внутри backend, если backend в этом деплое
# не пересоздавался — его heartbeat продолжит расти после checkpoint'а).
SERVICES="browser backend frontend"
SCRAPER_STOP_TS=""
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
echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)"
drained=""
for i in $(seq 1 30); do
# NB: не сливать "psql не ответил" с "0 running" — иначе неудачный
# прогон психgl молча читается как «слито», и graceful drain
# становится no-op именно в момент проблем с БД во время деплоя.
running_count=""
psql_out="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT COUNT(*) FROM scrape_runs WHERE status='running';" 2>/dev/null)" \
&& running_count="$(printf '%s' "$psql_out" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [ -n "$running_count" ] && [ "$running_count" = "0" ]; then
drained="yes"; break
fi
if [ -z "$running_count" ]; then
echo " ...не удалось прочитать running_count (psql failed) — считаем как «ещё активен», жду 10s (попытка $i/30)"
else
echo " ...${running_count} активных run(ов) ещё бегут, жду 10s (попытка $i/30)"
fi
sleep 10
done
if [ -n "$drained" ]; then
echo "→ Активных run'ов нет — recreate scraper безопасен."
else
echo "WARNING: активные scrape_runs остались после 5 мин ожидания — recreate продолжится."
echo " SIGTERM-drain (#1182) + stop_grace_period 120s постараются сохранить checkpoint;"
echo " startup-reap ниже подчистит то, что не успеет."
fi
# Checkpoint по часам БД (не раннера) прямо перед recreate.
# NB: tr -d '[:space:]' сломан для timestamptz-литерала — убирает и
# внутренний пробел между датой и временем ("2026-07-04 06:43" →
# "2026-07-0406:43"), CAST(...AS timestamptz) на такое падает молча
# (см. WARNING-фолбэк ниже). sed убирает только leading/trailing.
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=""
echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}"
SERVICES="$SERVICES scraper"
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES
if [ "${SCRAPER_CHANGED:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then
echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом"
# NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до
# сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD)
# — поэтому подставляем bash-значением напрямую. SCRAPER_STOP_TS сгенерирован
# самим Postgres (SELECT NOW()), не внешний ввод → безопасно.
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c "
UPDATE scrape_runs
SET status = 'cancelled',
finished_at = NOW(),
error = 'deploy #1951: tradein-scraper recreated mid-run (startup-reap, checkpoint ${SCRAPER_STOP_TS})'
WHERE status = 'running'
AND heartbeat_at < CAST('${SCRAPER_STOP_TS}' AS timestamptz);
" || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper"
fi
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с

View file

@ -157,6 +157,11 @@ services:
# Образ тот же что у backend (один Dockerfile), команда другая.
# НЕ пересоздаётся при каждом backend-деплое — селективный up в deploy-tradein.yml
# поднимает scraper только при изменениях scraper/infra путей (см. SCRAPER_CHANGED).
# #1951: когда scraper ВСЁ ЖЕ пересоздаётся, deploy-tradein.yml (1) ждёт до 5 мин
# graceful drain'а активных scrape_runs, (2) поднимает его АТОМАРНО одним compose
# invocation вместе с browser/backend/frontend (а не отдельной командой позже), и
# (3) сразу после recreate помечает 'cancelled' любые осиротевшие 'running'-строки
# вместо ожидания 6h zombie-reaper'а. Все три меры — в deploy-скрипте, не здесь.
scraper:
image: ghcr.io/lekss361/gendesign-tradein-backend:${IMAGE_TAG:-latest}
container_name: tradein-scraper