fix(tradein/deploy): миграции ДО подъёма app-контейнеров + manifest-инвариант (#2216) (#2228)
All checks were successful
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 20s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / build-frontend (push) Successful in 30s
All checks were successful
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 20s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / build-frontend (push) Successful in 30s
This commit is contained in:
parent
19ecd941f3
commit
0793571c4a
3 changed files with 359 additions and 22 deletions
|
|
@ -339,24 +339,44 @@ jobs:
|
||||||
export IMAGE_TAG="$IMAGE_TAG"
|
export IMAGE_TAG="$IMAGE_TAG"
|
||||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
|
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
|
||||||
|
|
||||||
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
|
||||||
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
|
||||||
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
|
||||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
|
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
|
||||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
|
||||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
|
||||||
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
|
|
||||||
|
|
||||||
# Применяем 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 ТОЛЬКО при первом
|
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
|
||||||
# старте volume. Здесь — для повторных миграций после первого запуска.
|
# старте volume. Здесь — для повторных миграций после первого запуска.
|
||||||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||||
sleep 5
|
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||||
|
# #2216) glob'ом не подхватывается.
|
||||||
|
|
||||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||||
|
|
@ -430,16 +450,27 @@ jobs:
|
||||||
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
|
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Retry backend lifespan hook AFTER migrations applied.
|
# (4) Теперь — новый app-код: схема уже актуальна.
|
||||||
# tradein-backend startup runs ensure_fdw_user_mapping which needs
|
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
||||||
# FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql).
|
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
||||||
# Without restart, the first compose-up's startup hook failed with
|
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
||||||
# "server gendesign_remote does not exist" because migrations hadn't run yet.
|
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps browser backend frontend
|
||||||
# See PR #493 deploy/1156 for the incident details.
|
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||||
echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup"
|
echo "→ scraper paths changed — recreating tradein-scraper"
|
||||||
docker restart tradein-backend
|
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
|
||||||
# Give backend time to come up before Caddy reload + health check below
|
else
|
||||||
sleep 5
|
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
|
# Caddy reload — основной Caddyfile содержит inline tradein routes
|
||||||
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
|
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
|
||||||
|
|
|
||||||
168
tradein-mvp/backend/data/sql/_manifest_applied.txt
Normal file
168
tradein-mvp/backend/data/sql/_manifest_applied.txt
Normal 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
|
||||||
138
tradein-mvp/backend/tests/test_migrations_manifest.py
Normal file
138
tradein-mvp/backend/tests/test_migrations_manifest.py
Normal 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}"
|
||||||
Loading…
Add table
Reference in a new issue