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