fix(tradein/search): снять STORED-generated с listings.tsv, не пересчитывать на каждом UPDATE #3007

Merged
lekss361 merged 1 commit from fix/listings-tsv-drop-generated into main 2026-08-20 19:59:15 +00:00
Owner

Первый из трёх шагов по write-amplification в listings (#2992, эпик #2989). Миграция 268_listings_tsv_drop_generated.sql.

Что чинит

listings.tsvGENERATED ALWAYS AS (...) STORED. Stored-generated колонка пересчитывается на каждом UPDATE строки, независимо от того, менялись ли description/address. Результат — свежий inline-датум, а не переиспользованный TOAST-указатель, поэтому строка ещё и перетостивается заново: около 5 ГБ оборота TOAST за 91 день на одной этой колонке, плюс работа GIN-индекса на каждый апдейт.

При 20,86 млн апдейтов за окно это заметная доля общего write-amplification, и правкой SET-списка в апсерте она не лечится — генерация срабатывает независимо от того, что перечислено в SET.

Посылка задачи не подтвердилась, и это к лучшему

Ставя задачу, я исходил из того, что сначала придётся переопределить listings_search_mv, чтобы она считала to_tsvector при REFRESH, а не тянула хранимую колонку. Проверка это опровергла.

search_query.py:125 (tsv @@ plainto_tsquery) обращается к витрине, а не к таблице: build_search_query собирает SELECT ... FROM listings_search_mv. А сама витрина никогда не читала listings.tsv — сверено байт-в-байт с pg_get_viewdef на проде и со всеми тремя миграциями, когда-либо её создававшими (050, 094, 261; grep 'l\.tsv\b' по всем SQL даёт ноль совпадений). Витрина считает свой tsvector сама:

to_tsvector('russian',
    coalesce(l.description,'') || ' ' || coalesce(l.address,'') || ' ' ||
    coalesce(h.developer_name,'')
) AS tsv

Значит поиск развязан с listings.tsv полностью, витрину трогать не нужно, и объём правки меньше запланированного.

Индекс действительно мёртв

listings_tsv_idx, 116 МБ. idx_scan = 0, причём pg_stat_database.stats_reset для этой БД пуст — то есть ноль сканов за всю историю базы, а не с последнего сброса счётчиков. pg_constraint.conindid по нему пуст — ни один UNIQUE/PK/EXCLUDE за ним не стоит. Дропается этой же миграцией.

Почему нет rewrite

ALTER TABLE ... ALTER COLUMN ... DROP EXPRESSION — операция чисто каталожная. По исходникам PostgreSQL 16 (ATExecDropExpression, tablecmds.c, REL_16_STABLE) она снимает attgenerated и удаляет строку pg_attrdef, а флаг tab->rewrite, по которому ATRewriteTables решает делать проход по страницам, не выставляется. ACCESS EXCLUSIVE держится ровно на время правки системного каталога.

Форма DROP EXPRESSION IF EXISTS штатная и нативно идемпотентна — при повторном прогоне выдаёт NOTICE, а не ошибку. Синтаксис сверен с \h ALTER TABLE на самом проде (PG 16.4).

Колонку намеренно оставляем

Снимается только генерация. DROP COLUMN — отдельная операция со своим риском, ломающая любой SELECT * по listings, и в эту задачу не входит. После миграции tsv — обычная замороженная tsvector-колонка без единого читателя. Поведение задокументировано через COMMENT ON COLUMN, чтобы следующий, кто на неё наткнётся, не принял замороженные значения за рабочие.

Критерии приёмки

Записаны в шапке миграции до применения:

  1. 268_listings_tsv_drop_generated.sql в _schema_migrations
  2. listings.tsv существует, is_generated = 'NEVER' (было ALWAYS), attgenerated = ''
  3. listings_tsv_idx отсутствует
  4. Определение listings_search_mv не изменилось байт-в-байт
  5. /api/v1/search с description_query возвращает результаты
  6. Через несколько дней — TOAST-оборот на listings заметно ниже базовых ~5 ГБ/91 день

Проверено

scripts/check-migration-lock-timeout.py — зелёный. pytest tests/test_migration_numbering.py — номер 268 свободен относительно forgejo/main. pytest tests/test_2857_search_mv_placeholder_columns.py — витрина не тронута. Прод только читался, SELECT-ами.

Refs #2992, #2989

Первый из трёх шагов по write-amplification в `listings` (#2992, эпик #2989). Миграция `268_listings_tsv_drop_generated.sql`. ## Что чинит `listings.tsv` — `GENERATED ALWAYS AS (...) STORED`. Stored-generated колонка пересчитывается на **каждом** `UPDATE` строки, независимо от того, менялись ли `description`/`address`. Результат — свежий inline-датум, а не переиспользованный TOAST-указатель, поэтому строка ещё и перетостивается заново: **около 5 ГБ оборота TOAST за 91 день** на одной этой колонке, плюс работа GIN-индекса на каждый апдейт. При 20,86 млн апдейтов за окно это заметная доля общего write-amplification, и правкой `SET`-списка в апсерте она **не** лечится — генерация срабатывает независимо от того, что перечислено в `SET`. ## Посылка задачи не подтвердилась, и это к лучшему Ставя задачу, я исходил из того, что сначала придётся переопределить `listings_search_mv`, чтобы она считала `to_tsvector` при `REFRESH`, а не тянула хранимую колонку. **Проверка это опровергла.** `search_query.py:125` (`tsv @@ plainto_tsquery`) обращается к витрине, а не к таблице: `build_search_query` собирает `SELECT ... FROM listings_search_mv`. А сама витрина никогда не читала `listings.tsv` — сверено байт-в-байт с `pg_get_viewdef` на проде и со всеми тремя миграциями, когда-либо её создававшими (050, 094, 261; `grep 'l\.tsv\b'` по всем SQL даёт ноль совпадений). Витрина считает свой tsvector сама: ```sql to_tsvector('russian', coalesce(l.description,'') || ' ' || coalesce(l.address,'') || ' ' || coalesce(h.developer_name,'') ) AS tsv ``` Значит поиск развязан с `listings.tsv` полностью, витрину трогать не нужно, и объём правки меньше запланированного. ## Индекс действительно мёртв `listings_tsv_idx`, 116 МБ. `idx_scan = 0`, причём `pg_stat_database.stats_reset` для этой БД пуст — то есть ноль сканов **за всю историю базы**, а не с последнего сброса счётчиков. `pg_constraint.conindid` по нему пуст — ни один `UNIQUE`/`PK`/`EXCLUDE` за ним не стоит. Дропается этой же миграцией. ## Почему нет rewrite `ALTER TABLE ... ALTER COLUMN ... DROP EXPRESSION` — операция чисто каталожная. По исходникам PostgreSQL 16 (`ATExecDropExpression`, `tablecmds.c`, REL_16_STABLE) она снимает `attgenerated` и удаляет строку `pg_attrdef`, а флаг `tab->rewrite`, по которому `ATRewriteTables` решает делать проход по страницам, **не выставляется**. `ACCESS EXCLUSIVE` держится ровно на время правки системного каталога. Форма `DROP EXPRESSION IF EXISTS` штатная и нативно идемпотентна — при повторном прогоне выдаёт `NOTICE`, а не ошибку. Синтаксис сверен с `\h ALTER TABLE` на самом проде (PG 16.4). ## Колонку намеренно оставляем Снимается только генерация. `DROP COLUMN` — отдельная операция со своим риском, ломающая любой `SELECT *` по `listings`, и в эту задачу не входит. После миграции `tsv` — обычная замороженная tsvector-колонка без единого читателя. Поведение задокументировано через `COMMENT ON COLUMN`, чтобы следующий, кто на неё наткнётся, не принял замороженные значения за рабочие. ## Критерии приёмки Записаны в шапке миграции **до** применения: 1. `268_listings_tsv_drop_generated.sql` в `_schema_migrations` 2. `listings.tsv` существует, `is_generated = 'NEVER'` (было `ALWAYS`), `attgenerated = ''` 3. `listings_tsv_idx` отсутствует 4. Определение `listings_search_mv` не изменилось байт-в-байт 5. `/api/v1/search` с `description_query` возвращает результаты 6. Через несколько дней — TOAST-оборот на `listings` заметно ниже базовых ~5 ГБ/91 день ## Проверено `scripts/check-migration-lock-timeout.py` — зелёный. `pytest tests/test_migration_numbering.py` — номер 268 свободен относительно `forgejo/main`. `pytest tests/test_2857_search_mv_placeholder_columns.py` — витрина не тронута. Прод только читался, `SELECT`-ами. Refs #2992, #2989
lekss361 added 1 commit 2026-08-20 19:53:36 +00:00
fix(tradein/search): снять STORED-generated с listings.tsv, не пересчитывать на каждом UPDATE
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m14s
28c374ab08
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
lekss361 merged commit 9b72e50d18 into main 2026-08-20 19:59:15 +00:00
lekss361 deleted branch fix/listings-tsv-drop-generated 2026-08-20 19:59:18 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3007
No description provided.