perf(objective_lots): inline DISTINCT ON for 3 view-fullscan consumers + cache landing stats (#1953) #2067

Merged
bot-backend merged 2 commits from fix/perf-view-fullscan-1953 into main 2026-06-28 17:25:42 +00:00
Collaborator

Контекст

Perf-батч #2 (follow-up эпика #1953, тот же класс что #2054 competitors). #1964 (PR #2032) репойнтнул несколько потребителей на view v_objective_lots_latest. Его DISTINCT ON — оптимизационный барьер: внешний WHERE/JOIN НЕ проталкивается ниже → планировщик материализует ВСЮ objective_lots (1.76M, Parallel Seq Scan width≈917-962 + external merge sort 50-100МБ/воркер) ПЕРЕД фильтрацией → ~6-8с на каждый вызов. Проактивный data-health хунт нашёл 3 оставшихся таких потребителя (parcels.py уже был inline из #1964).

Изменения

Файл Было Стало
landing.py _query_stats /landing/stats (хоумпейдж) uncached, KPI3 = full-view-aggregate 6694мс/load in-process TTL-кэш 1ч (threadpool-aware lock, DB вне lock, кэшируется только success; зеркало weather_cache)
supply_layers.py _L1_OPEN_SQL view, district-scoped ~5.9с inline DISTINCT ON, стабильные фильтры до дедупа → Bitmap на district_class_idx, ~0.45с (~13×)
special_indices.py _ARTIFICIAL_DEMAND_SQL view, district-scoped ~6.7с inline 2-слойный CTE: стабильные фильтры (premise_kind/district) до дедупа, меняющиеся (is_sold/contract_date/class) — после. district-scoped ~0.57с (~12×), EKB-wide 8.1с→5.5с

Корректность (критично — feed forecast)

Тонкость: classменяющийся атрибут (248 transient-физлотов из 605k меняют класс между снапшотами). Фильтр class ДО дедупа дрейфил vs view (стандарт +193, бизнес +20). Поэтому стабильные фильтры (premise_kind 0-transient, district 4-transient — index-драйвер, осознанный компромисс как parcels.py) проталкиваются до DISTINCT ON, а меняющиеся (class, is_sold, contract_date) — после.

Прод-верификация бит-в-бит (исправленный inline vs view):

  • Fix3 EKB-wide per class: бизнес 16637/9016 ✓, комфорт 234613/160349 ✓, премиум 950/300 ✓, стандарт 128721/75868 ✓, элит 0/0 ✓
  • Fix3 district: ЮЗ+комфорт 9601/5393 ✓
  • Fix2: 0 расхождений по ВСЕМ (district × class)

DISTINCT ON-ключ + ORDER BY ... snapshot_date DESC, id DESC точно совпадают с 175_v_objective_lots_latest.sql. Без миграции (используются существующие objective_lots_physflat_covering_idx / objective_lots_district_class_idx).

Verify

  • code-review APPROVE (cache concurrency + conventions + blast-radius)
  • ruff/ruff-format pre-commit passed
  • прод EXPLAIN: Bitmap Index Scan вместо Parallel Seq Scan на district-scoped; результат бит-в-бит = view
## Контекст Perf-батч #2 (follow-up эпика #1953, тот же класс что #2054 competitors). #1964 (PR #2032) репойнтнул несколько потребителей на view `v_objective_lots_latest`. Его `DISTINCT ON` — оптимизационный барьер: внешний WHERE/JOIN НЕ проталкивается ниже → планировщик материализует ВСЮ `objective_lots` (1.76M, Parallel Seq Scan width≈917-962 + external merge sort 50-100МБ/воркер) ПЕРЕД фильтрацией → ~6-8с на каждый вызов. Проактивный data-health хунт нашёл 3 оставшихся таких потребителя (parcels.py уже был inline из #1964). ## Изменения | Файл | Было | Стало | |---|---|---| | `landing.py` `_query_stats` | `/landing/stats` (хоумпейдж) **uncached**, KPI3 = full-view-aggregate **6694мс**/load | in-process TTL-кэш 1ч (threadpool-aware lock, DB вне lock, кэшируется только success; зеркало `weather_cache`) | | `supply_layers.py` `_L1_OPEN_SQL` | view, district-scoped ~5.9с | inline DISTINCT ON, стабильные фильтры до дедупа → Bitmap на `district_class_idx`, **~0.45с (~13×)** | | `special_indices.py` `_ARTIFICIAL_DEMAND_SQL` | view, district-scoped ~6.7с | inline 2-слойный CTE: стабильные фильтры (premise_kind/district) до дедупа, **меняющиеся (is_sold/contract_date/class) — после**. district-scoped **~0.57с (~12×)**, EKB-wide 8.1с→5.5с | ## Корректность (критично — feed forecast) Тонкость: `class` — **меняющийся** атрибут (248 transient-физлотов из 605k меняют класс между снапшотами). Фильтр class ДО дедупа дрейфил vs view (стандарт **+193**, бизнес +20). Поэтому стабильные фильтры (premise_kind 0-transient, district 4-transient — index-драйвер, осознанный компромисс как parcels.py) проталкиваются до DISTINCT ON, а меняющиеся (class, is_sold, contract_date) — после. **Прод-верификация бит-в-бит** (исправленный inline vs view): - Fix3 EKB-wide per class: бизнес 16637/9016 ✓, комфорт 234613/160349 ✓, премиум 950/300 ✓, стандарт 128721/75868 ✓, элит 0/0 ✓ - Fix3 district: ЮЗ+комфорт 9601/5393 ✓ - Fix2: 0 расхождений по ВСЕМ (district × class) `DISTINCT ON`-ключ + `ORDER BY ... snapshot_date DESC, id DESC` точно совпадают с `175_v_objective_lots_latest.sql`. Без миграции (используются существующие `objective_lots_physflat_covering_idx` / `objective_lots_district_class_idx`). ## Verify - ✅ code-review APPROVE (cache concurrency + conventions + blast-radius) - ✅ ruff/ruff-format pre-commit passed - ✅ прод EXPLAIN: Bitmap Index Scan вместо Parallel Seq Scan на district-scoped; результат бит-в-бит = view
bot-backend added 1 commit 2026-06-28 16:42:11 +00:00
perf(objective_lots): inline DISTINCT ON for 3 view-fullscan consumers + cache landing stats (#1953)
Some checks failed
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m10s
CI / backend-tests (pull_request) Failing after 14m34s
e33273591b
#1964 repointed landing/supply_layers/special_indices to v_objective_lots_latest,
whose DISTINCT ON is an optimization barrier — outer WHERE can't push below it, so
the planner full Parallel-Seq-Scans 1.76M rows + external merge sorts (~6-8s) per call.

- landing._query_stats: add 1h in-process TTL cache (homepage /landing/stats was
  uncached; KPI3 full-view-aggregate measured 6.7s/load). Threadpool-aware lock,
  DB query outside lock, only successful result cached. Mirrors weather_cache.
- supply_layers._L1_OPEN_SQL: view->inline DISTINCT ON, stable filters
  (premise_kind/district) pushed below dedup -> Bitmap on district_class_idx.
  district-scoped 5.9s->0.45s (~13x).
- special_indices._ARTIFICIAL_DEMAND_SQL: view->inline 2-layer CTE; stable filters
  before dedup, changing filters (is_sold/contract_date/class) AFTER dedup.
  class has 248 transient physflats -> before-dedup drifted +193 on standard.
  district-scoped 6.7s->0.57s (~12x), EKB-wide 8.1s->5.5s.

Result bit-identical to view, verified on prod across all classes/districts.
Uses existing covering / district_class indexes (no migration).
Light1YT added 1 commit 2026-06-28 17:10:50 +00:00
test(supply_layers): assert inline DISTINCT ON physflat-dedup, not the view
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m57s
CI / backend-tests (pull_request) Successful in 14m14s
c16e071859
Perf-батч #2 заменил _L1_OPEN_SQL view→inline DISTINCT ON. Тест из #1964
(test_sql_reads_physflat_view) ассертил наличие view + отсутствие FROM
objective_lots — теперь инвертирован под inline. Новый инвариант сохраняет
анти-регресс #1964 (требует physflat-дедуп через DISTINCT ON, иначе сырой
objective_lots раздул бы units_available ~2.91×) и запрещает view (full-scan).
bot-backend merged commit c8e7cb1c79 into main 2026-06-28 17:25:42 +00:00
bot-backend deleted branch fix/perf-view-fullscan-1953 2026-06-28 17:25:42 +00:00
Author
Collaborator

@lightmyself тесты прошли — красный был на первом коммите (e3327359), это устаревший тест, а не реальная регрессия.

Что произошло: тест test_sql_reads_physflat_view (из #1964) ассертил, что _L1_OPEN_SQL читает из view v_objective_lots_latest. Этот PR как раз заменяет view → inline DISTINCT ON (view = оптимизационный барьер → full-scan 1.76M ~6с). Т.е. тест проверял ровно то поведение, которое PR осознанно меняет.

Фикс: обновил тест под новый инвариант во втором коммите c16e0718 — теперь он требует physflat-дедуп через DISTINCT ON (это и был анти-регресс #1964: не читать СЫРОЙ objective_lots без дедупа, иначе units_available раздулся бы ~2.91×) и запрещает view. Локально весь backend-сьют: 1966 passed, 4 skipped.

Состояние:

  • c16e0718 (смерженный код): весь CI зелёный (backend-tests=success)
  • main c8e7cb1c: build-backend/worker/frontend + deploy = success
  • прод-verify live: /landing/stats 7.1с→0.0000с (TTL-кэш), supply_layers._L1_OPEN_SQL 5.9→0.77с, special_indices._ARTIFICIAL_DEMAND_SQL 6.7→0.40с (n_sold/n_mortgage 9601/5393 — бит-в-бит = view)

PR смержен и задеплоен. Если смотришь на вкладку commits — красный X на e3327359 это «до фикса»; актуальный HEAD c16e0718 зелёный.

@lightmyself тесты прошли ✅ — красный был на **первом** коммите (`e3327359`), это устаревший тест, а не реальная регрессия. **Что произошло:** тест `test_sql_reads_physflat_view` (из #1964) ассертил, что `_L1_OPEN_SQL` читает из view `v_objective_lots_latest`. Этот PR как раз заменяет view → inline `DISTINCT ON` (view = оптимизационный барьер → full-scan 1.76M ~6с). Т.е. тест проверял ровно то поведение, которое PR осознанно меняет. **Фикс:** обновил тест под новый инвариант во втором коммите `c16e0718` — теперь он требует physflat-дедуп через `DISTINCT ON` (это и был анти-регресс #1964: не читать СЫРОЙ objective_lots без дедупа, иначе units_available раздулся бы ~2.91×) и запрещает view. Локально весь backend-сьют: **1966 passed, 4 skipped**. **Состояние:** - `c16e0718` (смерженный код): весь CI зелёный (backend-tests=success) - main `c8e7cb1c`: build-backend/worker/frontend + deploy = success - прод-verify live: `/landing/stats` 7.1с→0.0000с (TTL-кэш), `supply_layers._L1_OPEN_SQL` 5.9→0.77с, `special_indices._ARTIFICIAL_DEMAND_SQL` 6.7→0.40с (n_sold/n_mortgage 9601/5393 — бит-в-бит = view) PR смержен и задеплоен. Если смотришь на вкладку commits — красный X на `e3327359` это «до фикса»; актуальный HEAD `c16e0718` зелёный.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2067
No description provided.