fix(tradein): гейт номеров миграций берёт эталон из git, ручной манифест удалён
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Failing after 21s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 55s
CI Trade-In / frontend-checks (pull_request) Successful in 1m26s
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (pull_request) Successful in 16m53s
Some checks failed
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Failing after 21s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 55s
CI Trade-In / frontend-checks (pull_request) Successful in 1m26s
CI / openapi-codegen-check (pull_request) Successful in 2m7s
CI / backend-tests (pull_request) Successful in 16m53s
_manifest_applied.txt по построению не мог покраснеть. Тест считал «новым» любой файл, которого нет в списке, а новые файлы от списка освобождены (докстринг test_manifest_covers_all_but_new_files: «НЕ требует, чтобы новый файл уже был в manifest»). Забытое имя и новая миграция PR для гейта — одно и то же, поэтому дрейф был не пропуском проверки, а её штатным исключением. Замер на main 2026-08-07: 15 имён не дописано, все четыре теста зелёные — через сутки после того, как #2692 догнал список руками. Список при этом был лишь копией того, что git и так знает: deploy-tradein.yml применяет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP, то есть «файл доехал до main» и есть «имя закреплено на проде». Ведём эталон в git — и дрейфовать становится нечему. Кросс-ветковая дыра закрыта тем же ходом: номер нового файла сверяется с ПОЛНЫМ origin/main, а не с рабочим деревом, поэтому коллизия с миграцией, смерженной после ветвления, находится. Проверено на живом PR #2754 (234_trade_in_estimates_retain_until против 234_scrape_runs_ban_kind_unknown из main): старый гейт зелёный, новый красный. Удаление/переименование применённой миграции сверяется с ТОЧКОЙ ВЕТВЛЕНИЯ, а не с origin/main: иначе ветка недельной давности краснела бы за чужие миграции. Проверено — ветка от 2026-07-30 при +43 миграциях в main зелёная. CI: checkout переведён на fetch-depth 0 + отдельный fetch main. Этот Forgejo не публикует refs/pull/N/merge (1620 */head, ноль */merge), а на depth=1 нет ни origin/main, ни общего предка — без этого гейту не с чем сверять, и он намеренно красный, а не тихо пропущенный. Контракт сведён к одной формулировке — докстринг test_migration_numbering.py; шапка манифеста, правило 3, хвост манифеста и рецепт из .claude/rules удалены или заменены ссылкой. Заодно исправлен сам рецепт: `git ls-tree` без `-r` печатает каталог, а не файлы. Refs #2683
This commit is contained in:
parent
306fd82836
commit
2e20b6307b
8 changed files with 270 additions and 392 deletions
|
|
@ -41,9 +41,21 @@ In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`,
|
|||
|
||||
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
|
||||
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
|
||||
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2,
|
||||
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
|
||||
не доверяй `tail`.
|
||||
деструктивный DDL хитит прод на деплое.
|
||||
|
||||
**Номер новой миграции сверяй с `origin/main`, не с локальным `ls`** — локальное дерево не видит
|
||||
миграций, смерженных после ветвления (так разъехались 212 в #2682 и 234 в #2754):
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
|
||||
```
|
||||
|
||||
`-r` обязателен — без него `ls-tree` печатает сам каталог одной строкой, а не файлы.
|
||||
|
||||
Правило целиком — в докстринге `tradein-mvp/backend/tests/test_migration_numbering.py` (единственная
|
||||
формулировка контракта, #2683); он же гейтит его в CI. Дописывать имя в какой-либо список НЕ надо:
|
||||
`_manifest_applied.txt` удалён — он отставал и по построению не мог покраснеть.
|
||||
|
||||
## Rapid-merge trap
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,21 @@ jobs:
|
|||
CI_PG: ci-pg-tradein-${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# ПОЛНАЯ история, а не дефолтный depth=1 (#2683).
|
||||
# tests/test_migration_numbering.py сверяет номер новой миграции с
|
||||
# origin/main и точкой ветвления — на shallow-клоне нет ни того, ни
|
||||
# другого, и гейт красится «нет git-эталона». Это намеренно: молча
|
||||
# пропущенная проверка и есть тот зелёный, который ничего не проверяет.
|
||||
# Пак репозитория ~33 MiB, полный fetch дешевле разбора коллизии на проде.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Дотянуть origin/main (эталон номеров миграций)
|
||||
# checkout выкачивает ТОЛЬКО ref события: на pull_request это
|
||||
# refs/pull/N/head (merge-ref этот Forgejo не публикует — ls-remote видит
|
||||
# 1620 */head и ноль */merge), так что ветки main в клоне нет вовсе.
|
||||
# Без этого шага сверять номера не с чем.
|
||||
run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
- name: Поднять Postgres и собрать схему tradein
|
||||
working-directory: .
|
||||
|
|
|
|||
|
|
@ -149,6 +149,13 @@ jobs:
|
|||
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует
|
||||
# origin/main и общего предка с HEAD. На depth=1 их нет, и гейт красный.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Дотянуть origin/main (эталон номеров миграций)
|
||||
run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
- name: Install uv
|
||||
# Официальный standalone-инсталлер: системный `pip install uv` на
|
||||
|
|
@ -396,8 +403,9 @@ jobs:
|
|||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||
# #2216) glob'ом не подхватывается.
|
||||
# ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало
|
||||
# до main, здесь и применяется, а имя закрепляется в _schema_migrations.
|
||||
# На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683).
|
||||
|
||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
|
||||
|
||||
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
|
||||
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
|
||||
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
|
||||
корневого каталога — не задача этого PR, поэтому здесь проверяются инварианты, которые
|
||||
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
|
||||
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
|
||||
Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был
|
||||
(tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога
|
||||
и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git:
|
||||
tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты,
|
||||
выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят
|
||||
регрессии, которые иначе всплывают только на проде во время деплоя.
|
||||
|
||||
Тест не требует БД — только чтение файлов.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,243 +0,0 @@
|
|||
# _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
|
||||
162_seed_deals_freshness_monitor.sql
|
||||
163_disable_deactivate_stale_domklik.sql
|
||||
164_yandex_url_canonicalize_active_dups.sql
|
||||
165_remove_n1_source.sql
|
||||
166_purge_listings_phones.sql
|
||||
167_drop_client_pii.sql
|
||||
168_fdw_osm_poi_ekb.sql
|
||||
169_osm_poi_ekb_local.sql
|
||||
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
|
||||
171_scrape_schedules_seed_geoportal_coords_backfill.sql
|
||||
172_trade_in_leads.sql
|
||||
173_scrape_proxies_add_domclick_affinity.sql
|
||||
174_domclick_session_cookies.sql
|
||||
175_scrape_schedules_seed_domclick_detail_backfill.sql
|
||||
176_domrf_kapremont.sql
|
||||
177_deals_city_region.sql
|
||||
178_deal_city_price_bands.sql
|
||||
179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||
180_seed_sber_freshness_monitor.sql
|
||||
181_clamp_bad_listing_dates.sql
|
||||
182_trade_in_leads_consent_proof.sql
|
||||
183_reenable_deactivate_stale_domklik.sql
|
||||
184_user_events.sql
|
||||
185_account_quota_overrides.sql
|
||||
186_tg_support.sql
|
||||
187_web_support_chat.sql
|
||||
188_tg_support_chat_id_scope.sql
|
||||
189_account_estimate_usage_nonnegative.sql
|
||||
190_sale_share_price_bucket_signature.sql
|
||||
191_account_quota_unlimited_flag.sql
|
||||
192_tradein_users_auth.sql
|
||||
193_tradein_users_seed.sql
|
||||
194_deal_city_price_bands_tiers.sql
|
||||
195_scrape_schedules_seed_deal_city_price_bands_refresh.sql
|
||||
196_listings_city.sql
|
||||
197_backfill_listings_city_from_url.sql
|
||||
198_scrape_proxy_rotations.sql
|
||||
199_scrape_proxies_asocks_rotate_url.sql
|
||||
200_region_code_foreign_cities.sql
|
||||
201_purge_dead_mobileproxy_proxies.sql
|
||||
202_listing_source_snapshot_budget_sec.sql
|
||||
203_purge_geocode_cache_house_letter.sql
|
||||
204_cian_oblast_sweeps_secondary.sql
|
||||
205_sales_vs_listings_city_filter.sql
|
||||
206_scrape_schedules_cut_wasteful_load.sql
|
||||
207_backfill_yandex_cian_city_geo_cleanup.sql
|
||||
208_reenable_domclick_detail_backfill.sql
|
||||
209_scrape_proxies_disabled_reason.sql
|
||||
210_scrape_proxy_source_bans.sql
|
||||
211_sales_vs_listings_segment_guard.sql
|
||||
212_sber_index_pull_weekly.sql
|
||||
213_listings_snapshots_status_vocab.sql
|
||||
214_drop_dead_run_metrics.sql
|
||||
215_avito_full_load_window_matches_cadence.sql
|
||||
216_dead_code_sweep.sql
|
||||
#
|
||||
# 2026-08-06: список догнан до факта прода. Проверка перед правкой —
|
||||
# _schema_migrations на tradein-postgres: 209 применённых имён, здесь было
|
||||
# 178; расхождение — 31 имя, все в одну сторону (применено, но не заморожено).
|
||||
# Обратного расхождения нет: ни одной строки, которой не было бы на проде.
|
||||
#
|
||||
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
|
||||
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
|
||||
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
|
||||
#
|
||||
# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент
|
||||
# правки они уже слиты в main и применены на проде (см. _schema_migrations),
|
||||
# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш;
|
||||
# self-maintenance-контракт (см. докстринг test_migrations_manifest.py)
|
||||
# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для
|
||||
# 222/225 по прецеденту 233_payments.sql.
|
||||
222_db_audit_cleanup.sql
|
||||
225_listing_source_snapshots_run_id_idx.sql
|
||||
233_payments.sql
|
||||
234_scrape_runs_ban_kind_unknown.sql
|
||||
|
|
@ -52,3 +52,12 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_deletes_only_anonymou
|
|||
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
|
||||
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
|
||||
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
|
||||
|
||||
# Гейт номеров миграций (#2683) сверяется с origin/main и точкой ветвления. Где
|
||||
# git-эталона нет — прогон внутри prod-образа, экспорт исходников без .git —
|
||||
# проверять не с чем, и тест это ГОВОРИТ вслух вместо тихого зелёного.
|
||||
# В CI пропуска не бывает: при CI/GITHUB_ACTIONS та же ветка делает pytest.fail
|
||||
# (отсутствие эталона в пайплайне — сломанный гейт, а не «нечего проверять»),
|
||||
# а ci-tradein.yml/deploy-tradein.yml тянут main отдельным шагом.
|
||||
tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted
|
||||
tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
|
||||
|
|
|
|||
215
tradein-mvp/backend/tests/test_migration_numbering.py
Normal file
215
tradein-mvp/backend/tests/test_migration_numbering.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Инварианты нумерации миграций trade-in (issues #2216, #2683).
|
||||
|
||||
КОНТРАКТ. ОДНА ФОРМУЛИРОВКА, И ОНА ЗДЕСЬ — больше нигде её дублировать не надо.
|
||||
|
||||
Эталон «что уже закреплено на проде» — origin/main, а не файл-список.
|
||||
deploy-tradein.yml прогоняет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP
|
||||
(падение миграции => красный деплой) и трекает применённое по bare filename в
|
||||
public._schema_migrations. То есть «файл доехал до main» == «имя закреплено на
|
||||
проде», и вести это знание отдельно от git незачем: git и есть журнал.
|
||||
|
||||
Отсюда ровно два инварианта, и ниже проверяются именно они.
|
||||
|
||||
1. Имя, существовавшее в точке ветвления, нельзя переименовать или удалить.
|
||||
Прод помнит СТАРОЕ имя; новое считается неприменённым и прогоняется
|
||||
повторно — дубль-INSERT / повторный DDL / PK violation под ON_ERROR_STOP,
|
||||
то есть либо красный деплой, либо тихо задвоенные данные. Нужно изменить
|
||||
уже применённую миграцию — заводи НОВЫЙ файл, старый оставь как есть.
|
||||
|
||||
2. НОВЫЙ файл обязан нести NN-префикс, свободный не только в рабочем дереве,
|
||||
но и в origin/main. Порядок применения — `ls | sort`, два файла с одним NN
|
||||
дают неопределённый порядок. Шесть исторических дублей (084/108/113/121/
|
||||
124/130) не «новые» и не флагаются.
|
||||
|
||||
ДОПИСЫВАТЬ НИЧЕГО НЕ НАДО. Автор миграции кладёт файл со свободным номером — и
|
||||
всё. Списка, который можно забыть обновить, здесь больше нет: до #2683 таким
|
||||
списком был data/sql/_manifest_applied.txt, и он по построению не мог
|
||||
покраснеть — «файл, которого нет в списке» и «новый файл этого PR» были для
|
||||
теста одним и тем же, поэтому забытое имя навсегда оставалось зелёным
|
||||
(замер 2026-08-07: 15 забытых имён, сьют зелёный).
|
||||
|
||||
ПОЧЕМУ ТОЧКА ВЕТВЛЕНИЯ, А НЕ САМ origin/main. Ветка, отведённая неделю назад,
|
||||
не содержит миграций, смерженных после неё. Правило «origin/main ⊆ рабочее
|
||||
дерево» красило бы каждую такую ветку без вины автора — и его отключили бы
|
||||
через неделю. Сверка с merge-base ловит ровно то, что удалила или
|
||||
переименовала ЭТА ветка, а номера при этом сверяются с ПОЛНЫМ origin/main,
|
||||
чтобы коллизия с миграцией, смерженной после ветвления, всё-таки нашлась.
|
||||
|
||||
КАК УБЕДИТЬСЯ, ЧТО СТОРОЖ УМЕЕТ КРАСНЕТЬ (не на слово):
|
||||
test_collision_rule_flags_a_taken_number ниже проверяет само правило на
|
||||
литеральных входах, а сквозной прогон воспроизводится так —
|
||||
git worktree add --detach /tmp/wt <sha до нужной миграции>
|
||||
touch /tmp/wt/tradein-mvp/backend/data/sql/<занятый-NN>_probe.sql
|
||||
(cd /tmp/wt/tradein-mvp/backend && pytest tests/test_migration_numbering.py)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
_TESTS_DIR = Path(__file__).resolve().parent
|
||||
_SQL_DIR = _TESTS_DIR.parent / "data" / "sql"
|
||||
_REPO_ROOT = _TESTS_DIR.parents[2]
|
||||
# Путь каталога ОТ КОРНЯ РЕПО — им адресуем дерево коммита через git ls-tree.
|
||||
_SQL_PATHSPEC = "tradein-mvp/backend/data/sql"
|
||||
|
||||
_NN_PREFIX = re.compile(r"^(\d+)_")
|
||||
# origin — штатный remote; forgejo/main оставлен как исторический алиас.
|
||||
_MAIN_REFS = ("origin/main", "forgejo/main", "main")
|
||||
|
||||
|
||||
def _git(*args: str) -> str | None:
|
||||
"""stdout git-команды, либо None если git недоступен/команда упала."""
|
||||
try:
|
||||
done = subprocess.run(
|
||||
# Фиксированный argv, без shell — args приходят только из этого модуля.
|
||||
["git", "-C", str(_REPO_ROOT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return done.stdout if done.returncode == 0 else None
|
||||
|
||||
|
||||
def _sql_names_at(rev: str) -> set[str]:
|
||||
"""Bare-имена *.sql в data/sql на ревизии rev."""
|
||||
out = _git("ls-tree", "-r", "-z", "--name-only", rev, "--", _SQL_PATHSPEC) or ""
|
||||
return {PurePosixPath(p).name for p in out.split("\0") if p.endswith(".sql")}
|
||||
|
||||
|
||||
def _sql_names_on_disk() -> set[str]:
|
||||
"""Bare-имена *.sql в рабочем дереве — включая ещё не закоммиченные."""
|
||||
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 _collisions(new_names: Iterable[str], universe: Iterable[str]) -> list[str]:
|
||||
"""Для каждого НОВОГО имени — чужие имена с тем же NN-префиксом."""
|
||||
by_prefix: dict[str, set[str]] = {}
|
||||
for name in universe:
|
||||
p = _prefix(name)
|
||||
if p is not None:
|
||||
by_prefix.setdefault(p, set()).add(name)
|
||||
|
||||
found: list[str] = []
|
||||
for name in sorted(new_names):
|
||||
p = _prefix(name)
|
||||
if p is None:
|
||||
continue
|
||||
others = sorted(by_prefix.get(p, set()) - {name})
|
||||
if others:
|
||||
found.append(f"{name} — номер {p} уже занят: {', '.join(others)}")
|
||||
return found
|
||||
|
||||
|
||||
def _baseline() -> tuple[set[str], set[str]]:
|
||||
"""(имена в точке ветвления, имена в main). Без git-эталона проверять нечего."""
|
||||
main_ref = next(
|
||||
(r for r in _MAIN_REFS if _git("rev-parse", "--verify", "--quiet", f"{r}^{{commit}}")),
|
||||
None,
|
||||
)
|
||||
merge_base = None
|
||||
if main_ref is not None:
|
||||
out = _git("merge-base", main_ref, "HEAD")
|
||||
merge_base = out.strip() if out else None
|
||||
|
||||
if main_ref is None or merge_base is None:
|
||||
why = (
|
||||
f"нет git-эталона миграций: ни один из {_MAIN_REFS} не резолвится либо у него "
|
||||
f"нет общего предка с HEAD (repo={_REPO_ROOT}). Лечится "
|
||||
"`git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main` "
|
||||
"и НЕ shallow-клоном (нужен общий предок)."
|
||||
)
|
||||
# В CI это не «нечего проверять», а сломанный гейт: пропуск здесь и есть
|
||||
# тот зелёный, который ничего не проверяет. Поэтому красим.
|
||||
if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"):
|
||||
pytest.fail(why)
|
||||
pytest.skip(why)
|
||||
|
||||
base_names = _sql_names_at(merge_base)
|
||||
main_names = _sql_names_at(main_ref)
|
||||
# Анти-вакуум: пустой эталон сделал бы обе проверки зелёными всегда.
|
||||
# Ровно так ломается сторож, если _SQL_PATHSPEC разъедется с раскладкой репо.
|
||||
assert base_names, (
|
||||
f"эталон пуст: git ls-tree {merge_base} -- {_SQL_PATHSPEC} не вернул ни одного "
|
||||
"*.sql. Проверка номеров была бы вакуумно-зелёной — почини путь."
|
||||
)
|
||||
assert main_names, f"в {main_ref} не найдено *.sql по пути {_SQL_PATHSPEC} — то же самое."
|
||||
return base_names, main_names
|
||||
|
||||
|
||||
def test_applied_migration_is_not_renamed_or_deleted() -> None:
|
||||
"""Файл, существовавший в точке ветвления, обязан существовать и сейчас.
|
||||
|
||||
Red => эта ветка переименовала или удалила миграцию, которую прод уже
|
||||
применил и помнит по СТАРОМУ имени. Верни исходное имя; нужно поправить
|
||||
поведение — заводи новый файл с новым номером.
|
||||
"""
|
||||
on_disk = _sql_names_on_disk()
|
||||
assert on_disk, f"не найдено *.sql в {_SQL_DIR}"
|
||||
base_names, _ = _baseline()
|
||||
|
||||
gone = sorted(base_names - on_disk)
|
||||
assert not gone, (
|
||||
f"миграции пропали из {_SQL_PATHSPEC}/ (переименованы или удалены): {gone}. "
|
||||
"Прод трекает их по bare-filename в _schema_migrations — под новым именем "
|
||||
"миграция прогонится повторно. Верни имена как были."
|
||||
)
|
||||
|
||||
|
||||
def test_new_migration_takes_a_free_number() -> None:
|
||||
"""Новый файл не переиспользует NN, занятый в origin/main или в этой ветке.
|
||||
|
||||
Red => номер уже занят. Возьми следующий свободный, сверяясь с origin/main:
|
||||
|
||||
git fetch origin main
|
||||
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
|
||||
|
||||
`-r` обязателен: без него ls-tree печатает сам каталог, а не файлы (в этом
|
||||
виде рецепт и ходил по issue #2683 — и молча возвращал одну строку).
|
||||
Локального `ls` недостаточно: он не видит миграций, смерженных после
|
||||
ветвления — ровно так разъехались 212 в #2682 и 234 в #2754.
|
||||
"""
|
||||
on_disk = _sql_names_on_disk()
|
||||
base_names, main_names = _baseline()
|
||||
|
||||
new_names = on_disk - base_names
|
||||
problems = _collisions(new_names, main_names | on_disk)
|
||||
assert not problems, "коллизия номеров миграций: " + "; ".join(problems)
|
||||
|
||||
|
||||
def test_collision_rule_flags_a_taken_number() -> None:
|
||||
"""Проверка самого правила — сторож обязан уметь краснеть (#2683 п.6).
|
||||
|
||||
Ожидания здесь — ЛИТЕРАЛЫ, а не производные от содержимого data/sql: тест,
|
||||
который берёт ожидание из охраняемой настройки, зелен при любой настройке.
|
||||
"""
|
||||
# Реальный случай #2754: ветка отвелась до того, как в main приехал 234_scrape.
|
||||
assert _collisions(
|
||||
["234_trade_in_estimates_retain_until.sql"],
|
||||
{
|
||||
"233_payments.sql",
|
||||
"234_scrape_runs_ban_kind_unknown.sql",
|
||||
"234_trade_in_estimates_retain_until.sql",
|
||||
},
|
||||
) == [
|
||||
"234_trade_in_estimates_retain_until.sql — номер 234 уже занят: "
|
||||
"234_scrape_runs_ban_kind_unknown.sql"
|
||||
]
|
||||
# Два новых файла с одним номером внутри одной ветки — оба названы.
|
||||
assert len(_collisions(["300_a.sql", "300_b.sql"], {"300_a.sql", "300_b.sql"})) == 2
|
||||
# Свободный номер — тишина; исторические дубли не новые и не флагаются.
|
||||
assert _collisions(["300_a.sql"], {"084_x.sql", "084_y.sql", "300_a.sql"}) == []
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
"""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