Merge branch 'main' into fix/2659-revisit-floor
All checks were successful
CI Trade-In / 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 / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m53s
All checks were successful
CI Trade-In / 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 / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m53s
This commit is contained in:
commit
ebbad60e21
2 changed files with 141 additions and 0 deletions
|
|
@ -0,0 +1,140 @@
|
|||
-- 250_drop_duplicate_expires_at_index.sql
|
||||
-- Issue #2752 — снос дубля индекса на trade_in_estimates(expires_at).
|
||||
-- Возврат после #2792 (снятие с деплоя) — теперь с lock_timeout, см. #2793/#2791.
|
||||
--
|
||||
-- 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). Замер 2026-08-09 16:54 UTC подтверждает картину ещё через
|
||||
-- двое суток: новый 21, старый ВСЁ ЕЩЁ 234. То есть планировщик перевёл на
|
||||
-- новый весь живой трафик, и это устойчивое состояние, а не переходное.
|
||||
--
|
||||
-- Причина не семантическая, а физическая: индексы идентичны, но новый
|
||||
-- собран позже с нуля и плотнее упакован — relpages 5 против 6 у старого,
|
||||
-- разъеденного месяцем UPDATE/DELETE. genericcostestimate() считает спуск
|
||||
-- по дереву от числа страниц, 5 < 6 → новый дешевле на доли единицы cost,
|
||||
-- и при прочих равных выигрывает. Никакого нового запроса не появилось:
|
||||
-- отношение idx_tup_read/idx_scan у обоих одного порядка (1.88 у старого,
|
||||
-- 0.95 у нового) — это один и тот же класс точечных 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, перепроверено 2026-08-09 — план тот же):
|
||||
-- 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` (134 строки из 1061), и
|
||||
-- планировщик берёт именно этот, более селективный индекс, а expires_at
|
||||
-- остаётся Filter'ом. Ни один из двух expires-индексов в этом плане не
|
||||
-- участвует. Так что аргумента «оставить именно индекс из 229, он заведён
|
||||
-- под конкретный запрос» не существует — запрос его не использует.
|
||||
-- Поэтому оставлен индекс из 001: он объявлен в миграции, создающей саму
|
||||
-- таблицу, и на свежей БД (001..N по порядку) переживший индекс совпадёт с
|
||||
-- прод-состоянием, без «001 создаёт — 250 сносит» на каждой новой БД.
|
||||
--
|
||||
-- ── Планы ДО и ПОСЛЕ ─────────────────────────────────────────────────────────
|
||||
-- Индексы побайтово идентичны, поэтому смена узла невозможна в принципе:
|
||||
-- меняется только имя индекса в строке плана и cost на одну страницу спуска.
|
||||
-- ДО (прод, 2026-08-09 16:54 UTC):
|
||||
-- Limit (cost=0.28..58.98 rows=100 width=24)
|
||||
-- → Index Scan using trade_in_estimates_expires_at_idx (cost=0.28..623.07)
|
||||
-- Index Cond: (expires_at < now())
|
||||
-- ПОСЛЕ ожидается тот же узел с именем trade_in_estimates_expires_idx и
|
||||
-- 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 идентичны; отличается только имя.
|
||||
--
|
||||
-- ── Стоимость блокировки и почему здесь SET LOCAL lock_timeout ──────────────
|
||||
-- Обычный DROP INDEX берёт ACCESS EXCLUSIVE на таблицу. УДЕРЖАНИЕ здесь
|
||||
-- дёшево: trade_in_estimates — 1061 строка, heap 1856 kB, сносимый индекс
|
||||
-- 40 kB; DROP INDEX ничего не переписывает (удаление строк каталога плюс
|
||||
-- unlink файла, единицы миллисекунд).
|
||||
--
|
||||
-- Дорого — ОЖИДАНИЕ выдачи лока, и это уже случилось. 2026-08-07 первая
|
||||
-- редакция этого файла (без строки ниже) ждала ACCESS EXCLUSIVE 29 минут за
|
||||
-- чужой аналитической psql-сессией (`CREATE TEMP TABLE tmp_res AS ...`,
|
||||
-- pid 83256), вторая попытка — ещё 16. Четыре прогона деплоя красные,
|
||||
-- четыре смерженных PR не доехали до прода; ждущий ACCESS EXCLUSIVE встаёт
|
||||
-- в очередь ПЕРЕД новыми запросами, поэтому за ним начали ждать и обычные
|
||||
-- SELECT приложения. Файл сняли с деплоя (#2792), конвенцию закрепили
|
||||
-- (#2791: гейт scripts/check-migration-lock-timeout.py + .claude/rules/sql.md).
|
||||
--
|
||||
-- Значение 5 s: снизу ограничено deadlock_timeout (на проде 1 s — сверено
|
||||
-- 2026-08-09) — автоотмена мешающего autovacuum срабатывает только после
|
||||
-- того, как ждущий отстоял эту секунду, поэтому 1-2 s гонялись бы с рутинным
|
||||
-- autovacuum. Сверху — потолок простоя очереди приложения; против
|
||||
-- наблюдённых 1740 s это в 348 раз меньше. На работу ПОД локом значение не
|
||||
-- влияет вообще.
|
||||
--
|
||||
-- Срабатывание таймаута = красный деплой через 5 секунд с `canceling
|
||||
-- statement due to lock timeout` вместо получасовой очереди. Это ожидаемое
|
||||
-- поведение, а не авария: миграция не помечается применённой, повторить
|
||||
-- позже. 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. Ничего не ждёт и никого не блокирует.
|
||||
--
|
||||
-- Критерий «таблица тиха» (записан ДО, выполнен 2026-08-09 16:54 UTC):
|
||||
-- SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation
|
||||
-- WHERE c.relname='trade_in_estimates' AND l.pid <> pg_backend_pid(); → 0
|
||||
--
|
||||
-- Критерий приёмки (записан ДО применения):
|
||||
-- 1. Запись в _schema_migrations по имени этого файла (а не «деплой зелёный»).
|
||||
-- 2. EXPLAIN того же запроса показывает Index Scan using
|
||||
-- trade_in_estimates_expires_idx — детерминированная проверка, доступна
|
||||
-- сразу.
|
||||
-- 3. pg_stat_user_indexes.idx_scan у trade_in_estimates_expires_idx уходит с
|
||||
-- 234. NB: наблюдаемый темп ~7 сканов/сутки (21 скан за трое суток у
|
||||
-- дубля), поэтому «в течение часа» — недостаточное окно; честный срок
|
||||
-- подтверждения ~сутки. Если через сутки счётчик всё ещё 234, значит
|
||||
-- трафик ушёл в Seq Scan — это опровергло бы разбор выше и требовало бы
|
||||
-- отката (вернуть индекс: CREATE INDEX CONCURRENTLY).
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Ограничивает ОЖИДАНИЕ лока, не работу под ним. Обоснование значения — в шапке
|
||||
-- и в .claude/rules/sql.md § lock_timeout.
|
||||
SET LOCAL lock_timeout = '5s';
|
||||
|
||||
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/#2793). Мотивировка 229 («под batched-DELETE в purge_expired_trade_in_data») на проде не подтвердилась: тот запрос сужен по created_by IS NULL и идёт через idx_trade_in_estimates_created_by_created_at, expires_at остаётся Filter''ом.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -242,3 +242,4 @@
|
|||
233_payments.sql
|
||||
234_scrape_runs_ban_kind_unknown.sql
|
||||
240_trade_in_estimates_retain_until.sql
|
||||
250_drop_duplicate_expires_at_index.sql
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue