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

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 на индекс никто не
ссылается, гранты живут на таблице.
This commit is contained in:
bot-backend 2026-08-07 14:21:23 +05:00
parent de4b2a4ae5
commit 1d4415c97a

View file

@ -0,0 +1,98 @@
-- 250_drop_duplicate_expires_at_index.sql
-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at).
--
-- WHY:
-- 229_trade_in_estimates_consent_proof.sql (применена 2026-08-06 17:09)
-- создала trade_in_estimates_expires_at_idx. Это ПОБАЙТОВЫЙ дубль
-- trade_in_estimates_expires_idx из 001_trade_in_estimates.sql.
--
-- Дословное сравнение на проде 2026-08-07 (pg_index, а не по имени):
-- name indkey indclass indoption indcollation pred am
-- trade_in_estimates_expires_idx 22 3127 0 0 — btree
-- trade_in_estimates_expires_at_idx 22 3127 0 0 — btree
-- Совпадает всё: колонка, класс операторов, направление сортировки,
-- NULLS-порядок (indoption=0 → ASC/NULLS LAST у обоих), коллация,
-- отсутствие частичного предиката, метод доступа. Ни один не привязан к
-- ограничению (pg_constraint.conindid пуст для обоих), в pg_depend на них
-- никто не ссылается — снос ничего не роняет по цепочке и НЕ требует
-- CASCADE (важно: в этом продукте DROP ... CASCADE уже терял гранты
-- FDW-пользователю). Гранты живут на таблице, не на индексе.
--
-- ── Почему у «нулевого» дубля появились сканы ────────────────────────────────
-- В теле #2752 значилось «у нового 0 сканов». Через сутки у него 15, а у
-- старого счётчик ЗАМОРОЖЕН на 234 (два замера, 09:14 и 09:18 UTC: старый
-- +0, новый +4). То есть планировщик перевёл на новый ВЕСЬ живой трафик.
--
-- Причина не семантическая, а физическая: индексы идентичны, но новый
-- собран вчера с нуля и плотнее упакован — relpages 5 против 6 у старого,
-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск
-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost,
-- и при прочих равных выигрывает. Никакого нового запроса не появилось:
-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого,
-- 0.93 у нового) — это один и тот же класс точечных lookup'ов, просто
-- переехавший на более свежий индекс. Со временем новый забронзовеет так же
-- и они поменялись бы местами обратно.
--
-- ── ОПРОВЕРГНУТО: обоснование индекса в самой 229 ────────────────────────────
-- 229 завела индекс осознанно, с мотивировкой «обслуживает retention-задачу
-- purge_expired_trade_in_data (migration 231) — без индекса batched-DELETE
-- делал бы full scan». На проде это НЕ так. Фактический план боевого
-- запроса из app/tasks/purge_expired_trade_in_data.py (EXPLAIN, прод
-- 2026-08-07):
-- Limit → Sort (Sort Key: expires_at)
-- → Bitmap Heap Scan Filter: (expires_at < now())
-- → Bitmap Index Scan on idx_trade_in_estimates_created_by_created_at
-- Index Cond: (created_by IS NULL)
-- Задача purge ограничена `AND created_by IS NULL` (129 строк из 1061), и
-- планировщик берёт именно этот, более селективный индекс, а expires_at
-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не
-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён
-- под конкретный запрос» не существует — запрос его не использует.
-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму
-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с
-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД.
--
-- ── Планы ДО и ПОСЛЕ ─────────────────────────────────────────────────────────
-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе:
-- меняется только имя индекса в строке плана и cost на одну страницу спуска.
-- Проверено на чистом PostgreSQL 16.4 (та же минорная версия, что на проде)
-- с воспроизведённым перекосом плотности:
-- ДО: Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..31.84)
-- ПОСЛЕ: Index Scan using trade_in_estimates_expires_idx (cost=0.28..38.30)
-- Форма плана, Index Cond и Filter идентичны; отличается только имя.
-- На проде разрыв плотности меньше (5 против 6 страниц, а не 5 против 8),
-- то есть и дельта cost будет меньше синтетической.
--
-- ── Стоимость блокировки ─────────────────────────────────────────────────────
-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. Здесь это дёшево:
-- trade_in_estimates — 1061 строка, heap 1856 kB (231 страница), сам
-- сносимый индекс 40 kB. DROP INDEX ничего не переписывает — это удаление
-- строк каталога плюс unlink файла, единицы миллисекунд. CONCURRENTLY не
-- нужен и был бы хуже: он не может выполняться внутри блока транзакции, а
-- значит файл пришлось бы оставить без BEGIN/COMMIT (см. разбор механики
-- раннера в 225_listing_source_snapshots_run_id_idx.sql).
--
-- IDEMPOTENCY / SAFETY:
-- - DROP INDEX IF EXISTS — безопасный re-run; без CASCADE.
-- - Одна DDL-операция внутри BEGIN/COMMIT: либо применилась, либо нет.
-- - COMMENT ON INDEX переносит знание из 229 на переживший индекс, чтобы
-- дубль не завели заново (в т.ч. фиксирует, что purge его НЕ использует).
--
-- Dependencies: 001_trade_in_estimates.sql (создаёт переживший индекс),
-- 229_trade_in_estimates_consent_proof.sql (создала сносимый дубль).
-- Deploy order: standalone. Ничего не ждёт и никого не блокирует.
--
-- Критерий приёмки (записан ДО применения):
-- после деплоя pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx
-- должен СДВИНУТЬСЯ с 234 в течение часа. Если он останется 234, значит
-- трафик ушёл не на переживший индекс, а в Seq Scan — это опровергло бы
-- разбор выше и требовало бы отката.
BEGIN;
DROP INDEX IF EXISTS trade_in_estimates_expires_at_idx;
COMMENT ON INDEX trade_in_estimates_expires_idx IS
'Единственный индекс на trade_in_estimates(expires_at) (001). НЕ заводить второй: 229 создала побайтовый дубль trade_in_estimates_expires_at_idx, снят миграцией 250 (#2752). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.';
COMMIT;