perf(objective_lots): inline-pushdown competitors._SOLD_COUNT_SQL + covering physflat index (#1953) #2054
4 changed files with 236 additions and 29 deletions
|
|
@ -494,12 +494,28 @@ _AVG_PRICE_SQL = text("""
|
|||
# #1964: objective_lots — current-state UPSERT, но НЕ «ровно одна строка на лот».
|
||||
# UNIQUE = objective_lot_id, а Объектив за пере-листинги присваивает ОДНОМУ
|
||||
# физлоту (project,corpus,section,floor,lot) НЕСКОЛЬКО objective_lot_id → таблица
|
||||
# раздута ~2.91× → flats_sold завышался. Читаем из v_objective_lots_latest
|
||||
# (physflat-дедуп view, по строке на физлот). В view objective_lot_id уникален
|
||||
# (603k строк = 603k distinct lot_id), поэтому COUNT(DISTINCT ol.objective_lot_id)
|
||||
# == COUNT(*) по физлотам — НО оставляем DISTINCT: маппинг 1:N по domrf_obj_id
|
||||
# (см. выше) при будущем fan-out задвоил бы строки view, COUNT(*) был бы не
|
||||
# fan-out-safe. DISTINCT даёт И physflat-дедуп (через view), И fan-out-защиту.
|
||||
# раздута ~2.91× → flats_sold завышался. Поэтому ОБЯЗАТЕЛЕН physflat-дедуп до
|
||||
# последнего снапшота на физлот (DISTINCT ON по physflat-ключу, snapshot_date DESC,
|
||||
# id DESC).
|
||||
#
|
||||
# #1964→hunt #1953: РАНЬШЕ дедуп брался из v_objective_lots_latest (JOIN ON
|
||||
# project_name). Регрессия: view материализует ВЕСЬ DISTINCT ON по 1.76M строк ДО
|
||||
# того, как применится фильтр маппинга (cm.domrf_obj_id = ANY) — qual не
|
||||
# проталкивается ниже DISTINCT ON view → Parallel Seq Scan + external sort всей
|
||||
# таблицы (~213 MB temp, ~6896 ms на каждый cold /analyze), хотя нужны лоты лишь
|
||||
# ~15 ЖК. FIX (зеркало #1964 inline-pushdown для analyze-консьюмеров +
|
||||
# market_metrics._STOCK_SQL): сперва фильтруем objective_complex_mapping в CTE
|
||||
# `mapped`, затем DISTINCT ON по СЫРОЙ objective_lots, JOIN ON project_name — так
|
||||
# objective_lots_project_idx (project_name, snapshot_date DESC) обслуживает фильтр
|
||||
# и дедуп материализуется ТОЛЬКО по лотам нужных ЖК. Прод-EXPLAIN: 6896 → ~437 ms
|
||||
# (15.7×), вывод побайтово идентичен (hunt: 7082=7082, 0 mismatch).
|
||||
#
|
||||
# COUNT(DISTINCT objective_lot_id) сохраняем (fan-out-safety из #1964): маппинг 1:N
|
||||
# по domrf_obj_id (UNIQUE = (objective_complex_name, objective_group), см. выше) при
|
||||
# fan-out задвоил бы строки, COUNT(*) был бы не fan-out-safe. DISTINCT ON даёт
|
||||
# physflat-дедуп, COUNT(DISTINCT) — fan-out-защиту по маппингу.
|
||||
#
|
||||
# ⚠ Читаем СЫРУЮ objective_lots inline, НЕ v_objective_lots_latest (см. выше).
|
||||
#
|
||||
# premise_kind='квартира' — как в _STOCK_SQL: без него проданные паркинги/
|
||||
# кладовые/нежилое считались бы как проданные квартиры (over-count).
|
||||
|
|
@ -508,19 +524,35 @@ _AVG_PRICE_SQL = text("""
|
|||
# (premise_kind="квартира") — оба считают продажи жилых лотов.
|
||||
_SOLD_PREMISE_KIND = "квартира"
|
||||
_SOLD_COUNT_SQL = text("""
|
||||
WITH mapped AS (
|
||||
SELECT cm.domrf_obj_id, cm.objective_complex_name
|
||||
FROM objective_complex_mapping cm
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
),
|
||||
latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
|
||||
)
|
||||
m.domrf_obj_id,
|
||||
ol.objective_lot_id,
|
||||
(
|
||||
ol.is_sold IS TRUE
|
||||
OR ol.contract_date IS NOT NULL
|
||||
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
||||
) AS sold_now
|
||||
FROM mapped m
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = m.objective_complex_name
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
ORDER BY
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||
ol.snapshot_date DESC, ol.id DESC
|
||||
)
|
||||
SELECT
|
||||
cm.domrf_obj_id AS obj_id,
|
||||
COUNT(DISTINCT ol.objective_lot_id) FILTER (
|
||||
WHERE ol.is_sold IS TRUE
|
||||
OR ol.contract_date IS NOT NULL
|
||||
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
||||
) AS flats_sold
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN v_objective_lots_latest ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND ol.premise_kind = :premise_kind
|
||||
GROUP BY cm.domrf_obj_id
|
||||
domrf_obj_id AS obj_id,
|
||||
COUNT(DISTINCT objective_lot_id) FILTER (WHERE sold_now) AS flats_sold
|
||||
FROM latest
|
||||
GROUP BY domrf_obj_id
|
||||
""")
|
||||
|
||||
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
|
||||
|
|
|
|||
|
|
@ -596,27 +596,38 @@ def test_sold_count_sql_is_fanout_safe() -> None:
|
|||
разный objective_group) → JOIN по project_name задвоил бы лоты. DISTINCT по
|
||||
objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) → fail.
|
||||
|
||||
#1964: даже после перехода на v_objective_lots_latest (где objective_lot_id
|
||||
уникален → COUNT(DISTINCT)==COUNT(*) по физлотам) DISTINCT СОХРАНЯЕТСЯ: будущий
|
||||
fan-out маппинга задвоил бы строки view, и COUNT(*) перестал бы быть fan-out-safe.
|
||||
#1964: DISTINCT СОХРАНЯЕТСЯ и в inline-варианте (hunt #1953): будущий fan-out
|
||||
маппинга задвоил бы строки, и COUNT(*) перестал бы быть fan-out-safe. В новом
|
||||
SQL final-SELECT агрегирует по CTE `latest` (без ol-алиаса) → COUNT(DISTINCT
|
||||
objective_lot_id).
|
||||
"""
|
||||
sql = _sold_sql_text()
|
||||
assert (
|
||||
"COUNT(DISTINCT ol.objective_lot_id)" in sql
|
||||
"COUNT(DISTINCT objective_lot_id)" in sql
|
||||
), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
|
||||
# COUNT(*) допустим внутри как агрегат? нет — sold-count агрегирует только distinct lot.
|
||||
assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"
|
||||
|
||||
|
||||
def test_sold_count_sql_reads_physflat_view() -> None:
|
||||
"""#1964: sold-count читает physflat-дедуп view, НЕ сырой objective_lots.
|
||||
def test_sold_count_sql_reads_raw_objective_lots_inline() -> None:
|
||||
"""hunt #1953 (регрессия #1964): sold-count читает СЫРУЮ objective_lots inline с
|
||||
DISTINCT ON по physflat-ключу, НЕ v_objective_lots_latest.
|
||||
|
||||
Сырой objective_lots раздут ~2.91× (мульти objective_lot_id на один физлот через
|
||||
пере-листинги) → flats_sold завышался. Репойнт на v_objective_lots_latest даёт по
|
||||
строке на физический лот. Регресс к `FROM objective_lots` (без _latest) → fail.
|
||||
Через view DISTINCT ON материализует ВСЮ 1.76M-таблицу ДО фильтра маппинга (qual
|
||||
не проталкивается ниже view-DISTINCT ON) → Parallel Seq Scan + external sort
|
||||
(~6896 ms, ~213 MB temp на каждый cold /analyze). Inline-pushdown: фильтр
|
||||
маппинга в CTE → DISTINCT ON по сырой objective_lots с JOIN ON project_name
|
||||
(objective_lots_project_idx) → ~437 ms, вывод побайтово идентичен. Регресс к
|
||||
`v_objective_lots_latest` → fail.
|
||||
"""
|
||||
sql = _sold_sql_text()
|
||||
assert "v_objective_lots_latest" in sql, "должен читать physflat-дедуп view (#1964)"
|
||||
assert "JOIN objective_lots " not in sql, "не должен джойнить сырой objective_lots"
|
||||
assert "v_objective_lots_latest" not in sql, "не должен читать view (#1953 regression)"
|
||||
assert "JOIN objective_lots" in sql, "должен джойнить СЫРУЮ objective_lots inline"
|
||||
# physflat-ключ inline DISTINCT ON.
|
||||
assert "DISTINCT ON (" in sql
|
||||
for col in ("project_name", "corpus_name", "section", "floor", "lot_number"):
|
||||
assert col in sql, f"physflat-ключ должен включать {col}"
|
||||
assert "snapshot_date DESC" in sql and "id DESC" in sql
|
||||
|
||||
|
||||
def test_sold_count_sql_filters_premise_kind() -> None:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
"""Пин SQL-формы competitors._SOLD_COUNT_SQL (perf follow-up эпика #1953, пункт #1).
|
||||
|
||||
Регрессия #1964→hunt: запрос JOIN'ил v_objective_lots_latest, чей DISTINCT ON по
|
||||
ВСЕЙ 1.76M-таблице материализуется ДО фильтра маппинга (qual не проталкивается ниже
|
||||
view-DISTINCT ON) → Parallel Seq Scan + external sort (~6896 ms, ~213 MB temp) на
|
||||
каждый cold /analyze. FIX — inline-pushdown: фильтруем objective_complex_mapping в
|
||||
CTE, затем DISTINCT ON по СЫРОЙ objective_lots с JOIN ON project_name (его
|
||||
обслуживает objective_lots_project_idx) → дедуп лишь по нужным ЖК (~437 ms).
|
||||
|
||||
Эти тесты — строковые инварианты на скомпилированном SQL (без живой БД), чтобы
|
||||
будущий рефактор случайно не вернул view-вариант и не сломал physflat-дедуп/
|
||||
fan-out-защиту. Вывод побайтово идентичен view-варианту (hunt: 7082=7082, 0
|
||||
mismatch) — поведение не меняется, меняется только план.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.site_finder.competitors import _SOLD_COUNT_SQL, _SOLD_PREMISE_KIND
|
||||
|
||||
_SQL = str(_SOLD_COUNT_SQL)
|
||||
_SQL_FLAT = re.sub(r"\s+", " ", _SQL).lower()
|
||||
|
||||
|
||||
def test_sold_count_reads_raw_objective_lots_not_view() -> None:
|
||||
"""Читаем СЫРУЮ objective_lots inline, НЕ physflat-VIEW (иначе вернётся
|
||||
regression: full-table DISTINCT ON до фильтра маппинга → seq-scan+spill)."""
|
||||
assert "v_objective_lots_latest" not in _SQL_FLAT
|
||||
assert re.search(r"\bjoin\s+objective_lots\b", _SQL_FLAT)
|
||||
|
||||
|
||||
def test_sold_count_uses_inline_distinct_on_physflat_key() -> None:
|
||||
"""physflat-дедуп делается inline через DISTINCT ON по полному physflat-ключу
|
||||
(project_name, corpus_name, section, floor, lot_number) — НЕ через view."""
|
||||
m = re.search(r"distinct on \((.*?)\)", _SQL_FLAT, re.DOTALL)
|
||||
assert m is not None, "ожидался inline DISTINCT ON"
|
||||
key = m.group(1)
|
||||
for col in ("project_name", "corpus_name", "section", "floor", "lot_number"):
|
||||
assert col in key, f"physflat-ключ должен включать {col}"
|
||||
|
||||
|
||||
def test_sold_count_orders_latest_snapshot_first() -> None:
|
||||
"""ORDER BY последним снапшотом вперёд (snapshot_date DESC, id DESC) — DISTINCT ON
|
||||
берёт первую строку группы → последний снапшот физлота."""
|
||||
assert "snapshot_date desc" in _SQL_FLAT
|
||||
assert "id desc" in _SQL_FLAT
|
||||
# tie-break: id DESC должен идти ПОСЛЕ snapshot_date DESC.
|
||||
assert _SQL_FLAT.index("snapshot_date desc") < _SQL_FLAT.index("id desc")
|
||||
|
||||
|
||||
def test_sold_count_filters_mapping_in_cte_before_join() -> None:
|
||||
"""Фильтр маппинга (domrf_obj_id = ANY) проталкивается в CTE ДО DISTINCT ON —
|
||||
суть pushdown'а (дедуп лишь по нужным ЖК, а не по всей таблице)."""
|
||||
assert "objective_complex_mapping" in _SQL_FLAT
|
||||
assert ":obj_ids" in _SQL
|
||||
join_at = _SQL_FLAT.index("join objective_lots")
|
||||
filter_at = _SQL_FLAT.index("domrf_obj_id = any(:obj_ids)")
|
||||
assert filter_at < join_at, "фильтр маппинга должен предшествовать JOIN objective_lots"
|
||||
|
||||
|
||||
def test_sold_count_keeps_distinct_objective_lot_id_fanout_safety() -> None:
|
||||
"""COUNT(DISTINCT objective_lot_id) сохранён (fan-out-safety из #1964: маппинг
|
||||
1:N по domrf_obj_id)."""
|
||||
assert "count(distinct objective_lot_id)" in _SQL_FLAT
|
||||
|
||||
|
||||
def test_sold_count_keeps_sold_predicate() -> None:
|
||||
"""sold-предикат — зеркало market_metrics._STOCK_SQL: is_sold OR contract_date
|
||||
NOT NULL OR status='продан'."""
|
||||
assert "is_sold is true" in _SQL_FLAT
|
||||
assert "contract_date is not null" in _SQL_FLAT
|
||||
assert "= 'продан'" in _SQL_FLAT
|
||||
|
||||
|
||||
def test_sold_count_filters_premise_kind_quartira() -> None:
|
||||
"""premise_kind-фильтр на месте (бинд) и дефолт = 'квартира' (без него
|
||||
проданные паркинги/кладовые считались бы как квартиры)."""
|
||||
assert ":premise_kind" in _SQL
|
||||
assert _SOLD_PREMISE_KIND == "квартира"
|
||||
|
||||
|
||||
def test_sold_count_no_double_colon_cast() -> None:
|
||||
"""psycopg v3 / backend.md: никаких :name::type (игнорируется парсером)."""
|
||||
assert not re.search(r":[a-z_]+::[a-z]", _SQL)
|
||||
76
data/sql/176_objective_lots_physflat_covering_idx.sql
Normal file
76
data/sql/176_objective_lots_physflat_covering_idx.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
-- Контекст: perf-батч после prod-EXPLAIN-охоты (follow-up эпика #1953, пункты #2/#6/#5).
|
||||
--
|
||||
-- #2/#6 — делаем physflat-индекс ПОКРЫВАЮЩИМ, дропаем старый partial.
|
||||
-- EKB-wide дефолт market_metrics._STOCK_SQL (has_district=F/has_obj_ids=F) и
|
||||
-- зеркальный _SALES_WINDOW_SQL дедуплицируют objective_lots до последнего снапшота
|
||||
-- на физлот через
|
||||
-- SELECT DISTINCT ON (project_name, corpus_name, section, floor, lot_number) …
|
||||
-- ORDER BY …, snapshot_date DESC, id DESC
|
||||
-- по квартирам (premise_kind='квартира'). Partial-индекс миграции 173
|
||||
-- (objective_lots_physflat_latest_idx) совпадал с ключом DISTINCT ON, НО НЕ нёс
|
||||
-- проецируемых колонок → Index-Only невозможен. Планировщик брал Parallel Seq Scan
|
||||
-- + external merge Sort всех 1.75M квартир-строк (прод-EXPLAIN 2026-06-28:
|
||||
-- ~5177 ms, temp-spill ~140 MB на трёх воркерах) вместо чтения из индекса.
|
||||
--
|
||||
-- Покрывающий индекс добавляет INCLUDE с колонками, которые проецируют/агрегируют
|
||||
-- ОБА запроса ПОСЛЕ DISTINCT ON:
|
||||
-- _STOCK_SQL: rooms_int, area_pd, sales_start_date + sold_now-предикат
|
||||
-- (is_sold, contract_date, status)
|
||||
-- _SALES_WINDOW_SQL: rooms_int, area_pd, contract_date, class
|
||||
-- Объединение INCLUDE = {rooms_int, area_pd, sales_start_date, is_sold,
|
||||
-- contract_date, status, class}. Ключ (physflat + snapshot_date DESC, id DESC) и
|
||||
-- partial-WHERE (premise_kind='квартира') не меняются → тот же DISTINCT ON, но
|
||||
-- теперь Index-Only Scan + Unique БЕЗ Sort.
|
||||
-- Прод DRY-RUN (BEGIN; CREATE covering; EXPLAIN ANALYZE EKB-wide _STOCK_SQL;
|
||||
-- ROLLBACK): Index Only Scan using objective_lots_physflat_covering_idx + Unique,
|
||||
-- внешней сортировки НЕТ, Execution ~1642 ms (vs ~5177 ms). Размер ~252 MB.
|
||||
--
|
||||
-- price_per_m2_rub в INCLUDE НЕ кладём: его проецируют только analyze-inline
|
||||
-- запросы (concepts._OBJECTIVE_MEDIAN_SQL, parcels district/geo-median), а они
|
||||
-- НЕ фильтруют premise_kind='квартира' → partial-индекс к ним и так неприменим
|
||||
-- (как и старый 173). Их обслуживают idx_objective_lots_pname_pricing / district-
|
||||
-- /geo-пути — не регрессируют (никогда не использовали partial physflat-индекс).
|
||||
--
|
||||
-- Старый partial objective_lots_physflat_latest_idx ДРОПАЕМ: покрывающий индекс
|
||||
-- его полностью замещает (тот же ключ + WHERE) и обслуживает все DISTINCT ON-
|
||||
-- потребители (#1959 inline _STOCK/_SALES, competitors._SOLD_COUNT_SQL inline,
|
||||
-- analyze-inline по квартирам). Держать оба — лишний write-amp на bulk objective-
|
||||
-- scrape.
|
||||
--
|
||||
-- Порядок: CREATE covering → DROP old (covering уже на месте, ни одного окна без
|
||||
-- индекса под DISTINCT ON). Idempotent (IF NOT EXISTS / IF EXISTS), BEGIN/COMMIT.
|
||||
--
|
||||
-- #5 — статистика objective_lots устаревала (last_analyze был 2026-05-11, planner
|
||||
-- оценивал ~880 строк при реальных ~603k → плохие планы). ANALYZE в конце tx
|
||||
-- (выполняется в транзакции штатно) освежает оценки под новый индекс.
|
||||
--
|
||||
-- ⚠ Деплой ВНЕ недельного окна objective-scrape: миграция дропает+создаёт ~252 MB
|
||||
-- индекс на 1.75M строк (кратковременный SHARE-lock на objective_lots).
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS objective_lots_physflat_covering_idx
|
||||
ON objective_lots (
|
||||
project_name,
|
||||
corpus_name,
|
||||
section,
|
||||
floor,
|
||||
lot_number,
|
||||
snapshot_date DESC,
|
||||
id DESC
|
||||
)
|
||||
INCLUDE (
|
||||
rooms_int,
|
||||
area_pd,
|
||||
sales_start_date,
|
||||
is_sold,
|
||||
contract_date,
|
||||
status,
|
||||
class
|
||||
)
|
||||
WHERE premise_kind = 'квартира';
|
||||
|
||||
DROP INDEX IF EXISTS objective_lots_physflat_latest_idx;
|
||||
|
||||
ANALYZE objective_lots;
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue