From 29f10002289324bba1147e3a9a5c5614d42232ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 7 Aug 2026 11:01:31 +0000 Subject: [PATCH] =?UTF-8?q?revert(tradein/db):=20=D1=81=D0=BD=D1=8F=D1=82?= =?UTF-8?q?=D1=8C=20250=20=D1=81=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D1=8F=20?= =?UTF-8?q?=E2=80=94=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C=20=D0=B7?= =?UTF-8?q?=D0=B0=20=D0=BB=D0=BE=D0=BA=D0=BE=D0=BC=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=D1=80=D1=83=D0=B5=D1=82=20=D0=BF=D0=B0=D0=B9=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=B9=D0=BD=20(#2792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../250_drop_duplicate_expires_at_index.sql | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql diff --git a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql b/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql deleted file mode 100644 index b0866aaa..00000000 --- a/tradein-mvp/backend/data/sql/250_drop_duplicate_expires_at_index.sql +++ /dev/null @@ -1,98 +0,0 @@ --- 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;