From b24de7d3cc9078a8dea40cbc18cb2f5b8b3aa8b3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 7 Jun 2026 13:43:17 +0300 Subject: [PATCH] =?UTF-8?q?perf(sf):=20concurrent=20geoportal-=D0=B2=D1=8B?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D1=8B=20=D0=B2=20=D0=98=D0=A0=D0=94-=D0=B1?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=D0=B5=20analyze=20(#1102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/services/site_finder/ird_analyze.py | 20 ++++- backend/tests/services/test_ird_analyze.py | 75 ++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/backend/app/services/site_finder/ird_analyze.py b/backend/app/services/site_finder/ird_analyze.py index 1acecc71..41a82922 100644 --- a/backend/app/services/site_finder/ird_analyze.py +++ b/backend/app/services/site_finder/ird_analyze.py @@ -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 diff --git a/backend/tests/services/test_ird_analyze.py b/backend/tests/services/test_ird_analyze.py index 86c709fb..197aece7 100644 --- a/backend/tests/services/test_ird_analyze.py +++ b/backend/tests/services/test_ird_analyze.py @@ -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)" -- 2.45.3