perf(tradein): снять блокировку event loop на POST /estimate (#2207)
All checks were successful
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

async estimate_quality выпускал ~28 синхронных psycopg PostGIS-запросов
прямо на event loop; прод — один uvicorn worker → одна медленная оценка
(холодный кэш, wide-radius fallback) стопила ВСЕ конкурентные запросы,
включая /health (флап healthcheck).

- 15 sync DB-кластеров обёрнуты в await asyncio.to_thread (паттерн
  geocoder.py), строго последовательно — Session не используется конкурентно
- SAVEPOINT-блок (_backfill_house_fias) целиком внутри одного потока,
  транзакционные точки не сдвинуты; INSERT+IMV-UPDATE+commit сгруппированы
  атомарно в один to_thread
- sync-сигнатуры внутренностей (_price_from_inputs, _fetch_analogs, ...)
  не тронуты — backtest-harness и frozen regression gate зелёные
- тест неблокирования: sleep(0.3) в _fetch_analogs, конкурентная корутина
  завершается за <0.25s

Refs #2207
This commit is contained in:
bot-backend 2026-07-02 23:27:47 +03:00
parent f5b0076e6f
commit b71cba916d
2 changed files with 251 additions and 122 deletions

View file

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