chore(ci): деплой не убивает worker посреди скрап-прогона (заплатка до #3074) #3084

Merged
lekss361 merged 1 commit from chore/3029-worker-recreate-guard into main 2026-08-24 17:25:23 +00:00
Owner

Зачем

Каждый деплой ПТИЦЫ безусловно пересоздавал worker — а он единственный, кто несёт скрап-прогоны. Разведка попутно поправила формулировку задачи: сервиса scraper в docker-compose.prod.yml нет вообще, beat только триггерит, так что гвардить надо именно worker и при этом не задеть backend/beat, которые пересоздавались той же строкой.

Полный прогон КН идёт часами, при неудаче — сутки. Любой мерж в это окно убивал прогон молча, и деплой при этом был зелёным.

Это временная заплатка до чекпоинтов (#3074): пока прогон не умеет продолжаться с места обрыва, дешевле не обрывать.

Логика

Пересоздание пропускается, только когда выполнены оба условия:

  1. образ действительно сменился (иначе пересоздание и так no-op — гвардить нечего);
  2. прямо сейчас есть живой прогон.

Маркер заводить не пришлось — он уже есть: kn_scrape_runs и objective_scrape_runs со status='running'. Литералы статуса сверены с backend/app/workers/lifecycle.py, а не угаданы.

Анти-зомби: считаются только прогоны свежее WORKER_GUARD_MAX_SKIP_H часов (дефолт 6) по COALESCE(heartbeat_at, started_at) — той же паре, что индексирует data/sql/55_schema_scrape_resume.sql:22 и по которой фильтрует reaper. Иначе зависший навечно running заморозил бы worker бесконечно.

Fail-safe перевёрнут в сторону статус-кво

psql молчит, отдал пустое, отдал не число, контейнера нет, image id не читается → пересоздаём как раньше и печатаем WARNING.

Обоснование: тихий no-op деплоя опаснее убитого прогона. При обратном выборе сломанный детект оставил бы worker на старом коде навсегда и незаметно.

Что нашло ревью

Два независимых рецензента в свежем контексте нашли одну и ту же критическую дыру, из-за которой заплатка не работала бы ровно в своём целевом случае.

Guard стоял после голого up -d по всему стеку, а тот сам пересоздаёт любой сервис со сменившимся образом. То есть worker убивался раньше, чем guard успевал сравнить digest'ы, и к моменту проверки они уже совпадали — печаталось «образ не изменился», ни SKIPPED, ни WARNING. Защита была мертва, и незаметно.

Фикс: worker явно исключён из bulk-команды, список берётся из docker compose config --services. Проверено на живом Docker (Compose v5.1.4), что config --services фильтрует по активным профилям — сервисы под неактивными профилями в список не попадают, поэтому семантика сегодняшнего up -d сохраняется и регрессии с infra-postgres из #3061 нет (его профиль на Beget не активен, значит он не будет назван явно и не активируется).

Вторая находка: tr -d '[:space:]' вырезал пробел внутри timestamp от NOW() (2026-08-24 09:12:33+002026-08-2409:12:33+00), из-за чего CAST падал и предупреждение «старый код + новая схема» не напечаталось бы никогда.

Риск — он реальный, назван прямо

Пропуск означает, что worker остаётся на старом образе до конца прогона и следующего деплоя. Миграции применяются ДО подъёма приложения, поэтому старый код может оказаться на новой схеме.

Смягчения:

  • отдельная строка в логе с готовой командой ручного recreate, если в этом же деплое применились data/sql/**;
  • предупреждение о расхождении digest'ов повторяется в каждом следующем деплое, пока расхождение живо — заплатка не может тихо забыться.

Откат

Переменная репозитория WORKER_RECREATE_GUARD в off → безусловное пересоздание, как раньше. Без коммита и без деплоя. Второй уровень — revert одного коммита, блок цельный.

Test plan

  • YAML парсится (yaml.safe_load)
  • шелл-блок извлечён из workflow и проходит bash -n
  • имена колонок сверены со схемой: run_id, heartbeat_at в обеих таблицах, _schema_migrations.applied_at
  • ${WORKER_RECREATE_GUARD:-on} даёт on и при пустом значении, и при unset (важно: actions-переменная, которой нет, приезжает пустой строкой)
  • поведение config --services с профилями проверено на живом Docker
  • первый деплой после мержа: в логе видно «активных прогонов нет — worker пересоздаётся», то есть обычный путь не изменился
  • проверка на живом прогоне — запустить прогон КН и сделать деплой, убедиться, что печатается рамка SKIPPED и прогон доживает

Refs #3029, #3074

## Зачем Каждый деплой ПТИЦЫ безусловно пересоздавал `worker` — а он единственный, кто несёт скрап-прогоны. Разведка попутно поправила формулировку задачи: сервиса `scraper` в `docker-compose.prod.yml` нет вообще, `beat` только триггерит, так что гвардить надо именно `worker` и при этом не задеть `backend`/`beat`, которые пересоздавались той же строкой. Полный прогон КН идёт часами, при неудаче — сутки. Любой мерж в это окно убивал прогон молча, и деплой при этом был зелёным. Это **временная заплатка до чекпоинтов (#3074)**: пока прогон не умеет продолжаться с места обрыва, дешевле не обрывать. ## Логика Пересоздание пропускается, только когда выполнены **оба** условия: 1. образ действительно сменился (иначе пересоздание и так no-op — гвардить нечего); 2. прямо сейчас есть живой прогон. Маркер заводить не пришлось — он уже есть: `kn_scrape_runs` и `objective_scrape_runs` со `status='running'`. Литералы статуса сверены с `backend/app/workers/lifecycle.py`, а не угаданы. Анти-зомби: считаются только прогоны свежее `WORKER_GUARD_MAX_SKIP_H` часов (дефолт 6) по `COALESCE(heartbeat_at, started_at)` — той же паре, что индексирует `data/sql/55_schema_scrape_resume.sql:22` и по которой фильтрует reaper. Иначе зависший навечно `running` заморозил бы worker бесконечно. ## Fail-safe перевёрнут в сторону статус-кво psql молчит, отдал пустое, отдал не число, контейнера нет, image id не читается → **пересоздаём как раньше** и печатаем WARNING. Обоснование: тихий no-op деплоя опаснее убитого прогона. При обратном выборе сломанный детект оставил бы worker на старом коде навсегда и незаметно. ## Что нашло ревью Два независимых рецензента в свежем контексте нашли одну и ту же **критическую** дыру, из-за которой заплатка не работала бы ровно в своём целевом случае. Guard стоял **после** голого `up -d` по всему стеку, а тот сам пересоздаёт любой сервис со сменившимся образом. То есть worker убивался раньше, чем guard успевал сравнить digest'ы, и к моменту проверки они уже совпадали — печаталось «образ не изменился», ни SKIPPED, ни WARNING. Защита была мертва, и незаметно. Фикс: worker явно исключён из bulk-команды, список берётся из `docker compose config --services`. Проверено на живом Docker (Compose v5.1.4), что `config --services` фильтрует по активным профилям — сервисы под неактивными профилями в список не попадают, поэтому семантика сегодняшнего `up -d` сохраняется и **регрессии с `infra-postgres` из #3061 нет** (его профиль на Beget не активен, значит он не будет назван явно и не активируется). Вторая находка: `tr -d '[:space:]'` вырезал пробел **внутри** timestamp от `NOW()` (`2026-08-24 09:12:33+00` → `2026-08-2409:12:33+00`), из-за чего `CAST` падал и предупреждение «старый код + новая схема» не напечаталось бы никогда. ## Риск — он реальный, назван прямо Пропуск означает, что worker остаётся **на старом образе** до конца прогона и следующего деплоя. Миграции применяются ДО подъёма приложения, поэтому старый код может оказаться на новой схеме. Смягчения: - отдельная строка в логе с готовой командой ручного recreate, если в этом же деплое применились `data/sql/**`; - предупреждение о расхождении digest'ов повторяется в **каждом** следующем деплое, пока расхождение живо — заплатка не может тихо забыться. ## Откат Переменная репозитория `WORKER_RECREATE_GUARD` в `off` → безусловное пересоздание, как раньше. Без коммита и без деплоя. Второй уровень — revert одного коммита, блок цельный. ## Test plan - [x] YAML парсится (`yaml.safe_load`) - [x] шелл-блок извлечён из workflow и проходит `bash -n` - [x] имена колонок сверены со схемой: `run_id`, `heartbeat_at` в обеих таблицах, `_schema_migrations.applied_at` - [x] `${WORKER_RECREATE_GUARD:-on}` даёт `on` и при пустом значении, и при unset (важно: actions-переменная, которой нет, приезжает пустой строкой) - [x] поведение `config --services` с профилями проверено на живом Docker - [ ] первый деплой после мержа: в логе видно «активных прогонов нет — worker пересоздаётся», то есть обычный путь не изменился - [ ] проверка на живом прогоне — запустить прогон КН и сделать деплой, убедиться, что печатается рамка SKIPPED и прогон доживает Refs #3029, #3074
lekss361 added 1 commit 2026-08-24 17:06:48 +00:00
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
6bb3189bc4
Каждый деплой ПТИЦЫ безусловно пересоздавал 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
lekss361 merged commit 17d23cfaae into main 2026-08-24 17:25:23 +00:00
lekss361 deleted branch chore/3029-worker-recreate-guard 2026-08-24 17:25:24 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3084
No description provided.