fix(tradein/db): снять побайтовый дубль индекса на trade_in_estimates(expires_at) (#2752) #2784

Merged
bot-backend merged 1 commit from chore/2752-duplicate-index into main 2026-08-07 09:30:15 +00:00
Collaborator

Закрывает #2752 (основной пункт). Хвосты разобраны ниже — отдельными правками, в этот PR не смешаны.

Дословное сравнение, а не по имени

pg_index на проде 2026-08-07:

индекс indkey indclass indoption indcollation indpred am
trade_in_estimates_expires_idx (001) 22 3127 0 0 btree
trade_in_estimates_expires_at_idx (229) 22 3127 0 0 btree

Совпадает всё: колонка, класс операторов, направление сортировки, NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация, отсутствие частичного предиката, метод доступа. Предпосылка задачи подтверждена: это побайтовый дубль.

Почему у «нулевого» дубля появились сканы

В теле задачи было «0 сканов, статистика не показательна». Сейчас 15 — но важнее другое: счётчик старого заморожен. Два замера подряд:

09:14 UTC   expires_idx 234 | expires_at_idx 11
09:16 UTC   expires_idx 234 | expires_at_idx 15
09:18 UTC   expires_idx 234 | expires_at_idx 15

Старый +0, новый +4. Планировщик перевёл на дубль весь живой трафик; 234 у старого — накопленное историческое, не текущее.

Причина физическая, не семантическая: индексы идентичны, но новый собран вчера с нуля и плотнее — relpages 5 против 6. genericcostestimate() считает спуск по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost и выигрывает при прочих равных. Нового запроса не появилось: idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого, 0.93 у нового) — тот же класс точечных lookup'ов, просто переехавший на более свежий индекс. Со временем новый забронзовел бы так же и они поменялись бы обратно.

ОПРОВЕРГНУТО: обоснование индекса в самой 229

229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу purge_expired_trade_in_data — без индекса batched-DELETE делал бы full scan». На проде это не так. EXPLAIN боевого запроса из app/tasks/purge_expired_trade_in_data.py:

Limit
  ->  Sort  (Sort Key: expires_at)
        ->  Bitmap Heap Scan on trade_in_estimates
              Recheck Cond: (created_by IS NULL)
              Filter: (expires_at < now())
              ->  Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at
                    Index Cond: (created_by IS NULL)

Запрос сужен по created_by IS NULL (129 строк из 1061), планировщик берёт более селективный индекс, а expires_at остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не участвует. То есть развилки из задачи («если в 229 индекс заводился под конкретный запрос — оставить его») не существует: этот запрос его не использует. Оставлен индекс из 001 — он объявлен в миграции, создающей таблицу, и на свежей БД переживший индекс совпадёт с прод-состоянием, без «001 создаёт → 250 сносит» на каждой новой базе.

Планы ДО и ПОСЛЕ

Индексы побайтово идентичны, поэтому смена узла невозможна в принципе — меняется только имя в строке плана и cost на одну страницу спуска. Снято на чистом PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым перекосом плотности:

ДО:     Limit  (cost=0.28..31.84 rows=500 width=24)
          ->  Index Scan using trade_in_estimates_expires_at_idx  (cost=0.28..58.85 rows=928)
                Index Cond: (expires_at < now())
                Filter: (created_by IS NULL)

ПОСЛЕ:  Limit  (cost=0.28..38.30 rows=500 width=24)
          ->  Index Scan using trade_in_estimates_expires_idx     (cost=0.28..70.85 rows=928)
                Index Cond: (expires_at < now())
                Filter: (created_by IS NULL)

Форма плана, Index Cond и Filter идентичны. На проде разрыв плотности меньше синтетического (5 против 6 страниц, а не 5 против 8) → и дельта cost будет меньше.

pg_stat_statements в этой БД не установлен (pg_extension: pg_trgm, pgcrypto, plpgsql, postgis, postgres_fdw) — потребители искались по коду, а не по статистике запросов.

Гранты и зависимости

pg_depend по обоим индексам — 0 строк; pg_constraint.conindid — 0 строк, ни один не обслуживает ограничение. DROP INDEX без CASCADE. Гранты живут на таблице (role_table_grants на trade_in_estimates: только tradein, 7 привилегий), индекс их не несёт — цепочка, на которой C3 терял гранты FDW-пользователю, здесь не задействована.

