From 6bb3189bc4bdf7fd7963e9815a4d61a060fbdb09 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 24 Aug 2026 20:06:00 +0300 Subject: [PATCH] =?UTF-8?q?chore(ci):=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BD=D0=B5=20=D1=83=D0=B1=D0=B8=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20worker=20=D0=BF=D0=BE=D1=81=D1=80=D0=B5=D0=B4=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D0=B0=D0=BF-=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каждый деплой ПТИЦЫ безусловно пересоздавал 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 --- .forgejo/workflows/deploy.yml | 112 +++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index e1d314bf..99ef6e21 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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