perf(sf): concurrent geoportal-вызовы в ИРД-блоке analyze (#1102) #1123
2 changed files with 91 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue