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

_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:
bot-backend 2026-08-07 14:35:28 +05:00
parent 306fd82836
commit 2e20b6307b
8 changed files with 270 additions and 392 deletions

View file

@ -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` `tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична — в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2, деструктивный DDL хитит прод на деплое.
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
не доверяй `tail`. **Номер новой миграции сверяй с `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 ## Rapid-merge trap

View file

@ -85,6 +85,21 @@ jobs:
CI_PG: ci-pg-tradein-${{ github.run_id }} CI_PG: ci-pg-tradein-${{ github.run_id }}
steps: steps:
- uses: actions/checkout@v4 - 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 - name: Поднять Postgres и собрать схему tradein
working-directory: . working-directory: .

View file

@ -149,6 +149,13 @@ jobs:
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
steps: steps:
- uses: actions/checkout@v4 - 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 - name: Install uv
# Официальный standalone-инсталлер: системный `pip install uv` на # Официальный standalone-инсталлер: системный `pip install uv` на
@ -396,8 +403,9 @@ jobs:
# 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.
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант # ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало
# #2216) glob'ом не подхватывается. # до main, здесь и применяется, а имя закрепляется в _schema_migrations.
# На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683).
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это # Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
# первый deploy после внедрения tracking на уже-наполненной prod-БД # первый deploy после внедрения tracking на уже-наполненной prod-БД

View file

@ -1,11 +1,11 @@
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql). """Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по (tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git:
корневого каталога не задача этого PR, поэтому здесь проверяются инварианты, которые tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты,
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя. регрессии, которые иначе всплывают только на проде во время деплоя.
Тест не требует БД только чтение файлов. Тест не требует БД только чтение файлов.
""" """

View file

@ -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

View file

@ -52,3 +52,12 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_deletes_only_anonymou
# дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234: # дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234:
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе. # на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint 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

View 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"}) == []

View file

@ -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}"