fix(site_finder): correct best-layouts supply fan-out (~8.5x) + objects-first perf #1944
3 changed files with 100 additions and 17 deletions
|
|
@ -210,7 +210,52 @@ _INLINE_VELOCITY_SQL = text("""
|
||||||
# становились dead lookup. SF-08 euro-биннинг откладываем до момента, когда его
|
# становились dead lookup. SF-08 euro-биннинг откладываем до момента, когда его
|
||||||
# начнёт отдавать velocity-сторона.
|
# начнёт отдавать velocity-сторона.
|
||||||
|
|
||||||
|
# Perf + CORRECTNESS fix (perf/best-layouts-objects-first):
|
||||||
|
# objects-first MATERIALIZED CTE + устранение fan-out по снимкам объекта.
|
||||||
|
#
|
||||||
|
# === Корректность (главное) ===
|
||||||
|
# domrf_kn_objects — это snapshot-таблица: UNIQUE (obj_id, snapshot_date)
|
||||||
|
# [uq_kn_objects_id_snap], ~8 снимков на obj_id, координаты могут меняться между
|
||||||
|
# снимками. Прежний запрос джойнил flats к domrf_kn_objects по obj_id БЕЗ фильтра
|
||||||
|
# o.snapshot_date → каждая квартира считалась по разу на КАЖДУЮ строку-объект,
|
||||||
|
# прошедшую ST_DWithin (fan-out ~×8.5 на проде, радиус 1.5км: 82251 → 9612).
|
||||||
|
# Последствие в выдаче (best_layouts.py): supply_units_in_radius завышен ~8.5×,
|
||||||
|
# sold_pct_of_supply (= deals/supply) занижен ~8.5×, is_oversold недосрабатывает.
|
||||||
|
# Фикс: latest_obj дедуплицирует до ОДНОЙ строки на obj_id (последний снимок —
|
||||||
|
# самые свежие координаты) через DISTINCT ON; ST_DWithin применяется к этим
|
||||||
|
# каноническим координатам. nearby содержит уникальные obj_id → JOIN с flats не
|
||||||
|
# фанаутит → units = ровно один счёт на квартиру (cross-check на проде:
|
||||||
|
# units == count(DISTINCT f.id) == 9612 для 65 объектов в радиусе).
|
||||||
|
#
|
||||||
|
# === Перформанс ===
|
||||||
|
# Селективный предикат — ST_DWithin по маленькой таблице объектов (~1.5k уникальных
|
||||||
|
# obj_id), поэтому фильтруем объекты ПЕРВЫМИ, затем джойним большую flats
|
||||||
|
# (последний полный снимок ~376k строк) через idx_kn_flats_obj. Прежний flats-first
|
||||||
|
# план параллельно seq-сканировал весь снимок flats → ~28k buffers / ~240-360ms
|
||||||
|
# (cold ~549ms). Этот план: objects-first + меньше строк после дедупа.
|
||||||
|
#
|
||||||
|
# MATERIALIZED у nearby — обязателен: без него планировщик инлайнит CTE и
|
||||||
|
# возвращается к flats-first seq scan (domrf_kn_objects не имеет GIST-индекса).
|
||||||
_SUPPLY_BATCH_SQL = text("""
|
_SUPPLY_BATCH_SQL = text("""
|
||||||
|
WITH latest_obj AS (
|
||||||
|
SELECT DISTINCT ON (o.obj_id)
|
||||||
|
o.obj_id, o.latitude, o.longitude
|
||||||
|
FROM domrf_kn_objects o
|
||||||
|
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
|
||||||
|
ORDER BY o.obj_id, o.snapshot_date DESC
|
||||||
|
),
|
||||||
|
nearby AS MATERIALIZED (
|
||||||
|
SELECT lo.obj_id
|
||||||
|
FROM latest_obj lo
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
ST_SetSRID(ST_MakePoint(lo.longitude, lo.latitude), 4326)::geography,
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)),
|
||||||
|
4326
|
||||||
|
)::geography,
|
||||||
|
CAST(:radius_m AS float)
|
||||||
|
)
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
CASE
|
CASE
|
||||||
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
|
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
|
||||||
|
|
@ -228,18 +273,10 @@ _SUPPLY_BATCH_SQL = text("""
|
||||||
ELSE '100+'
|
ELSE '100+'
|
||||||
END AS ab,
|
END AS ab,
|
||||||
COUNT(*) AS units
|
COUNT(*) AS units
|
||||||
FROM domrf_kn_flats f
|
FROM nearby n
|
||||||
JOIN domrf_kn_objects o ON f.obj_id = o.obj_id
|
JOIN domrf_kn_flats f
|
||||||
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
|
ON f.obj_id = n.obj_id
|
||||||
AND ST_DWithin(
|
AND f.snapshot_date = CAST(:latest_snap AS date)
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
|
||||||
ST_SetSRID(
|
|
||||||
ST_MakePoint(CAST(:center_lon AS float), CAST(:center_lat AS float)),
|
|
||||||
4326
|
|
||||||
)::geography,
|
|
||||||
CAST(:radius_m AS float)
|
|
||||||
)
|
|
||||||
AND f.snapshot_date = CAST(:latest_snap AS date)
|
|
||||||
GROUP BY rb, ab
|
GROUP BY rb, ab
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,10 +261,33 @@ class TestDomrfKnFlats:
|
||||||
"""Проверяем таблицу domrf_kn_flats и её колонки."""
|
"""Проверяем таблицу domrf_kn_flats и её колонки."""
|
||||||
|
|
||||||
def test_supply_batch_query(self, phantom_check_session: Session) -> None:
|
def test_supply_batch_query(self, phantom_check_session: Session) -> None:
|
||||||
"""best_layouts._SUPPLY_BATCH_SQL — total_area, rooms, is_studio, flat_type."""
|
"""best_layouts._SUPPLY_BATCH_SQL — total_area, rooms, is_studio, flat_type.
|
||||||
|
|
||||||
|
Зеркалит objects-first MATERIALIZED CTE (latest-snapshot dedup, anti-fan-out)
|
||||||
|
с хардкод-значениями вместо bind-параметров — EXPLAIN-гейт на phantom-колонки
|
||||||
|
остаётся осмысленным после перехода с flats-first на objects-first.
|
||||||
|
"""
|
||||||
_explain(
|
_explain(
|
||||||
phantom_check_session,
|
phantom_check_session,
|
||||||
"""
|
"""
|
||||||
|
WITH latest_obj AS (
|
||||||
|
SELECT DISTINCT ON (o.obj_id)
|
||||||
|
o.obj_id, o.latitude, o.longitude
|
||||||
|
FROM domrf_kn_objects o
|
||||||
|
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
|
||||||
|
ORDER BY o.obj_id, o.snapshot_date DESC
|
||||||
|
),
|
||||||
|
nearby AS MATERIALIZED (
|
||||||
|
SELECT lo.obj_id
|
||||||
|
FROM latest_obj lo
|
||||||
|
WHERE ST_DWithin(
|
||||||
|
ST_SetSRID(ST_MakePoint(lo.longitude, lo.latitude), 4326)::geography,
|
||||||
|
ST_SetSRID(
|
||||||
|
ST_MakePoint(CAST(60.6 AS float), CAST(56.84 AS float)), 4326
|
||||||
|
)::geography,
|
||||||
|
CAST(1500 AS float)
|
||||||
|
)
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
CASE
|
CASE
|
||||||
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
|
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
|
||||||
|
|
@ -282,10 +305,10 @@ class TestDomrfKnFlats:
|
||||||
ELSE '100+'
|
ELSE '100+'
|
||||||
END AS ab,
|
END AS ab,
|
||||||
COUNT(*) AS units
|
COUNT(*) AS units
|
||||||
FROM domrf_kn_flats f
|
FROM nearby n
|
||||||
JOIN domrf_kn_objects o ON f.obj_id = o.obj_id
|
JOIN domrf_kn_flats f
|
||||||
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
|
ON f.obj_id = n.obj_id
|
||||||
AND f.snapshot_date = CAST('2026-01-01' AS date)
|
AND f.snapshot_date = CAST('2026-01-01' AS date)
|
||||||
GROUP BY rb, ab
|
GROUP BY rb, ab
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,29 @@ def test_supply_does_not_emit_euro_buckets() -> None:
|
||||||
assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229"
|
assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229"
|
||||||
|
|
||||||
|
|
||||||
|
def test_supply_dedups_objects_to_latest_snapshot() -> None:
|
||||||
|
"""Regression guard против object-snapshot fan-out (supply units ~8.5x inflation).
|
||||||
|
|
||||||
|
domrf_kn_objects — snapshot-dimension (UNIQUE (obj_id, snapshot_date), ~8
|
||||||
|
снимков/obj_id). Если supply снова заджойнит flats НАПРЯМУЮ к objects без
|
||||||
|
дедупа до одного снимка на obj_id, каждый flat посчитается ~8.5x →
|
||||||
|
supply_units_in_radius завышен, sold_pct_of_supply занижен, is_oversold
|
||||||
|
недо-фаерит (все три — user-facing). Фикс: objects-first MATERIALIZED CTE
|
||||||
|
с DISTINCT ON (obj_id) по последнему снимку, flats джойнятся к нему.
|
||||||
|
"""
|
||||||
|
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
|
||||||
|
|
||||||
|
sql_text = str(_SUPPLY_BATCH_SQL.text)
|
||||||
|
# objects дедуплицируются до одного (последнего) снимка на obj_id
|
||||||
|
assert "DISTINCT ON (o.obj_id)" in sql_text, "supply не дедупит objects → fan-out вернётся"
|
||||||
|
assert "snapshot_date DESC" in sql_text, "DISTINCT ON должен брать ПОСЛЕДНИЙ снимок"
|
||||||
|
# objects-first CTE материализован — иначе планнер инлайнит → flats-first seq scan
|
||||||
|
assert "MATERIALIZED" in sql_text, "nearby CTE должен быть MATERIALIZED (план objects-first)"
|
||||||
|
# flats джойнятся к дедуплицированному nearby, НЕ напрямую к raw objects
|
||||||
|
assert "FROM nearby" in sql_text
|
||||||
|
assert "JOIN domrf_kn_objects" not in sql_text, "прямой flats→objects join = fan-out по снимкам"
|
||||||
|
|
||||||
|
|
||||||
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────
|
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue