From 116af2ebc165b94d996523ece0375178bf506829 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 27 Jun 2026 09:49:39 +0500 Subject: [PATCH] perf(analyze): partial index on objective_corpus_room_month for EKB velocity baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _get_ekb_median() (velocity.py:706) runs on EVERY POST /parcels/{cad}/analyze (the hottest endpoint) and seq-scanned the whole objective_corpus_room_month (~95MB, ~12159 buffers, 144ms) — its predicates (report_month >= now-6mo AND deals_total_count > 0) had no usable index (the 5 existing report_month indexes aren't partial on deals_total_count; a bare range matches 27% of rows, so the planner correctly chose Seq Scan). Add partial b-tree (report_month) WHERE deals_total_count > 0 (~280kB, 8.9% selectivity). Prod EXPLAIN (BEGIN/ROLLBACK): 144ms→38ms (~3.8x), buffers 12281→3136 (-74%); planner uses it naturally (Index/Bitmap scan). Independently dry-run-verified: Index Only Scan, 2747 buffers. Write cost negligible (objective_corpus_room_month written only by weekly ETL, not request-path). Idempotent (IF NOT EXISTS); plain CREATE INDEX (not CONCURRENTLY, can't run in the migration's BEGIN/COMMIT) — sub-second build, SHARE lock blocks only the weekly ETL writer, not analyze readers. Found via pg_stat_user_tables seq-scan audit + database-expert EXPLAIN analysis. --- .../171_objective_crm_report_month_idx.sql | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 backend/data/sql/171_objective_crm_report_month_idx.sql diff --git a/backend/data/sql/171_objective_crm_report_month_idx.sql b/backend/data/sql/171_objective_crm_report_month_idx.sql new file mode 100644 index 00000000..65b068ff --- /dev/null +++ b/backend/data/sql/171_objective_crm_report_month_idx.sql @@ -0,0 +1,45 @@ +-- 171_objective_crm_report_month_idx.sql +-- Partial index ускоряющий EKB-wide velocity-baseline на КАЖДОМ /analyze. +-- +-- WHY (hot-path perf, найдено через pg_stat_user_tables + EXPLAIN ANALYZE на проде): +-- `_get_ekb_median()` (app/services/site_finder/velocity.py:706) вызывается +-- безусловно из compute_velocity() → внутри POST /parcels/{cad}/analyze +-- (parcels.py:3157) — т.е. РАЗ НА КАЖДЫЙ analyze (самый горячий эндпоинт). +-- Он агрегирует EKB-wide медиану sqm/мес по всем проектам, без district/project +-- фильтра; единственные предикаты — `report_month >= CURRENT_DATE - 6mo` +-- AND `deals_total_count > 0`. +-- +-- До индекса: Seq Scan по всей objective_corpus_room_month (81893 строк, ~95 MB, +-- ~12159 блоков) на каждый analyze. EXPLAIN (ANALYZE,BUFFERS) на проде: +-- Seq Scan ... Rows Removed by Filter: 74589 (keeps 7304); Execution Time: 144 ms. +-- Существующие 5 индексов с report_month НЕ помогают: ни один не partial по +-- deals_total_count, а голый `report_month >=` диапазон = 27% строк (не +-- селективно). С enable_seqscan=off планнер падает на full index scan (6100 +-- буферов / 68 ms) — хуже seq scan, поэтому по умолчанию выбирает Seq Scan. +-- +-- WHAT: +-- Partial b-tree на (report_month) WHERE deals_total_count > 0. Реальная +-- селективность запроса 8.9% (7304/81893) → bitmap index scan. +-- После (проверено BEGIN…ROLLBACK на проде): +-- Bitmap Heap Scan ... rows=7304; Execution Time: 37.6 ms; buffers ~3136. +-- 144 ms → 38 ms (~3.8x), buffers 12281 → 3136 (−74%). Планнер использует +-- индекс ЕСТЕСТВЕННО (без enable_seqscan-хаков). +-- +-- COST: индекс ~280 kB (37204 строк matching partial-предикат). Запись в таблицу +-- идёт ТОЛЬКО еженедельным ETL (objective_etl.py:288), не на request-path → +-- write-overhead пренебрежим. Build sub-second (плейн CREATE INDEX, SHARE-lock +-- блокирует лишь weekly-ETL writer, не analyze-читателей). +-- +-- IDEMPOTENCY: CREATE INDEX IF NOT EXISTS → повторный прогон no-op. +-- NB: плейн CREATE INDEX (НЕ CONCURRENTLY) — CONCURRENTLY нельзя внутри BEGIN/COMMIT, +-- а deploy-runner оборачивает миграцию в транзакцию; для 280 kB это безопасно. +-- +-- Deploy order: после 170_osm_utility_infrastructure_ekb.sql. + +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_objective_crm_report_month_sold + ON objective_corpus_room_month (report_month) + WHERE deals_total_count > 0; + +COMMIT;