Merge pull request 'fix(tradein): гейт номеров миграций берёт эталон из git, ручной манифест удалён' (#2786) from fix/2683-manifest-drift into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-browser (push) Successful in 46s
Deploy / build-worker (push) Successful in 53s
Deploy / build-backend (push) Successful in 54s
Deploy / deploy (push) Successful in 1m14s
Deploy / deploy-status (push) Successful in 1s
Deploy Trade-In / build-frontend (push) Successful in 2m41s
Deploy Trade-In / test (push) Successful in 4m0s
Deploy Trade-In / build-backend (push) Successful in 32s
Deploy Trade-In / deploy (push) Successful in 1m31s
Deploy Trade-In / deploy-status (push) Successful in 1s

This commit is contained in:
lekss361 2026-08-15 20:04:16 +00:00
commit a617ec7939
8 changed files with 269 additions and 406 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`
в `.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

View file

@ -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 и с точкой ветвления. Ровно этот флаг их и даёт: при
# depth=0 checkout идёт refspec'ом `+refs/heads/*:refs/remotes/origin/*`
# (видно в логе прогона), при depth=1 — только `+<sha>:refs/remotes/
# pull/N/head`, то есть ни ветки main, ни общего предка в клоне нет.
# Дотянуть main отдельным `git fetch` НЕЛЬЗЯ: из job-контейнера
# git.gendsgn.ru:443 недостижим (проверено, run 6977 — connection
# refused), сеть есть только у самого checkout.
#
# Гейт при отсутствии эталона краснеет, а не пропускается: молча
# пропущенная проверка и есть тот зелёный, который ничего не проверяет.
# Пак репозитория ~33 MiB — полный fetch дешевле разбора коллизии на проде.
fetch-depth: 0
- name: Поднять Postgres и собрать схему tradein
working-directory: .

View file

@ -176,6 +176,11 @@ 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=0 — при
# depth=1 checkout тянет один sha и ветки main в клоне нет.
fetch-depth: 0
- name: Install uv
# Официальный standalone-инсталлер: системный `pip install uv` на
@ -679,8 +684,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-БД

View file

@ -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. Здесь же проверяются инварианты,
выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят
регрессии, которые иначе всплывают только на проде во время деплоя.
Тест не требует БД только чтение файлов.
"""

View file

@ -1,257 +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
240_trade_in_estimates_retain_until.sql
250_drop_duplicate_expires_at_index.sql
251_listings_drop_ceiling_height.sql
254_listings_backfill_avito_rating_glued_address.sql
257_listings_backfill_yandex_source_url.sql
258_houses_imv_transient_attempts.sql
259_data_quality_drop_pct_cadastr.sql
260_houses_drop_has_panorama.sql
261_listings_search_mv_drop_placeholder_columns.sql
262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
263_scrape_schedules_wave2_cian_newbuilding_only_false.sql
264_deactivate_stale_avito_cap_mult.sql
265_deactivate_stale_yandex_cap_mult.sql
266_seed_deactivate_stale_null_segment_yandex_cian.sql

View file

@ -60,6 +60,16 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy
# на мок-лэйне (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 берут checkout с fetch-depth: 0 — при нём
# checkout сам приносит refs/remotes/origin/*, отдельный git fetch не нужен и
# из job-контейнера всё равно не проходит (run 6977, connection refused).
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
# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут

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