From 74f1ffb50066a73b1a57d89efe4ff09e3fdb279f Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 28 Jun 2026 05:00:47 +0500 Subject: [PATCH] perf(objective): request-path consumers dedup inline, not via whole-table view (#1964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/api/v1/concepts.py | 29 ++- backend/app/api/v1/parcels.py | 168 ++++++++++++------ .../api/v1/test_analyze_competitors_status.py | 38 +++- .../generative/test_market_price_lookup.py | 18 ++ data/sql/175_v_objective_lots_latest.sql | 26 ++- 5 files changed, 207 insertions(+), 72 deletions(-) diff --git a/backend/app/api/v1/concepts.py b/backend/app/api/v1/concepts.py index 748ba35c..30058d8b 100644 --- a/backend/app/api/v1/concepts.py +++ b/backend/app/api/v1/concepts.py @@ -72,16 +72,33 @@ _DISTRICT_FOR_POINT_SQL = text( # Медиана цены продажи жилья из объявлений Objective по району + размер выборки. # 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( """ + 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, COUNT(*) AS sample_size - FROM v_objective_lots_latest - WHERE district = CAST(:dn AS text) - AND price_per_m2_rub IS NOT NULL + FROM latest + WHERE price_per_m2_rub IS NOT NULL AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric) """ ) diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index d3f00c36..28f06766 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -1869,30 +1869,52 @@ def analyze_parcel( 3000 ) ), - obj_pricing AS ( - -- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id. - -- AVG/COUNT per domrf_obj_id независимы между группами, поэтому - -- ограничение 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 + nearby_projects AS ( + -- Имена Objective-проектов ближних obj_id — scope для physflat-дедупа. + SELECT DISTINCT cm.domrf_obj_id, cm.objective_complex_name 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) - 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, o.comm_name, @@ -2541,10 +2563,24 @@ def analyze_parcel( dp_row = ( db.execute( text(""" - -- #1964: physflat-дедуп view вместо сырого objective_lots - -- (раздут ~2.91× мульти lot_id на физлот) → sample_size и - -- вес медианы теперь по физическим квартирам, не по - -- пере-листингам. + -- #1964 physflat-дедуп: objective_lots раздут ~2.91× (мульти + -- 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 MIN(price_per_m2_rub) AS price_min, MAX(price_per_m2_rub) AS price_max, @@ -2552,9 +2588,8 @@ def analyze_parcel( ORDER BY price_per_m2_rub ) AS price_median, COUNT(*) AS sample_size - FROM v_objective_lots_latest - WHERE district = CAST(:dn AS text) - AND price_per_m2_rub IS NOT NULL + FROM latest + WHERE price_per_m2_rub IS NOT NULL AND price_per_m2_rub BETWEEN 30000 AND 600000 """), {"dn": district_row["district_name"]}, @@ -2913,31 +2948,58 @@ def analyze_parcel( grp_row = ( db.execute( text(""" - -- #1964: physflat-дедуп view (сырой objective_lots - -- раздут ~2.91× мульти lot_id на физлот) → n и вес - -- медианы по физическим квартирам, гейт n≥10 честный. + -- #1964 physflat-дедуп: objective_lots раздут ~2.91× + -- (мульти lot_id на физлот) → n/вес медианы/гейт n≥10 + -- были по пере-листингам. Дедупим 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 - percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub) - AS median, - count(*) AS n, - count(DISTINCT c.id) AS n_complexes - FROM complexes c - JOIN v_objective_lots_latest ol - ON ol.complex_id = c.id - 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) - ) + percentile_cont(0.5) WITHIN GROUP ( + ORDER BY price_per_m2_rub + ) AS median, + count(*) AS n, + count(DISTINCT complex_id) AS n_complexes + FROM latest + WHERE price_per_m2_rub IS NOT NULL """), { "lon": centroid_lon, diff --git a/backend/tests/api/v1/test_analyze_competitors_status.py b/backend/tests/api/v1/test_analyze_competitors_status.py index 0d59a3d4..6175a38f 100644 --- a/backend/tests/api/v1/test_analyze_competitors_status.py +++ b/backend/tests/api/v1/test_analyze_competitors_status.py @@ -174,12 +174,36 @@ class TestObjPricingPushdown: "совпадал с выводимыми конкурентами" ) - def test_aggregation_unchanged(self) -> None: - """Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope.""" + def test_aggregation_per_domrf_obj_id(self) -> None: + """Агрегаты (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() - # Логика цен/fallback (#1332) неизменна: те же агрегаты per domrf_obj_id. - assert "ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql - assert "COUNT(*) FILTER (WHERE ol.price_per_m2_rub IS NOT NULL )" in sql.replace( + assert "ROUND(AVG(oll.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql + assert "lots_with_price" in sql + 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", " " - ) or "lots_with_price" in sql - assert "GROUP BY cm.domrf_obj_id" in sql + ), "obj_lots_latest должен дедупить по physflat-ключу" + assert "snapshot_date DESC, ol.id DESC" in sql, "берём последний снапшот физлота" + assert ( + "v_objective_lots_latest" not in sql + ), "request-path: view материализует всю таблицу — нужен inline DISTINCT ON (#1964)" diff --git a/backend/tests/services/generative/test_market_price_lookup.py b/backend/tests/services/generative/test_market_price_lookup.py index 0ec05908..5667f38e 100644 --- a/backend/tests/services/generative/test_market_price_lookup.py +++ b/backend/tests/services/generative/test_market_price_lookup.py @@ -48,6 +48,24 @@ class _FakeSession: _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: # Район найден + Objective n>=10 → выбирается медиана Objective. db = _FakeSession( diff --git a/data/sql/175_v_objective_lots_latest.sql b/data/sql/175_v_objective_lots_latest.sql index 481ff23b..31499906 100644 --- a/data/sql/175_v_objective_lots_latest.sql +++ b/data/sql/175_v_objective_lots_latest.sql @@ -16,16 +16,30 @@ -- которым нужна fan-out-защита по маппингу (competitors._SOLD_COUNT_SQL), могут -- использовать 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='квартира') --- → для квартир-scoped потребителей планировщик берёт Index Scan + Unique без --- полной сортировки. Все current-state потребители #1964 — premise_kind='квартира'. --- Новый индекс НЕ добавляем (173 покрывает). +-- НЕ обслуживает этот view. Внутри view НЕТ premise-фильтра, а внешний +-- WHERE premise_kind/district потребителя НЕ проталкивается НИЖЕ DISTINCT ON → +-- индекс-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 в -- _STOCK_SQL/_SALES_WINDOW_SQL) и его 9 forecast/scoring-вызовов — НЕ дублируем. --- Этот view — для ОСТАЛЬНЫХ current-state консьюмеров (supply_layers L1, конкурент- --- карточки, district-цены, sample_size-гейты). -- -- НЕ трогаем (time-series, корректны): sales_series._SOURCE_B_SQL, -- objective_lots_history, mv_sales_tracker_*.