From a0522b9a860db992d2a9b217cf5011456f38c162 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 14:28:55 +0500 Subject: [PATCH] =?UTF-8?q?fix(tradein/dedup):=20=D0=BC=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20311=20=D0=BD=D0=B5=20=D0=B2=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=91=D1=82=20=D0=B2=20=D0=BE=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D1=8C=20=D0=B7=D0=B0=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=BE=D0=B9=20=D0=93=D0=90=D0=A0=20=E2=80=94?= =?UTF-8?q?=20lock=5Ftimeout=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20REINDEX=20?= =?UTF-8?q?(#1772)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI / changes краснел гейтом #2752: «блокирующий DDL без lock_timeout (REINDEX INDEX gar_house_flats_canon_idx)». Добавлен SET LOCAL lock_timeout = '5s' сразу под BEGIN. REINDEX оценён замером на проде 17.09 (только чтение): - gar_house_flats 3,27 млн строк / 942 МБ heap (1,5 ГБ с индексами), индекс частичный (flat_count > 0), 201 513 строк, 8,8 МБ; - эквивалент перестройки (seq scan + tradein_canon_addr в один поток) 5,6 с — столько таблица закрыта для записи и для планировщика любого запроса к ней; читают её только ручной ГАР-загрузчик и ре-матч, пользовательский путь не задет; - шаг S0b меняет ключ у 0 из 201 513 индексируемых строк: сегодня REINDEX ничего не меняет, но оставлен в той же транзакции, что и тело функции, чтобы строки, загруженные до деплоя, не остались со старым ключом. Прежнее обоснование («следующая загрузка ГАР») было неверным: новые строки и так получают ключ нового тела. REINDEX CONCURRENTLY возможен (раннер гонит файл psql'ем в autocommit, образец 270), но не взят: ждёт все транзакции базы старше своего снимка (сборщики держат idle in transaction минутами), lock_timeout не терпит, оборванный оставляет невалидный *_ccnew, тело функции коммитится раньше индекса. Тест по значению на живом Postgres: 311 гоняется как в раннере, пока «загрузчик» держит ROW EXCLUSIVE на gar_house_flats — миграция падает LockNotAvailable быстрее 10 с, тело функции откатывается (xmin строки pg_proc не меняется). Без SET LOCAL тест красный: DID NOT RAISE — миграция дождалась загрузчика. Co-Authored-By: Claude Opus 5 --- ...n_strip_district_glued_to_house_number.sql | 28 +++++++++++-- tradein-mvp/backend/tests/skip_allowlist.txt | 1 + .../backend/tests/test_house_dedup_merge.py | 40 +++++++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) 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):