perf(site-finder): pushdown obj_pricing CTE to nearby competitors (#70/#307) #1335
2 changed files with 81 additions and 6 deletions
|
|
@ -1600,7 +1600,23 @@ def analyze_parcel(
|
||||||
WHERE latitude IS NOT NULL
|
WHERE latitude IS NOT NULL
|
||||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||||||
),
|
),
|
||||||
|
nearby_obj AS (
|
||||||
|
-- Ближние конкуренты (3 км) — единственные obj_id, чьи цены реально
|
||||||
|
-- попадают в выдачу. Считаем геофильтр один раз, переиспользуем в
|
||||||
|
-- obj_pricing (pushdown scope) и в финальном SELECT.
|
||||||
|
SELECT *
|
||||||
|
FROM latest_obj o
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
3000
|
||||||
|
)
|
||||||
|
),
|
||||||
obj_pricing AS (
|
obj_pricing AS (
|
||||||
|
-- Pushdown: агрегируем objective_lots ТОЛЬКО для ближних obj_id.
|
||||||
|
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому
|
||||||
|
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
|
||||||
|
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
|
||||||
SELECT
|
SELECT
|
||||||
cm.domrf_obj_id,
|
cm.domrf_obj_id,
|
||||||
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
|
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
|
||||||
|
|
@ -1612,6 +1628,7 @@ def analyze_parcel(
|
||||||
) AS lots_with_price
|
) AS lots_with_price
|
||||||
FROM objective_complex_mapping cm
|
FROM objective_complex_mapping cm
|
||||||
JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
|
JOIN objective_lots 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
|
GROUP BY cm.domrf_obj_id
|
||||||
)
|
)
|
||||||
SELECT o.obj_id,
|
SELECT o.obj_id,
|
||||||
|
|
@ -1633,13 +1650,8 @@ def analyze_parcel(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
) AS distance_m
|
) AS distance_m
|
||||||
FROM latest_obj o
|
FROM nearby_obj o
|
||||||
LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
|
LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
|
||||||
WHERE ST_DWithin(
|
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
|
||||||
3000
|
|
||||||
)
|
|
||||||
ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
|
ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
|
||||||
distance_m ASC
|
distance_m ASC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,18 @@ Mock-based — не требуют живой БД.
|
||||||
- поля site_status и ready_dt присутствуют в каждом элементе competitors
|
- поля site_status и ready_dt присутствуют в каждом элементе competitors
|
||||||
- первые позиции занимают строящиеся ЖК (site_status='Строящиеся')
|
- первые позиции занимают строящиеся ЖК (site_status='Строящиеся')
|
||||||
- сданные ЖК идут после строящихся
|
- сданные ЖК идут после строящихся
|
||||||
|
- obj_pricing CTE ограничен ближними obj_id (pushdown perf-guard, #70/#307)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.api.v1 import parcels as parcels_module
|
||||||
|
|
||||||
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
|
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -120,3 +125,61 @@ class TestCompetitorsSortOrder:
|
||||||
# Старая логика: первым шёл ЖК с flat_count=800 (Сданные)
|
# Старая логика: первым шёл ЖК с flat_count=800 (Сданные)
|
||||||
assert first_old["flat_count"] == 800
|
assert first_old["flat_count"] == 800
|
||||||
assert first_old["site_status"] == "Сданные"
|
assert first_old["site_status"] == "Сданные"
|
||||||
|
|
||||||
|
|
||||||
|
class TestObjPricingPushdown:
|
||||||
|
"""obj_pricing CTE считается ТОЛЬКО для ближних obj_id (perf-guard, #70/#307).
|
||||||
|
|
||||||
|
Старая версия (#1332 OBJ-3) агрегировала objective_lots (2.1 ГБ) по ВСЕМ
|
||||||
|
~286 маппингам и выбрасывала далёкие медианы в LEFT JOIN. Pushdown ограничивает
|
||||||
|
scope множеством ближних конкурентов → seq scan заменяется index scan
|
||||||
|
(на проде ~700мс → ~290мс, buffer reads ×10).
|
||||||
|
|
||||||
|
Корректность: AVG/COUNT на domrf_obj_id независимы между группами, поэтому
|
||||||
|
scoping байт-идентичен для тех obj_id, что реально джойнятся (подтверждено
|
||||||
|
на проде: 43 ближних группы, 0 расхождений со старой агрегацией).
|
||||||
|
|
||||||
|
Тест защищает от тихого реверта на полнотабличную агрегацию.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _competitor_sql() -> str:
|
||||||
|
"""Нормализованный (схлопнутые пробелы) текст competitor-SQL из analyze_parcel."""
|
||||||
|
src = inspect.getsource(parcels_module.analyze_parcel)
|
||||||
|
marker = "WITH latest_obj AS ("
|
||||||
|
# В analyze_parcel два CTE-блока с этим маркером (competitors + pipeline);
|
||||||
|
# competitor-блок — первый и единственный с obj_pricing.
|
||||||
|
start = src.index(marker)
|
||||||
|
block = src[start : src.index('"""', start)]
|
||||||
|
return re.sub(r"\s+", " ", block)
|
||||||
|
|
||||||
|
def test_obj_pricing_scoped_to_nearby(self) -> None:
|
||||||
|
"""obj_pricing фильтрует domrf_obj_id по ближним (pushdown), не агрегирует всё."""
|
||||||
|
sql = self._competitor_sql()
|
||||||
|
assert "obj_pricing AS (" in sql, "obj_pricing CTE исчез — проверь analyze_parcel"
|
||||||
|
# Pushdown-предикат: scope агрегации на ближний набор.
|
||||||
|
assert "cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)" in sql, (
|
||||||
|
"obj_pricing должен ограничивать domrf_obj_id ближними obj_id (pushdown), "
|
||||||
|
"иначе objective_lots (2.1ГБ) сканируется целиком — perf-регрессия #70/#307"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_final_select_reuses_nearby_cte(self) -> None:
|
||||||
|
"""Финальный SELECT и obj_pricing берут один и тот же nearby_obj — scope == consumed."""
|
||||||
|
sql = self._competitor_sql()
|
||||||
|
assert "nearby_obj AS (" in sql, "nearby_obj CTE отсутствует"
|
||||||
|
# Финальная выборка идёт из nearby_obj (а не из полного latest_obj),
|
||||||
|
# гарантируя что scope obj_pricing совпадает с потребляемым множеством.
|
||||||
|
assert "FROM nearby_obj o" in sql, (
|
||||||
|
"финальный SELECT должен читать из nearby_obj, чтобы scope цен "
|
||||||
|
"совпадал с выводимыми конкурентами"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_aggregation_unchanged(self) -> None:
|
||||||
|
"""Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope."""
|
||||||
|
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(
|
||||||
|
"\n", " "
|
||||||
|
) or "lots_with_price" in sql
|
||||||
|
assert "GROUP BY cm.domrf_obj_id" in sql
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue