perf(sf): concurrent geoportal-вызовы в ИРД-блоке analyze (#1102)
This commit is contained in:
parent
59f2628e0b
commit
b24de7d3cc
2 changed files with 91 additions and 4 deletions
|
|
@ -21,6 +21,7 @@ parcels.py даже не зовёт этот модуль. Сетевой timeou
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from concurrent.futures import Future, ThreadPoolExecutor
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -113,8 +114,23 @@ def build_ird_analyze_block(
|
||||||
# Слой генплан-функц.зон пуст в геопортале ЕКБ (0 фич, 2026-06); источник = ФГИС ТП (#1058).
|
# Слой генплан-функц.зон пуст в геопортале ЕКБ (0 фич, 2026-06); источник = ФГИС ТП (#1058).
|
||||||
# Не дёргаем geoportal впустую — вернём _functional_zone_at когда появится ingest.
|
# Не дёргаем geoportal впустую — вернём _functional_zone_at когда появится ingest.
|
||||||
block["functional_zone"] = None
|
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
|
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)
|
DB/сеть не дёргаются: мокаем parcel_ird_overlaps + get_or_fetch_zone_regulation (module-level)
|
||||||
и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию
|
и передаём fake EKBGeoportalClient. Проверяем сборку блока, split opportunity_*, graceful-деградацию
|
||||||
каждого источника и skip geoportal при отсутствии centroid.
|
каждого источника, skip geoportal при отсутствии centroid, и корректность конкурентного исполнения.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.services.site_finder import ird_analyze
|
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["ird_overlaps"] == []
|
||||||
assert block["opportunity_overlaps"] == []
|
assert block["opportunity_overlaps"] == []
|
||||||
assert block["ird_by_kind"] == {}
|
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