perf(sf): concurrent geoportal-вызовы в ИРД-блоке analyze (#1102)
All checks were successful
CI / changes (push) Successful in 6s
CI / changes (pull_request) Successful in 5s
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m22s

This commit is contained in:
bot-backend 2026-06-07 13:43:17 +03:00
parent 59f2628e0b
commit b24de7d3cc
2 changed files with 91 additions and 4 deletions

View file

@ -21,6 +21,7 @@ parcels.py даже не зовёт этот модуль. Сетевой timeou
from __future__ import annotations
import logging
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Any
from sqlalchemy.orm import Session
@ -113,8 +114,23 @@ def build_ird_analyze_block(
# Слой генплан-функц.зон пуст в геопортале ЕКБ (0 фич, 2026-06); источник = ФГИС ТП (#1058).
# Не дёргаем geoportal впустую — вернём _functional_zone_at когда появится ingest.
block["functional_zone"] = None
block["krt"] = _krt_at(client, lon, lat)
block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat)
# Конкурентный вызов: _krt_at (чистая сеть, без Session) уходит в фоновый поток,
# _zone_regulation_at (использует db: Session) выполняется в основном потоке —
# SQLAlchemy Session не потокобезопасна, шаринг запрещён. Пока основной поток занят
# zone_regulation, krt-вызов перекрывается с ним. Итого ≈ max(krt, zr) вместо sum.
with ThreadPoolExecutor(max_workers=1) as pool:
krt_future: Future[list[dict[str, Any]]] = pool.submit(_krt_at, client, lon, lat)
# zone_regulation исполняется в ОСНОВНОМ потоке (Session не выходит из него).
zone_reg = _zone_regulation_at(db, client, lon, lat)
try:
krt_result = krt_future.result()
except Exception as exc: # неожиданное — _krt_at уже ловит; на всякий случай
logger.warning("ird_analyze: krt future unexpected error: %s", exc)
krt_result = []
block["krt"] = krt_result
block["zone_regulation"] = zone_reg
return block

View file

@ -1,12 +1,13 @@
"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring).
"""Тесты build_ird_analyze_block (#1067 D9b analyze-wiring, #1102 concurrent geoportal).
DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation (module-level)
и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию
каждого источника и skip geoportal при отсутствии centroid.
каждого источника, skip geoportal при отсутствии centroid, и корректность конкурентного исполнения.
"""
from __future__ import annotations
import threading
from typing import Any
from app.services.site_finder import ird_analyze
@ -120,3 +121,73 @@ def test_empty_overlay(monkeypatch: Any) -> None:
assert block["ird_overlaps"] == []
assert block["opportunity_overlaps"] == []
assert block["ird_by_kind"] == {}
# ---------------------------------------------------------------------------
# Concurrency tests (#1102)
# ---------------------------------------------------------------------------
def test_concurrent_both_calls_succeed(monkeypatch: Any) -> None:
"""krt и zone_regulation оба вызываются и попадают в block (конкурентный путь)."""
_patch_overlay(monkeypatch, _OVERLAY)
zone_reg_calls: list[int] = []
def _fake_zone_reg(db: Any, lon: float, lat: float, client: Any = None) -> dict[str, Any]:
zone_reg_calls.append(1)
return {"zone_index": "Ж-3"}
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg)
client = _FakeClient(fz=[], krt=[_Feat({"num_oks_krt": "КРТ-99"})])
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == [{"num_oks_krt": "КРТ-99"}]
assert block["zone_regulation"] == {"zone_index": "Ж-3"}
assert len(zone_reg_calls) == 1
# krt layer was requested
krt_calls = [layer for layer, _, _ in client.calls if layer == "krt"]
assert len(krt_calls) == 1
def test_concurrent_krt_exception_graceful(monkeypatch: Any) -> None:
"""Исключение в krt (через ThreadPoolExecutor) → block['krt']==[] и zone_regulation заполнен."""
_patch_overlay(monkeypatch, _OVERLAY)
monkeypatch.setattr(
ird_analyze,
"get_or_fetch_zone_regulation",
lambda db, lon, lat, client=None: {"zone_index": "Б-1"},
)
# boom=True → features_at_point бросает RuntimeError → _krt_at ловит и возвращает []
client = _FakeClient(fz=[], krt=[], boom=True)
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert block["krt"] == []
assert block["zone_regulation"] == {"zone_index": "Б-1"}
assert len(block["ird_overlaps"]) == 3
def test_session_not_shared_between_threads(monkeypatch: Any) -> None:
"""Session не шарится между потоками: zone_regulation вызывается в основном потоке.
Проверяем через threading.current_thread(): zone_reg видит тот же поток, что и вызывающий
(основной), а не поток из ThreadPoolExecutor (который обрабатывает krt_future).
"""
_patch_overlay(monkeypatch, _OVERLAY)
main_thread = threading.current_thread()
zone_reg_thread: list[threading.Thread] = []
def _fake_zone_reg(db: Any, lon: float, lat: float, client: Any = None) -> dict[str, Any]:
zone_reg_thread.append(threading.current_thread())
return {"zone_index": "Ц-2"}
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _fake_zone_reg)
client = _FakeClient(fz=[], krt=[_Feat({"a": 1})])
ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
assert len(zone_reg_thread) == 1
assert (
zone_reg_thread[0] is main_thread
), "zone_regulation должен исполняться в основном потоке, а не в фоновом (Session safety)"