fix(db/ci): 077 гардится по USER MAPPING, деплой ждёт готовности БД по TCP

Deep-review BLOCK на PR #3011: обе правки чинили заявленный симптом только
частично.

077: гард считал pending-строки по source='rosreestr' AND dedup_hash ~
md5-паттерн и пропускал backfill, только если таких строк 0. На чистой БД
они есть — 003_seed_deals.sql сеет синтетические сделки с тем же паттерном,
значит pending > 0 уже на пустом томе, и миграция всё равно падала на
"user mapping not found" (воспроизведено в CI run 8257). Первичный гард
теперь проверяет напрямую наличие USER MAPPING для gendesign_remote
(идиома из app/core/fdw.py:57-62), счётчик pending оставлен вторым —
экономит обращение к FDW, когда мигрировать уже нечего.

deploy-tradein.yml: цикл ожидания готовности postgres ходил по unix-сокету
(pg_isready без -h). На пустом томе временный init-сервер отвечает на
сокете, пока docker-entrypoint-initdb.d ещё прогоняет цепочку миграций —
проба зеленела посреди initdb. Добавлен -h 127.0.0.1 (тот же приём уже
есть в ci-tradein.yml:157) — TCP открывается только после полного
завершения initdb.d.

Отдельно ужесточён sentinel baseline-детекции: раньше «схема уже накачена»
проверялась одной таблицей listings (миграция 002, почти голова цепочки).
Если бы гонка готовности когда-нибудь вернулась, listings был бы уже
создан, а хвост цепочки — ещё нет, и baseline тихо пометил бы недостающие
миграции применёнными без прогона. Теперь проверяются оба конца — listings
(голова) и houses_geog_gist_idx, индекс из миграции 270 (хвост); при
несовпадении (ровно один конец на месте) деплой падает громко с explicit
ошибкой вместо угадывания.
This commit is contained in:
bot-backend 2026-08-21 15:35:26 +03:00
parent efc965a257
commit a3ccbbd045
2 changed files with 98 additions and 22 deletions

View file

