fix(site-finder): optimize zone-regulation resolve in /analyze (#1850) #1901
6 changed files with 398 additions and 12 deletions
|
|
@ -438,10 +438,10 @@ _GEOM_LABEL_MICRO_HA = 0.05 # ниже → label "микро" (комбинир
|
|||
_GEOM_LABEL_GOOD = 0.7
|
||||
_GEOM_LABEL_MEDIUM = 0.4
|
||||
|
||||
# ПЗЗ-градрегламент в analyze (#1067): bounded timeout (сек) для live-geoportal вызова
|
||||
# на cache-miss. Короткий, чтобы медленный геопортал не вешал hot-path analyze —
|
||||
# cache-hit (норма для ЕКБ) сетевых вызовов не делает вообще.
|
||||
_ZONE_REGULATION_TIMEOUT_S = 3
|
||||
# ПЗЗ-градрегламент в analyze (#1067/#1850): bounded timeout для live-geoportal вызова на
|
||||
# cache-miss теперь единый — settings.geoportal_timeout_s (раньше локальная константа 3s
|
||||
# расходилась с ird_analyze 4s для того же резолвера). cache-hit (норма для ЕКБ) +
|
||||
# мемоизация coord→zone_index сетевых вызовов не делают вообще.
|
||||
|
||||
|
||||
def _polygon_suitability(geom_wkt: str) -> dict[str, Any]:
|
||||
|
|
@ -2078,7 +2078,7 @@ def analyze_parcel(
|
|||
# Резолвим числовые предельные параметры (КСИТ/max_far, высота, этажность,
|
||||
# %застройки, min площадь ЗУ) по centroid через get_or_fetch_zone_regulation:
|
||||
# cache-first (кэш прогрет для ЕКБ), live-geoportal только на miss и закапан
|
||||
# коротким timeout'ом (_ZONE_REGULATION_TIMEOUT_S), чтобы медленный геопортал не
|
||||
# единым timeout'ом (settings.geoportal_timeout_s), чтобы медленный геопортал не
|
||||
# вешал analyze. Hot-path-safe: любой сбой/таймаут → поля остаются None, analyze
|
||||
# не падает.
|
||||
#
|
||||
|
|
@ -2094,6 +2094,12 @@ def analyze_parcel(
|
|||
# raw_props.subcategory, по которым gate детектит жильё), только ДОБАВЛЯЕМ
|
||||
# regulation-поля. Расширение dump-резидентности — OUT OF SCOPE (PR-B).
|
||||
_nspd_zoning = nspd_dump_data.get("nspd_zoning")
|
||||
# Регламент, резолвленный ЗДЕСЬ (zoning-merge), переиспользуется ИРД-блоком (#1850 Fix 2):
|
||||
# _zone_regulation_resolved=True означает «этот блок отработал и _regulation валиден
|
||||
# (dict|None)» → build_ird_analyze_block получит готовое значение, без повторного резолва.
|
||||
# Когда блок НЕ отработал (флаг off / не-ЕКБ / нет centroid) — ИРД-блок резолвит сам.
|
||||
_regulation: dict[str, Any] | None = None
|
||||
_zone_regulation_resolved = False
|
||||
# Region-guard: EKB-геопортал покрывает ТОЛЬКО Екатеринбург (КН 66:41). Для
|
||||
# участков вне ЕКБ zone_index_at всё равно вернул бы None — экономим WFS-round-trip
|
||||
# (резолвер теперь запускается на каждом analyze, а не только при dump-зоне).
|
||||
|
|
@ -2103,8 +2109,9 @@ def analyze_parcel(
|
|||
and centroid_lat is not None
|
||||
and centroid_lon is not None
|
||||
):
|
||||
_zone_regulation_resolved = True
|
||||
try:
|
||||
_client = EKBGeoportalClient(timeout=_ZONE_REGULATION_TIMEOUT_S)
|
||||
_client = EKBGeoportalClient(timeout=settings.geoportal_timeout_s)
|
||||
_regulation = get_or_fetch_zone_regulation(
|
||||
db, centroid_lon, centroid_lat, client=_client
|
||||
)
|
||||
|
|
@ -3282,8 +3289,15 @@ def analyze_parcel(
|
|||
# сбой не меняет успех/форму остального ответа. Additive: extra="allow" в AnalyzeResponse.
|
||||
if settings.enable_ird_analyze:
|
||||
try:
|
||||
# #1850 Fix 2: если zoning-merge блок выше уже резолвил регламент по тому же
|
||||
# centroid — передаём его готовым (_regulation, dict|None), ИРД-блок не делает
|
||||
# повторный резолв/DB-read. Если блок НЕ отработал (флаг off / не-ЕКБ / нет
|
||||
# centroid) — НЕ передаём kwarg, ИРД-блок резолвит сам (Fix 1 снимает network-цену).
|
||||
_ird_kwargs: dict[str, Any] = {}
|
||||
if _zone_regulation_resolved:
|
||||
_ird_kwargs["resolved_zone_regulation"] = _regulation
|
||||
result_payload["ird"] = build_ird_analyze_block(
|
||||
db, geom_wkt, centroid_lon, centroid_lat, cad_num
|
||||
db, geom_wkt, centroid_lon, centroid_lat, cad_num, **_ird_kwargs
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
|
|
|
|||
|
|
@ -217,6 +217,14 @@ class Settings(BaseSettings):
|
|||
# полностью пропускает резолв — поведение analyze без изменений.
|
||||
enable_zoning_regulation_in_analyze: bool = True
|
||||
|
||||
# Единый bounded-timeout (сек) для live geoportal-вызовов резолвера ПЗЗ-регламента в
|
||||
# hot-пути analyze (#1850). Раньше было два расходящихся значения: parcels.py=3s,
|
||||
# ird_analyze.py=4s для ОДНОГО и того же резолвера. Унифицировано в 4s (более безопасный
|
||||
# запас). Оба колл-сайта работают cache-first (+ мемоизация coord→zone_index), поэтому
|
||||
# живые вызовы редки. Дефолт клиента EKBGeoportalClient (20s) слишком долог для sync-хэндлера.
|
||||
# Override через env GEOPORTAL_TIMEOUT_S.
|
||||
geoportal_timeout_s: int = 4
|
||||
|
||||
# Area-gate для blocker'а «инженерная/утилитарная охранная зона» в gate_verdict.
|
||||
# ЗОУИТ охранной зоны инж.сети (ЛЭП/газ/трубопровод/тепло/электро) в РФ ограничивает
|
||||
# застройку ВНУТРИ полосы (отступы, запрет капстроя над линией), а НЕ стерилизует весь
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from typing import Any
|
|||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.scrapers.ekb_geoportal_client import EKBGeoportalClient
|
||||
from app.services.site_finder.functional_zone_lookup import parcel_functional_zones
|
||||
from app.services.site_finder.granddoc_lookup import parcel_granddoc
|
||||
|
|
@ -44,8 +45,10 @@ from app.services.site_finder.zone_regulation import get_or_fetch_zone_regulatio
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Короткий timeout для geoportal-вызовов в hot-пути analyze (default клиента — 20s, слишком долго).
|
||||
_GEOPORTAL_TIMEOUT_S = 4
|
||||
# Sentinel: «регламент НЕ передан извне» — отличает «caller не резолвил» от «резолв дал None»
|
||||
# (None — валидный результат: в точке нет терзоны). См. resolved_zone_regulation в
|
||||
# build_ird_analyze_block (#1850 Fix 2).
|
||||
_UNSET: Any = object()
|
||||
|
||||
# SQL: читаем КРТ-геометрии из БД (м.140 ekb_krt_geometry) по точке centroid участка.
|
||||
# ST_Intersects с GIST → дёшево (~1ms), без сетевой задержки.
|
||||
|
|
@ -112,6 +115,7 @@ def build_ird_analyze_block(
|
|||
cad_num: str | None = None,
|
||||
*,
|
||||
client: EKBGeoportalClient | None = None,
|
||||
resolved_zone_regulation: Any = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
"""Собрать ИРД-блок участка для analyze (#1067 D9b).
|
||||
|
||||
|
|
@ -123,6 +127,13 @@ def build_ird_analyze_block(
|
|||
None → reservations=[].
|
||||
client: переиспользуемый EKBGeoportalClient (для тестов/одного TLS-пула). По умолчанию
|
||||
создаётся с укороченным timeout.
|
||||
resolved_zone_regulation: УЖЕ резолвленный ПЗЗ-регламент (dict|None) из parcels.py
|
||||
zoning-merge блока (#1850 Fix 2). Если передан (включая None — «зоны нет») —
|
||||
``_zone_regulation_at`` НЕ вызывается, используется переданное значение (исключает
|
||||
повторный DB-read кэша на тот же centroid). Если НЕ передан (``_UNSET``, дефолт) —
|
||||
блок резолвит сам (путь «enable_zoning_regulation_in_analyze off, enable_ird_analyze
|
||||
on»). Network-стоимость второго резолва уже снята мемоизацией coord→zone_index
|
||||
(Fix 1); этот параметр убирает оставшийся лишний DB-read.
|
||||
|
||||
Returns:
|
||||
dict: ``ird_overlaps``/``ird_by_kind`` (DB-overlay, incl opportunity_*),
|
||||
|
|
@ -190,6 +201,12 @@ def build_ird_analyze_block(
|
|||
block["functional_zones"] = fz
|
||||
block["functional_zone"] = fz[0] if fz else None
|
||||
|
||||
# #1850 Fix 2: если регламент УЖЕ резолвлен в parcels.py (zoning-merge блок), берём его
|
||||
# без повторного резолва — исключает лишний DB-read кэша на тот же centroid. None — тоже
|
||||
# валидное переданное значение («зоны нет»), поэтому отличаем «передан» через _UNSET.
|
||||
if resolved_zone_regulation is not _UNSET:
|
||||
block["zone_regulation"] = resolved_zone_regulation
|
||||
|
||||
# guard активен только в unit-тестах; в prod centroid_lon/lat всегда float
|
||||
# (EKB-fallback в parcels.py).
|
||||
if lon is not None and lat is not None:
|
||||
|
|
@ -198,9 +215,11 @@ def build_ird_analyze_block(
|
|||
block["krt"] = _krt_at(db, lon, lat)
|
||||
|
||||
# zone_regulation остаётся единственным живым geoportal-вызовом в hot-пути.
|
||||
# Резолвим САМИ только если caller не передал готовый регламент (Fix 2).
|
||||
# Выполняем в основном потоке (Session не потокобезопасна).
|
||||
client = client or EKBGeoportalClient(timeout=_GEOPORTAL_TIMEOUT_S)
|
||||
block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat)
|
||||
if resolved_zone_regulation is _UNSET:
|
||||
client = client or EKBGeoportalClient(timeout=settings.geoportal_timeout_s)
|
||||
block["zone_regulation"] = _zone_regulation_at(db, client, lon, lat)
|
||||
|
||||
return block
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import shape
|
||||
|
|
@ -44,6 +46,61 @@ from app.services.scrapers.ekb_geoportal_client import (
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Мемоизация coord → zone_index (WFS-lookup) (#1850) ───────────────────────
|
||||
# Маппинг «координата → индекс терзоны» СТАТИЧЕН: границы зон не двигаются. Поэтому
|
||||
# единственный живой WFS-вызов резолвера (``client.zone_index_at``) кэшируется
|
||||
# процесс-локально. Важно: кэшируется ТОЛЬКО индекс зоны — СОДЕРЖИМОЕ регламента всё
|
||||
# равно читается свежим из ``zone_regulation_cache`` на каждый вызов, поэтому корректность
|
||||
# обновления кэша регламента сохраняется (refresh-воркер обновляет БД, не этот memo).
|
||||
#
|
||||
# Ключ — координата, округлённая до 5 знаков (≈1 м): безопасно мельче границ зон,
|
||||
# но достаточно грубо чтобы соседние /analyze одного участка (один centroid) попадали
|
||||
# в один ключ. Округлять КРУПНЕЕ нельзя — рискуем перепрыгнуть границу зоны.
|
||||
#
|
||||
# Bounded LRU (OrderedDict, FIFO-эвикция самого старого) — клиент не хешируемый, поэтому
|
||||
# ``functools.lru_cache`` неприменим; держим собственный OrderedDict-LRU.
|
||||
# THREAD-SAFETY (#1850): /analyze — sync def → исполняется в Starlette threadpool, т.е.
|
||||
# несколько потоков мутируют этот module-global OrderedDict ОДНОВРЕМЕННО. Свой LRU (в
|
||||
# отличие от functools.lru_cache с внутренним локом) НЕ атомарен: check-then-act между
|
||||
# ``key in memo`` и ``move_to_end``/``[]`` против чужого ``popitem`` даёт KeyError. Поэтому
|
||||
# мутации под Lock'ом; WFS-вызов — ВНЕ лока (иначе медленный геопортал сериализует все потоки).
|
||||
_ZONE_INDEX_MEMO_MAXSIZE = 4096
|
||||
_zone_index_memo: OrderedDict[tuple[float, float], str | None] = OrderedDict()
|
||||
_zone_index_memo_lock = threading.Lock()
|
||||
|
||||
# Sentinel «координата вне терзоны» — отличает закэшированный негативный результат
|
||||
# (None-зона, не пере-дёргаем WFS для участков вне ЕКБ) от «ключа нет в memo».
|
||||
_NO_ZONE = "\x00__no_zone__\x00"
|
||||
|
||||
|
||||
def _memoized_zone_index(client: EKBGeoportalClient, lon: float, lat: float) -> str | None:
|
||||
"""Индекс терзоны в точке с процесс-локальной мемоизацией (#1850).
|
||||
|
||||
Кэширует И положительный (индекс зоны), И отрицательный (None → участок вне терзоны/ЕКБ)
|
||||
результат, чтобы повтор по тем же/близким координатам не делал WFS round-trip.
|
||||
"""
|
||||
key = (round(lon, 5), round(lat, 5))
|
||||
with _zone_index_memo_lock:
|
||||
if key in _zone_index_memo:
|
||||
_zone_index_memo.move_to_end(key) # LRU: освежаем недавно использованный ключ
|
||||
cached = _zone_index_memo[key]
|
||||
return None if cached == _NO_ZONE else cached
|
||||
# WFS-вызов ВНЕ лока: сетевой round-trip не должен сериализовать тёплые потоки.
|
||||
# Дубль-вызов на одном холодном ключе из двух потоков безвреден (lookup идемпотентен).
|
||||
zone_index = client.zone_index_at(lon, lat)
|
||||
with _zone_index_memo_lock:
|
||||
_zone_index_memo[key] = _NO_ZONE if zone_index is None else zone_index
|
||||
_zone_index_memo.move_to_end(key)
|
||||
if len(_zone_index_memo) > _ZONE_INDEX_MEMO_MAXSIZE:
|
||||
_zone_index_memo.popitem(last=False) # эвикция самого старого
|
||||
return zone_index
|
||||
|
||||
|
||||
def _reset_zone_index_memo() -> None:
|
||||
"""Очистить memo coord→zone_index (для тестов; prod — процесс-локальный кэш)."""
|
||||
_zone_index_memo.clear()
|
||||
|
||||
|
||||
# bbox агломерации ЕКБ (minlon, minlat, maxlon, maxlat) EPSG:4326 — границы для WFS-перечисления
|
||||
# терзон. Слой territorial_zone геопортала — ЕКБ-only, поэтому bbox это просто bounds-обёртка
|
||||
# (а не фильтр города). Шире чем нужно — лучше захватить лишнее, дедуп по zone_index снимет повтор.
|
||||
|
|
@ -263,7 +320,9 @@ def get_or_fetch_zone_regulation(
|
|||
"""
|
||||
client = client or EKBGeoportalClient()
|
||||
try:
|
||||
zone_index = client.zone_index_at(lon, lat)
|
||||
# Мемоизированный coord→zone_index: повтор (тот же centroid в рамках запроса или
|
||||
# между запросами) не делает WFS round-trip (#1850).
|
||||
zone_index = _memoized_zone_index(client, lon, lat)
|
||||
except Exception as exc:
|
||||
logger.warning("get_or_fetch_zone_regulation: zone_index_at failed: %s", exc)
|
||||
return None
|
||||
|
|
@ -279,6 +338,15 @@ def get_or_fetch_zone_regulation(
|
|||
if reg is None or not reg.zone_index:
|
||||
return None
|
||||
upsert_zone_regulation(db, reg, city=city)
|
||||
# SMELL (#1850, item 4): db.commit() здесь коммитит ВНЕШНЮЮ /analyze-транзакцию
|
||||
# mid-request (она в норме идёт на SAVEPOINT'ах). upsert_zone_regulation пишет внутри
|
||||
# begin_nested() (SAVEPOINT), но релиз SAVEPOINT не персистит durable — нужен commit
|
||||
# внешней tx. Изолировать апсёрт в собственную короткую транзакцию здесь нельзя без
|
||||
# смены семантики резолвера (он получает SHARED Session от хэндлера, не владеет её
|
||||
# границами); отдельная сессия/коннект рискует потерять только что записанный регламент.
|
||||
# ОСТАВЛЕНО как есть: cache-miss редок (Fix 1 мемоизирует zone_index, кэш ЕКБ прогрет ~33
|
||||
# зоны), поэтому фактический коммит почти не случается в hot-пути. Корректный фикс —
|
||||
# отдельная write-сессия для апсёрта — отдельной задачей, чтобы не рисковать резолвером.
|
||||
db.commit()
|
||||
return get_cached_zone_regulation(db, reg.zone_index, city=city)
|
||||
|
||||
|
|
@ -412,6 +480,7 @@ def backfill_ekb_zone_regulations(
|
|||
|
||||
|
||||
__all__ = [
|
||||
"_reset_zone_index_memo",
|
||||
"backfill_ekb_zone_regulations",
|
||||
"get_cached_zone_regulation",
|
||||
"get_or_fetch_zone_regulation",
|
||||
|
|
|
|||
|
|
@ -562,3 +562,109 @@ def test_ppt_tep_empty_when_no_overlap(monkeypatch: Any) -> None:
|
|||
|
||||
assert "ppt_tep" in block
|
||||
assert block["ppt_tep"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 2 (#1850): резолв регламента ОДИН раз — переиспользование в ИРД-блоке
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolved_zone_regulation_skips_resolver(monkeypatch: Any) -> None:
|
||||
"""resolved_zone_regulation=<dict> → _zone_regulation_at НЕ зовётся, значение взято as-is."""
|
||||
_patch_overlay(monkeypatch, _OVERLAY)
|
||||
resolver_calls: list[int] = []
|
||||
|
||||
def _spy(*a: Any, **kw: Any) -> Any:
|
||||
resolver_calls.append(1)
|
||||
return {"zone_index": "DIFFERENT"}
|
||||
|
||||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _spy)
|
||||
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
|
||||
client = _FakeClient(fz=[])
|
||||
|
||||
block = ird_analyze.build_ird_analyze_block(
|
||||
object(),
|
||||
"POINT(60 56)",
|
||||
60.6,
|
||||
56.8,
|
||||
client=client,
|
||||
resolved_zone_regulation={"zone_index": "Ц-1", "max_far": 4},
|
||||
)
|
||||
|
||||
# Переданное значение использовано напрямую; резолвер ни разу не вызван.
|
||||
assert block["zone_regulation"] == {"zone_index": "Ц-1", "max_far": 4}
|
||||
assert resolver_calls == []
|
||||
|
||||
|
||||
def test_resolved_zone_regulation_none_skips_resolver(monkeypatch: Any) -> None:
|
||||
"""resolved_zone_regulation=None (валидное «зоны нет») → резолвер НЕ зовётся, итог None."""
|
||||
_patch_overlay(monkeypatch, _OVERLAY)
|
||||
resolver_calls: list[int] = []
|
||||
|
||||
def _spy(*a: Any, **kw: Any) -> Any:
|
||||
resolver_calls.append(1)
|
||||
return {"zone_index": "SHOULD_NOT_APPEAR"}
|
||||
|
||||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _spy)
|
||||
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
|
||||
client = _FakeClient(fz=[])
|
||||
|
||||
block = ird_analyze.build_ird_analyze_block(
|
||||
object(),
|
||||
"POINT(60 56)",
|
||||
60.6,
|
||||
56.8,
|
||||
client=client,
|
||||
resolved_zone_regulation=None,
|
||||
)
|
||||
|
||||
assert block["zone_regulation"] is None
|
||||
assert resolver_calls == [] # None — переданное значение, не повод резолвить
|
||||
|
||||
|
||||
def test_unset_resolved_regulation_resolves_itself(monkeypatch: Any) -> None:
|
||||
"""Параметр НЕ передан (дефолт _UNSET) → блок резолвит сам (path: zoning-flag off, ird on)."""
|
||||
_patch_overlay(monkeypatch, _OVERLAY)
|
||||
resolver_calls: list[int] = []
|
||||
|
||||
def _spy(db: Any, lon: float, lat: float, client: Any = None) -> dict[str, Any]:
|
||||
resolver_calls.append(1)
|
||||
return {"zone_index": "Ж-2"}
|
||||
|
||||
monkeypatch.setattr(ird_analyze, "get_or_fetch_zone_regulation", _spy)
|
||||
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
|
||||
client = _FakeClient(fz=[])
|
||||
|
||||
block = ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8, client=client)
|
||||
|
||||
assert block["zone_regulation"] == {"zone_index": "Ж-2"}
|
||||
assert resolver_calls == [1] # сам резолвил, ровно один раз
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fix 3 (#1850): единый timeout из settings.geoportal_timeout_s
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_geoportal_client_uses_settings_timeout(monkeypatch: Any) -> None:
|
||||
"""Когда клиент не передан, ИРД-блок конструирует EKBGeoportalClient с settings.timeout."""
|
||||
_patch_overlay(monkeypatch, _OVERLAY)
|
||||
monkeypatch.setattr(
|
||||
ird_analyze, "get_or_fetch_zone_regulation", lambda db, lon, lat, client=None: None
|
||||
)
|
||||
monkeypatch.setattr(ird_analyze, "_krt_at", lambda db, lon, lat: [])
|
||||
monkeypatch.setattr(ird_analyze.settings, "geoportal_timeout_s", 7)
|
||||
|
||||
constructed: list[int] = []
|
||||
|
||||
class _SpyClient:
|
||||
def __init__(self, timeout: int = 20) -> None:
|
||||
constructed.append(timeout)
|
||||
|
||||
monkeypatch.setattr(ird_analyze, "EKBGeoportalClient", _SpyClient)
|
||||
|
||||
# client=None → блок сам создаёт EKBGeoportalClient(timeout=settings.geoportal_timeout_s).
|
||||
ird_analyze.build_ird_analyze_block(object(), "POINT(60 56)", 60.6, 56.8)
|
||||
|
||||
assert constructed == [7]
|
||||
|
||||
|
|
|
|||
170
backend/tests/services/test_zone_regulation_memo.py
Normal file
170
backend/tests/services/test_zone_regulation_memo.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""Тесты мемоизации coord→zone_index в get_or_fetch_zone_regulation (#1850 Fix 1).
|
||||
|
||||
Маппинг «координата → индекс терзоны» статичен → единственный живой WFS-вызов резолвера
|
||||
(``client.zone_index_at``) кэшируется процесс-локально. Проверяем:
|
||||
- повтор по тем же координатам → zone_index_at вызван ОДИН раз (memo hit),
|
||||
- негативное кэширование: координата без зоны (None) тоже кэшируется (вне ЕКБ не дёргаем WFS),
|
||||
- округление до 5 знаков: близкие координаты (<1e-5) → один ключ,
|
||||
- _reset_zone_index_memo() очищает кэш между тестами.
|
||||
|
||||
Сеть/БД не дёргаются: фейковый клиент считает вызовы zone_index_at; кэш регламента
|
||||
(get_cached_zone_regulation) замокан так, чтобы зона считалась уже закэшированной
|
||||
(short-circuit до urbanCard).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.site_finder import zone_regulation as zr
|
||||
|
||||
|
||||
class _CountingClient:
|
||||
"""Фейковый WFS-клиент: считает вызовы zone_index_at, отдаёт заданный индекс (или None)."""
|
||||
|
||||
def __init__(self, zone_index: str | None) -> None:
|
||||
self._zone_index = zone_index
|
||||
self.index_calls: list[tuple[float, float]] = []
|
||||
self.regulation_calls: int = 0
|
||||
|
||||
def zone_index_at(self, lon: float, lat: float) -> str | None:
|
||||
self.index_calls.append((lon, lat))
|
||||
return self._zone_index
|
||||
|
||||
def zone_regulation_at(self, lon: float, lat: float) -> Any:
|
||||
self.regulation_calls += 1
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_memo() -> Any:
|
||||
"""Чистый memo до и после каждого теста (процесс-локальный кэш течёт между тестами)."""
|
||||
zr._reset_zone_index_memo()
|
||||
yield
|
||||
zr._reset_zone_index_memo()
|
||||
|
||||
|
||||
def test_zone_index_memoized_across_calls(monkeypatch: Any) -> None:
|
||||
"""Два вызова с теми же координатами → zone_index_at вызван РОВНО один раз (memo hit)."""
|
||||
# Зона "Ц-1" уже в кэше регламента → short-circuit до urbanCard, изолируем memo.
|
||||
monkeypatch.setattr(
|
||||
zr,
|
||||
"get_cached_zone_regulation",
|
||||
lambda db, zone_index, **kw: {"zone_index": zone_index},
|
||||
)
|
||||
client = _CountingClient("Ц-1")
|
||||
|
||||
r1 = zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client)
|
||||
r2 = zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client)
|
||||
|
||||
assert r1 == {"zone_index": "Ц-1"}
|
||||
assert r2 == {"zone_index": "Ц-1"}
|
||||
assert len(client.index_calls) == 1 # второй вызов — memo hit, без WFS
|
||||
|
||||
|
||||
def test_negative_result_is_memoized(monkeypatch: Any) -> None:
|
||||
"""Координата без терзоны (None) кэшируется → второй вызов не дёргает WFS (вне ЕКБ)."""
|
||||
# get_cached не должен влиять: zone_index=None → ветка кэша не выполняется,
|
||||
# zone_regulation_at вернёт None → результат None.
|
||||
monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None)
|
||||
client = _CountingClient(None)
|
||||
|
||||
r1 = zr.get_or_fetch_zone_regulation(object(), 1.0, 1.0, client=client)
|
||||
r2 = zr.get_or_fetch_zone_regulation(object(), 1.0, 1.0, client=client)
|
||||
|
||||
assert r1 is None
|
||||
assert r2 is None
|
||||
assert len(client.index_calls) == 1 # негативный результат закэширован
|
||||
|
||||
|
||||
def test_nearby_coords_share_memo_key(monkeypatch: Any) -> None:
|
||||
"""Координаты, совпадающие после round(,5), делят ключ → один WFS-вызов."""
|
||||
monkeypatch.setattr(
|
||||
zr,
|
||||
"get_cached_zone_regulation",
|
||||
lambda db, zone_index, **kw: {"zone_index": zone_index},
|
||||
)
|
||||
client = _CountingClient("Ж-2")
|
||||
|
||||
# Различие в 6-м знаке → одинаковый ключ после round(lon, 5)/round(lat, 5).
|
||||
zr.get_or_fetch_zone_regulation(object(), 60.600001, 56.800002, client=client)
|
||||
zr.get_or_fetch_zone_regulation(object(), 60.600002, 56.800001, client=client)
|
||||
|
||||
assert len(client.index_calls) == 1
|
||||
|
||||
|
||||
def test_distinct_coords_each_hit_wfs(monkeypatch: Any) -> None:
|
||||
"""Координаты, различимые на уровне 5 знаков, бьют WFS отдельно (round не слишком грубый)."""
|
||||
monkeypatch.setattr(
|
||||
zr,
|
||||
"get_cached_zone_regulation",
|
||||
lambda db, zone_index, **kw: {"zone_index": zone_index},
|
||||
)
|
||||
client = _CountingClient("Ц-2")
|
||||
|
||||
zr.get_or_fetch_zone_regulation(object(), 60.60000, 56.80000, client=client)
|
||||
zr.get_or_fetch_zone_regulation(object(), 60.60002, 56.80000, client=client) # ~1.2 м восточнее
|
||||
|
||||
assert len(client.index_calls) == 2
|
||||
|
||||
|
||||
def test_reset_memo_clears_cache(monkeypatch: Any) -> None:
|
||||
"""_reset_zone_index_memo() сбрасывает кэш → следующий вызов снова бьёт WFS."""
|
||||
monkeypatch.setattr(
|
||||
zr,
|
||||
"get_cached_zone_regulation",
|
||||
lambda db, zone_index, **kw: {"zone_index": zone_index},
|
||||
)
|
||||
client = _CountingClient("Ц-1")
|
||||
|
||||
zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client)
|
||||
assert len(client.index_calls) == 1
|
||||
|
||||
zr._reset_zone_index_memo()
|
||||
zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client)
|
||||
assert len(client.index_calls) == 2 # после reset — снова WFS
|
||||
|
||||
|
||||
def test_memo_evicts_oldest_over_maxsize(monkeypatch: Any) -> None:
|
||||
"""LRU-эвикция: при превышении maxsize выбрасывается самый старый ключ."""
|
||||
monkeypatch.setattr(zr, "_ZONE_INDEX_MEMO_MAXSIZE", 3)
|
||||
monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None)
|
||||
client = _CountingClient(None)
|
||||
|
||||
# 4 различных ключа при maxsize=3 → первый (самый старый) эвиктится.
|
||||
for i in range(4):
|
||||
zr.get_or_fetch_zone_regulation(object(), 60.0 + i * 0.001, 56.0, client=client)
|
||||
assert len(client.index_calls) == 4
|
||||
|
||||
# Повтор самого старого (60.000) → cache miss (эвиктнут) → ещё один WFS-вызов.
|
||||
zr.get_or_fetch_zone_regulation(object(), 60.0, 56.0, client=client)
|
||||
assert len(client.index_calls) == 5
|
||||
|
||||
|
||||
def test_memo_concurrent_access_no_error(monkeypatch: Any) -> None:
|
||||
"""#1850 thread-safety: N потоков сквозь _memoized_zone_index при tiny maxsize →
|
||||
мутации под Lock'ом не дают KeyError/гонок (memo делится между threadpool-потоками)."""
|
||||
import threading
|
||||
|
||||
monkeypatch.setattr(zr, "_ZONE_INDEX_MEMO_MAXSIZE", 2) # форсим частую эвикцию
|
||||
client = _CountingClient("Ц-1")
|
||||
errors: list[BaseException] = []
|
||||
barrier = threading.Barrier(8)
|
||||
|
||||
def worker(n: int) -> None:
|
||||
try:
|
||||
barrier.wait() # стартуем одновременно → максимум контеншена
|
||||
for i in range(200):
|
||||
zr._memoized_zone_index(client, 60.0 + (i % 5) * 0.001, 56.0)
|
||||
except BaseException as exc: # фиксируем любую гонку (KeyError/RuntimeError)
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"concurrent memo access raised: {errors[:3]}"
|
||||
Loading…
Add table
Reference in a new issue