(high) async-хендлер get_poi_score(decay=routing) делает синхронный блокирующий ORS-вызов на 12с в event loop #1337

Closed
opened 2026-06-14 18:08:38 +00:00 by bot-backend · 1 comment
Collaborator

Описание

Хендлер async def get_poi_score при decay="routing" напрямую вызывает синхронную compute_poi_routing_decay(...), которая через ors_client.matrix_durations_min(...) делает блокирующий httpx.Client().post(...) к OpenRouteService с таймаутом до 12с. В async-хендлере FastAPI это блокирует ВЕСЬ event loop процесса (а не один threadpool-слот) на время HTTP-запроса. При медленном/rate-limited/недоступном ORS (free-tier 429 → таймаут срабатывает через 12с) один запрос замораживает обработку всех конкурентных запросов воркера. Graceful fallback через OrsUnavailableError не спасает — блокировка происходит ВО ВРЕМЯ HTTP-вызова, до возможного исключения.

Корень

Блокирующий сетевой IO выполняется внутри async def без offload в threadpool/executor. Путь полностью достижим в проде: decay=routing — публичный query-параметр (Query(pattern="^(straight|routing)$")), без feature-флага. Dockerfile запускает uvicorn app.main:app без --workers → один event loop на процесс. Нарушение конвенции .claude/rules/backend.md («FastAPI handlers: async def», блокирующий IO в async без executor = баг).

Evidence

parcels.py:3073,3126-3127:

async def get_poi_score(...):
    ...
    if decay == "routing":
        return compute_poi_routing_decay(db, cad_num, lat, lon, radius_m=radius_m)  # синхронный блокирующий вызов

poi_score.py:313-316:

try:
    times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)  # blocking httpx

ors_client.py:105-106 (default _DEFAULT_TIMEOUT_S = 12.0):

with httpx.Client(timeout=timeout_s) as client:
    resp = client.post(url, json=body, headers=headers)

Grep по parcels.py не нашёл ни run_in_threadpool, ни run_in_executor, ни asyncio.to_thread.

Suggested fix

Сделать хендлер синхронным def get_poi_score(...) (Starlette уведёт его в threadpool), ЛИБО обернуть вызов: from starlette.concurrency import run_in_threadpool; return await run_in_threadpool(compute_poi_routing_decay, db, cad_num, lat, lon, radius_m=radius_m). Альтернатива — перевести ors_client на httpx.AsyncClient и await-ить. Минимум — run_in_executor вокруг matrix_durations_min.

Обоснование severity

high: один запрос к деградировавшему ORS стопорит весь воркер на ~12с, блокируя все конкурентные async-запросы. Не critical, т.к. есть таймаут и fallback (нет вечной недоступности и data corruption). Объединены две находки одного корня (api-parcels + sf-poi-noise), confidence 0.97.


week ревью 1 · multi-agent аудит 2026-06-14 · severity=high · category=async-concurrency

Затронутые файлы:

  • /Users/anton/Птица/gendesign/backend/app/api/v1/parcels.py
  • /Users/anton/Птица/gendesign/backend/app/services/site_finder/poi_score.py
  • /Users/anton/Птица/gendesign/backend/app/services/site_finder/ors_client.py
## Описание Хендлер `async def get_poi_score` при `decay="routing"` напрямую вызывает синхронную `compute_poi_routing_decay(...)`, которая через `ors_client.matrix_durations_min(...)` делает блокирующий `httpx.Client().post(...)` к OpenRouteService с таймаутом до 12с. В async-хендлере FastAPI это блокирует ВЕСЬ event loop процесса (а не один threadpool-слот) на время HTTP-запроса. При медленном/rate-limited/недоступном ORS (free-tier 429 → таймаут срабатывает через 12с) один запрос замораживает обработку всех конкурентных запросов воркера. Graceful fallback через `OrsUnavailableError` не спасает — блокировка происходит ВО ВРЕМЯ HTTP-вызова, до возможного исключения. ## Корень Блокирующий сетевой IO выполняется внутри `async def` без offload в threadpool/executor. Путь полностью достижим в проде: `decay=routing` — публичный query-параметр (`Query(pattern="^(straight|routing)$")`), без feature-флага. Dockerfile запускает `uvicorn app.main:app` без `--workers` → один event loop на процесс. Нарушение конвенции `.claude/rules/backend.md` («FastAPI handlers: async def», блокирующий IO в async без executor = баг). ## Evidence `parcels.py:3073,3126-3127`: ```python async def get_poi_score(...): ... if decay == "routing": return compute_poi_routing_decay(db, cad_num, lat, lon, radius_m=radius_m) # синхронный блокирующий вызов ``` `poi_score.py:313-316`: ```python try: times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile) # blocking httpx ``` `ors_client.py:105-106` (default `_DEFAULT_TIMEOUT_S = 12.0`): ```python with httpx.Client(timeout=timeout_s) as client: resp = client.post(url, json=body, headers=headers) ``` Grep по `parcels.py` не нашёл ни `run_in_threadpool`, ни `run_in_executor`, ни `asyncio.to_thread`. ## Suggested fix Сделать хендлер синхронным `def get_poi_score(...)` (Starlette уведёт его в threadpool), ЛИБО обернуть вызов: `from starlette.concurrency import run_in_threadpool; return await run_in_threadpool(compute_poi_routing_decay, db, cad_num, lat, lon, radius_m=radius_m)`. Альтернатива — перевести `ors_client` на `httpx.AsyncClient` и await-ить. Минимум — `run_in_executor` вокруг `matrix_durations_min`. ## Обоснование severity high: один запрос к деградировавшему ORS стопорит весь воркер на ~12с, блокируя все конкурентные async-запросы. Не critical, т.к. есть таймаут и fallback (нет вечной недоступности и data corruption). Объединены две находки одного корня (api-parcels + sf-poi-noise), confidence 0.97. --- _week ревью 1 · multi-agent аудит 2026-06-14 · severity=**high** · category=`async-concurrency`_ Затронутые файлы: - `/Users/anton/Птица/gendesign/backend/app/api/v1/parcels.py` - `/Users/anton/Птица/gendesign/backend/app/services/site_finder/poi_score.py` - `/Users/anton/Птица/gendesign/backend/app/services/site_finder/ors_client.py`
bot-backend added the
week ревью 1
label 2026-06-14 18:08:38 +00:00
bot-backend added the
scope/backend
label 2026-06-15 11:01:48 +00:00
Owner

