fix(site_finder): correct best-layouts supply fan-out (~8.5x) + objects-first perf #1944

Merged
bot-backend merged 1 commit from perf/best-layouts-objects-first into main 2026-06-27 05:58:38 +00:00
3 changed files with 100 additions and 17 deletions

View file

@ -210,7 +210,52 @@ _INLINE_VELOCITY_SQL = text("""
# становились dead lookup. SF-08 euro-биннинг откладываем до момента, когда его
# начнёт отдавать 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("""
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
CASE
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
@ -228,18 +273,10 @@ _SUPPLY_BATCH_SQL = text("""
ELSE '100+'
END AS ab,
COUNT(*) AS units
FROM domrf_kn_flats f
JOIN domrf_kn_objects o ON f.obj_id = o.obj_id
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
AND ST_DWithin(
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)
FROM nearby n
JOIN domrf_kn_flats f
ON f.obj_id = n.obj_id
AND f.snapshot_date = CAST(:latest_snap AS date)
GROUP BY rb, ab
""")

View file

@ -261,10 +261,33 @@ class TestDomrfKnFlats:
"""Проверяем таблицу domrf_kn_flats и её колонки."""
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(
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
CASE
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
@ -282,10 +305,10 @@ class TestDomrfKnFlats:
ELSE '100+'
END AS ab,
COUNT(*) AS units
FROM domrf_kn_flats f
JOIN domrf_kn_objects o ON f.obj_id = o.obj_id
WHERE o.latitude IS NOT NULL AND o.longitude IS NOT NULL
AND f.snapshot_date = CAST('2026-01-01' AS date)
FROM nearby n
JOIN domrf_kn_flats f
ON f.obj_id = n.obj_id
AND f.snapshot_date = CAST('2026-01-01' AS date)
GROUP BY rb, ab
""",
)

View file

@ -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"
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) ───────────────────────────