gendesign/tradein-mvp/backend/tests/test_estimator_event_loop_2207.py
lekss361 4ce0f51bb8
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 1m27s
Deploy Trade-In / deploy (push) Successful in 1m5s
perf(tradein): снять блокировку event loop на POST /estimate (#2207) (#2225)
2026-07-02 20:39:48 +00: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