From 15ca70ad709384820207e017ae1abaa6eb9c78a9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 01:49:54 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/deploy):=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BF=D0=B5=D1=80=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B5=20=D1=81=20=D0=B1=D1=8D=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=BE=D0=BC=20+=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D1=8F?= =?UTF-8?q?=20(#2679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Почему: `tradein-backend`, `tradein-scraper` и `tradein-tgbot` — один образ gendesign-tradein-backend, но пересоздание скрапера было привязано к allowlist'у путей («файлы, которые исполняет планировщик»). Список перечислял только то, что вспомнили: 2026-07-02 (#2188) на нём погорел fias-dedup — починили ДОБАВЛЕНИЕМ путей; 2026-08-05 (#2675) тот же механизм выстрелил снова на house_imv_backfill.py / product_handlers.py. За июнь-август 48% (193 из 402) backend-мержей не попадали ни в один путь списка. Что сделано вместо очередного пополнения списка: - фильтр `scraper` удалён; признак пересоздания = `if:` джобы build-backend (backend || infra || workflow_dispatch), т.е. «образ мог пересобраться»; - добавлена сверка image ID backend/scraper/tgbot после health-checks: при расхождении деплой падает ДО записи .tradein-deployed-sha, а не отчитывается успехом. Следующий прогон возьмёт ту же базу и пересоберёт накопленное. «Phase 0»-компромисс (infra не трогает скрапер, чтобы не убить многочасовой прогон) снят: с #1951 перед recreate'ом идёт graceful drain + startup-reap, а `compose up -d` на неизменившемся образе — no-op. Цена по факту (43 прод-деплоя 31.07-05.08): деплой со скрапером median 113s / mean 146s против 76s / 74s без него; средний деплой вырастет примерно на 40s, худший случай — до +5 мин (потолок drain'а при длинном full-load'е). Refs #2679 --- .forgejo/workflows/deploy-tradein.yml | 100 ++++++++++++++++++-------- 1 file changed, 71 insertions(+), 29 deletions(-) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index d2425ca4..894dcb02 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -28,7 +28,8 @@ jobs: frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }} browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }} infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }} - scraper: ${{ steps.set-all.outputs.scraper || steps.filter.outputs.scraper }} + # Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE + # в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend. steps: - uses: actions/checkout@v4 with: @@ -91,7 +92,6 @@ jobs: echo "frontend=true" >> "$GITHUB_OUTPUT" echo "browser=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 # (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/deploy/**' - '.forgejo/workflows/deploy-tradein.yml' - scraper: - - 'tradein-mvp/backend/app/services/scrapers/**' - - 'tradein-mvp/backend/app/services/scrape_pipeline.py' - - 'tradein-mvp/backend/app/services/scheduler.py' - - 'tradein-mvp/backend/app/scheduler_main.py' - - 'tradein-mvp/backend/app/tasks/**' - # #2188: scheduler исполняет matching/dedup при каждом scrape-тике — - # без этих путей scraper-контейнер оставался на старом коде - # (2026-07-02: fias-dedup доехал до tradein-backend, но не до - # tradein-scraper). После USE_KIT_SCHEDULER=true kit-код и есть - # scheduler — его правки тоже обязаны пересоздавать контейнер. - - 'tradein-mvp/backend/app/services/matching/**' - - 'tradein-mvp/backend/app/services/house_dedup_merge.py' - - 'tradein-mvp/packages/scraper-kit/**' + # УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом + # «файлов, которые исполняет планировщик», и перечислял только то, + # что вспомнили. Дважды выстрелило одинаково: + # 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не + # до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**, + # house_dedup_merge.py) — залатали случай, не механизм; + # 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py + # в списке не значились → планировщик час крутил старый код, + # деплой при этом отчитался успехом. + # За июнь-август 48% (193 из 402) backend-мержей не попадали ни в + # один из путей списка, т.е. половина правок доезжала до scraper'а + # только со следующим «удачным» деплоем. Теперь пересоздание + # привязано не к списку файлов, а к факту пересборки образа — + # см. SCRAPER_RECREATE в job deploy. # Quality gate: pytest MUST pass before any image is built/deployed (#666). # Runs the tradein-mvp/backend suite; a red test blocks build + deploy. @@ -295,20 +295,28 @@ jobs: env: IMAGE_TAG: latest GHCR_PAT: ${{ secrets.GHCR_PAT }} - # Phase 0: generic infra edits (compose / workflow / deploy/**) must NOT - # recreate the scraper and SIGKILL a running multi-hour job. Only genuine - # scraper-code paths (the `scraper` paths-filter already covers - # app/services/scrapers/**, scrape_pipeline.py, scheduler.py, - # scheduler_main.py, app/tasks/**) — or a manual workflow_dispatch — - # should trigger a scraper recreate. (infra term intentionally dropped.) - SCRAPER_CHANGED: ${{ needs.changes.outputs.scraper == 'true' || github.event_name == 'workflow_dispatch' }} + # #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ + # gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса, + # одна строка image, разный command). Значит вопрос «пересоздавать ли + # scraper» — это не «трогали ли его файлы», а «мог ли пересобраться + # образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend: + # backend || infra || workflow_dispatch. Ровно тогда в реестре мог + # появиться новый :latest, и оставить scraper на старом — значит + # оставить планировщик на старом коде (инцидент #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 }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,GHCR_PAT,SCRAPER_CHANGED,GITHUB_SHA + envs: IMAGE_TAG,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA script: | set -euo pipefail cd /opt/gendesign @@ -489,8 +497,9 @@ jobs: # с browser/backend/frontend, отдельного graceful-drain не требует. SERVICES="browser backend frontend tgbot" SCRAPER_STOP_TS="" - if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then - echo "→ scraper paths changed — waiting for in-flight scrape_runs to drain (up to 5 min)" + if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then + echo "→ backend-образ мог пересобраться — scraper пересоздаётся вместе с backend (#2679);" + echo " ждём слива in-flight scrape_runs (до 5 мин)" drained="" for i in $(seq 1 30); do # NB: не сливать "psql не ответил" с "0 running" — иначе неудачный @@ -530,12 +539,13 @@ jobs: SERVICES="$SERVICES scraper" else - echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)" + echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем" + echo " (сверка образов ниже всё равно проверит, что он не отстал)" 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 + if [ "${SCRAPER_RECREATE:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" # NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до # сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD) @@ -653,7 +663,7 @@ jobs: # снижает шанс поймать контейнер ровно в момент between-restarts # промежуточного "running" внутри crash-loop. # tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES); - # scraper — только когда SCRAPER_CHANGED (см. блок выше) — поэтому + # scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому # проверяем только то, что реально входит в текущий $SERVICES. for svc in tgbot scraper; do case " $SERVICES " in @@ -683,6 +693,38 @@ jobs: echo "→ tradein-$svc running." 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 "missing") + image_mismatch="" + for svc in scraper tgbot; do + svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "missing") + if [ "$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 " Лечение вручную: docker compose -p gendesign-tradein \\" + echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\" + echo " up -d --force-recreate --no-deps scraper tgbot" + exit 1 + fi + echo "→ образы совпадают: backend/scraper/tgbot на $backend_image." + # Cleanup старых образов for repo in ghcr.io/lekss361/gendesign-tradein-backend \ ghcr.io/lekss361/gendesign-tradein-frontend; do From bb6e5c7e41156d14e26f7eecb73397e1d5abb42e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 01:58:34 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein/deploy):=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D1=82=D1=8C=20=D0=B2=20drain,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=87=D0=B5=D0=B3=D0=BE=20(#2679)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SCRAPER_RECREATE` истинно и на infra-правках (compose / workflow / deploy/**), а те почти всегда собирают тот же образ по кэшу: digest не меняется, `up -d` выходит no-op — и за него платили пятиминутным ожиданием слива scrape_runs, прерывая сбор. Теперь после `docker compose pull` (порядок важен: до pull'а под :latest ещё старый образ) сравниваем ID подтянутого образа с тем, на котором бежит tradein-scraper. Совпало — печатаем «пересоздавать нечего» и идём дальше без drain'а; не совпало или контейнера/тега нет — drain как раньше. Заодно закрыт ложный startup-reap: чекпоинт и reap завязаны на тот же признак и больше не выполняются, когда recreate'а не было. Иначе прогон, переживший таймаут drain'а в НЕ пересозданном контейнере, помечался бы 'cancelled', продолжая работать. scraper остаётся в $SERVICES в обоих случаях — при совпавшем образе `up -d` no-op, но правка самого compose (env/лимиты сервиса) так всё же доезжает. Сверка образов: «контейнера нет» и «контейнер отстал» теперь разные сообщения — это разные аварии и чинятся по-разному; отсутствие tradein-backend (эталона) тоже отдельная строка. Refs #2679 --- .forgejo/workflows/deploy-tradein.yml | 75 +++++++++++++++++++++------ 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 894dcb02..102dfde0 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -294,6 +294,9 @@ jobs: uses: appleboy/ssh-action@v1.0.3 env: IMAGE_TAG: latest + # Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не + # уходить в drain, когда пересоздавать нечего (см. ниже, #2679). + IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }} GHCR_PAT: ${{ secrets.GHCR_PAT }} # #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ # gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса, @@ -316,7 +319,7 @@ jobs: username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} - envs: IMAGE_TAG,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA + envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA script: | set -euo pipefail cd /opt/gendesign @@ -497,8 +500,42 @@ jobs: # с browser/backend/frontend, отдельного graceful-drain не требует. SERVICES="browser backend frontend tgbot" SCRAPER_STOP_TS="" + scraper_stale="" if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then - echo "→ backend-образ мог пересобраться — scraper пересоздаётся вместе с backend (#2679);" + # Пересоздавать нечего — и ждать нечего (#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="" for i in $(seq 1 30); do @@ -536,16 +573,11 @@ jobs: 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 "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем" - echo " (сверка образов ниже всё равно проверит, что он не отстал)" fi docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps $SERVICES - if [ "${SCRAPER_RECREATE:-true}" = "true" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then + if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" # NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до # сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD) @@ -707,20 +739,31 @@ jobs: # узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт # ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же # базу и пересоберёт всё накопленное (тот же приём, что в health-check). - backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "missing") + # «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся + # по-разному, поэтому сообщения различаются явно. + 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 "missing") - if [ "$svc_image" != "$backend_image" ]; then - echo "ERROR: tradein-$svc на образе $svc_image, tradein-backend — на $backend_image" + 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 " Лечение вручную: docker compose -p gendesign-tradein \\" - echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\" - echo " up -d --force-recreate --no-deps scraper tgbot" + 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."