perf(tradein): снять блокировку event loop на POST /estimate (#2207) #2225

Merged
lekss361 merged 1 commit from fix/tradein-estimate-event-loop into main 2026-07-02 20:39:49 +00:00
2 changed files with 251 additions and 122 deletions

View file

@ -2651,7 +2651,9 @@ async def estimate_quality(
if geo is None:
# Без координат не можем искать через PostGIS. Возвращаем low confidence.
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
return _empty_estimate(payload, db, reason="address_not_geocoded", created_by=created_by)
return await asyncio.to_thread(
_empty_estimate, payload, db, reason="address_not_geocoded", created_by=created_by
)
# 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса.
# Best-effort: graceful None при отсутствии credentials / quota / fail.
@ -2674,7 +2676,8 @@ async def estimate_quality(
dadata_fias = dadata.house_fias_id if dadata else None
match_fias = payload.target_fias_id or dadata_fias
try:
match = match_house_readonly(
match = await asyncio.to_thread(
match_house_readonly,
db,
address=(dadata.canonical_address if dadata else None) or geo.full_address,
lat=geo.lat,
@ -2693,7 +2696,8 @@ async def estimate_quality(
# Guard `house_fias_id IS NULL` — никогда не перетираем curated id.
# SAVEPOINT изолирует сбой UPDATE от внешней estimate-транзакции.
if dadata_fias and dadata is not None:
try:
def _backfill_house_fias() -> None:
with db.begin_nested():
db.execute(
text(
@ -2711,6 +2715,9 @@ async def estimate_quality(
"hid": target_house_id,
},
)
try:
await asyncio.to_thread(_backfill_house_fias)
except Exception as exc: # pragma: no cover — defensive
logger.warning("estimate fias back-fill failed (graceful): %s", exc)
except Exception as exc: # pragma: no cover — defensive
@ -2751,7 +2758,8 @@ async def estimate_quality(
if cohort_range is not None:
cy_min, cy_max = cohort_range
listings_tier0, _, analog_tier = _fetch_analogs(
listings_tier0, _, analog_tier = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
@ -2775,7 +2783,8 @@ async def estimate_quality(
fallback_used = False
else:
# Tier 0 пуст/мал — graceful fallback на Tier A без cohort
listings, fallback_used, analog_tier = _fetch_analogs(
listings, fallback_used, analog_tier = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
@ -2791,7 +2800,8 @@ async def estimate_quality(
area_widened = False
if len(listings) < 5:
listings_wide, _, analog_tier_wide = _fetch_analogs(
listings_wide, _, analog_tier_wide = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
@ -2812,7 +2822,8 @@ async def estimate_quality(
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
if len(listings) < 3:
listings_widearea, _, analog_tier_wa = _fetch_analogs(
listings_widearea, _, analog_tier_wa = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
@ -2835,7 +2846,8 @@ async def estimate_quality(
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
# corridor_high был доступен для Tier C-гейта и soft-клампа headline.
dkp_raw = _fetch_dkp_corridor(
dkp_raw = await asyncio.to_thread(
_fetch_dkp_corridor,
db,
address=geo.full_address,
rooms=payload.rooms,
@ -2878,7 +2890,7 @@ async def estimate_quality(
label="yandex_valuation",
)
if yandex_val is not None:
saved_hist = _save_yandex_history_items(db, yandex_val)
saved_hist = await asyncio.to_thread(_save_yandex_history_items, db, yandex_val)
logger.info(
"yandex_valuation: history items processed=%d saved=%d"
" (house_id=NULL — matching deferred)",
@ -2931,7 +2943,8 @@ async def estimate_quality(
and payload.area_m2
and (payload.address or (geo is not None and geo.full_address))
):
_anchor_comps_pre, _anchor_tier_pre = _fetch_anchor_comps(
_anchor_comps_pre, _anchor_tier_pre = await asyncio.to_thread(
_fetch_anchor_comps,
db,
address=payload.address or geo.full_address,
target_house_id=target_house_id,
@ -2944,7 +2957,8 @@ async def estimate_quality(
_anchor_comps_pre, _anchor_tier_pre = [], None
# ── Pre-fetch: house IMV anchor ONCE (used for both blend and display) ───
imv_anchor_data = _fetch_house_imv_anchor(
imv_anchor_data = await asyncio.to_thread(
_fetch_house_imv_anchor,
db,
target_house_id=target_house_id,
rooms=payload.rooms,
@ -2975,7 +2989,8 @@ async def estimate_quality(
)
# ── Deterministic pricing orchestration ──────────────────────────────────
pr = _price_from_inputs(
pr = await asyncio.to_thread(
_price_from_inputs,
listings=listings,
area_m2=payload.area_m2,
rooms=payload.rooms,
@ -3026,7 +3041,8 @@ async def estimate_quality(
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
# исключены — больше не скёюят median вторички ~110-120 К/м².
deals = _fetch_deals(
deals = await asyncio.to_thread(
_fetch_deals,
db,
lat=geo.lat,
lon=geo.lon,
@ -3077,6 +3093,9 @@ async def estimate_quality(
if dadata is not None and dadata.metro
else None
)
# #2207: INSERT+commit сгруппированы и вынесены с event loop (to_thread).
def _persist_estimate_and_commit() -> None:
db.execute(
text(
"""
@ -3191,6 +3210,8 @@ async def estimate_quality(
db.commit()
await asyncio.to_thread(_persist_estimate_and_commit)
logger.info(
"estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s%s",
estimate_id,
@ -3211,7 +3232,8 @@ async def estimate_quality(
last_scraped_at = _compute_last_scraped_at(metadata_lots)
# Месячный ₽/м² тренд целевого дома (web TREND chart) — best-effort, None если нет данных.
# #audit-3: передаём freshness_months из настроек — исключаем устаревшие items.
price_trend_raw = _fetch_price_trend(
price_trend_raw = await asyncio.to_thread(
_fetch_price_trend,
db,
target_house_id=target_house_id,
freshness_months=settings.estimate_price_trend_max_age_months,
@ -3228,9 +3250,11 @@ async def estimate_quality(
# median/expected_sold/ranges. Best-effort: degrade в (False, None, None) при
# отсутствии таблиц. premium_building_class — класс из curated (None для
# невыверенных MV-домов).
premium_building, premium_building_median_ppm2, premium_building_class = _is_premium_building(
db, target_house_id
)
(
premium_building,
premium_building_median_ppm2,
premium_building_class,
) = await asyncio.to_thread(_is_premium_building, db, target_house_id)
# #audit-2: структурный analog_tier — стабильный enum для фронта.
# anchor-путь: anchor_tier "A" → "same_building", "C" → "micro_radius".

View file

@ -0,0 +1,105 @@
"""#2207 — estimate_quality не должен блокировать event loop.
`estimate_quality` выпускает ~28 СИНХРОННЫХ psycopg PostGIS-запросов (multi-tier
analogs ×4, deals, price_trend, anchor comps, INSERT+commit и т.д.). На проде это
один uvicorn worker одна медленная оценка раньше стопила ВЕСЬ event loop,
включая /health. Фикс #2207: каждый sync-кластер вынесен через
``asyncio.to_thread(...)``.
Тест патчит ОДИН тяжёлый sync-хелпер (`_fetch_analogs`) на блокирующий
``time.sleep(0.3)`` и запускает `estimate_quality` конкурентно с лёгкой
корутиной (``asyncio.sleep(0.05)``). Если бы loop блокировался, лёгкая корутина
не смогла бы возобновиться до конца sync-сна; мы ассертим, что она завершается
у ~0.05с задолго до оценки, что доказывает свободный loop.
Стиль зеркалит tests/test_estimator_client_coords.py.
"""
import asyncio
import contextlib
import os
import time
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
from unittest.mock import AsyncMock, MagicMock, patch
def _make_payload():
from app.schemas.trade_in import TradeInEstimateInput
return TradeInEstimateInput(
address="ЕКБ, ул. Учителей, 18",
area_m2=38.8,
rooms=1,
floor=4,
total_floors=16,
# in-EKB coords → geocode() пропускается (Variant A), сеть не трогаем.
lat=56.838,
lon=60.595,
)
# Тяжёлый sync-хелпер: имитирует медленный PostGIS-запрос блокирующим сном.
def _slow_fetch_analogs(*_args, **_kwargs):
time.sleep(0.3)
return ([], False, "W")
def _downstream_patches():
"""Оффлайн-моки так, чтобы estimate_quality дошёл до конца детерминированно."""
return (
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
patch("app.services.estimator.match_house_readonly", return_value=None),
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
patch("app.services.estimator._fetch_analogs", side_effect=_slow_fetch_analogs),
patch("app.services.estimator._fetch_deals", return_value=[]),
patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)),
patch(
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
new=AsyncMock(return_value=None),
),
patch(
"app.services.estimator.estimate_via_cian_valuation",
new=AsyncMock(return_value=None),
),
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
)
def test_estimate_quality_does_not_block_event_loop() -> None:
"""Медленный sync-путь оценки не должен starve'ить event loop."""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload()
async def _run() -> dict[str, float]:
with contextlib.ExitStack() as stack:
for cm in _downstream_patches():
stack.enter_context(cm)
timings: dict[str, float] = {}
start = time.perf_counter()
async def light() -> None:
await asyncio.sleep(0.05)
timings["light"] = time.perf_counter() - start
async def heavy() -> None:
await estimate_quality(payload, db)
timings["estimate"] = time.perf_counter() - start
await asyncio.gather(light(), heavy())
return timings
timings = asyncio.run(_run())
# Лёгкая корутина завершилась ДО оценки в целом.
assert timings["light"] < timings["estimate"], timings
# И — ключевое — возобновилась у ~0.05с, а НЕ после 0.3с sync-сна
# (было бы >=0.3, если бы loop блокировался). Потолок с запасом на CI-jitter.
assert timings["light"] < 0.25, timings
# Оценка реально ждала sync-работу в потоке(ах): >=0.3с (>=1 sleep).
assert timings["estimate"] >= 0.3, timings