perf(objective): request-path consumers dedup inline, not via whole-table view (#1964)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m49s
CI / backend-tests (pull_request) Successful in 13m58s

deep-review #1964: v_objective_lots_latest has NO premise/district filter inside,
so a consumer's outer WHERE cannot push below DISTINCT ON → the view materializes
the WHOLE table (Parallel Seq Scan + external Sort 1.76M rows, ~55MB spill) on
every query. For REQUEST-PATH consumers inside analyze_parcel this is a ~19x latency
regression vs the pre-#1964 raw-table plan.

DISPROVEN remedy (NOT applied): a full index on the physflat-key does NOT help —
DISTINCT ON selects ol.* (51 cols, width≈945) so index-only-unique is impossible;
the planner ignores the index (seq-scan+sort still cheaper) and even forced it is
~3.9 s. A 142MB index for zero request-path benefit + slower bulk-INSERT during
objective-scrape is wrong. Honors original #1964 decision "no new index".

Prod EXPLAIN (Академический / 3km radius, 2026-06-28):
  consumer            via view      inline (this commit)
  concepts median     5854 ms       1640 ms   (bitmap district + sort)
  parcels district    5854 ms       1640 ms
  parcels geo-median  6443 ms        122 ms   (NestedLoop geo->complex bitmap)
  parcels obj_pricing 5721 ms        441 ms   (project bitmap per nearby ЖК)

FIX: keep v_objective_lots_latest ONLY for batch/background/cached consumers
(supply_layers L1, competitors._SOLD_COUNT_SQL, special_indices [/forecast bg task
30-180s], admin, landing). Revert the 4 request-path consumers inside analyze_parcel
to inline DISTINCT ON (physflat-key, latest snapshot) with the filter pushed INTO the
CTE so the district/spatial/project index applies:
- concepts._OBJECTIVE_MEDIAN_SQL
- parcels.py district price block
- parcels.py geo-radius median (complex_id-scoped)
- parcels.py obj_pricing CTE (project_name-scoped; aggregates over deduped set)

Migration 175 header CORRECTED: accurately states the partial mig-173 index does NOT
serve the view (qual can't push below DISTINCT ON), the full index is disproven/not
added, and which consumers use the view vs inline. No DDL change (still view-only).

Tests: +guards (concepts/obj_pricing dedup inline, not view; obj_pricing physflat
DISTINCT ON; perf-pushdown scope preserved). 965 passed.
This commit is contained in:
Light1YT 2026-06-28 05:00:47 +05:00
parent e67cb721bf
commit 74f1ffb500
5 changed files with 207 additions and 72 deletions

View file

@ -72,16 +72,33 @@ _DISTRICT_FOR_POINT_SQL = text(
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки. # Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3. # price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
# #1964: читаем v_objective_lots_latest (physflat-дедуп view) — сырой objective_lots #
# раздут ~2.91× (мульти lot_id на физлот) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE # #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти objective_lot_id на
# были по пере-листингам, а не по физическим квартирам; вес медианы тоже смещался. # один физлот через пере-листинги) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
# были по пере-листингам, вес медианы смещался. Дедупим INLINE через DISTINCT ON
# (physflat-ключ, последний снапшот snapshot_date DESC, id DESC) — НЕ через общий
# physflat-VIEW. Причина (deep-review #1964, прод-EXPLAIN): view не несёт
# district-фильтр внутри, qual не проталкивается ниже DISTINCT ON → view сортирует
# ВСЮ таблицу 1.76M (~5.8 s, request-path внутри analyze_parcel). Inline с district
# В CTE → bitmap по району ~240k строк + sort (~1.7 s), индекс по district работает.
# Сегмент-фильтры (price NOT NULL / диапазон) объективны по физлоту → применяем
# ПОСЛЕ дедупа. Зеркало паттерна market_metrics._STOCK_SQL.
_OBJECTIVE_MEDIAN_SQL = text( _OBJECTIVE_MEDIAN_SQL = text(
""" """
WITH latest AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
price_per_m2_rub
FROM objective_lots
WHERE district = CAST(:dn AS text)
ORDER BY project_name, corpus_name, section, floor, lot_number,
snapshot_date DESC, id DESC
)
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2, SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
COUNT(*) AS sample_size COUNT(*) AS sample_size
FROM v_objective_lots_latest FROM latest
WHERE district = CAST(:dn AS text) WHERE price_per_m2_rub IS NOT NULL
AND price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric) AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
""" """
) )

View file

@ -1869,30 +1869,52 @@ def analyze_parcel(
3000 3000
) )
), ),
obj_pricing AS ( nearby_projects AS (
-- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id. -- Имена Objective-проектов ближних obj_id scope для physflat-дедупа.
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому SELECT DISTINCT cm.domrf_obj_id, cm.objective_complex_name
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
-- #1964: читаем из v_objective_lots_latest (physflat-дедуп view) —
-- сырой objective_lots раздут ~2.91× (мульти lot_id на физлот)
-- units_sold/units_available карточки конкурента были завышены.
-- View = 1 строка на физлот; маппинг прод 1:1 по domrf_obj_id
-- (303 строки = 303 distinct) COUNT(*) корректен.
SELECT
cm.domrf_obj_id,
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(ol.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE ol.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT ol.is_sold) AS units_available,
COUNT(*) FILTER (
WHERE ol.price_per_m2_rub IS NOT NULL
) AS lots_with_price
FROM objective_complex_mapping cm FROM objective_complex_mapping cm
JOIN v_objective_lots_latest ol
ON ol.project_name = cm.objective_complex_name
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj) WHERE cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)
GROUP BY cm.domrf_obj_id ),
obj_lots_latest AS (
-- #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти lot_id
-- на физлот) units_sold/units_available карточки конкурента были
-- завышены. Дедупим INLINE (DISTINCT ON physflat-ключ, последний
-- снапшот), scope протолкнут через project_name ближних ЖК. НЕ через
-- общий physflat-VIEW: он материализует ВСЮ таблицу (join не проходит
-- ниже DISTINCT ON) seq-scan+sort 1.76M (~5.7 s на request-path
-- analyze_parcel); inline scoped ~доли секунды (прод-EXPLAIN #1964).
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
ol.project_name,
ol.price_per_m2_rub,
ol.area_pd,
ol.is_sold
FROM objective_lots ol
WHERE ol.project_name IN (
SELECT objective_complex_name FROM nearby_projects
)
ORDER BY ol.project_name, ol.corpus_name, ol.section, ol.floor,
ol.lot_number, ol.snapshot_date DESC, ol.id DESC
),
obj_pricing AS (
-- Pushdown: агрегируем ТОЛЬКО для ближних obj_id. AVG/COUNT per
-- domrf_obj_id независимы между группами scope даёт идентичные
-- значения для джойнящихся obj_id. Маппинг прод 1:1 по domrf_obj_id
-- (303 строки = 303 distinct) COUNT(*) по дедуп-физлотам корректен.
SELECT
np.domrf_obj_id,
ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(oll.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available,
COUNT(*) FILTER (
WHERE oll.price_per_m2_rub IS NOT NULL
) AS lots_with_price
FROM nearby_projects np
JOIN obj_lots_latest oll
ON oll.project_name = np.objective_complex_name
GROUP BY np.domrf_obj_id
) )
SELECT o.obj_id, SELECT o.obj_id,
o.comm_name, o.comm_name,
@ -2541,10 +2563,24 @@ def analyze_parcel(
dp_row = ( dp_row = (
db.execute( db.execute(
text(""" text("""
-- #1964: physflat-дедуп view вместо сырого objective_lots -- #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти
-- (раздут ~2.91× мульти lot_id на физлот) sample_size и -- lot_id на физлот) sample_size/вес медианы были по
-- вес медианы теперь по физическим квартирам, не по -- пере-листингам. Дедупим INLINE через DISTINCT ON
-- пере-листингам. -- (physflat-ключ, последний снапшот), district протолкнут В
-- CTE. НЕ через v_objective_lots_latest: view не несёт
-- district внутри qual не проходит ниже DISTINCT ON
-- сортировка ВСЕЙ таблицы 1.76M (~5.8 s на request-path
-- analyze_parcel). Inline bitmap по району + sort (~1.7 s).
WITH latest AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
price_per_m2_rub
FROM objective_lots
WHERE district = CAST(:dn AS text)
ORDER BY project_name, corpus_name, section, floor,
lot_number, snapshot_date DESC, id DESC
)
SELECT SELECT
MIN(price_per_m2_rub) AS price_min, MIN(price_per_m2_rub) AS price_min,
MAX(price_per_m2_rub) AS price_max, MAX(price_per_m2_rub) AS price_max,
@ -2552,9 +2588,8 @@ def analyze_parcel(
ORDER BY price_per_m2_rub ORDER BY price_per_m2_rub
) AS price_median, ) AS price_median,
COUNT(*) AS sample_size COUNT(*) AS sample_size
FROM v_objective_lots_latest FROM latest
WHERE district = CAST(:dn AS text) WHERE price_per_m2_rub IS NOT NULL
AND price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN 30000 AND 600000 AND price_per_m2_rub BETWEEN 30000 AND 600000
"""), """),
{"dn": district_row["district_name"]}, {"dn": district_row["district_name"]},
@ -2913,31 +2948,58 @@ def analyze_parcel(
grp_row = ( grp_row = (
db.execute( db.execute(
text(""" text("""
-- #1964: physflat-дедуп view (сырой objective_lots -- #1964 physflat-дедуп: objective_lots раздут ~2.91×
-- раздут ~2.91× мульти lot_id на физлот) n и вес -- (мульти lot_id на физлот) n/вес медианы/гейт n10
-- медианы по физическим квартирам, гейт n10 честный. -- были по пере-листингам. Дедупим INLINE через DISTINCT ON
-- (physflat-ключ, последний снапшот), scope протолкнут В
-- CTE через complex_id ближних ЖК. НЕ через
-- v_objective_lots_latest: view материализует ВСЮ таблицу
-- (qual/join не проходят ниже DISTINCT ON) seq-scan+sort
-- 1.76M (~6.4 s на request-path analyze_parcel). Inline:
-- geo-index по complexes Nested Loop bitmap
-- objective_lots_complex_idx по ~186 ЖК ~120 ms
-- (прод-EXPLAIN deep-review #1964). Дедуп до price-фильтра:
-- цена объективна по физлоту (последний снапшот).
WITH nearby_cx AS (
SELECT c.id
FROM complexes c
WHERE c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(
ST_MakePoint(c.longitude, c.latitude),
4326
)::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:lon AS float),
CAST(:lat AS float)
), 4326
)::geography,
CAST(:radius_m AS float)
)
),
latest AS (
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section,
ol.floor, ol.lot_number
)
ol.price_per_m2_rub,
ol.complex_id
FROM objective_lots ol
WHERE ol.complex_id IN (SELECT id FROM nearby_cx)
ORDER BY ol.project_name, ol.corpus_name, ol.section,
ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT SELECT
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub) percentile_cont(0.5) WITHIN GROUP (
AS median, ORDER BY price_per_m2_rub
count(*) AS n, ) AS median,
count(DISTINCT c.id) AS n_complexes count(*) AS n,
FROM complexes c count(DISTINCT complex_id) AS n_complexes
JOIN v_objective_lots_latest ol FROM latest
ON ol.complex_id = c.id WHERE price_per_m2_rub IS NOT NULL
AND ol.price_per_m2_rub IS NOT NULL
WHERE c.latitude IS NOT NULL
AND c.longitude IS NOT NULL
AND ST_DWithin(
ST_SetSRID(
ST_MakePoint(c.longitude, c.latitude), 4326
)::geography,
ST_SetSRID(
ST_MakePoint(
CAST(:lon AS float), CAST(:lat AS float)
), 4326
)::geography,
CAST(:radius_m AS float)
)
"""), """),
{ {
"lon": centroid_lon, "lon": centroid_lon,

View file

@ -174,12 +174,36 @@ class TestObjPricingPushdown:
"совпадал с выводимыми конкурентами" "совпадал с выводимыми конкурентами"
) )
def test_aggregation_unchanged(self) -> None: def test_aggregation_per_domrf_obj_id(self) -> None:
"""Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope.""" """Агрегаты (AVG/COUNT FILTER) считаются per domrf_obj_id (логика цен #1332).
#1964: источник агрегатов сменился с сырого objective_lots (alias ol) на
physflat-дедуп CTE obj_lots_latest (alias oll) см. test_obj_pricing_*_physflat
ниже. Сами агрегатные выражения и группировка per-obj_id неизменны.
"""
sql = self._competitor_sql() sql = self._competitor_sql()
# Логика цен/fallback (#1332) неизменна: те же агрегаты per domrf_obj_id. assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql assert "lots_with_price" in sql
assert "COUNT(*) FILTER (WHERE ol.price_per_m2_rub IS NOT NULL )" in sql.replace( assert "COUNT(*) FILTER (WHERE oll.is_sold) AS units_sold" in sql
assert "COUNT(*) FILTER (WHERE NOT oll.is_sold) AS units_available" in sql
assert "GROUP BY np.domrf_obj_id" in sql
def test_obj_pricing_dedups_physflat_inline(self) -> None:
"""#1964: obj_pricing агрегирует physflat-дедуп набор (DISTINCT ON), НЕ сырой.
objective_lots раздут ~2.91× (мульти objective_lot_id на физлот через
пере-листинги) units_sold/units_available карточки конкурента были завышены.
Дедупим INLINE (DISTINCT ON physflat-ключ, последний снапшот) со scope по
project_name ближних ЖК. НЕ через v_objective_lots_latest: view материализует
ВСЮ таблицу 1.76M (join не проходит ниже DISTINCT ON seq-scan+sort ~5.7 s на
request-path analyze_parcel; inline scoped ~0.4 s, прод-EXPLAIN #1964).
"""
sql = self._competitor_sql()
assert "obj_lots_latest AS (" in sql, "должен быть physflat-дедуп CTE (#1964)"
assert "DISTINCT ON ( ol.project_name, ol.corpus_name, ol.section," in sql.replace(
"\n", " " "\n", " "
) or "lots_with_price" in sql ), "obj_lots_latest должен дедупить по physflat-ключу"
assert "GROUP BY cm.domrf_obj_id" in sql assert "snapshot_date DESC, ol.id DESC" in sql, "берём последний снапшот физлота"
assert (
"v_objective_lots_latest" not in sql
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"

View file

@ -48,6 +48,24 @@ class _FakeSession:
_WKT = "POINT (60.6 56.83)" _WKT = "POINT (60.6 56.83)"
def test_objective_median_sql_dedups_inline_not_via_view() -> None:
"""#1964: _OBJECTIVE_MEDIAN_SQL дедупит INLINE (DISTINCT ON physflat-ключ), НЕ
через v_objective_lots_latest.
Это REQUEST-PATH запрос (внутри analyze/концепт-генерации). View не несёт
district-фильтр внутри qual не проталкивается ниже DISTINCT ON view сортирует
ВСЮ таблицу 1.76M (~5.8 s, прод-EXPLAIN deep-review #1964). Inline DISTINCT ON с
district В CTE bitmap по району + sort (~1.7 s). Регресс к
`FROM v_objective_lots_latest` (whole-table materialize) fail.
"""
sql = str(concepts._OBJECTIVE_MEDIAN_SQL)
assert "DISTINCT ON (" in sql, "должен дедупить физлоты inline"
assert "snapshot_date DESC" in sql, "берём последний снапшот физлота"
assert (
"v_objective_lots_latest" not in sql
), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)"
def test_objective_median_selected_when_sample_large_enough() -> None: def test_objective_median_selected_when_sample_large_enough() -> None:
# Район найден + Objective n>=10 → выбирается медиана Objective. # Район найден + Objective n>=10 → выбирается медиана Objective.
db = _FakeSession( db = _FakeSession(

View file

@ -16,16 +16,30 @@
-- которым нужна fan-out-защита по маппингу (competitors._SOLD_COUNT_SQL), могут -- которым нужна fan-out-защита по маппингу (competitors._SOLD_COUNT_SQL), могут
-- использовать COUNT(DISTINCT objective_lot_id) поверх view. -- использовать COUNT(DISTINCT objective_lot_id) поверх view.
-- --
-- Ключ/направления DISTINCT ON совпадают с partial-индексом -- ⚠ ПЛАН/ИНДЕКС (deep-review #1964, прод-EXPLAIN 2026-06-28): partial-индекс
-- objective_lots_physflat_latest_idx (миграция 173, WHERE premise_kind='квартира') -- objective_lots_physflat_latest_idx (миграция 173, WHERE premise_kind='квартира')
-- → для квартир-scoped потребителей планировщик берёт Index Scan + Unique без -- НЕ обслуживает этот view. Внутри view НЕТ premise-фильтра, а внешний
-- полной сортировки. Все current-state потребители #1964 — premise_kind='квартира'. -- WHERE premise_kind/district потребителя НЕ проталкивается НИЖЕ DISTINCT ON →
-- Новый индекс НЕ добавляем (173 покрывает). -- индекс-173 не применим. View материализует ВСЮ таблицу: Parallel Seq Scan +
-- external Sort 1.76M строк → ~5.8 s (count по одному району).
-- ПОЛНЫЙ индекс (project,corpus,section,floor,lot_number,snapshot_date DESC,id DESC)
-- НЕ помогает и НЕ добавлен: DISTINCT ON отдаёт ol.* (51 кол., width≈945) →
-- index-only-unique невозможен; 605k heap-проб через 142 MB индекс ДОРОЖЕ
-- seq-scan+sort (планировщик его игнорирует: forced index-path всё равно ~3.9 s).
-- 142 MB write-amp ради нуля на request-path + замедление bulk-INSERT objective-
-- scrape — не берём. Подтверждает исходное решение #1964 «новый индекс НЕ добавляем».
--
-- Следствие: view применяем ТОЛЬКО к BATCH/широким консьюмерам, где полная
-- материализация амортизирована или кэширована (supply_layers L1 группирует ВСЕ
-- районы; competitors._SOLD_COUNT_SQL — по mapping; special_indices; admin; landing).
-- REQUEST-PATH консьюмеры внутри analyze_parcel с фильтром по ОДНОМУ району
-- (concepts._OBJECTIVE_MEDIAN_SQL, parcels district-price/geo-median) view НЕ
-- используют — у них inline DISTINCT ON с district, протолкнутым В CTE (bitmap по
-- району ~240k строк + sort ~19 MB → ~1.7 s vs 5.8 s через view). Дедуп платится в
-- любом случае (старый 303 ms был быстрым потому что считал РАЗДУТЫЕ сырые строки).
-- --
-- #1959 уже устранил инфляцию ВНУТРИ compute_market_metrics (inline DISTINCT ON в -- #1959 уже устранил инфляцию ВНУТРИ compute_market_metrics (inline DISTINCT ON в
-- _STOCK_SQL/_SALES_WINDOW_SQL) и его 9 forecast/scoring-вызовов — НЕ дублируем. -- _STOCK_SQL/_SALES_WINDOW_SQL) и его 9 forecast/scoring-вызовов — НЕ дублируем.
-- Этот view — для ОСТАЛЬНЫХ current-state консьюмеров (supply_layers L1, конкурент-
-- карточки, district-цены, sample_size-гейты).
-- --
-- НЕ трогаем (time-series, корректны): sales_series._SOURCE_B_SQL, -- НЕ трогаем (time-series, корректны): sales_series._SOURCE_B_SQL,
-- objective_lots_history, mv_sales_tracker_*. -- objective_lots_history, mv_sales_tracker_*.