gendesign/backend/tests/services/test_zone_regulation_memo.py
Light1YT 18942898d1
All checks were successful
CI / backend-tests (pull_request) Successful in 11m8s
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m46s
fix(site-finder): memoize zone_index WFS lookup + dedupe resolve in /analyze (#1850)
- memoize coord→zone_index (bounded thread-safe LRU, ±1m key, negative caching) so
  repeat /analyze skips the live WFS round-trip; regulation content still read fresh
  from zone_regulation_cache (cache-update correctness preserved). Lock guards the
  module-global memo (sync /analyze runs in threadpool); WFS call stays outside lock.
- resolve once, thread into build_ird_analyze_block (resolved_zone_regulation kwarg)
  → no redundant 2nd resolve when both enable_zoning_regulation_in_analyze and
  enable_ird_analyze are on.
- unify divergent timeouts (parcels 3s / ird 4s) into settings.geoportal_timeout_s=4.
- document (not change) the mid-request db.commit() smell on cache-miss (separate task).

Closes #1850
2026-06-25 14:00:13 +05:00

170 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты мемоизации 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]}"