gendesign/tradein-mvp/backend/tests/test_estimator_event_loop_2207.py
bot-backend b71cba916d
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
perf(tradein): снять блокировку event loop на POST /estimate (#2207)
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
2026-07-02 23:27:47 +03:00

105 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#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