🔍 week ревью 1 — верификация (ПОДТВЕРЖДЕНО)

Текущее состояние кода: На forgejo/main backend/app/api/v1/parcels.py:3109async def get_poi_score с параметром decay: Annotated[str, Query(pattern="^(straight|routing)$")] = "straight" (3113). Ветка 3162-3163: if decay == "routing": return compute_poi_routing_decay(db, ...) — синхронный вызов без offload. poi_score.py:314 times_min = ors_client.matrix_durations_min(...) (внутри compute_poi_routing_decay, def на 246). ors_client.py:105-106with httpx.Client(timeout=timeout_s) as client: resp = client.post(...), _DEFAULT_TIMEOUT_S = 12.0 (32). В region'е хендлера нет ни run_in_threadpool, ни to_thread.

(NB: в текущей рабочей ветке feat/cian-city-sweep-schedule фича POI-routing ещё отсутствует — параметра decay и compute_poi_routing_decay в локальном parcels.py нет; баг — на forgejo/main, куда метит issue.)

Корень: Блокирующий сетевой IO (httpx.Client.post к ORS, до 12с) выполняется в async def-хендлере без выхода в threadpool → один медленный/rate-limited ORS-запрос замораживает весь event loop воркера. Graceful fallback через OrsUnavailableError (poi_score.py:318) не спасает — блокировка происходит ВО ВРЕМЯ HTTP-вызова.

Что править:

  • Сделать хендлер синхронным def get_poi_score(...) (Starlette уведёт в threadpool, как сосед analyze_parcel), ЛИБО обернуть обе ветки: return await run_in_threadpool(compute_poi_routing_decay, db, cad_num, lat, lon, radius_m=radius_m) (и symmetric для compute_poi_weighted_top7 на 3164).
  • Долгосрочно — перевести ors_client на httpx.AsyncClient и await-ить.

Что посмотреть:

  • Код (на main): backend/app/api/v1/parcels.py:3109-3164, backend/app/services/site_finder/poi_score.py:246-318, backend/app/services/site_finder/ors_client.py:47-106. Тест: смоук на decay=routing не блокирует конкурентные запросы.
  • Vault: релевантных заметок не найдено.
**🔍 week ревью 1 — верификация (ПОДТВЕРЖДЕНО)** **Текущее состояние кода:** На `forgejo/main` `backend/app/api/v1/parcels.py:3109` — `async def get_poi_score` с параметром `decay: Annotated[str, Query(pattern="^(straight|routing)$")] = "straight"` (3113). Ветка 3162-3163: `if decay == "routing": return compute_poi_routing_decay(db, ...)` — синхронный вызов без offload. `poi_score.py:314` `times_min = ors_client.matrix_durations_min(...)` (внутри `compute_poi_routing_decay`, def на 246). `ors_client.py:105-106` — `with httpx.Client(timeout=timeout_s) as client: resp = client.post(...)`, `_DEFAULT_TIMEOUT_S = 12.0` (32). В region'е хендлера нет ни `run_in_threadpool`, ни `to_thread`. (NB: в текущей рабочей ветке `feat/cian-city-sweep-schedule` фича POI-routing ещё отсутствует — параметра `decay` и `compute_poi_routing_decay` в локальном parcels.py нет; баг — на `forgejo/main`, куда метит issue.) **Корень:** Блокирующий сетевой IO (httpx.Client.post к ORS, до 12с) выполняется в `async def`-хендлере без выхода в threadpool → один медленный/rate-limited ORS-запрос замораживает весь event loop воркера. Graceful fallback через `OrsUnavailableError` (poi_score.py:318) не спасает — блокировка происходит ВО ВРЕМЯ HTTP-вызова. **Что править:** - Сделать хендлер синхронным `def get_poi_score(...)` (Starlette уведёт в threadpool, как сосед `analyze_parcel`), ЛИБО обернуть обе ветки: `return await run_in_threadpool(compute_poi_routing_decay, db, cad_num, lat, lon, radius_m=radius_m)` (и symmetric для `compute_poi_weighted_top7` на 3164). - Долгосрочно — перевести `ors_client` на `httpx.AsyncClient` и await-ить. **Что посмотреть:** - Код (на main): `backend/app/api/v1/parcels.py:3109-3164`, `backend/app/services/site_finder/poi_score.py:246-318`, `backend/app/services/site_finder/ors_client.py:47-106`. Тест: смоук на `decay=routing` не блокирует конкурентные запросы. - Vault: релевантных заметок не найдено.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1337
No description provided.