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
105 lines
4.6 KiB
Python
105 lines
4.6 KiB
Python
"""#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
|