[infra][db] Инцидент #361 повторится: мигратор падает на миграциях «задним числом» из долгоживущих веток — CI-гейт порядка + allowUnorderedMigrations; провери… #363

Open
opened 2026-07-05 01:27:27 +03:00 by agent_vscode · 0 comments
Collaborator

Суть

Инцидент #361 (crash-loop + 502 на ~11 минут после деплоя develop-билда 2026-07-04) — не разовая ошибка, а класс отказа, который гарантированно повторится. Механика: PR #234 жил в ветке долго и принёс миграцию 20260627T130000-ai-chat-runs с датой в имени старше уже применённых на проде миграций (например, 20260704T120000-client-metrics из #355). Kysely-мигратор с дефолтными настройками требует, чтобы применённые миграции были префиксом отсортированного списка → «corrupted migrations» → приложение не стартует → crash-loop.

Проверено по коду: оба мигратора работают с дефолтами, allowUnorderedMigrations не включён:

Непосредственная угроза: открытые долгоживущие PR #119 (git-sync, живёт с июня) и #120 (offline-sync). Если в них есть миграции с июньскими датами в именах — их мерж уронит прод точно так же, как #234.

Решение (два уровня, оба)

1. CI-гейт порядка миграций (основная защита, предотвращает)

Скрипт в существующий CI-пайплайн PR: сравнить имена файлов в apps/server/src/database/migrations/ между веткой PR и целевой веткой; добавленные файлы обязаны быть лексикографически новее самого нового файла в целевой ветке:

#!/usr/bin/env bash
# migration-order-gate: added migrations must sort AFTER the newest one on the
# target branch, otherwise the startup migrator crash-loops in prod (#361).
set -euo pipefail
MIG_DIR="apps/server/src/database/migrations"
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}"...HEAD -- "$MIG_DIR")
bad=0
for f in $added; do
  if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
    echo "::error::Migration $f sorts BEFORE newest on ${TARGET_BRANCH} ($newest_on_target) — rename with a current timestamp (#361)"
    bad=1
  fi
done
exit $bad

Правило для долгоживущих веток становится механическим: перед мержем переименовать миграцию на текущий таймстамп (содержимое не трогать). Гейт делает забывание невозможным.

2. allowUnorderedMigrations: true (страховка, смягчает)

Включить в обоих местах создания Migrator. Kysely тогда применяет «пропущенные» старые миграции вместо отказа — прод не падает, даже если гейт обойдён (ручной пуш, hotfix-ветка). Трейд-офф, который надо зафиксировать в комментарии к коду: порядок применения на разных инстансах может отличаться от лексикографического, поэтому миграции должны оставаться независимыми (наши и так таковы: каждая создаёт свои объекты); гейт из п. 1 остаётся основной линией обороны, страховка — на случай его обхода.

Немедленное действие (до/вне этого тикета)

Проверить содержимое #119 и #120 на миграции со старыми датами:

git fetch gitea && git diff --diff-filter=A --name-only develop...origin/feat/<branch> -- apps/server/src/database/migrations/

Если есть — переименовать на актуальный таймстамп в ветке до мержа (это правка имени файла, содержимое и ревью не затрагивает).

Крайние случаи

  • Переименование уже применённой миграции запрещено: kysely трекает по имени файла; переименовывать можно только миграции, которые ещё нигде не применялись (ветка не деплоилась ни на один инстанс). Для #119/#120 это так — они не мержились.
  • Гейт при мерже через merge-очередь/ребейз: сравнение с origin/${TARGET_BRANCH} на момент CI-рана; гонка двух одновременных PR с миграциями остаётся возможной (оба новее develop, но один старше другого) — её закрывает страховка п. 2.
  • Даунгрейд (migrate down) с unordered-миграциями: down-путь у нас фактически не используется в проде; отметить в доке, что порядок down при unordered не гарантирован.

Тесты / проверка

  • Юнит на гейт-скрипт: added-старее → fail; added-новее → pass; нет added → pass.
  • Интеграционный тест мигратора (есть образец share-aliases.migration.spec.ts): применить N миграций, «подложить» файл со старым именем, убедиться, что с allowUnorderedMigrations он применяется, без — падает (документирующий тест текущего поведения).
  • Ручная проверка: воспроизвести сценарий #361 на dev-БД — с включённой страховкой старт проходит.

Вне скоупа

  • Автоматическое переименование миграций при мерже (магия в CI хуже явного правила).
  • Переход на другой фреймворк миграций.

План работ

  1. Проверить #119/#120 на старые миграции, переименовать в ветках при необходимости (немедленно).
  2. CI-гейт в пайплайн PR.
  3. allowUnorderedMigrations в оба Migrator + комментарий с трейд-оффом + документирующий тест.
  4. Строчка в AGENTS.md/контрибьютор-доку: «миграция мержится только с таймстампом новее develop; гейт проверяет».
