diff --git a/tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql b/tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql index fca87ef5..c925562c 100644 --- a/tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql +++ b/tradein-mvp/backend/data/sql/311_canon_strip_district_glued_to_house_number.sql @@ -29,9 +29,29 @@ -- DEPENDENCIES (existing prod objects): -- - tradein_canon_addr(text) — мигр. 144/147 (заменяем тело, IMMUTABLE, сигнатура та же). -- - gar_house_flats_canon_idx — функциональный индекс на tradein_canon_addr(norm_address) --- (мигр. 144). По замеру значения не меняются, но REINDEX оставлен по правилу мигр. 147: --- Postgres не перестраивает функциональный индекс при смене тела функции, и полагаться --- на «сейчас ни одна строка не затронута» для следующей загрузки ГАР нельзя. +-- WHERE flat_count > 0 (мигр. 144). Postgres не перестраивает функциональный индекс при +-- смене тела функции: СТАРЫЕ ключи остаются, новые строки получают ключ нового тела. +-- Старый ключ неверен ровно у строк, где срабатывает S0b (lower+ё→е ~ '[0-9][а-я]?р-н '). +-- Прод 17.09: 0 из 201 513 индексируемых строк → сегодня REINDEX ничего не меняет. +-- Оставлен в ТОЙ ЖЕ транзакции, что и замена тела: замер не покрывает строки, загруженные +-- между 17.09 и деплоем, а атомарная пара «тело + индекс» не оставляет окна со старыми ключами. +-- +-- ЦЕНА REINDEX (прод 17.09, только чтение): таблица 3,27 млн строк / 942 МБ heap, индекс 8,8 МБ. +-- Эквивалент перестройки — seq scan + tradein_canon_addr по flat_count > 0 в один поток — 5,6 с +-- (скан 0,96 с, вычисление ключей ~4,6 с). Всё это время REINDEX держит SHARE на таблице и +-- ACCESS EXCLUSIVE на индексе: планировщик любого запроса к gar_house_flats ждёт. Читают и +-- пишут таблицу только ручной ГАР-загрузчик и ре-матч (app/services/gar_flats_loader.py; в +-- scrape_schedules их нет; в pg_stat_statements с 27.08 — только INSERT загрузчика и ручные +-- замеры) — путь запроса пользователя не задет. +-- lock_timeout 5s ограничивает ОЖИДАНИЕ лока (#2752): если в момент деплоя идёт загрузка ГАР, +-- миграция падает целиком (тело откатывается вместе с REINDEX), деплой красный, повторить позже. +-- +-- Почему не REINDEX CONCURRENTLY (раннер гонит файл psql'ем в autocommit, так что вне +-- BEGIN/COMMIT он возможен, образец — 270): CIC ждёт ВСЕ транзакции базы старше своего +-- снимка, а сборщики держат «idle in transaction» минутами (17.09: 3 мин) — деплой висит без +-- lock_timeout, который CIC не терпит; оборванный CIC оставляет невалидный *_ccnew и красный +-- деплой с ручной чисткой на проде; тело функции коммитится раньше индекса. Ради ~5 с лока на +-- таблице пакетного загрузчика это хуже. -- -- POST-DEPLOY: ничего запускать не нужно. Следующий прогон расписания house_dedup_merge -- (еженедельно) сольёт пары, прошедшие страж 250 м, с журналом в house_merge_log. @@ -41,6 +61,8 @@ BEGIN; +SET LOCAL lock_timeout = '5s'; + CREATE OR REPLACE FUNCTION tradein_canon_addr(s text) RETURNS text AS $func$ SELECT regexp_replace( -- S6: оставить только [а-я0-9] regexp_replace( -- S5: срез типов улиц (токены на границе не-кириллицы) diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index d8c050e4..1ef07f4e 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -38,6 +38,7 @@ tests/test_house_dedup_merge.py::test_real_canon_strips_district_glued_to_house_ tests/test_house_dedup_merge.py::test_real_fias_pass_cross_guard_and_identity_carryover tests/test_house_dedup_merge.py::test_real_fias_pass_keeps_geo_guard tests/test_house_dedup_merge.py::test_real_merge_is_reversible_via_journal +tests/test_house_dedup_merge.py::test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing tests/test_house_dedup_merge.py::test_real_merge_repoints_dedups_deletes_and_is_idempotent tests/test_user_events.py::test_real_record_event_inserts_row tests/test_3469_showcase_schedule.py::test_live_migration_puts_showcase_into_schedules_and_digest diff --git a/tradein-mvp/backend/tests/test_house_dedup_merge.py b/tradein-mvp/backend/tests/test_house_dedup_merge.py index 0ab7a608..f8266955 100644 --- a/tradein-mvp/backend/tests/test_house_dedup_merge.py +++ b/tradein-mvp/backend/tests/test_house_dedup_merge.py @@ -1071,6 +1071,46 @@ def test_real_canon_strips_district_glued_to_house_number() -> None: db.close() +@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") +def test_real_migration_311_gives_up_on_busy_gar_table_instead_of_queueing() -> None: + """Migration 311 under a running GAR load fails in ~5 s and rolls back whole (#2752). + + Its REINDEX needs SHARE on gar_house_flats; the loader's INSERT holds ROW EXCLUSIVE. Without + lock_timeout the deploy waits for the load and every query on the table queues behind it. + The file is run the way the deploy runner runs it (autocommit, BEGIN/COMMIT inside); the + blocker lets go after 12 s, so a missing timeout shows up as a migration that succeeds. + """ + import threading + import time + + import psycopg + + db = _live_session() + assert db is not None + dsn = db.get_bind().url.set(drivername="postgresql").render_as_string(hide_password=False) + db.close() + sql = (_SQL_DIR / "311_canon_strip_district_glued_to_house_number.sql").read_text("utf-8") + body_version = "SELECT xmin::text FROM pg_proc WHERE proname = 'tradein_canon_addr'" + + with psycopg.connect(dsn, autocommit=True) as deploy, psycopg.connect(dsn) as loader: + before = deploy.execute(body_version).fetchone() + loader.execute("LOCK TABLE gar_house_flats IN ROW EXCLUSIVE MODE") + release = threading.Timer(12, loader.rollback) + release.start() + started = time.monotonic() + try: + with pytest.raises(psycopg.errors.LockNotAvailable): + deploy.execute(sql) + finally: + release.cancel() + release.join() + loader.rollback() + assert time.monotonic() - started < 10 + deploy.execute("ROLLBACK") + # CREATE OR REPLACE rewrites the pg_proc row: an unchanged xmin = the body was rolled back + assert deploy.execute(body_version).fetchone() == before + + @pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB") def test_real_fias_pass_cross_guard_and_identity_carryover() -> None: """End-to-end on a real DB for the #1772 follow-up (fias pass + cross-fias guard + carry-over):