chore(ci): деплой не убивает worker посреди скрап-прогона
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / openapi-codegen-check (pull_request) Successful in 2m10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m32s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / openapi-codegen-check (pull_request) Successful in 2m10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Successful in 17m32s
Каждый деплой ПТИЦЫ безусловно пересоздавал worker — а он единственный, кто несёт скрап-прогоны (beat только триггерит, отдельного сервиса scraper в этом compose нет вовсе). Полный прогон КН идёт часами, при неудаче — сутки; любой мерж в это окно убивал его молча, и деплой при этом был зелёным. Заплатка до чекпоинтов (#3074): пока прогон не умеет продолжаться с места обрыва, дешевле не обрывать. Пересоздание worker'а пропускается, только когда выполнены оба условия: образ действительно сменился (иначе пересоздание и так no-op) и прямо сейчас есть живой прогон. Маркер заводить не пришлось, он уже есть — kn_scrape_runs и objective_scrape_runs со status='running'; литералы сверены с backend/app/workers/lifecycle.py, а не угаданы. Анти-зомби: считаются только прогоны свежее WORKER_GUARD_MAX_SKIP_H часов по COALESCE(heartbeat_at, started_at) — той же паре, что индексирует data/sql/55_schema_scrape_resume.sql и по которой фильтрует reaper. Иначе зависший навечно 'running' заморозил бы worker бесконечно. Fail-safe перевёрнут в сторону статус-кво: psql молчит, отдал пустое, не число, контейнера нет, image id не читается — пересоздаём как раньше и печатаем WARNING. Тихий no-op деплоя опаснее убитого прогона: сломанный детект иначе оставил бы worker на старом коде навсегда и незаметно. Ревью в свежем контексте нашло критическую дыру, из-за которой заплатка не работала бы ровно в своём целевом случае. Guard стоял ПОСЛЕ голого `up -d` по всему стеку, а тот сам пересоздаёт любой сервис со сменившимся образом. То есть worker убивался раньше, чем guard успевал сравнить digest'ы, и к моменту проверки они уже совпадали — печаталось «образ не изменился», ни SKIPPED, ни WARNING. Теперь worker явно исключён из bulk-команды: список берётся из `config --services`, который фильтрует по активным профилям, так что семантика сегодняшнего `up -d` сохраняется, а решение по worker'у принимает только guard. Там же поправлено: `tr -d '[:space:]'` вырезал пробел ВНУТРИ timestamp от NOW(), из-за чего CAST падал и предупреждение «старый код + новая схема» не печаталось бы никогда. Риск заплатки назван прямо, потому что он реальный: пропуск означает, что worker остаётся на старом образе до конца прогона. Миграции применяются ДО подъёма приложения, поэтому старый код может оказаться на новой схеме — на этот случай отдельная строка в логе с готовой командой ручного recreate, и предупреждение о расхождении digest'ов повторяется в каждом следующем деплое, пока расхождение живо. Откат — переменная репозитория WORKER_RECREATE_GUARD в 'off', без коммита. Проверено: YAML парсится, шелл-блок проходит bash -n, имена колонок сверены со схемой (run_id, heartbeat_at в обеих таблицах, _schema_migrations.applied_at), поведение `config --services` с профилями проверено на живом Docker — сервисы под неактивными профилями в список не попадают, регрессии с #3061 нет. Refs #3029, #3074
This commit is contained in:
parent
536137d460
commit
6bb3189bc4
1 changed files with 109 additions and 3 deletions
|
|
@ -583,6 +583,11 @@ jobs:
|
|||
# own-portfolio каннибализации. Non-sensitive (публичные id) → actions
|
||||
# variable. UNSET → каннибализация отдаёт proxy (фича дормант).
|
||||
OWN_DEVELOPER_IDS: ${{ vars.OWN_DEVELOPER_IDS }}
|
||||
# #3029: guard на пересоздание worker'а во время активного скрап-прогона
|
||||
# (временная заплатка до чекпоинтов #3074). Откат — переменная репозитория
|
||||
# в 'off', без коммита. Non-sensitive → actions variable, не secret.
|
||||
WORKER_RECREATE_GUARD: ${{ vars.WORKER_RECREATE_GUARD }}
|
||||
WORKER_GUARD_MAX_SKIP_H: ${{ vars.WORKER_GUARD_MAX_SKIP_H }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
|
|
@ -591,7 +596,7 @@ jobs:
|
|||
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
|
||||
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
|
||||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS
|
||||
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS,WORKER_RECREATE_GUARD,WORKER_GUARD_MAX_SKIP_H
|
||||
script: |
|
||||
set -euo pipefail
|
||||
# #2950: взаимное исключение докер-секции двух прод-деплоев.
|
||||
|
|
@ -725,6 +730,19 @@ jobs:
|
|||
# Apply pending SQL migrations
|
||||
set -a; source .env; set +a
|
||||
|
||||
# #3029: checkpoint по часам БД ДО миграций — worker-guard ниже (после
|
||||
# force-recreate блока) сверяет его с applied_at, чтобы честно
|
||||
# предупредить «worker на старом коде + новая схема», если recreate
|
||||
# worker'а был пропущен именно в деплое, где данные схемы поменялись.
|
||||
# `tr -d '[:space:]'` здесь СЛОМАН бы CAST ниже: NOW() отдаёт
|
||||
# "2026-08-24 09:12:33+00" с пробелом ВНУТРИ значения (дата/время),
|
||||
# который [:space:] тоже вырезает → "2026-08-2409:12:33+00" не
|
||||
# парсится как timestamptz. Убираем только CR/LF (psql -tA не
|
||||
# добавляет ведущих/хвостовых пробелов, только trailing \n).
|
||||
DEPLOY_MIGRATIONS_START_TS="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT NOW();" 2>/dev/null | tr -d '\r\n')" \
|
||||
|| DEPLOY_MIGRATIONS_START_TS=""
|
||||
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=on -c "
|
||||
CREATE TABLE IF NOT EXISTS _schema_migrations (
|
||||
|
|
@ -862,7 +880,17 @@ jobs:
|
|||
# Cache-friendly: первый build ~30s, последующие 1-3s если файлы не менялись.
|
||||
docker compose -p gendesign -f docker-compose.prod.yml build glitchtip-auth-forwarder
|
||||
|
||||
docker compose -p gendesign -f docker-compose.prod.yml up -d
|
||||
# #3029 fail-safe review fix: голый `up -d` без списка сервисов сам
|
||||
# пересоздаёт ЛЮБОЙ сервис с изменившимся image — включая worker,
|
||||
# ДО того как guard ниже (~896) успевает сравнить pulled vs running.
|
||||
# К моменту проверки они уже совпадают (worker только что
|
||||
# пересоздан этим самым up -d) → guard видит "не изменился" и
|
||||
# печатает no-op, хотя worker уже убит и прогон уже потерян.
|
||||
# Фикс: явно исключаем worker из этого bulk up -d — его recreate
|
||||
# решается ТОЛЬКО guard-блоком ниже (строка ~977), который видит
|
||||
# ещё не тронутый running_worker_image.
|
||||
UP_SERVICES="$(docker compose -p gendesign -f docker-compose.prod.yml config --services | grep -v '^worker$')"
|
||||
docker compose -p gendesign -f docker-compose.prod.yml up -d $UP_SERVICES
|
||||
|
||||
# Defense: ensure postgres is in gendesign_shared network for tradein FDW.
|
||||
# `compose up -d` should detect networks: shared addition and recreate
|
||||
|
|
@ -883,8 +911,86 @@ jobs:
|
|||
# требуют --force-recreate — обычный `up -d` не перечитывает env_file
|
||||
# если только image не сменился. На deploy где меняется только runtime
|
||||
# без backend image change — без этого backend остаётся со старым DSN.
|
||||
#
|
||||
# #3029: worker исключён из безусловного recreate. Временная заплатка
|
||||
# до чекпоинтов (#3074) — стиль (digest-сверка pulled vs running,
|
||||
# $SERVICES) как SCRAPER_RECREATE в deploy-tradein.yml, но здесь
|
||||
# расхождение digest после skip уходит в WARNING, не в exit 1 (там
|
||||
# scraper не имеет второго потребителя схемы; здесь пропуск recreate
|
||||
# worker'а может оставить его читать старую версию схемы — риск, а не
|
||||
# ошибка деплоя). backend/beat пересоздаются безусловно, как раньше.
|
||||
WORKER_SERVICES="backend beat"
|
||||
if [ "${WORKER_RECREATE_GUARD:-on}" != "on" ]; then
|
||||
echo "→ WORKER_RECREATE_GUARD=off — безусловное пересоздание worker'а (fallback на старое поведение)"
|
||||
WORKER_SERVICES="$WORKER_SERVICES worker"
|
||||
else
|
||||
pulled_worker_image=$(docker image inspect -f '{{.Id}}' "ghcr.io/lekss361/gendesign-worker:$IMAGE_TAG" 2>/dev/null || echo "")
|
||||
running_worker_image=$(docker inspect -f '{{.Image}}' "$(docker compose -p gendesign -f docker-compose.prod.yml ps -q worker)" 2>/dev/null || echo "")
|
||||
if [ -z "$pulled_worker_image" ] || [ -z "$running_worker_image" ]; then
|
||||
echo "WARNING (#3029): не удалось прочитать worker image id (pulled='$pulled_worker_image' running='$running_worker_image') — детект не отработал, fail-safe = пересоздаём worker как обычно."
|
||||
WORKER_SERVICES="$WORKER_SERVICES worker"
|
||||
elif [ "$pulled_worker_image" = "$running_worker_image" ]; then
|
||||
echo "→ образ worker'а не изменился ($pulled_worker_image) — пересоздание и так no-op, worker в recreate"
|
||||
WORKER_SERVICES="$WORKER_SERVICES worker"
|
||||
else
|
||||
# Оба трекера прогонов — строки в БД (lifecycle.py:108 kn_scrape_runs,
|
||||
# lifecycle.py:267 objective_scrape_runs), литералы статуса сверены с
|
||||
# кодом: 'running' в обеих таблицах. Анти-зомби: считаем только
|
||||
# прогоны свежее WORKER_GUARD_MAX_SKIP_H часов (started_at /
|
||||
# heartbeat_at) — иначе зависший навечно 'running' блокировал бы
|
||||
# recreate worker'а бесконечно.
|
||||
# NB: `assignment="$(...)" && next=...` (не отдельная строка) — под
|
||||
# `set -euo pipefail` (шапка скрипта) присвоение, упавшее КАК
|
||||
# ПОСЛЕДНЯЯ команда своего стейтмента, роняет весь деплой. Внутри
|
||||
# AND-списка (не последним звеном) — нет, ошибка молча даёт пустой
|
||||
# $kn_count/$obj_count, что и проверяем ниже. Тот же приём —
|
||||
# deploy-tradein.yml psql_out/running_count.
|
||||
guard_max_h="${WORKER_GUARD_MAX_SKIP_H:-6}"
|
||||
kn_count=""
|
||||
kn_out="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
|
||||
"SELECT COUNT(*) FROM kn_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval);" 2>/dev/null)" \
|
||||
&& kn_count="$(printf '%s' "$kn_out" | tr -d '[:space:]')"
|
||||
obj_count=""
|
||||
obj_out="$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
|
||||
"SELECT COUNT(*) FROM objective_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval);" 2>/dev/null)" \
|
||||
&& obj_count="$(printf '%s' "$obj_out" | tr -d '[:space:]')"
|
||||
if ! printf '%s' "$kn_count" | grep -qE '^[0-9]+$' \
|
||||
|| ! printf '%s' "$obj_count" | grep -qE '^[0-9]+$'; then
|
||||
echo "WARNING (#3029): не удалось прочитать running-прогоны (psql молчит/пусто/не число) — детект не отработал, fail-safe = пересоздаём worker как обычно."
|
||||
WORKER_SERVICES="$WORKER_SERVICES worker"
|
||||
else
|
||||
total_running=$((kn_count + obj_count))
|
||||
if [ "$total_running" -gt 0 ]; then
|
||||
echo "!!! WORKER RECREATE SKIPPED (#3029) — ${total_running} running runs, worker остаётся на образе ${running_worker_image} !!!"
|
||||
echo "WARNING (#3029): worker digest разошёлся с pulled — running=${running_worker_image} pulled=${pulled_worker_image}"
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
|
||||
"SELECT 'kn run_id=' || run_id || ' started_at=' || started_at FROM kn_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval) ORDER BY started_at ASC;" 2>/dev/null || true
|
||||
docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
|
||||
"SELECT 'objective run_id=' || run_id || ' started_at=' || started_at FROM objective_scrape_runs WHERE status='running' AND COALESCE(heartbeat_at, started_at) > NOW() - CAST('${guard_max_h} hours' AS interval) ORDER BY started_at ASC;" 2>/dev/null || true
|
||||
if [ -n "$DEPLOY_MIGRATIONS_START_TS" ]; then
|
||||
migrations_since=""
|
||||
migrations_since=$(docker compose -p gendesign -f docker-compose.prod.yml exec -T postgres \
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc \
|
||||
"SELECT COUNT(*) FROM _schema_migrations WHERE applied_at > CAST('${DEPLOY_MIGRATIONS_START_TS}' AS timestamptz);" 2>/dev/null | tr -d '[:space:]') \
|
||||
|| migrations_since=""
|
||||
if printf '%s' "$migrations_since" | grep -qE '^[0-9]+$' && [ "$migrations_since" -gt 0 ]; then
|
||||
echo "WARNING (#3029): WORKER НА СТАРОМ КОДЕ + НОВАЯ СХЕМА — в этом деплое применились ${migrations_since} data/sql/** миграций, а worker остался на старом образе. Нужен ручной recreate после прогона: docker compose -p gendesign -f docker-compose.prod.yml up -d --force-recreate --no-deps worker"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "→ активных прогонов (kn/objective) нет — worker пересоздаётся вместе с backend/beat"
|
||||
WORKER_SERVICES="$WORKER_SERVICES worker"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
docker compose -p gendesign -f docker-compose.prod.yml up -d \
|
||||
--force-recreate --no-deps backend worker beat
|
||||
--force-recreate --no-deps $WORKER_SERVICES
|
||||
|
||||
# Caddy: force-recreate чтобы подхватить изменения в Caddyfile
|
||||
# И в особенности новые volume mounts из docker-compose.prod.yml
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue