fix(tradein/dedup): миграция 311 не встаёт в очередь за загрузкой ГАР — lock_timeout перед REINDEX (#1772)
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 <noreply@anthropic.com>
This commit is contained in:
parent
e2b1a8adee
commit
a0522b9a86
3 changed files with 66 additions and 3 deletions
|
|
@ -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: срез типов улиц (токены на границе не-кириллицы)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue