fix(tradein/deploy): миграции ДО подъёма app-контейнеров + инвариант manifest (#2216)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m42s

deploy-tradein.yml: up только postgres → pg_isready-wait (30×2s) → весь блок
миграций → затем up browser/backend/frontend(/scraper). Сбой миграции = exit 1
до нового кода, старые контейнеры остаются на консистентной паре код↔схема.
Удалён docker restart tradein-backend (lifespan-hook теперь видит миграции
с первого старта; история PR #493/deploy-1156 сохранена в комментарии).

Реконсиляция «152 vs 131» из аудита не актуальна: прод _schema_migrations и
репо data/sql совпадают (154 == 154, diff пуст; проверено 2026-07-02).

Инвариант против rename-trap (tracking по bare-filename → переименование
применённой миграции = повторное выполнение на проде):
test_migrations_manifest.py + _manifest_applied.txt (154 имени) — red при
rename/rm применённой и при дубль-NN-префиксе новых (6 исторических дублей
084/108/113/121/124/130 grandfathered).

Refs #2216
This commit is contained in:
bot-backend 2026-07-02 23:51:25 +03:00
parent 4ce0f51bb8
commit 6409b5fdf7
3 changed files with 359 additions and 22 deletions

View file

@ -339,24 +339,44 @@ jobs:
export IMAGE_TAG="$IMAGE_TAG"
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — recreating tradein-scraper"
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
# нового кода → старые контейнеры продолжают работать на СТАРОМ коде +
# СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый
# код на старой схеме». Откат = просто ничего не поднимали.
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
# (1) Только БД — чтобы прогнать миграции до нового app-кода.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
echo "→ Ожидание готовности postgres..."
pg_ready=""
for i in $(seq 1 30); 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_ready="yes"; break
fi
sleep 2
done
if [ -z "$pg_ready" ]; then
echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой."
echo " Новый app-код НЕ поднят; старые контейнеры не тронуты."
exit 1
fi
echo "→ postgres ready."
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
# старте volume. Здесь — для повторных миграций после первого запуска.
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
sleep 5
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
# #2216) glob'ом не подхватывается.
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
# первый deploy после внедрения tracking на уже-наполненной prod-БД
@ -430,16 +450,27 @@ jobs:
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
fi
# Retry backend lifespan hook AFTER migrations applied.
# tradein-backend startup runs ensure_fdw_user_mapping which needs
# FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql).
# Without restart, the first compose-up's startup hook failed with
# "server gendesign_remote does not exist" because migrations hadn't run yet.
# See PR #493 deploy/1156 for the incident details.
echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup"
docker restart tradein-backend
# Give backend time to come up before Caddy reload + health check below
sleep 5
# (4) Теперь — новый app-код: схема уже актуальна.
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps browser backend frontend
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
echo "→ scraper paths changed — recreating tradein-scraper"
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
else
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
fi
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
# "server gendesign_remote does not exist" — FOREIGN SERVER создаёт
# 060_postgres_fdw_extension.sql, ещё не прогнанная на тот момент.
# Требовался рестарт для повторной попытки хука. Теперь backend
# стартует на шаге (4), т.е. ПОСЛЕ применения миграций (шаг 3) →
# lifespan-hook гарантированно видит применённые миграции (FOREIGN
# SERVER gendesign_remote существует) уже с первого старта. Рестарт удалён.
# Caddy reload — основной Caddyfile содержит inline tradein routes
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS

View file

@ -0,0 +1,168 @@
# _manifest_applied.txt — CONTRACT (issue #2216)
#
# Отсортированный список ВСЕХ имён миграций (bare filename) в data/sql/,
# которые на момент коммита уже применены/забейслайнены на проде.
# Прод трекает миграции по bare-filename в public._schema_migrations —
# переименование или удаление применённого файла => повторный прогон на
# проде (новый filename считается неприменённым) => дубль-эффекты/ошибки.
#
# ПРАВИЛА (enforced tests/test_migrations_manifest.py):
# 1. Каждое имя здесь ОБЯЗАНО существовать в data/sql/ (нельзя rename/rm applied).
# 2. Новый .sql-файл => НЕ переиспользуй NN-префикс (кроме 6 grandfathered дублей).
# 3. Добавляя новую миграцию — допиши её имя сюда В ТОМ ЖЕ PR (список отсортирован).
#
# Комментарии (# ...) и пустые строки тест игнорирует.
001_trade_in_estimates.sql
002_core_tables.sql
003_seed_deals.sql
004_extend_trade_in_estimates.sql
005_geocode_tracking.sql
007_estimate_photos.sql
008_crm_fields.sql
009_houses.sql
010_houses_alter.sql
011_listings_alter.sql
012_sellers.sql
013_listings_alter_seller.sql
014_house_reviews.sql
015_scrape_runs.sql
016_listings_snapshots.sql
017_house_placement_history.sql
018_avito_imv_evaluations.sql
019_listings_alter_cian.sql
020_houses_alter_cian.sql
021_management_companies.sql
022_agents_table.sql
023_offer_price_history.sql
024_houses_price_dynamics.sql
025_house_reliability_checks.sql
026_external_valuations.sql
027_cian_session_cookies.sql
028_matching_tables.sql
029_extend_matching_valuation_dynamics.sql
030_avito_imv_cache_key_unique.sql
031_houses_alter_yandex.sql
032_yandex_history.sql
033_listings_alter_yandex.sql
034_trade_in_estimates_geom.sql
035_drop_duplicate_indexes.sql
040_houses_extend.sql
041_house_sources_noop.sql
042_listing_sources_price_divergence_idx.sql
043_house_reviews_extend.sql
044_external_valuations_link.sql
045_house_placement_history_extend.sql
046_views.sql
047_cian_history_sanitize.sql
050_search_optimization.sql
051_scrape_runs_extend.sql
052_scrape_schedules.sql
053_scraper_settings.sql
054_scraper_settings_global.sql
060_postgres_fdw_extension.sql
061_drop_legacy_cad_buildings.sql
062_clean_avito_addresses.sql
063_backfill_houses_and_link_listings.sql
064_house_imv_phase_c.sql
065_trade_in_estimates_floor_optional.sql
066_address_mismatch_audit.sql
067_v_street_sales_vs_listings.sql
068_drop_v_street_sales_vs_listings.sql
069_trade_in_estimates_dadata_fields.sql
070_houses_dadata_enrichment.sql
071_houses_cian_zhk_url.sql
072_scrape_schedules_seed_cian_rosreestr.sql
073_normalize_repair_state.sql
075_backfill_repair_state_from_description.sql
076_account_estimate_quota.sql
077_dedup_hash_plain_key_backfill.sql
078_scrape_schedules_seed_yandex_sweep.sql
079_listing_source_history.sql
080_asking_to_sold_ratios.sql
081_trade_in_estimates_expected_sold.sql
082_scrape_schedules_seed_ratio_refresh.sql
083_trade_in_estimates_created_by.sql
084_brand_praktika_fill.sql
084_scrape_schedules_seed_n1_sweep.sql
085_quarter_price_index_fdw.sql
086_deals_address_trgm_index.sql
087_fdw_server_options.sql
088_scrape_schedules_seed_search_matview_refresh.sql
089_listings_geo_precision.sql
090_scrape_schedules_seed_deactivate_stale_avito.sql
091_scrape_schedules_seed_yandex_address_backfill.sql
092_sber_price_index.sql
093_scrape_schedules_seed_sber_index_pull.sql
094_cadastral_unify.sql
095_dead_schema.sql
096_scrape_schedules_seed_rosreestr_quarter_poll.sql
097_index_hygiene.sql
098_asking_to_sold_ratios_tiered.sql
099_brand_praktika_logo_wordmark.sql
100_enable_deactivate_stale_avito.sql
101_gendesign_reader_role.sql
102_grant_listings_gendesign_reader.sql
103_scrape_schedules_seed_newbuilding_enrich.sql
104_index_hygiene_geom_dedup.sql
105_market_schema_yandex_enrichment.sql
106_scrape_schedules_seed_yandex_newbuilding_sweep.sql
107_scrape_schedules_seed_cian_city_sweep.sql
108_clean_avito_addresses_v2.sql
108_merge_duplicate_houses.sql
109_asking_to_sold_ratio_segment_filter.sql
110_scrape_schedules_seed_geocode_missing_listings.sql
111_listings_avito_detail_fields.sql
112_scrape_schedules_seed_avito_detail_backfill.sql
113_deactivate_ghost_duplicate_listings.sql
113_yandex_detail_backfill.sql
114_disable_n1_sweep.sql
115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql
116_offer_price_history_change_trigger.sql
117_listings_last_seen_deactivate_index.sql
118_enable_cian_city_sweep.sql
119_yandex_city_sweep_center_combos.sql
120_restore_partial_active_indexes.sql
121_remove_brand_praktika.sql
121_yandex_rich_fields.sql
122_enable_domclick_city_sweep.sql
123_avito_newbuilding_sweep_schedule.sql
124_cad_buildings_local.sql
124_deglue_avito_addresses.sql
125_scrape_schedules_seed_cadastral_geo_match.sql
126_scrape_schedules_seed_cian_full_load.sql
127_scrape_schedules_seed_avito_full_load.sql
128_listings_card_hash.sql
129_avito_full_load_incremental_split.sql
130_backfill_listings_house_id_fk.sql
130_ekb_geoportal_buildings.sql
131_fix_diff_percent_overflow.sql
132_scrape_schedules_seed_house_imv.sql
133_listings_uq_source_source_id.sql
134_listings_geom_geography_gist.sql
135_scrape_schedules_seed_house_dedup_merge.sql
136_backfill_listings_house_id_fk_source_identity.sql
137_listings_addr_norm_trgm.sql
138_domclick_bff_rewrite_schedule.sql
139_premium_houses.sql
140_yandex_house_type_backfill.sql
141_cian_promote_house_type.sql
142_premium_buildings_curated.sql
143_building_sale_share_schema.sql
144_gar_canon_addr_match.sql
145_building_sale_share_plausible_denom.sql
146_sale_share_45d_and_zhkh_denom.sql
147_canon_strip_geo_prefixes.sql
148_dedup_apartments_in_sale_share.sql
149_zhkh_priority_denominator.sql
150_sale_share_listing_geo_filter.sql
151_clean_bare_street_aliases.sql
152_sale_share_floors_guard.sql
153_sale_share_listings_floors_plausibility.sql
154_market_contract_views.sql
155_reader_grants_to_contract_views.sql
156_revoke_raw_from_reader.sql
157_scrape_proxies.sql
158_seed_proxy_healthcheck_schedule.sql
159_houses_fias_idx.sql
160_seed_deactivate_stale_domklik_n1.sql
161_backfill_scraped_at_active_recent.sql

View file

@ -0,0 +1,138 @@
"""Invariants over data/sql migrations (issue #2216).
Прод применяет миграции по BARE FILENAME: deploy-tradein.yml трекает каждый
`data/sql/*.sql` в таблице `public._schema_migrations` (PRIMARY KEY = filename).
Из этого следуют два хрупких инварианта, которые этот тест защищает от регрессии:
1. Переименование / удаление УЖЕ ПРИМЕНЁННОЙ миграции ломает прод: новый
filename считается неприменённым и прогоняется повторно (дубль-INSERT,
повторный DDL, PK violation под ON_ERROR_STOP => деплой падает или, хуже,
молча дублирует данные). Manifest `data/sql/_manifest_applied.txt` это
слепок применённых имён на момент коммита; любой из них ОБЯЗАН существовать.
2. Два разных файла с одинаковым NN-префиксом ("дубль-префикс") источник
двусмысленного порядка применения (`ls | sort` детерминирован, но человек
легко создаёт коллизию). 6 исторических дублей grandfathered'ы (оба в
manifest). Любой НОВЫЙ файл обязан нести уникальный префикс.
Self-maintenance: добавляя новую миграцию, допиши её имя в manifest В ТОМ ЖЕ PR
(см. assert-сообщения ниже). Тест требует data/sql manifest и уникальность
префикса у новых файлов; сам manifest дополняет автор миграции.
"""
from __future__ import annotations
import re
from pathlib import Path
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
_SQL_DIR = _BACKEND_ROOT / "data" / "sql"
_MANIFEST = _SQL_DIR / "_manifest_applied.txt"
_NN_PREFIX = re.compile(r"^(\d+)_")
def _read_manifest() -> list[str]:
"""Имена миграций из manifest; # comments и пустые строки игнорируются."""
names: list[str] = []
for raw in _MANIFEST.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
names.append(line)
return names
def _actual_sql_files() -> set[str]:
return {p.name for p in _SQL_DIR.glob("*.sql")}
def _prefix(name: str) -> str | None:
m = _NN_PREFIX.match(name)
return m.group(1) if m else None
def test_manifest_entries_all_exist() -> None:
"""Каждый файл из manifest СУЩЕСТВУЕТ в data/sql/.
Red => применённая миграция переименована или удалена. Прод трекает по
bare-filename: старое имя остаётся в _schema_migrations, НОВОЕ имя считается
неприменённым и прогоняется повторно. Восстанови исходное имя файла (или,
если переименование намеренное так делать НЕЛЬЗЯ для уже-применённых
миграций: заведи НОВЫЙ файл, а старый оставь как есть).
"""
actual = _actual_sql_files()
manifest = _read_manifest()
missing = sorted(n for n in manifest if n not in actual)
assert not missing, (
"Миграции из _manifest_applied.txt отсутствуют в data/sql/ "
f"(переименованы/удалены?): {missing}. Эти имена уже применены на проде "
"(tracking по bare-filename в _schema_migrations) — их нельзя "
"переименовывать/удалять. Верни исходные имена файлов."
)
def test_manifest_is_sorted_and_unique() -> None:
"""Manifest отсортирован (codepoint) и без дублей — детерминированный слепок."""
manifest = _read_manifest()
assert manifest == sorted(manifest), (
"_manifest_applied.txt не отсортирован. Пересортируй записи "
"(LC_ALL=C sort / Python sorted())."
)
dupes = sorted({n for n in manifest if manifest.count(n) > 1})
assert not dupes, f"Дублирующиеся строки в _manifest_applied.txt: {dupes}"
def test_new_files_do_not_reuse_prefix() -> None:
"""Новые (не в manifest) .sql-файлы НЕ переиспользуют существующий NN-префикс.
Grandfathered дубли (084/108/113/121/124/130) оба файла в manifest, поэтому
не флагаются: считаются "существующими", а не "новыми".
Red => новый файл взял префикс уже присутствующей миграции. Присвой
следующий свободный NN и допиши имя в _manifest_applied.txt (тот же PR).
"""
actual = _actual_sql_files()
manifest = set(_read_manifest())
# Префиксы, «занятые» уже-применёнными (manifest) миграциями.
baseline_prefixes: set[str] = set()
for name in manifest:
p = _prefix(name)
if p is not None:
baseline_prefixes.add(p)
new_files = sorted(actual - manifest)
collisions: list[str] = []
# Внутри новых файлов префикс тоже обязан быть уникален (два новых с одним NN).
seen_new_prefix: dict[str, str] = {}
for name in new_files:
p = _prefix(name)
if p is None:
continue
if p in baseline_prefixes:
collisions.append(f"{name} (префикс {p} занят применённой миграцией)")
elif p in seen_new_prefix:
collisions.append(f"{name} (префикс {p} уже у нового {seen_new_prefix[p]})")
else:
seen_new_prefix[p] = name
assert not collisions, (
"Новые миграции переиспользуют NN-префикс: " + "; ".join(collisions) + ". "
"Присвой следующий свободный номер и добавь имя файла в "
"_manifest_applied.txt в ЭТОМ ЖЕ PR."
)
def test_manifest_covers_all_but_new_files() -> None:
"""data/sql ⊇ manifest, и каждый новый файл имеет уникальный префикс —
напоминание о self-maintenance: manifest дополняется вместе с миграцией.
Этот тест НЕ требует, чтобы новый файл уже был в manifest (иначе PR с новой
миграцией всегда красный). Он лишь гарантирует, что manifest не отстал от
реальности В ЧАСТИ применённых имён (см. test_manifest_entries_all_exist) и
что новые файлы не создают префикс-коллизий (см. предыдущий тест).
"""
# Sanity: manifest непустой и в data/sql есть файлы — защита от битых путей.
assert _actual_sql_files(), f"Не найдено *.sql в {_SQL_DIR}"
assert _read_manifest(), f"_manifest_applied.txt пуст: {_MANIFEST}"