# Суть Инцидент #361 (crash-loop + 502 на ~11 минут после деплоя develop-билда 2026-07-04) — не разовая ошибка, а **класс отказа, который гарантированно повторится**. Механика: PR #234 жил в ветке долго и принёс миграцию `20260627T130000-ai-chat-runs` с датой в имени **старше** уже применённых на проде миграций (например, `20260704T120000-client-metrics` из #355). Kysely-мигратор с дефолтными настройками требует, чтобы применённые миграции были префиксом отсортированного списка → «corrupted migrations» → приложение не стартует → crash-loop. Проверено по коду: **оба** мигратора работают с дефолтами, `allowUnorderedMigrations` не включён: - [migrate.ts](apps/server/src/database/migrate.ts) (CLI) - [migration.service.ts](apps/server/src/database/services/migration.service.ts) (автомиграция при старте — именно она уронила прод) **Непосредственная угроза:** открытые долгоживущие PR [#119](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/pulls/119) (git-sync, живёт с июня) и [#120](https://gitea.vvzvlad.xyz/vvzvlad/gitmost/pulls/120) (offline-sync). Если в них есть миграции с июньскими датами в именах — их мерж уронит прод точно так же, как #234. # Решение (два уровня, оба) ## 1. CI-гейт порядка миграций (основная защита, предотвращает) Скрипт в существующий CI-пайплайн PR: сравнить имена файлов в `apps/server/src/database/migrations/` между веткой PR и целевой веткой; **добавленные** файлы обязаны быть лексикографически новее самого нового файла в целевой ветке: ```bash #!/usr/bin/env bash # migration-order-gate: added migrations must sort AFTER the newest one on the # target branch, otherwise the startup migrator crash-loops in prod (#361). set -euo pipefail MIG_DIR="apps/server/src/database/migrations" newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1) added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}"...HEAD -- "$MIG_DIR") bad=0 for f in $added; do if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then echo "::error::Migration $f sorts BEFORE newest on ${TARGET_BRANCH} ($newest_on_target) — rename with a current timestamp (#361)" bad=1 fi done exit $bad ``` Правило для долгоживущих веток становится механическим: перед мержем **переименовать** миграцию на текущий таймстамп (содержимое не трогать). Гейт делает забывание невозможным. ## 2. `allowUnorderedMigrations: true` (страховка, смягчает) Включить в обоих местах создания `Migrator`. Kysely тогда применяет «пропущенные» старые миграции вместо отказа — прод не падает, даже если гейт обойдён (ручной пуш, hotfix-ветка). Трейд-офф, который надо зафиксировать в комментарии к коду: порядок применения на разных инстансах может отличаться от лексикографического, поэтому миграции должны оставаться независимыми (наши и так таковы: каждая создаёт свои объекты); гейт из п. 1 остаётся основной линией обороны, страховка — на случай его обхода. # Немедленное действие (до/вне этого тикета) Проверить содержимое `#119` и `#120` на миграции со старыми датами: ```bash git fetch gitea && git diff --diff-filter=A --name-only develop...origin/feat/<branch> -- apps/server/src/database/migrations/ ``` Если есть — переименовать на актуальный таймстамп **в ветке до мержа** (это правка имени файла, содержимое и ревью не затрагивает). # Крайние случаи - **Переименование уже применённой миграции запрещено**: kysely трекает по имени файла; переименовывать можно только миграции, которые ещё нигде не применялись (ветка не деплоилась ни на один инстанс). Для #119/#120 это так — они не мержились. - **Гейт при мерже через merge-очередь/ребейз**: сравнение с `origin/${TARGET_BRANCH}` на момент CI-рана; гонка двух одновременных PR с миграциями остаётся возможной (оба новее develop, но один старше другого) — её закрывает страховка п. 2. - **Даунгрейд** (`migrate down`) с unordered-миграциями: down-путь у нас фактически не используется в проде; отметить в доке, что порядок down при unordered не гарантирован. # Тесты / проверка - Юнит на гейт-скрипт: added-старее → fail; added-новее → pass; нет added → pass. - Интеграционный тест мигратора (есть образец [share-aliases.migration.spec.ts](apps/server/src/database/share-aliases.migration.spec.ts)): применить N миграций, «подложить» файл со старым именем, убедиться, что с `allowUnorderedMigrations` он применяется, без — падает (документирующий тест текущего поведения). - Ручная проверка: воспроизвести сценарий #361 на dev-БД — с включённой страховкой старт проходит. # Вне скоупа - Автоматическое переименование миграций при мерже (магия в CI хуже явного правила). - Переход на другой фреймворк миграций. # План работ 1. Проверить #119/#120 на старые миграции, переименовать в ветках при необходимости (немедленно). 2. CI-гейт в пайплайн PR. 3. `allowUnorderedMigrations` в оба Migrator + комментарий с трейд-оффом + документирующий тест. 4. Строчка в AGENTS.md/контрибьютор-доку: «миграция мержится только с таймстампом новее develop; гейт проверяет».
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#363