From a83dd582a66d0240e94b11287c7f28a9bdd462fc Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 3 Jul 2026 05:32:56 +0500 Subject: [PATCH] data/sql/183: physflat covering index v2 with price_per_m2_rub (#2240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up review #2240: best_layouts._SUPPLY_ONLY_LOTS_SQL projects price_per_m2_rub after DISTINCT ON (physflat), but the 176-й covering index does not carry it in INCLUDE, so Index-Only Scan is impossible and heavy projects fall back to heap scan + external-merge Sort (~7 MB temp, ~500 ms). Rebuild the covering index as v2 with price_per_m2_rub added to INCLUDE. Same key + partial-WHERE as 176-й, so all prior consumers are unchanged; supply-only now gets Index Only Scan (~54 ms, prod EXPLAIN on the two heaviest projects). CREATE new v2 name -> DROP old (mirrors 176-й ordering; never a window without a covering index under DISTINCT ON). Plain CREATE INDEX in BEGIN/COMMIT, NOT CONCURRENTLY: deploy.yml pipes each .sql whole through psql and the file self-wraps in BEGIN/COMMIT, where CONCURRENTLY is forbidden (same policy as 168-й/176-й). Deploy outside the weekly objective-scrape window (~252 MB build under brief SHARE-lock on objective_lots, ~1.9M rows). ANALYZE at end of tx. Co-Authored-By: Claude Fable 5 --- ...ve_lots_physflat_covering_v2_price_idx.sql | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 data/sql/183_objective_lots_physflat_covering_v2_price_idx.sql diff --git a/data/sql/183_objective_lots_physflat_covering_v2_price_idx.sql b/data/sql/183_objective_lots_physflat_covering_v2_price_idx.sql new file mode 100644 index 00000000..07c540f0 --- /dev/null +++ b/data/sql/183_objective_lots_physflat_covering_v2_price_idx.sql @@ -0,0 +1,104 @@ +-- Контекст: follow-up ревью #2240 — best-layouts supply-only запрос уходит в +-- heap scan + external-merge Sort на тяжёлых проектах, потому что покрывающий +-- индекс 176-й (objective_lots_physflat_covering_idx) НЕ несёт price_per_m2_rub. +-- +-- best_layouts._SUPPLY_ONLY_LOTS_SQL дедуплицирует objective_lots до последнего +-- снапшота на каждый ФИЗИЧЕСКИЙ лот через +-- SELECT DISTINCT ON (project_name, corpus_name, section, floor, lot_number) … +-- ORDER BY …, snapshot_date DESC, id DESC +-- по квартирам (premise_kind='квартира'), а ПОСЛЕ DISTINCT ON проецирует +-- (best_layouts.py:381-386): project_name (ключ), rooms_int, area_pd, +-- price_per_m2_rub, is_sold, status. Все они, КРОМЕ price_per_m2_rub, уже лежат +-- в INCLUDE-нагрузке 176-го covering-индекса. price_per_m2_rub там сознательно +-- НЕ клали (см. коммент 176: «его проецируют только analyze-inline запросы, +-- которые не фильтруют premise_kind='квартира'») — но supply-only запрос +-- #2240 как раз фильтрует premise_kind='квартира' И проецирует price_per_m2_rub, +-- поэтому одной недостающей колонки хватает, чтобы Index-Only стал невозможен: +-- планировщик добирает price_per_m2_rub из кучи → Bitmap/Heap fetch + внешняя +-- сортировка проекции. +-- +-- ЗАМЕР (prod EXPLAIN ANALYZE, два тяжелейших проекта, 2026-07-02): +-- ДО (176-й covering без price_per_m2_rub): +-- heap scan + external-merge Sort (~7 MB temp на диск), Execution ~500 ms. +-- ПОСЛЕ (covering + price_per_m2_rub в INCLUDE, создан в BEGIN…ROLLBACK для +-- проверки плана): +-- Index Only Scan using objective_lots_physflat_covering_v2_idx + Unique, +-- внешней сортировки НЕТ, Execution ~54 ms (≈9×). +-- +-- Пересоздаём покрывающий индекс, добавляя price_per_m2_rub в INCLUDE. Ключ +-- (physflat + snapshot_date DESC, id DESC) и partial-WHERE (premise_kind= +-- 'квартира') НЕ меняются → тот же DISTINCT ON обслуживает ВСЕХ прежних +-- потребителей 176-го (market_metrics._STOCK_SQL / _SALES_WINDOW_SQL, +-- competitors._SOLD_COUNT_SQL inline, #1959 inline) без регрессии — расширенная +-- нагрузка их только дополняет, ключ и порядок идентичны. Дополнительно теперь +-- Index-Only обслуживается и best_layouts supply-only. +-- +-- ── ИМЯ / ПОРЯДОК ──────────────────────────────────────────────────────────── +-- Новое имя objective_lots_physflat_covering_v2_idx + DROP старого +-- objective_lots_physflat_covering_idx (тем же приёмом, что 176: CREATE-new → +-- DROP-old). Нельзя `CREATE INDEX IF NOT EXISTS` под ТЕМ ЖЕ именем: старый индекс +-- уже существует под этим именем → IF NOT EXISTS стал бы no-op и молча пропустил +-- бы пересборку (расширение INCLUDE не применилось бы). v2-суффикс + DROP старого: +-- • идемпотентно при ре-apply (IF NOT EXISTS / IF EXISTS); +-- • ни одного окна без индекса под DISTINCT ON — новый уже на месте, когда +-- дропаем старый (оба кратковременно сосуществуют). +-- Имя индекса нигде в backend-коде не хардкодится (планировщик выбирает индекс +-- по форме запроса, не по имени) — переименование прозрачно для приложения. +-- +-- ── LOCK-ПРОФИЛЬ / CONCURRENTLY ────────────────────────────────────────────── +-- Обычный (НЕ CONCURRENTLY) CREATE INDEX берёт SHARE-lock на objective_lots — +-- блокирует writes на таблицу на время сборки (~252 MB + одна numeric-колонка на +-- ~1.9M строк). CONCURRENTLY здесь НЕВОЗМОЖЕН: deploy.yml гоняет каждый .sql +-- целиком через `psql < file`, а этот файл (как 176/168/173) само-оборачивается +-- в BEGIN/COMMIT; CREATE INDEX CONCURRENTLY внутри транзакции PostgreSQL +-- запрещает. Тот же класс риска и то же решение, что 176 и 168 (см. их +-- lock-комменты). Поэтому: обычный CREATE INDEX в BEGIN/COMMIT. +-- ⚠ Деплой ВНЕ недельного окна objective-scrape (bulk INSERT/UPDATE на +-- objective_lots): миграция создаёт ~252 MB индекс на ~1.9M строк под +-- кратковременным SHARE-lock на objective_lots, затем дропает старый. +-- +-- #5-эхо: ANALYZE objective_lots в конце tx — освежает оценки под новый индекс +-- (тот же приём, что 176; выполняется в транзакции штатно). +-- +-- Idempotent (IF NOT EXISTS / IF EXISTS), self-wrapped BEGIN/COMMIT. +-- Порядок применения для prod: миграция применяется деплоем в NN-порядке +-- (трекер _schema_migrations). Backend-код НЕ требует изменений — индекс +-- прозрачен для существующего _SUPPLY_ONLY_LOTS_SQL. +BEGIN; + +CREATE INDEX IF NOT EXISTS objective_lots_physflat_covering_v2_idx + ON objective_lots ( + project_name, + corpus_name, + section, + floor, + lot_number, + snapshot_date DESC, + id DESC + ) + INCLUDE ( + rooms_int, + area_pd, + sales_start_date, + is_sold, + contract_date, + status, + class, + price_per_m2_rub + ) + WHERE premise_kind = 'квартира'; + +COMMENT ON INDEX objective_lots_physflat_covering_v2_idx IS + 'Покрывающий physflat-индекс v2 (#2240): 176-й covering + price_per_m2_rub в ' + 'INCLUDE. Обслуживает Index-Only Scan + Unique для DISTINCT ON (physflat) ' + 'ORDER BY snapshot_date DESC, id DESC по квартирам у ВСЕХ прежних потребителей ' + '176-го (market_metrics._STOCK/_SALES, competitors, #1959) И для ' + 'best_layouts._SUPPLY_ONLY_LOTS_SQL, который проецирует price_per_m2_rub. Без ' + 'этой колонки supply-only уходил в heap scan + external-merge Sort ' + '(~7 MB temp, ~500 ms); с ней — Index Only Scan (~54 ms). См. 176-й.'; + +DROP INDEX IF EXISTS objective_lots_physflat_covering_idx; + +ANALYZE objective_lots; + +COMMIT; -- 2.45.3