Merge pull request 'fix(tradein/search): снять STORED-generated с listings.tsv, не пересчитывать на каждом UPDATE' (#3007) from fix/listings-tsv-drop-generated into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m30s
Deploy Trade-In / build-backend (push) Successful in 36s
Deploy Trade-In / deploy (push) Successful in 1m43s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s

This commit is contained in:
lekss361 2026-08-20 19:59:15 +00:00
commit 9b72e50d18

View file

@ -0,0 +1,152 @@
-- 268_listings_tsv_drop_generated.sql
-- Issue #2992 (эпик #2989) — остановить пересчёт listings.tsv на каждом UPDATE.
--
-- ── ЧТО ЗА ПРОБЛЕМА ─────────────────────────────────────────────────────────
-- listings.tsv — STORED generated column:
-- GENERATED ALWAYS AS (
-- setweight(to_tsvector('russian', COALESCE(description,'')),'A')
-- || setweight(to_tsvector('russian', COALESCE(address,'')),'B')
-- ) STORED
-- Stored-generated колонка пересчитывается на КАЖДОМ UPDATE строки listings,
-- независимо от того, менялись ли description/address. Результат — свежий
-- inline-датум (не переиспользованный старый TOAST-указатель), поэтому строка
-- ещё и перетостивается заново: ~5 ГБ оборота TOAST за 91 день на этой
-- колонке, плюс лишняя работа GIN-индекса на каждый UPDATE.
--
-- ── РАЗВЕДКА: search_query.py читает listings ИЛИ listings_search_mv? ──────
-- app/services/search_query.py:125 — предикат `tsv @@ plainto_tsquery(...)`.
-- Прочитан файл целиком: build_search_query собирает
-- `SELECT ... FROM listings_search_mv WHERE {where_sql} ...`
-- (docstring: «Возвращает (sql, args) для SELECT из listings_search_mv»).
-- Единственный писатель предиката — этот файл, других обращений к `tsv` во
-- всём tradein-mvp нет (`grep -rn '\btsv\b' tradein-mvp --include=*.py`
-- даёт ровно эту одну строку).
--
-- Значит `tsv` в предикате — колонка ВИТРИНЫ listings_search_mv, не
-- listings.tsv напрямую. Дальше — ключевой вопрос: чем засеяна tsv-колонка
-- самой витрины.
--
-- Прочитаны все миграции, трогавшие listings_search_mv (050, 094, 261 —
-- единственные; `grep -rn 'l\.tsv\b' tradein-mvp/backend/data/sql/*.sql`
-- даёт 0 совпадений ВООБЩЕ). Актуальное определение — 261 (сверено байт-в-байт
-- с `pg_get_viewdef('listings_search_mv'::regclass, true)` на проде
-- 2026-08-20):
-- to_tsvector('russian',
-- coalesce(l.description, '') || ' ' ||
-- coalesce(l.address, '') || ' ' ||
-- coalesce(h.developer_name, '')
-- ) AS tsv
-- Витрина считает to_tsvector САМА, из сырых listings.description /
-- listings.address / houses.developer_name, в момент CREATE/REFRESH. Она
-- НИКОГДА не читала listings.tsv (ни alias `l.tsv AS tsv`, ни `SELECT *`) —
-- отдельное определение появилось уже в 050 и не менялось по составу
-- источника с тех пор (094 поменяла только l.kadastr_num → l.cadastral_number
-- в других колонках витрины, 261 убрала три колонки-заглушки; выражение tsv
-- дословно то же).
--
-- ВЫВОД: listings_search_mv полностью развязана с listings.tsv. Снятие
-- GENERATED с listings.tsv не делает поиск устаревшим ни на секунду — витрина
-- как считала to_tsvector из сырых колонок при каждом REFRESH, так и будет
-- продолжать. Пункт «переопределить listings_search_mv» из исходной
-- постановки задачи — ПРОВЕРЕН И ОТКЛОНЁН: посылка «MV тянет l.tsv» не
-- подтвердилась, витрину в этой миграции трогать не нужно и она не тронута.
--
-- ── ИНДЕКС listings_tsv_idx: ДЕЙСТВИТЕЛЬНО МЁРТВ ────────────────────────────
-- Проверено на проде 2026-08-20 (SELECT-only):
-- pg_stat_user_indexes: idx_scan = 0, pg_stat_database.stats_reset для БД
-- tradein пуст (счётчики ни разу не сбрасывались за всё время жизни БД) —
-- то есть 0 сканов не «с последнего сброса», а ЗА ВСЮ ИСТОРИЮ. Размер
-- индекса 116 MB.
-- pg_constraint: SELECT ... WHERE conindid = 'listings_tsv_idx'::regclass
-- → 0 строк. Индекс не обслуживает ни один UNIQUE/PK/EXCLUDE.
-- Дропаем.
--
-- ── ПОЧЕМУ DROP EXPRESSION НЕ ВЫЗЫВАЕТ REWRITE ──────────────────────────────
-- Проверено по исходникам PostgreSQL 16 (ATExecDropExpression, tablecmds.c,
-- REL_16_STABLE): DROP EXPRESSION правит ТОЛЬКО каталог — снимает
-- attgenerated ('s' → '\0') и удаляет pg_attrdef строку через
-- RemoveAttrDefault. `tab->rewrite` (флаг, которым ATRewriteTables решает,
-- нужен ли проход по каждой странице таблицы) НЕ выставляется в этой ветке.
-- Значит `ALTER TABLE listings ALTER COLUMN tsv DROP EXPRESSION` — чисто
-- каталожная операция: держит ACCESS EXCLUSIVE ровно на время правки system
-- catalog (единицы миллисекунд на прогретом кэше), полного скана/переписи
-- 93k+ строк listings НЕ делает. Подтверждено и синтаксисом на самом проде
-- (`\h ALTER TABLE`, PostgreSQL 16.4): `ALTER [COLUMN] column_name DROP
-- EXPRESSION [IF EXISTS]` — форма с IF EXISTS штатная, не throw error если
-- колонка уже не generated (NOTICE вместо ошибки) => идемпотентна сама по
-- себе, без обёртки в DO $$.
--
-- ── КОЛОНКА tsv ОСТАЁТСЯ ─────────────────────────────────────────────────
-- Саму колонку НЕ дропаем. DROP COLUMN — это отдельная операция с полным
-- ACCESS EXCLUSIVE на время удаления атрибута из каждой строки (rewrite не
-- обязателен физически для DROP COLUMN — PG просто помечает attisdropped, но
-- это самостоятельный шаг с собственным риском и ломает любой `SELECT *`,
-- который сегодня по listings делает бэкенд/скрипты) — вне рамок этой задачи
-- и явно запрещено постановкой. После этой миграции `tsv` — обычная
-- tsvector-колонка, замороженная на последнем сосчитанном значении: новые
-- description/address её больше не обновляют. Читателей у неё теперь 0
-- (последний, listings_tsv_idx, снесён этой же миграцией), поэтому
-- staleness никому не видна. Кандидат на будущий отдельный DROP COLUMN —
-- отдельная rewrite-миграция, не входит в эту.
--
-- ── ПОРЯДОК ───────────────────────────────────────────────────────────────
-- DROP INDEX перед DROP EXPRESSION: индекс всё равно синхронизирован с
-- generated-значением на момент своего удаления, порядок между этими двумя
-- операциями физически не важен (обе развязаны с listings_search_mv), но
-- дропать использующий колонку объект первым — консервативнее.
--
-- IDEMPOTENCY / SAFETY:
-- - DROP INDEX IF EXISTS — безопасный re-run.
-- - DROP EXPRESSION IF EXISTS — штатно идемпотентна (NOTICE, не error).
-- - Обе операции — catalog-only, без TABLE REWRITE (см. разбор выше).
-- - Данных не теряем: tsv не дропается, только перестаёт быть generated.
--
-- Dependencies: 050_search_optimization.sql (завела generated-колонку и
-- индекс), 261_listings_search_mv_drop_placeholder_columns.sql (последняя
-- пересоздавшая listings_search_mv — сверена и НЕ тронута этой миграцией).
-- Deploy order: standalone, независимо от кода — search_query.py читает
-- listings_search_mv, её эта миграция не меняет.
--
-- КРИТЕРИЙ ПРИЁМКИ (записан ДО применения):
-- 1. Строка '268_listings_tsv_drop_generated.sql' в _schema_migrations.
-- 2. information_schema.columns: listings.tsv по-прежнему существует,
-- is_generated = 'NEVER' (было 'ALWAYS').
-- pg_attribute.attgenerated = '' (было 's').
-- 3. pg_indexes: listings_tsv_idx отсутствует.
-- 4. pg_matviews.definition листings_search_mv НЕ меняется (байт-в-байт
-- как до миграции) — витрину эта миграция не трогает.
-- 5. /api/v1/search с description_query по-прежнему возвращает результаты
-- (витрина независима от listings.tsv, регрессии быть не должно).
-- 6. Через несколько дней после деплоя: TOAST-оборот на listings
-- (pg_stat_user_tables.n_tup_upd / связанный toast relation) заметно
-- ниже, чем базовые ~5 ГБ/91д, замеренные до фикса.
BEGIN;
-- Ограничивает ОЖИДАНИЕ выдачи лока, не работу под ним (обе операции ниже —
-- catalog-only, доли миллисекунд под локом; риск — простоять в очереди за
-- чужой долгой сессией). См. .claude/rules/sql.md § lock_timeout.
SET LOCAL lock_timeout = '5s';
-- Мёртвый индекс: 0 idx_scan за всю историю БД, не обслуживает ни один
-- constraint (pg_constraint.conindid пуст) — см. разбор в шапке.
DROP INDEX IF EXISTS listings_tsv_idx;
-- Снимает GENERATED ALWAYS ... STORED. Catalog-only (attgenerated + удаление
-- pg_attrdef), без table rewrite — см. разбор ATExecDropExpression в шапке.
-- Колонка остаётся обычным tsvector, замороженным на текущем значении.
ALTER TABLE listings ALTER COLUMN tsv DROP EXPRESSION IF EXISTS;
COMMENT ON COLUMN listings.tsv IS
'Больше НЕ generated (снято миграцией 268, #2992/#2989): STORED-генерация '
'пересчитывала tsv на каждом UPDATE независимо от того, менялись ли '
'description/address, и перетостивала строку заново (~5 ГБ TOAST-оборота '
'за 91 день). Значение заморожено на последнем пересчитанном состоянии. '
'listings_search_mv (050/094/261) НИКОГДА не читала эту колонку — она '
'сама считает to_tsvector из сырых description/address/developer_name '
'при каждом REFRESH, поэтому /api/v1/search не деградирует. Единственный '
'индекс на этой колонке, listings_tsv_idx, снесён той же миграцией (0 '
'idx_scan за всю историю). Колонку намеренно не дропаем — DROP COLUMN '
'ломает listings.SELECT * и требует отдельной rewrite-миграции.';
COMMIT;