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_await47 мс) и 500 строк в минуту.Tid Range Scanчитает файл последовательно. - Сходимость по построению. Номер блока растёт безусловно. Признак «порция ничего не починила» как условие остановки неверен: в окне больше половины строк не резолвится и после починки, и проход встанет на первом же их скоплении.
- Повтор при
deadlock_detected. Починка и проектор правят одни строки в разном порядке — взаимоблокировка здесь штатное событие.BEGIN/EXCEPTIONзаводит неявный savepoint, поэтому откатывается одна попытка, а не диапазон. Без повтора первый прогон умер на втором диапазоне, аON_ERROR_STOP=1оборвал скрипт молча. - Границы окна сверху и снизу. Верхняя граница — момент остановки писателя. Без неё обход гоняется за движущейся целью и тратит время на строки, уже записанные правильно.
Проверка результата — замером, а не счётчиком скрипта: доля NULL в окне инцидента
должна сойтись с фоном.
Чтобы класс не повторялся. Миграция, осмысленная только вместе с кодом, обязана нести
это в себе: предусловие в шапке, а лучше — гейт, который отказывается применяться, если
предусловие не выполнено (например, функция возвращает NULL там, где раньше возвращала
значение). Порядок выкатки, живущий только в тексте спеки, не выполняется, когда миграция
доезжает до прода отдельным путём.
Эскалация
- Порча растёт, а писателя остановить не удаётся → эскалировать немедленно: каждая минута умножает объём починки.
- Откатная миграция не проходит по
lock_timeout→ почти наверняка окно бэкапа, см.../deployment.md, раздел про DDL и бэкап.
Связано
../deployment.md— грабли деплой-джобы на DB-VPScanonical-identity-rekey-cutover.mdevent-store-projection-lag.mddocs/superpowers/specs/2026-07-31-valuenorm-value-identity-design.md— порядок выкатки §5, из которого и вырос этот инцидент