fix(tradein/search): снять STORED-generated с listings.tsv, не пересчитывать на каждом UPDATE #3007
1 changed files with 152 additions and 0 deletions
152
tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql
Normal file
152
tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue