perf(objective_lots): inline DISTINCT ON for 3 view-fullscan consumers + cache landing stats (#1953) (#2067)
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 2m8s
Deploy / build-worker (push) Successful in 3m16s
Deploy / deploy (push) Successful in 5m41s

This commit is contained in:
bot-backend 2026-06-28 17:25:41 +00:00
parent 03ca46fe1d
commit c8e7cb1c79
4 changed files with 147 additions and 35 deletions

View file

@ -3,6 +3,8 @@
from __future__ import annotations
import logging
import threading
import time
from datetime import date
from typing import Annotated, Any
@ -18,6 +20,32 @@ logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# In-process TTL-кэш результата _query_stats (перф-батч #2, #1953-followup)
# ---------------------------------------------------------------------------
# /landing/stats бьётся на КАЖДЫЙ load хоумпейджа и НЕ кэширован. KPI 3 (доля квартир
# с ценой) делает полный агрегат по physflat-дедупу objective_lots: price_per_m2_rub
# нет в covering-INCLUDE и фильтра нет → ~6.7 s heap-скан 603k физлотов на каждый
# запрос. Данные дневной гранулярности (snapshot_date) → 1 ч staleness приемлем.
#
# Кэшируем ВЕСЬ dict (все 5 KPI), не только KPI 3 — там ещё pg_class deals + 2
# MAX(snapshot_date). Только УСПЕШНЫЙ результат (None/fallback НЕ кэшируем — пустая
# dev-БД / сбой запроса не должны залипать на час). Потокобезопасно (threading.Lock,
# удерживается только на check/store); analyze идёт в threadpool под Uvicorn. Стиль —
# зеркало app.services.weather_cache. time.monotonic устойчив к скачкам системных часов.
_STATS_TTL_S: float = 3600.0 # 1 час — дневная гранулярность данных
# (result_dict, expires_at_monotonic_seconds). Только успешный результат.
_STATS_CACHE: dict[str, Any] | None = None
_STATS_EXPIRES_AT: float = 0.0
_STATS_LOCK = threading.Lock()
def _now() -> float:
"""Монотонный таймер (тесты подменяют этот helper, а не сам `time`)."""
return time.monotonic()
# ---------------------------------------------------------------------------
# Response schema
# ---------------------------------------------------------------------------
@ -129,6 +157,30 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
return None
def _query_stats_cached(db: Session) -> dict[str, Any] | None:
"""`_query_stats` с in-process TTL-кэшем (TTL=1 ч). См. блок-комментарий вверху.
Кэширует ТОЛЬКО успешный dict; None (сбой/пустая БД) не кэшируется следующий
запрос попробует снова. Lock удерживается лишь на check/store; сам DB-запрос идёт
ВНЕ lock'а (не сериализуем одновременные cold-start, last-write wins — запрос
идемпотентен, TTL длинный).
"""
global _STATS_CACHE, _STATS_EXPIRES_AT
now = _now()
with _STATS_LOCK:
if _STATS_CACHE is not None and _STATS_EXPIRES_AT > now:
return _STATS_CACHE
# MISS / истёк → DB-запрос ВНЕ lock'а.
result = _query_stats(db)
if result is None:
# Не кэшируем fallback — пусть следующий запрос повторит попытку.
return None
with _STATS_LOCK:
_STATS_CACHE = result
_STATS_EXPIRES_AT = _now() + _STATS_TTL_S
return result
@router.get("/landing/stats", response_model=LandingStatsOut)
def landing_stats(
db: Annotated[Session, Depends(get_db)],
@ -144,7 +196,7 @@ def landing_stats(
- paradox: строка-парадокс портфеля для hero CTA
- stale: true если данные недоступны (DB ошибка или пустая БД)
"""
result = _query_stats(db)
result = _query_stats_cached(db)
if result is None:
return LandingStatsOut(**_FALLBACK_DATA)
return LandingStatsOut(**result)

View file

@ -828,25 +828,62 @@ def _unavailable(key: str, *, reason: str) -> SpecialIndex:
# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает
# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ
# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type.
# #1964: читаем из v_objective_lots_latest (physflat-дедуп view) — сырой
# objective_lots раздут ~2.91× (мульти lot_id на физлот); доля ипотеки —
# отношение, к дублям устойчива, но n_sold/n_mortgage теперь честные (по физлотам).
# #1964: считаем по ФИЗИЧЕСКИМ лотам (дедуп) — сырой objective_lots раздут ~2.91×
# (мульти lot_id на физлот); доля ипотеки — отношение, к дублям устойчива, но
# n_sold/n_mortgage теперь честные (по физлотам).
#
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view (DISTINCT ON ol.*)
# — оптимизационный барьер: WHERE снаружи НЕ проталкивается ниже DISTINCT ON →
# планировщик материализует ВСЮ таблицу (1.76M, Parallel Seq Scan + external merge sort
# 66-81 МБ/воркер) ПЕРЕД фильтром (~5.4-6.4 s district-scoped, прод-EXPLAIN). Inline:
# bitmap по objective_lots_district_class_idx + узкий sort (~3-5 МБ/воркер) ~0.37 s
# (≈15×).
#
# ⚠️ КОРРЕКТНОСТЬ дедупа (слои фильтров РАЗНЫЕ — не путать):
# • premise_kind — ПОЛНОСТЬЮ стабилен (0 mixed-групп на проде) → в WHERE ДО DISTINCT ON.
# • district — почти стабилен (4 transient-группы на 605k физлотов). Протолкнут ДО
# DISTINCT ON ОСОЗНАННО: это index-драйвер objective_lots_district_class_idx; увод
# фильтра ПОСЛЕ дедупа = full seq-scan, убивает перф-фикс. Принятый компромисс
# паттерна parcels.py/#1964 (≤4 возможных дрейфа при district-scoped запросе).
# • class — МЕНЯЮЩИЙСЯ (248 transient-групп на проде): класс физлота дрейфует между
# снапшотами. Фильтр lower(class)=… ДО DISTINCT ON ловил бы СТАРЫЙ снапшот под
# старым классом (прод-замер: бизнес 16637→16657 +20, стандарт 128721→128914 +193
# vs view). View фильтрует class на ПОСЛЕДНЕМ снапшоте → class-фильтр применяем
# ПОСЛЕ дедупа (в sold-CTE). Тогда эквивалентность view бит-в-бит по ВСЕМ классам.
# • sold-фильтр (is_sold / contract_date) — тоже МЕНЯЮЩИЙСЯ: ПОСЛЕ дедупа (на
# ПОСЛЕДНЕМ снапшоте), как делал view. Протолкнуть ДО DISTINCT ON СЛОМАЛО БЫ
# семантику (включил бы когда-либо-проданные + дедуп среди sold-снапшотов).
_ARTIFICIAL_DEMAND_SQL = text(
"""
WITH sold AS (
SELECT
ol.objective_lot_id,
(ol.encumbrance_type IS NOT NULL OR ol.bank_name IS NOT NULL) AS has_mortgage
FROM v_objective_lots_latest ol
WHERE ol.premise_kind = CAST(:premise_kind AS text)
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
WITH latest AS (
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
objective_lot_id,
is_sold,
contract_date,
class,
encumbrance_type,
bank_name
FROM objective_lots
WHERE premise_kind = CAST(:premise_kind AS text)
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
OR district = ANY(CAST(:districts AS text[]))
)
ORDER BY project_name, corpus_name, section, floor,
lot_number, snapshot_date DESC, id DESC
),
sold AS (
SELECT
objective_lot_id,
(encumbrance_type IS NOT NULL OR bank_name IS NOT NULL) AS has_mortgage
FROM latest
WHERE (is_sold IS TRUE OR contract_date IS NOT NULL)
AND (
CAST(:obj_class AS text) IS NULL
OR lower(ol.class) = lower(CAST(:obj_class AS text))
OR lower(class) = lower(CAST(:obj_class AS text))
)
)
SELECT

View file

@ -185,29 +185,46 @@ def _l2_confidence(n_objects_total: int, n_objects_with_free_flats: int) -> Conf
# / наличием contract_date (Объектив заполняет неконсистентно — зеркало _STOCK_SQL).
# Группируем по (district, class). class — lowercase в lots (м.68 коммент).
#
# #1964: читаем из v_objective_lots_latest (physflat-дедуп view), НЕ из сырого
# objective_lots. Сырая таблица раздута ~2.91× (мульти objective_lot_id на один
# физлот через пере-листинги) → units_available по району был завышен ~3-4.6×
# (прод: Юго-Западный комфорт 58 606 → 12 620) → дефицит-форсайт переоценивал
# открытое предложение L1. View отдаёт по строке на физический лот.
# #1964: дедуп до ФИЗИЧЕСКОГО лота. Сырая objective_lots раздута ~2.91× (мульти
# objective_lot_id на один физлот через пере-листинги) → units_available по району
# был завышен ~3-4.6× (прод: Юго-Западный комфорт 58 606 → 12 620) → дефицит-форсайт
# переоценивал открытое предложение L1.
#
# Перф-батч #2 (#1953-followup): дедупим INLINE через DISTINCT ON (physflat-ключ,
# последний снапшот), НЕ через v_objective_lots_latest. Причина: view селектит ol.* и
# его DISTINCT ON — оптимизационный барьер: WHERE снаружи (premise_kind/district) НЕ
# проталкивается ниже DISTINCT ON → планировщик материализует ВСЮ таблицу (1.76M,
# Parallel Seq Scan width≈891 + external merge sort 76-101 МБ/воркер) ПЕРЕД фильтром
# (~6.0 s, прод-EXPLAIN). Inline протолкивает СТАБИЛЬНЫЕ фильтры (premise_kind, district
# — НЕ меняются между снапшотами физлота: премайз-кайнд 0 mixed-групп на проде, район ~4
# transient на 605k) В WHERE ДО DISTINCT ON → bitmap по objective_lots_district_class_idx
# + узкий sort (~5 МБ/воркер) ~0.47 s district-scoped (≈13× быстрее; EKB-wide остаётся
# seq-scan, но width≈99 вместо 891 → ~4.6 s). Эквивалентность view-vs-inline доказана
# бит-в-бит на проде (все district×class пары, 0 расхождений) — зеркало parcels.py.
# sold_now (is_sold/contract_date/status='продан') — МЕНЯЮЩИЕСЯ атрибуты → считаем
# ПОСЛЕ дедупа (на последнем снапшоте), как делал view.
_L1_OPEN_SQL = text(
"""
WITH lots AS (
SELECT
ol.district,
ol.class AS obj_class,
ol.area_pd,
SELECT DISTINCT ON (
project_name, corpus_name, section, floor, lot_number
)
district,
class AS obj_class,
area_pd,
(
ol.is_sold IS TRUE
OR ol.contract_date IS NOT NULL
OR LOWER(COALESCE(ol.status, '')) = 'продан'
is_sold IS TRUE
OR contract_date IS NOT NULL
OR LOWER(COALESCE(status, '')) = 'продан'
) AS sold_now
FROM v_objective_lots_latest ol
WHERE ol.premise_kind = :premise_kind
FROM objective_lots
WHERE premise_kind = CAST(:premise_kind AS text)
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
OR district = ANY(CAST(:districts AS text[]))
)
ORDER BY project_name, corpus_name, section, floor,
lot_number, snapshot_date DESC, id DESC
)
SELECT
district,

View file

@ -229,16 +229,22 @@ class TestLayer1Open:
assert "CAST(:districts AS text[])" in sql
_assert_no_double_colon_cast(sql)
def test_sql_reads_physflat_view(self) -> None:
# #1964: L1 открытое предложение читает physflat-дедуп view, НЕ сырой
# objective_lots (раздут ~2.91× мульти lot_id/физлот → units_available был
# завышен ~3-4.6× → дефицит-форсайт переоценивал L1). Регресс к
# `FROM objective_lots` → fail.
def test_sql_dedups_physflat_inline(self) -> None:
# Перф-батч #2 (#1953-followup): L1 дедупит INLINE через DISTINCT ON по
# physflat-ключу (последний снапшот), НЕ через v_objective_lots_latest
# (view = оптимизационный барьер → full-scan 1.76M, ~6с). Анти-регресс
# #1964 сохранён: чтение СЫРОГО objective_lots БЕЗ дедупа раздуло бы
# units_available ~2.91× (мульти lot_id/физлот) — поэтому требуем DISTINCT ON.
db = _mock_db([])
compute_layer1_open(db, district="Автовокзал")
sql = _executed_sql(db)
assert "v_objective_lots_latest" in sql, "L1 должен читать physflat-дедуп view"
assert "FROM objective_lots " not in sql, "L1 не должен читать сырой objective_lots"
norm = " ".join(sql.split())
assert "DISTINCT ON" in norm, "L1 должен physflat-дедупить (анти-инфляция #1964)"
for col in ("project_name", "corpus_name", "section", "floor", "lot_number"):
assert col in norm, f"physflat-ключ DISTINCT ON должен включать {col}"
assert "snapshot_date DESC" in norm, "дедуп к последнему снапшоту физлота"
assert "FROM objective_lots" in norm, "L1 читает сырой objective_lots (inline-дедуп)"
assert "v_objective_lots_latest" not in sql, "view = full-scan, должен быть inline"
def test_params(self) -> None:
db = _mock_db([])