Чего стоит блокировка

Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. Здесь дёшево: trade_in_estimates — 1061 строка, heap 1856 kB (231 страница), сносимый индекс 40 kB. DROP INDEX ничего не переписывает — удаление строк каталога плюс unlink файла, единицы миллисекунд.

CONCURRENTLY не нужен и был бы хуже. Раннер (.forgejo/workflows/deploy-tradein.yml) гоняет psql -v ON_ERROR_STOP=on < "$sql_file" без -1, то есть autocommit — DROP INDEX CONCURRENTLY формально прошёл бы, но потребовал бы отказаться от BEGIN/COMMIT в файле ради операции, которая и так занимает миллисекунды.

Проверка

Файл прогнан дважды на чистом PostgreSQL 16.4: второй прогон печатает NOTICE: index ... does not exist, skipping и завершается COMMIT. Идемпотентность под strict ON_ERROR_STOP=on подтверждена.

Критерий приёмки (записан ДО применения)

После деплоя idx_scan у trade_in_estimates_expires_idx должен сдвинуться с 234 в течение часа. Останется 234 — значит трафик ушёл не на переживший индекс, а в Seq Scan; это опровергло бы разбор выше и потребовало бы отката.

Три хвоста — разобраны отдельно, здесь не тронуты

  1. hph_source_idx — сносить НЕЛЬЗЯ, префикс-избыточность тут обманчива. Он (source), а house_placement_history_source_ext_item_id_key(source, ext_item_id), то есть формально да, префикс. Но размеры: 1336 kB против 8696 kB, и профиль нагрузки не точечный: 94 скана прочитали 4 544 184 тупла (≈48 тыс. на скан) при 757 233 fetch. Это bulk-проходы по source, где узкий индекс в 6.5 раза дешевле по I/O. Снос переселил бы их на индекс в 6.5 раза толще на таблице 104 MB / 100 724 строки. Предпосылка «префикс-избыточный ⇒ лишний» здесь опровергнута; отдельного PR на снос быть не должно.
  2. indisvalid. Конкретного повода нет: SELECT ... FROM pg_index WHERE NOT indisvalid OR NOT indisready OR NOT indislive по всей боевой БД — 0 строк. Недостроенных индексов нет ни здесь, ни где-либо ещё, так что «занимает место и мешает пересозданию» к этому случаю не относится.
  3. Молчаливый пропуск невалидного индекса при обрыве CONCURRENTLY — дыра реальная и по-прежнему открыта. В data/sql 13 файлов с CREATE INDEX CONCURRENTLY; механика: файл падает по таймауту → INSERT INTO _schema_migrations не выполняется → следующий деплой прогоняет файл заново → IF NOT EXISTS видит невалидный индекс, молча его не пересоздаёт → файл «успешен» и записывается как применённый. 225_listing_source_snapshots_run_id_idx.sql этот сценарий уже описал словами, но проверки нет. Правильное место — не по DO-блоку в каждый файл, а один запрос в раннере после цикла миграций: ловит все файлы, прошлые и будущие, без бойлерплейта. Отдельной задачей: правка deploy-tradein.yml тянет infra-фильтр и полную пересборку — не место для попутного коммита в PR про индекс.
Закрывает #2752 (основной пункт). Хвосты разобраны ниже — отдельными правками, в этот PR не смешаны. ## Дословное сравнение, а не по имени `pg_index` на проде 2026-08-07: | индекс | indkey | indclass | indoption | indcollation | indpred | am | |---|---|---|---|---|---|---| | `trade_in_estimates_expires_idx` (001) | 22 | 3127 | 0 | 0 | — | btree | | `trade_in_estimates_expires_at_idx` (229) | 22 | 3127 | 0 | 0 | — | btree | Совпадает всё: колонка, класс операторов, направление сортировки, NULLS-порядок (`indoption=0` → ASC/NULLS LAST у обоих), коллация, отсутствие частичного предиката, метод доступа. Предпосылка задачи **подтверждена**: это побайтовый дубль. ## Почему у «нулевого» дубля появились сканы В теле задачи было «0 сканов, статистика не показательна». Сейчас 15 — но важнее другое: **счётчик старого заморожен**. Два замера подряд: ``` 09:14 UTC expires_idx 234 | expires_at_idx 11 09:16 UTC expires_idx 234 | expires_at_idx 15 09:18 UTC expires_idx 234 | expires_at_idx 15 ``` Старый +0, новый +4. Планировщик перевёл на дубль **весь** живой трафик; 234 у старого — накопленное историческое, не текущее. Причина физическая, не семантическая: индексы идентичны, но новый собран вчера с нуля и плотнее — `relpages` **5 против 6**. `genericcostestimate()` считает спуск по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost и выигрывает при прочих равных. **Нового запроса не появилось**: `idx_tup_read/idx_scan` у обоих одного порядка (1.88 у старого, 0.93 у нового) — тот же класс точечных lookup'ов, просто переехавший на более свежий индекс. Со временем новый забронзовел бы так же и они поменялись бы обратно. ## ОПРОВЕРГНУТО: обоснование индекса в самой 229 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу `purge_expired_trade_in_data` — без индекса batched-DELETE делал бы full scan». **На проде это не так.** `EXPLAIN` боевого запроса из `app/tasks/purge_expired_trade_in_data.py`: ``` Limit -> Sort (Sort Key: expires_at) -> Bitmap Heap Scan on trade_in_estimates Recheck Cond: (created_by IS NULL) Filter: (expires_at < now()) -> Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at Index Cond: (created_by IS NULL) ``` Запрос сужен по `created_by IS NULL` (129 строк из 1061), планировщик берёт более селективный индекс, а `expires_at` остаётся `Filter`'ом. Ни один из двух expires-индексов в этом плане не участвует. То есть развилки из задачи («если в 229 индекс заводился под конкретный запрос — оставить его») **не существует**: этот запрос его не использует. Оставлен индекс из 001 — он объявлен в миграции, создающей таблицу, и на свежей БД переживший индекс совпадёт с прод-состоянием, без «001 создаёт → 250 сносит» на каждой новой базе. ## Планы ДО и ПОСЛЕ Индексы побайтово идентичны, поэтому смена узла невозможна в принципе — меняется только имя в строке плана и cost на одну страницу спуска. Снято на чистом PostgreSQL 16.4 (та же минорная версия, что на проде) с воспроизведённым перекосом плотности: ``` ДО: Limit (cost=0.28..31.84 rows=500 width=24) -> Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..58.85 rows=928) Index Cond: (expires_at < now()) Filter: (created_by IS NULL) ПОСЛЕ: Limit (cost=0.28..38.30 rows=500 width=24) -> Index Scan using trade_in_estimates_expires_idx (cost=0.28..70.85 rows=928) Index Cond: (expires_at < now()) Filter: (created_by IS NULL) ``` Форма плана, `Index Cond` и `Filter` идентичны. На проде разрыв плотности меньше синтетического (5 против 6 страниц, а не 5 против 8) → и дельта cost будет меньше. `pg_stat_statements` в этой БД **не установлен** (`pg_extension`: pg_trgm, pgcrypto, plpgsql, postgis, postgres_fdw) — потребители искались по коду, а не по статистике запросов. ## Гранты и зависимости `pg_depend` по обоим индексам — 0 строк; `pg_constraint.conindid` — 0 строк, ни один не обслуживает ограничение. `DROP INDEX` **без CASCADE**. Гранты живут на таблице (`role_table_grants` на `trade_in_estimates`: только `tradein`, 7 привилегий), индекс их не несёт — цепочка, на которой C3 терял гранты FDW-пользователю, здесь не задействована. ## Чего стоит блокировка Обычный `DROP INDEX` берёт `ACCESS EXCLUSIVE` на таблицу. Здесь дёшево: `trade_in_estimates` — 1061 строка, heap 1856 kB (231 страница), сносимый индекс 40 kB. `DROP INDEX` ничего не переписывает — удаление строк каталога плюс unlink файла, единицы миллисекунд. `CONCURRENTLY` не нужен и был бы хуже. Раннер (`.forgejo/workflows/deploy-tradein.yml`) гоняет `psql -v ON_ERROR_STOP=on < "$sql_file"` без `-1`, то есть autocommit — `DROP INDEX CONCURRENTLY` формально прошёл бы, но потребовал бы отказаться от `BEGIN/COMMIT` в файле ради операции, которая и так занимает миллисекунды. ## Проверка Файл прогнан **дважды** на чистом PostgreSQL 16.4: второй прогон печатает `NOTICE: index ... does not exist, skipping` и завершается `COMMIT`. Идемпотентность под strict `ON_ERROR_STOP=on` подтверждена. ## Критерий приёмки (записан ДО применения) После деплоя `idx_scan` у `trade_in_estimates_expires_idx` должен **сдвинуться с 234** в течение часа. Останется 234 — значит трафик ушёл не на переживший индекс, а в Seq Scan; это опровергло бы разбор выше и потребовало бы отката. ## Три хвоста — разобраны отдельно, здесь не тронуты 1. **`hph_source_idx` — сносить НЕЛЬЗЯ, префикс-избыточность тут обманчива.** Он `(source)`, а `house_placement_history_source_ext_item_id_key` — `(source, ext_item_id)`, то есть формально да, префикс. Но размеры: **1336 kB против 8696 kB**, и профиль нагрузки не точечный: 94 скана прочитали **4 544 184** тупла (≈48 тыс. на скан) при 757 233 fetch. Это bulk-проходы по `source`, где узкий индекс в 6.5 раза дешевле по I/O. Снос переселил бы их на индекс в 6.5 раза толще на таблице 104 MB / 100 724 строки. Предпосылка «префикс-избыточный ⇒ лишний» здесь **опровергнута**; отдельного PR на снос быть не должно. 2. **`indisvalid`.** Конкретного повода нет: `SELECT ... FROM pg_index WHERE NOT indisvalid OR NOT indisready OR NOT indislive` по всей боевой БД — **0 строк**. Недостроенных индексов нет ни здесь, ни где-либо ещё, так что «занимает место и мешает пересозданию» к этому случаю не относится. 3. **Молчаливый пропуск невалидного индекса при обрыве `CONCURRENTLY`** — дыра реальная и по-прежнему открыта. В `data/sql` 13 файлов с `CREATE INDEX CONCURRENTLY`; механика: файл падает по таймауту → `INSERT INTO _schema_migrations` не выполняется → следующий деплой прогоняет файл заново → `IF NOT EXISTS` видит невалидный индекс, молча его не пересоздаёт → файл «успешен» и записывается как применённый. `225_listing_source_snapshots_run_id_idx.sql` этот сценарий уже описал словами, но проверки нет. Правильное место — **не по DO-блоку в каждый файл, а один запрос в раннере после цикла миграций**: ловит все файлы, прошлые и будущие, без бойлерплейта. Отдельной задачей: правка `deploy-tradein.yml` тянет `infra`-фильтр и полную пересборку — не место для попутного коммита в PR про индекс.
bot-backend added 1 commit 2026-08-07 09:22:33 +00:00
fix(tradein/db): снять побайтовый дубль индекса на trade_in_estimates(expires_at) (#2752)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
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 4m28s
1d4415c97a
229 создала trade_in_estimates_expires_at_idx — побайтовую копию
trade_in_estimates_expires_idx из 001 (pg_index: indkey/indclass/indoption/
indcollation совпадают, предиката нет у обоих, оба btree, ни один не привязан
к ограничению).

Число из тела задачи устарело: у дубля уже не 0 сканов, а 15, при этом
счётчик старого заморожен на 234 — планировщик перевёл весь живой трафик на
дубль. Причина физическая, не семантическая: свежесобранный индекс плотнее
(relpages 5 против 6), спуск по дереву дешевле. Нового запроса не появилось —
idx_tup_read/idx_scan у обоих одного порядка.

Опровергнуто обоснование самой 229: заявленный потребитель
(purge_expired_trade_in_data) на проде ни один из двух индексов не использует —
запрос сужен по created_by IS NULL и идёт через
idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter'ом.
Аргумента «оставить именно индекс из 229» нет, поэтому оставлен индекс из 001.

Планы ДО/ПОСЛЕ сняты на чистом PostgreSQL 16.4 с воспроизведённым перекосом
плотности: форма плана, Index Cond и Filter идентичны, меняется только имя
индекса и cost на одну страницу спуска.

DROP INDEX без CONCURRENTLY и без CASCADE: таблица 1061 строка / heap 1856 kB,
ACCESS EXCLUSIVE держится единицы миллисекунд; в pg_depend на индекс никто не
ссылается, гранты живут на таблице.
bot-backend merged commit a9096f125a into main 2026-08-07 09:30:15 +00:00
bot-backend deleted branch chore/2752-duplicate-index 2026-08-07 09:30: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#2784
No description provided.