@ -713,11 +713,29 @@ jobs:
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
#
# `-h 127.0.0.1` ОБЯЗАТЕЛЕН (#2990) — по тому же образцу, что уже в
# ci-tradein.yml:157. На пустом томе образ postgres поднимает
# ВРЕМЕННЫЙ сервер с listen_addresses='' на время прогона
# docker-entrypoint-initdb.d (сюда смонтирован весь
# backend/data/sql/*.sql, см. docker-compose.prod.yml). Этот временный
# сервер отвечает "accepting connections" по unix-сокету уже через
# пару секунд — а pg_isready БЕЗ -h ходит именно по сокету через
# `docker compose exec`. Проба зеленела посреди initdb, до того как
# цепочка миграций реально доехала до конца, и код ниже (детект
# baseline vs пустая БД) видел частично накаченную схему. TCP-порт
# 5432 открывается только когда initdb.d полностью отработал и
# postgres перезапустился как настоящий сервер — проба по 127.0.0.1
# зеленеет ровно тогда, когда БД реально готова.
#
# 90 попыток × 2с = до 3 минут: на пустом томе postgres прогоняет
# ВСЮ цепочку миграций (270+ файлов) внутри initdb, это медленнее,
# чем ожидание живого сервера на непустом томе (обычный деплой).
echo "→ Ожидание готовности postgres..."
pg_ready=""
for i in $(seq 1 30); do
for i in $(seq 1 90); do
if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
pg_isready -h 127.0.0.1 -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
pg_ready="yes"; break
fi
sleep 2
@ -755,11 +773,27 @@ jobs:
# 2) ПУСТАЯ БД на новом сервере → baseline пометил бы все миграции
# применёнными, ни одной не прогнав, и деплой уехал бы зелёным
# на пустой схеме. Отказ тихий и обнаружился бы уже под нагрузкой.
# Различаем по живой таблице listings: она есть только если схема реально
# применялась (initdb или предыдущим циклом миграций).
schema_already_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
#
# Раньше различали одной живой таблицей listings — она создаётся
# миграцией 002, то есть почти в САМОМ НАЧАЛЕ цепочки. Этого мало: на
# пустом томе до фикса ожидания готовности (см. выше, -h 127.0.0.1)
# проба зеленела ПОСРЕДИ initdb, когда listings уже создан, а хвост
# цепочки — ещё нет; результат — тихий baseline недокачанной схемы.
# Фикс готовности эту гонку убирает (TCP открывается только после
# полного прохода initdb.d), но сентинел всё равно проверяем по ОБОИМ
# концам цепочки как defense-in-depth: если голова и хвост когда-нибудь
# разъедутся — это тот самый гоночный симптом, и его надо ловить явно,
# а не гадать.
#
# Хвост — houses_geog_gist_idx, индекс из миграции 270 (#2997, самая
# свежая на момент правки #2990). При добавлении новых миграций после
# 270 обнови этот сентинел на объект из новой последней миграции.
schema_head_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT to_regclass('public.listings') IS NOT NULL;" | tr -d '[:space:]')
schema_tail_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \
"SELECT to_regclass('public.houses_geog_gist_idx') IS NOT NULL;" | tr -d '[:space:]')
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on -c "
@ -769,18 +803,23 @@ jobs:
);
"
if [ "$migrations_table_existed" != "t" ] && [ "$schema_already_present" != "t" ]; then
# ПУСТАЯ БД: baseline пропускаем намеренно. Цикл ниже применит всю
# цепочку с нуля под ON_ERROR_STOP — это и есть штатный путь чистого
# старта на новом сервере.
echo "→ БД пуста (нет ни _schema_migrations, ни listings) — baseline ПРОПУЩЕН,"
if [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" != "t" ] && [ "$schema_tail_present" != "t" ]; then
# ПУСТАЯ БД: ни головы, ни хвоста цепочки — baseline пропускаем
# намеренно. Цикл ниже применит всю цепочку с нуля под
# ON_ERROR_STOP — это и есть штатный путь чистого старта на новом
# сервере (initdb.d уже должен был всё применить сам; этот цикл —
# подстраховка на случай, если монтирование почему-то не сработало).
echo "→ БД пуста (нет ни _schema_migrations, ни listings, ни хвоста цепочки) — baseline ПРОПУЩЕН,"
echo " вся цепочка миграций будет применена циклом ниже."
elif [ "$migrations_table_existed" != "t" ]; then
# BASELINE: таблицы не было, но схема есть → seed ВСЕ текущие миграции
# как applied БЕЗ их прогона. prod уже работает на этой схеме; помечаем
# её текущим состоянием, чтобы под строгий gate попадали только НОВЫЕ
# (077+) миграции. INSERT ... ON CONFLICT DO NOTHING — идемпотентно.
echo "→ _schema_migrations отсутствовала, но схема на месте — baseline существующих миграций (без прогона)"
elif [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" = "t" ] && [ "$schema_tail_present" = "t" ]; then
# BASELINE: таблицы не было, но и голова, и хвост цепочки на месте →
# seed ВСЕ текущие миграции как applied БЕЗ их прогона. Это либо
# наполненный прод до внедрения tracking, либо чистый старт, где
# initdb.d уже честно доехал до конца сам (ожидание готовности это
# теперь гарантирует). В обоих случаях повторный прогон не нужен —
# помечаем текущим состоянием, чтобы под строгий gate попадали
# только НОВЫЕ миграции. INSERT ... ON CONFLICT DO NOTHING — идемпотентно.
echo "→ _schema_migrations отсутствовала, но схема на месте целиком (голова + хвост) — baseline существующих миграций (без прогона)"
for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do
fname=$(basename "$sql_file")
echo " baseline: $fname"
@ -789,6 +828,21 @@ jobs:
"INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
done
echo "Baseline complete — existing schema marked as applied."
elif [ "$migrations_table_existed" != "t" ]; then
# НЕОДНОЗНАЧНО: ровно один из концов цепочки на месте, второго нет,
# а _schema_migrations отсутствует. Это и есть симптом гонки
# готовности (см. комментарий выше) — молча баселайнить тут нельзя:
# либо схема реально недокачана (baseline пометил бы недостающий
# хвост как применённый без прогона), либо и то и другое пусто, но
# тогда tail-check не должен был сработать. Падаем громко, а не
# гадаем — новый app-код НЕ поднят, старые контейнеры не тронуты.
echo "ERROR: неоднозначное состояние схемы — _schema_migrations нет,"
echo " listings присутствует=${schema_head_present}, houses_geog_gist_idx присутствует=${schema_tail_present}."
echo " Похоже на недокачанную схему (гонка готовности postgres) —"
echo " baseline пропущен намеренно, чтобы не пометить недостающие"
echo " миграции применёнными без прогона. Прерываю деплой; нужен"
echo " ручной разбор состояния тома перед повторным запуском."
exit 1
fi
for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do

View file

@ -23,9 +23,12 @@
-- SELECT падал бы с "user mapping not found", обрывая docker-entrypoint-initdb.d
-- и не давая контейнеру подняться вообще.
--
-- Поэтому backfill выполняется ТОЛЬКО если есть что мигрировать. На чистой БД строк
-- в md5-форме нет по определению (их создавал исторический импорт), гард выходит
-- раньше обращения к FDW, и миграция становится честным no-op вместо падения.
-- ВАЖНО: гард по количеству pending-строк тут НЕ защищает от FDW-падения —
-- 003_seed_deals.sql на чистой БД сеет синтетические сделки с source='rosreestr'
-- и md5-хэшем в dedup_hash, то есть pending > 0 уже на пустом томе. Первичный
-- гард обязан проверять именно наличие USER MAPPING (pg_user_mappings), а не
-- количество строк. Счётчик pending оставлен вторым, уже после проверки
-- мэппинга — он просто экономит обращение к FDW, когда мигрировать нечего.
--
-- Файл НЕ удалён намеренно: прод помнит его по bare-filename в _schema_migrations,
-- а tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted
@ -41,15 +44,34 @@ DO $migration_077$
DECLARE
pending bigint;
BEGIN
-- Гард: считаем строки, оставшиеся в md5-форме. Плоский ключ 'ros:dkp:N'
-- под этот шаблон не подходит, поэтому после успешного прогона pending = 0.
-- Гард 1 (первичный, обязателен): USER MAPPING создаётся бэкендом при
-- старте (app/core/fdw.py), то есть ПОСЛЕ docker-entrypoint-initdb.d. На
-- чистом томе его физически ещё не существует, а количество pending-строк
-- тут ни при чём — счётчик ниже не защищает от FDW-падения, потому что
-- сидовые данные (003_seed_deals.sql) генерируют строки с
-- source='rosreestr' и md5-хэшем, то есть pending > 0 уже на пустой БД.
-- Идиома совпадает с проверкой в app/core/fdw.py:57-62.
IF NOT EXISTS (
SELECT 1 FROM pg_user_mappings
WHERE srvname = 'gendesign_remote'
AND (usename = current_user OR usename IS NULL)
) THEN
RAISE NOTICE '077: USER MAPPING для gendesign_remote нет — backfill пропущен';
RETURN;
END IF;
-- Гард 2 (вторичный): считаем строки, оставшиеся в md5-форме. Плоский
-- ключ 'ros:dkp:N' под этот шаблон не подходит, поэтому после успешного
-- прогона pending = 0. Ранний выход тут — просто чтобы не трогать FDW
-- зря, когда мигрировать нечего (мэппинг уже есть, но backfill уже
-- применён).
SELECT count(*) INTO pending
FROM deals
WHERE source = 'rosreestr'
AND dedup_hash ~ '^[0-9a-f]{32}$';
IF pending = 0 THEN
RAISE NOTICE '077: строк в md5-форме нет — backfill пропущен, FDW не читается';
RAISE NOTICE '077: строк в md5-форме нет — backfill пропущен';
RETURN;
END IF;