gendesign/backend/tests/services/test_zone_regulation_memo.py
bot-backend d2773e0c91
All checks were successful
Deploy / build-backend (push) Successful in 1m40s
Deploy / deploy (push) Successful in 1m26s
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 3m9s
fix(site-finder): optimize zone-regulation resolve in /analyze (#1850)
Memoize coord→zone_index WFS lookup (thread-safe bounded LRU, negative caching), resolve once and thread into IRD block (no double resolve when both flags on), unify timeout into settings.geoportal_timeout_s. Lock guards the threadpool-shared memo (WFS call outside lock). Documented the cache-miss db.commit() smell (separate task). Deep-review .

Closes #1850
2026-06-25 09:12:41 +00: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]}"