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