Runbook: миграция уехала на прод без своего кода

Severity (default): P1 Owner: backend / on-call Связанные алерты: нет — класс инцидентов проявляется как тихая порча данных, а не как ошибка

Симптом

Схема на проде ушла вперёд относительно выкаченного образа: миграция применена, а код, без которого она осмысленна, ещё не задеплоен. Наружу это выходит не ошибкой, а тихой порчей данных — колонка молча заполняется NULL, счётчик молча обнуляется, ключ связывания молча перестаёт совпадать.

Разбор конкретного случая — 2026-07-31/08-01, resolve_canonical_value_id:

  • миграции 0270 (16:39 UTC) и 0271 (20:13:59 UTC) применились без кода цикла;
  • обязательный шаг «догнать value_id до нуля» никто не выполнил, потому что словарной фазы бэкфилла в выкаченном образе не было;
  • функция стала возвращать NULL для 99.9% написаний, и facts-projector записал этот NULL в offer_characteristic_facts.canonical_value_id — ключ, по которому matcher Tier 2 и struct-overlap аналогов сравнивают офферы разных поставщиков;
  • задето ~890 тысяч строк из 15.3 млн, из них ~510 тысяч потеряли живой id.

Почему это возможно: deploy-db-vps.sh применяет миграции до docker compose up, а POSTGRES_MIGRATE_ON_START=false делает деплой-джобу единственным аппликатором. Если джоба применила миграции и упала до подъёма контейнеров — схема впереди кода. То же происходит, когда в main попадает миграция из ветки, чей код ещё не собран в образ.

Диагностика

Схема против кода:

# версия схемы на проде
ssh tracium-db "docker exec \$(docker ps --format '{{.Names}}'|grep -i postgres|head -1) \
  psql -U tracium -d tracium -tAc 'SELECT max(version_id) FROM goose_db_version'"
 
# какой образ реально крутится
ssh tracium-db "docker inspect -f '{{.Config.Image}}' tracium-facts-projector-1"

Есть ли тихая порча — сравнить свежезаписанные строки с фоном. Пример для canonical_value_id; подставить свою колонку и момент применения миграции:

SELECT CASE WHEN updated_at >= TIMESTAMPTZ '<время применения миграции>'
              THEN 'после миграции' ELSE 'фон' END AS bucket,
       count(*) AS rows,
       round(100.0 * count(*) FILTER (WHERE canonical_value_id IS NULL) / count(*), 1) AS pct_null
  FROM (SELECT updated_at, canonical_value_id
          FROM offer_characteristic_facts TABLESAMPLE SYSTEM (0.2)) s
 GROUP BY 1;

Резкое расхождение долей («после миграции» 100% против фона 43%) — подтверждение. Совпадение долей — порчи нет, ищите другое.

Смягчение

1. Остановить писателя. Иначе порча растёт всё время, пока идёт разбор.

ssh tracium-db "docker update --restart=no tracium-facts-projector-1 && \
                docker stop -t 15 tracium-facts-projector-1"
ssh tracium-db "docker inspect -f '{{.State.Status}} restart={{.HostConfig.RestartPolicy.Name}}' \
                tracium-facts-projector-1"

docker kill и docker stop без снятия политики НЕ РАБОТАЮТ. При restart=unless-stopped контейнер поднимается обратно через минуту. В разборе 2026-08-01 это стоило полутора часов незамеченной порчи: проектор считался остановленным, а писал. Всегда проверять статус после остановки, а не считать команду успешной.

2. Убедиться, что запись прекратилась — по данным, а не по статусу контейнера:

SELECT count(*) FROM offer_characteristic_facts
 WHERE updated_at >= now() - interval '5 minutes';

Ноль — кровь остановлена. Не ноль — ищите второго писателя через pg_stat_activity (client_addr → контейнер через docker inspect).

3. Откатить схему отдельной миграцией, а не goose down. На проде обычно уже стоят версии поверх виновной, и down снял бы и их. Содержимое — блок Down виновной миграции, одной транзакцией: пока функция уже отдаёт новое значение, а FK ещё смотрит на старую таблицу, каждая вставка падает по внешнему ключу.

Пример — 0274_revert_resolve_canonical_value_id_to_dictionary.sql.

Устранение root cause

Починка данных. Пересчитать только испорченный диапазон, а не всю таблицу: у canonical_value_id фоновая доля NULL — 43%, и трогать 6.5 млн фоновых строк вместо ~370 тысяч пострадавших означает часы лишнего IO.

Готовый скрипт: backend/scripts/valuenorm_incident_repair.sql. Что в нём важно и что стоит повторить в любом похожем:

  • Обход по диапазонам ctid, а не по индексу времени. Индексный обход выдаёт строки в порядке времени, физически разбросанные по файлу: замер на проде дал ~120 мс на строку (около 2.5 случайных чтений при r_await 47 мс) и 500 строк в минуту. Tid Range Scan читает файл последовательно.
  • Сходимость по построению. Номер блока растёт безусловно. Признак «порция ничего не починила» как условие остановки неверен: в окне больше половины строк не резолвится и после починки, и проход встанет на первом же их скоплении.
  • Повтор при deadlock_detected. Починка и проектор правят одни строки в разном порядке — взаимоблокировка здесь штатное событие. BEGIN/EXCEPTION заводит неявный savepoint, поэтому откатывается одна попытка, а не диапазон. Без повтора первый прогон умер на втором диапазоне, а ON_ERROR_STOP=1 оборвал скрипт молча.
  • Границы окна сверху и снизу. Верхняя граница — момент остановки писателя. Без неё обход гоняется за движущейся целью и тратит время на строки, уже записанные правильно.

Проверка результата — замером, а не счётчиком скрипта: доля NULL в окне инцидента должна сойтись с фоном.

Чтобы класс не повторялся. Миграция, осмысленная только вместе с кодом, обязана нести это в себе: предусловие в шапке, а лучше — гейт, который отказывается применяться, если предусловие не выполнено (например, функция возвращает NULL там, где раньше возвращала значение). Порядок выкатки, живущий только в тексте спеки, не выполняется, когда миграция доезжает до прода отдельным путём.

Эскалация

  • Порча растёт, а писателя остановить не удаётся → эскалировать немедленно: каждая минута умножает объём починки.
  • Откатная миграция не проходит по lock_timeout → почти наверняка окно бэкапа, см. ../deployment.md, раздел про DDL и бэкап.

Связано