diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index b58f73ad..0e86e40b 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -339,24 +339,44 @@ jobs: export IMAGE_TAG="$IMAGE_TAG" docker compose -p gendesign-tradein -f docker-compose.prod.yml pull - # Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) — - # один image :latest на backend+scraper, блочный up -d рестартовал бы scraper - # и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях. - docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend - if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then - echo "→ scraper paths changed — recreating tradein-scraper" - docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper - else - echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)" - fi + # ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ────────── + # Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое + # миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема). + # Теперь строго: (1) только postgres → (2) ждём готовности БД → + # (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health. + # ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма + # нового кода → старые контейнеры продолжают работать на СТАРОМ коде + + # СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый + # код на старой схеме». Откат = просто ничего не поднимали. - # Применяем SQL миграции (если есть backend/data/sql/*.sql) + # (1) Только БД — чтобы прогнать миграции до нового app-кода. + docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres + + # (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep). + echo "→ Ожидание готовности postgres..." + pg_ready="" + for i in $(seq 1 30); do + if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ + pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then + pg_ready="yes"; break + fi + sleep 2 + done + if [ -z "$pg_ready" ]; then + echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой." + echo " Новый app-код НЕ поднят; старые контейнеры не тронуты." + exit 1 + fi + echo "→ postgres ready." + + # (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app. # Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом # старте volume. Здесь — для повторных миграций после первого запуска. # Tracking через _schema_migrations (порт паттерна из deploy.yml): # каждый .sql применяется РОВНО один раз, failed migration → exit 1 # (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp. - sleep 5 + # NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант + # #2216) glob'ом не подхватывается. # Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это # первый deploy после внедрения tracking на уже-наполненной prod-БД @@ -430,16 +450,27 @@ jobs: echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться" fi - # Retry backend lifespan hook AFTER migrations applied. - # tradein-backend startup runs ensure_fdw_user_mapping which needs - # FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql). - # Without restart, the first compose-up's startup hook failed with - # "server gendesign_remote does not exist" because migrations hadn't run yet. - # See PR #493 deploy/1156 for the incident details. - echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup" - docker restart tradein-backend - # Give backend time to come up before Caddy reload + health check below - sleep 5 + # (4) Теперь — новый app-код: схема уже актуальна. + # Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) — + # один image :latest на backend+scraper, блочный up -d рестартовал бы scraper + # и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях. + docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps browser backend frontend + if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then + echo "→ scraper paths changed — recreating tradein-scraper" + docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper + else + echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)" + fi + + # (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216). + # История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД + # миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с + # "server gendesign_remote does not exist" — FOREIGN SERVER создаёт + # 060_postgres_fdw_extension.sql, ещё не прогнанная на тот момент. + # Требовался рестарт для повторной попытки хука. Теперь backend + # стартует на шаге (4), т.е. ПОСЛЕ применения миграций (шаг 3) → + # lifespan-hook гарантированно видит применённые миграции (FOREIGN + # SERVER gendesign_remote существует) уже с первого старта. Рестарт удалён. # Caddy reload — основной Caddyfile содержит inline tradein routes # (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt new file mode 100644 index 00000000..e278cbf7 --- /dev/null +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -0,0 +1,168 @@ +# _manifest_applied.txt — CONTRACT (issue #2216) +# +# Отсортированный список ВСЕХ имён миграций (bare filename) в data/sql/, +# которые на момент коммита уже применены/забейслайнены на проде. +# Прод трекает миграции по bare-filename в public._schema_migrations — +# переименование или удаление применённого файла => повторный прогон на +# проде (новый filename считается неприменённым) => дубль-эффекты/ошибки. +# +# ПРАВИЛА (enforced tests/test_migrations_manifest.py): +# 1. Каждое имя здесь ОБЯЗАНО существовать в data/sql/ (нельзя rename/rm applied). +# 2. Новый .sql-файл => НЕ переиспользуй NN-префикс (кроме 6 grandfathered дублей). +# 3. Добавляя новую миграцию — допиши её имя сюда В ТОМ ЖЕ PR (список отсортирован). +# +# Комментарии (# ...) и пустые строки тест игнорирует. +001_trade_in_estimates.sql +002_core_tables.sql +003_seed_deals.sql +004_extend_trade_in_estimates.sql +005_geocode_tracking.sql +007_estimate_photos.sql +008_crm_fields.sql +009_houses.sql +010_houses_alter.sql +011_listings_alter.sql +012_sellers.sql +013_listings_alter_seller.sql +014_house_reviews.sql +015_scrape_runs.sql +016_listings_snapshots.sql +017_house_placement_history.sql +018_avito_imv_evaluations.sql +019_listings_alter_cian.sql +020_houses_alter_cian.sql +021_management_companies.sql +022_agents_table.sql +023_offer_price_history.sql +024_houses_price_dynamics.sql +025_house_reliability_checks.sql +026_external_valuations.sql +027_cian_session_cookies.sql +028_matching_tables.sql +029_extend_matching_valuation_dynamics.sql +030_avito_imv_cache_key_unique.sql +031_houses_alter_yandex.sql +032_yandex_history.sql +033_listings_alter_yandex.sql +034_trade_in_estimates_geom.sql +035_drop_duplicate_indexes.sql +040_houses_extend.sql +041_house_sources_noop.sql +042_listing_sources_price_divergence_idx.sql +043_house_reviews_extend.sql +044_external_valuations_link.sql +045_house_placement_history_extend.sql +046_views.sql +047_cian_history_sanitize.sql +050_search_optimization.sql +051_scrape_runs_extend.sql +052_scrape_schedules.sql +053_scraper_settings.sql +054_scraper_settings_global.sql +060_postgres_fdw_extension.sql +061_drop_legacy_cad_buildings.sql +062_clean_avito_addresses.sql +063_backfill_houses_and_link_listings.sql +064_house_imv_phase_c.sql +065_trade_in_estimates_floor_optional.sql +066_address_mismatch_audit.sql +067_v_street_sales_vs_listings.sql +068_drop_v_street_sales_vs_listings.sql +069_trade_in_estimates_dadata_fields.sql +070_houses_dadata_enrichment.sql +071_houses_cian_zhk_url.sql +072_scrape_schedules_seed_cian_rosreestr.sql +073_normalize_repair_state.sql +075_backfill_repair_state_from_description.sql +076_account_estimate_quota.sql +077_dedup_hash_plain_key_backfill.sql +078_scrape_schedules_seed_yandex_sweep.sql +079_listing_source_history.sql +080_asking_to_sold_ratios.sql +081_trade_in_estimates_expected_sold.sql +082_scrape_schedules_seed_ratio_refresh.sql +083_trade_in_estimates_created_by.sql +084_brand_praktika_fill.sql +084_scrape_schedules_seed_n1_sweep.sql +085_quarter_price_index_fdw.sql +086_deals_address_trgm_index.sql +087_fdw_server_options.sql +088_scrape_schedules_seed_search_matview_refresh.sql +089_listings_geo_precision.sql +090_scrape_schedules_seed_deactivate_stale_avito.sql +091_scrape_schedules_seed_yandex_address_backfill.sql +092_sber_price_index.sql +093_scrape_schedules_seed_sber_index_pull.sql +094_cadastral_unify.sql +095_dead_schema.sql +096_scrape_schedules_seed_rosreestr_quarter_poll.sql +097_index_hygiene.sql +098_asking_to_sold_ratios_tiered.sql +099_brand_praktika_logo_wordmark.sql +100_enable_deactivate_stale_avito.sql +101_gendesign_reader_role.sql +102_grant_listings_gendesign_reader.sql +103_scrape_schedules_seed_newbuilding_enrich.sql +104_index_hygiene_geom_dedup.sql +105_market_schema_yandex_enrichment.sql +106_scrape_schedules_seed_yandex_newbuilding_sweep.sql +107_scrape_schedules_seed_cian_city_sweep.sql +108_clean_avito_addresses_v2.sql +108_merge_duplicate_houses.sql +109_asking_to_sold_ratio_segment_filter.sql +110_scrape_schedules_seed_geocode_missing_listings.sql +111_listings_avito_detail_fields.sql +112_scrape_schedules_seed_avito_detail_backfill.sql +113_deactivate_ghost_duplicate_listings.sql +113_yandex_detail_backfill.sql +114_disable_n1_sweep.sql +115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql +116_offer_price_history_change_trigger.sql +117_listings_last_seen_deactivate_index.sql +118_enable_cian_city_sweep.sql +119_yandex_city_sweep_center_combos.sql +120_restore_partial_active_indexes.sql +121_remove_brand_praktika.sql +121_yandex_rich_fields.sql +122_enable_domclick_city_sweep.sql +123_avito_newbuilding_sweep_schedule.sql +124_cad_buildings_local.sql +124_deglue_avito_addresses.sql +125_scrape_schedules_seed_cadastral_geo_match.sql +126_scrape_schedules_seed_cian_full_load.sql +127_scrape_schedules_seed_avito_full_load.sql +128_listings_card_hash.sql +129_avito_full_load_incremental_split.sql +130_backfill_listings_house_id_fk.sql +130_ekb_geoportal_buildings.sql +131_fix_diff_percent_overflow.sql +132_scrape_schedules_seed_house_imv.sql +133_listings_uq_source_source_id.sql +134_listings_geom_geography_gist.sql +135_scrape_schedules_seed_house_dedup_merge.sql +136_backfill_listings_house_id_fk_source_identity.sql +137_listings_addr_norm_trgm.sql +138_domclick_bff_rewrite_schedule.sql +139_premium_houses.sql +140_yandex_house_type_backfill.sql +141_cian_promote_house_type.sql +142_premium_buildings_curated.sql +143_building_sale_share_schema.sql +144_gar_canon_addr_match.sql +145_building_sale_share_plausible_denom.sql +146_sale_share_45d_and_zhkh_denom.sql +147_canon_strip_geo_prefixes.sql +148_dedup_apartments_in_sale_share.sql +149_zhkh_priority_denominator.sql +150_sale_share_listing_geo_filter.sql +151_clean_bare_street_aliases.sql +152_sale_share_floors_guard.sql +153_sale_share_listings_floors_plausibility.sql +154_market_contract_views.sql +155_reader_grants_to_contract_views.sql +156_revoke_raw_from_reader.sql +157_scrape_proxies.sql +158_seed_proxy_healthcheck_schedule.sql +159_houses_fias_idx.sql +160_seed_deactivate_stale_domklik_n1.sql +161_backfill_scraped_at_active_recent.sql diff --git a/tradein-mvp/backend/tests/test_migrations_manifest.py b/tradein-mvp/backend/tests/test_migrations_manifest.py new file mode 100644 index 00000000..26e3375f --- /dev/null +++ b/tradein-mvp/backend/tests/test_migrations_manifest.py @@ -0,0 +1,138 @@ +"""Invariants over data/sql migrations (issue #2216). + +Прод применяет миграции по BARE FILENAME: deploy-tradein.yml трекает каждый +`data/sql/*.sql` в таблице `public._schema_migrations` (PRIMARY KEY = filename). +Из этого следуют два хрупких инварианта, которые этот тест защищает от регрессии: + +1. Переименование / удаление УЖЕ ПРИМЕНЁННОЙ миграции ломает прод: новый + filename считается неприменённым и прогоняется повторно (дубль-INSERT, + повторный DDL, PK violation под ON_ERROR_STOP => деплой падает или, хуже, + молча дублирует данные). Manifest `data/sql/_manifest_applied.txt` — это + слепок применённых имён на момент коммита; любой из них ОБЯЗАН существовать. + +2. Два разных файла с одинаковым NN-префиксом ("дубль-префикс") — источник + двусмысленного порядка применения (`ls | sort` детерминирован, но человек + легко создаёт коллизию). 6 исторических дублей grandfathered'ы (оба в + manifest). Любой НОВЫЙ файл обязан нести уникальный префикс. + +Self-maintenance: добавляя новую миграцию, допиши её имя в manifest В ТОМ ЖЕ PR +(см. assert-сообщения ниже). Тест требует data/sql ⊇ manifest и уникальность +префикса у новых файлов; сам manifest дополняет автор миграции. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +_SQL_DIR = _BACKEND_ROOT / "data" / "sql" +_MANIFEST = _SQL_DIR / "_manifest_applied.txt" + +_NN_PREFIX = re.compile(r"^(\d+)_") + + +def _read_manifest() -> list[str]: + """Имена миграций из manifest; # comments и пустые строки игнорируются.""" + names: list[str] = [] + for raw in _MANIFEST.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + names.append(line) + return names + + +def _actual_sql_files() -> set[str]: + return {p.name for p in _SQL_DIR.glob("*.sql")} + + +def _prefix(name: str) -> str | None: + m = _NN_PREFIX.match(name) + return m.group(1) if m else None + + +def test_manifest_entries_all_exist() -> None: + """Каждый файл из manifest СУЩЕСТВУЕТ в data/sql/. + + Red => применённая миграция переименована или удалена. Прод трекает по + bare-filename: старое имя остаётся в _schema_migrations, НОВОЕ имя считается + неприменённым и прогоняется повторно. Восстанови исходное имя файла (или, + если переименование намеренное — так делать НЕЛЬЗЯ для уже-применённых + миграций: заведи НОВЫЙ файл, а старый оставь как есть). + """ + actual = _actual_sql_files() + manifest = _read_manifest() + missing = sorted(n for n in manifest if n not in actual) + assert not missing, ( + "Миграции из _manifest_applied.txt отсутствуют в data/sql/ " + f"(переименованы/удалены?): {missing}. Эти имена уже применены на проде " + "(tracking по bare-filename в _schema_migrations) — их нельзя " + "переименовывать/удалять. Верни исходные имена файлов." + ) + + +def test_manifest_is_sorted_and_unique() -> None: + """Manifest отсортирован (codepoint) и без дублей — детерминированный слепок.""" + manifest = _read_manifest() + assert manifest == sorted(manifest), ( + "_manifest_applied.txt не отсортирован. Пересортируй записи " + "(LC_ALL=C sort / Python sorted())." + ) + dupes = sorted({n for n in manifest if manifest.count(n) > 1}) + assert not dupes, f"Дублирующиеся строки в _manifest_applied.txt: {dupes}" + + +def test_new_files_do_not_reuse_prefix() -> None: + """Новые (не в manifest) .sql-файлы НЕ переиспользуют существующий NN-префикс. + + Grandfathered дубли (084/108/113/121/124/130) — оба файла в manifest, поэтому + не флагаются: считаются "существующими", а не "новыми". + + Red => новый файл взял префикс уже присутствующей миграции. Присвой + следующий свободный NN и допиши имя в _manifest_applied.txt (тот же PR). + """ + actual = _actual_sql_files() + manifest = set(_read_manifest()) + + # Префиксы, «занятые» уже-применёнными (manifest) миграциями. + baseline_prefixes: set[str] = set() + for name in manifest: + p = _prefix(name) + if p is not None: + baseline_prefixes.add(p) + + new_files = sorted(actual - manifest) + collisions: list[str] = [] + # Внутри новых файлов префикс тоже обязан быть уникален (два новых с одним NN). + seen_new_prefix: dict[str, str] = {} + for name in new_files: + p = _prefix(name) + if p is None: + continue + if p in baseline_prefixes: + collisions.append(f"{name} (префикс {p} занят применённой миграцией)") + elif p in seen_new_prefix: + collisions.append(f"{name} (префикс {p} уже у нового {seen_new_prefix[p]})") + else: + seen_new_prefix[p] = name + + assert not collisions, ( + "Новые миграции переиспользуют NN-префикс: " + "; ".join(collisions) + ". " + "Присвой следующий свободный номер и добавь имя файла в " + "_manifest_applied.txt в ЭТОМ ЖЕ PR." + ) + + +def test_manifest_covers_all_but_new_files() -> None: + """data/sql ⊇ manifest, и каждый новый файл имеет уникальный префикс — + напоминание о self-maintenance: manifest дополняется вместе с миграцией. + + Этот тест НЕ требует, чтобы новый файл уже был в manifest (иначе PR с новой + миграцией всегда красный). Он лишь гарантирует, что manifest не отстал от + реальности В ЧАСТИ применённых имён (см. test_manifest_entries_all_exist) и + что новые файлы не создают префикс-коллизий (см. предыдущий тест). + """ + # Sanity: manifest непустой и в data/sql есть файлы — защита от битых путей. + assert _actual_sql_files(), f"Не найдено *.sql в {_SQL_DIR}" + assert _read_manifest(), f"_manifest_applied.txt пуст: {_MANIFEST}"