From 720e812dda8b3ae295df37d558e26c8bfc86f335 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 09:47:52 +0300 Subject: [PATCH] fix(tradein/deploy): atomic recreate + graceful scraper drain + startup-reap (#1951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomicity: browser/backend/frontend/scraper recreated in one docker compose up invocation instead of two sequential calls. Graceful drain: before recreating scraper, poll scrape_runs for running=0 up to 5min; WARNING+proceed on timeout (SIGTERM-drain + stop_grace_period + startup-reap below remain the safety net). Startup-reap: mark orphaned running rows (heartbeat before a DB-clock checkpoint taken right before stop) as cancelled instead of waiting for the 6h zombie reaper. Implemented entirely in the deploy workflow shell, not scheduler_main.py/scrapers, to avoid touching files owned by the in-flight scraper_kit migration (#2277). Review fixups: tr -d '[:space:]' was stripping the internal space in the DB-clock timestamp literal (CAST(...timestamptz) failed silently on every deploy) — switched to sed trim of leading/ trailing whitespace only. Drain-loop no longer conflates a failed psql read with a confirmed running_count=0 (would have silently skipped the drain wait on any transient docker-exec/DB hiccup). --- .forgejo/workflows/deploy-tradein.yml | 98 +++++++++++++++++++++++++-- tradein-mvp/docker-compose.prod.yml | 5 ++ 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 13b41fa9..14b6fa07 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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) падал с diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index c1ccdcd6..56fcfd18 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -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