From 28c374ab084124e8f6ca714fe19bfc8789a9d7da Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 22:49:15 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/search):=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20STORED-generated=20=D1=81=20listings.tsv,=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=87=D0=B8=D1=82=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D0=B6?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=20UPDATE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listings.tsv (GENERATED ALWAYS ... STORED) пересчитывался на КАЖДОМ UPDATE listings независимо от того, менялись ли description/address, и заново перетостивался — ~5 ГБ TOAST-оборота за 91 день. Разведка: /api/v1/search (search_query.py) читает listings_search_mv, не listings напрямую, а витрина сама считает to_tsvector из сырых description/address/developer_name при каждом REFRESH — l.tsv она никогда не читала. DROP EXPRESSION безопасен для поиска и, по ATExecDropExpression (PG16), не вызывает table rewrite — catalog-only операция. listings_tsv_idx (GIN, 116 MB) снесён отдельно: 0 idx_scan за всю историю БД, не обслуживает ни один constraint. Колонка tsv остаётся (заморожена, без читателей) — DROP COLUMN вне рамок этой миграции. Refs #2992, #2989 --- .../sql/268_listings_tsv_drop_generated.sql | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql diff --git a/tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql b/tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql new file mode 100644 index 00000000..489194f6 --- /dev/null +++ b/tradein-mvp/backend/data/sql/268_listings_tsv_drop_generated.sql @@ -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; -- 2.45.3