Cache-miss upsert now runs in a fresh SessionLocal() instead of committing the shared /analyze transaction mid-request. Read-back via shared session unchanged. Refs #1850
164 lines
7 KiB
Python
164 lines
7 KiB
Python
"""Тесты изолированной write-сессии в get_or_fetch_zone_regulation (#1850 item 4).
|
||
|
||
На cache-MISS резолвер пишет регламент в кэш в СОБСТВЕННОЙ короткой сессии (SessionLocal()),
|
||
а НЕ коммитит SHARED-сессию вызывающего (/analyze коммитит её ровно один раз в конце через
|
||
persist_analysis_run). Проверяем:
|
||
- happy path: shared db.commit НИКОГДА не вызывается, fresh write-session коммитит РОВНО раз,
|
||
сессия закрыта; read-back идёт по SHARED db (форма результата сохранена для callers);
|
||
- graceful: upsert_zone_regulation вернул None (таблица недоступна) → fresh session rollback,
|
||
shared db не тронута, функция возвращает результат read-back без падения.
|
||
|
||
Сеть/БД не дёргаются: фейковый клиент + monkeypatch SessionLocal (recording-фейк) +
|
||
отдельная фейковая SHARED Session, считающая commit/rollback.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.services.scrapers.ekb_geoportal_client import ZoneRegulation
|
||
from app.services.site_finder import zone_regulation as zr
|
||
|
||
|
||
def _reg(idx: str) -> ZoneRegulation:
|
||
return ZoneRegulation(
|
||
zone_index=idx,
|
||
zone_full_name=f"{idx} зона",
|
||
main_vri=[],
|
||
conditional_vri=[],
|
||
auxiliary_vri=[],
|
||
limit_params=[],
|
||
)
|
||
|
||
|
||
class _MissThenResolveClient:
|
||
"""Фейковый WFS-клиент: индекс зоны есть, но кэш промахивается → live-резолв регламента."""
|
||
|
||
def __init__(self, zone_index: str) -> None:
|
||
self._zone_index = zone_index
|
||
self.index_calls = 0
|
||
self.regulation_calls = 0
|
||
|
||
def zone_index_at(self, lon: float, lat: float) -> str:
|
||
self.index_calls += 1
|
||
return self._zone_index
|
||
|
||
def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation:
|
||
self.regulation_calls += 1
|
||
return _reg(self._zone_index)
|
||
|
||
|
||
class _SharedDB:
|
||
"""Фейковая SHARED Session вызывающего: считает commit/rollback, ничего не пишет.
|
||
|
||
commit() должен остаться НЕТРОНУТЫМ — фикс #1850 item 4 запрещает преждевременный
|
||
коммит in-flight analyze-транзакции из резолвера.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self.commits = 0
|
||
self.rollbacks = 0
|
||
|
||
def commit(self) -> None:
|
||
self.commits += 1
|
||
|
||
def rollback(self) -> None:
|
||
self.rollbacks += 1
|
||
|
||
|
||
class _WriteDB:
|
||
"""Фейковая короткоживущая write-сессия (то, что отдаёт monkeypatched SessionLocal)."""
|
||
|
||
def __init__(self) -> None:
|
||
self.commits = 0
|
||
self.rollbacks = 0
|
||
self.closed = False
|
||
|
||
def commit(self) -> None:
|
||
self.commits += 1
|
||
|
||
def rollback(self) -> None:
|
||
self.rollbacks += 1
|
||
|
||
def close(self) -> None:
|
||
self.closed = True
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_memo() -> Any:
|
||
"""Чистый memo до/после теста (процесс-локальный кэш течёт между тестами)."""
|
||
zr._reset_zone_index_memo()
|
||
yield
|
||
zr._reset_zone_index_memo()
|
||
|
||
|
||
def test_cache_miss_writes_in_isolated_session(monkeypatch: Any) -> None:
|
||
"""Cache-miss: shared db.commit НЕ вызывается, fresh write-session коммитит РОВНО раз."""
|
||
write_db = _WriteDB()
|
||
created: list[_WriteDB] = []
|
||
|
||
def _session_local() -> _WriteDB:
|
||
created.append(write_db)
|
||
return write_db
|
||
|
||
monkeypatch.setattr(zr, "SessionLocal", _session_local)
|
||
upserts: list[ZoneRegulation] = []
|
||
# upsert вернул params_numeric (не None) → write-сессия должна закоммитить.
|
||
monkeypatch.setattr(
|
||
zr, "upsert_zone_regulation", lambda db, reg, **kw: upserts.append(reg) or {"x": [1]}
|
||
)
|
||
# get_cached дёргается на SHARED db ДВАЖДЫ: 1) до fetch (cache MISS → None, форсит резолв),
|
||
# 2) read-back после commit write-сессии (READ COMMITTED видит строку → dict для callers).
|
||
cache_reads: list[Any] = []
|
||
|
||
def _cached(db: Any, zone_index: str | None, **kw: Any) -> dict[str, Any] | None:
|
||
cache_reads.append(db)
|
||
assert db is shared # резолвер читает кэш ИМЕННО по SHARED db, не по write-сессии
|
||
return None if len(cache_reads) == 1 else {"zone_index": zone_index}
|
||
|
||
monkeypatch.setattr(zr, "get_cached_zone_regulation", _cached)
|
||
|
||
shared = _SharedDB()
|
||
client = _MissThenResolveClient("Ц-1")
|
||
result = zr.get_or_fetch_zone_regulation(shared, 60.6, 56.8, client=client) # type: ignore[arg-type]
|
||
|
||
# SHARED транзакция НЕ тронута резолвером.
|
||
assert shared.commits == 0
|
||
assert shared.rollbacks == 0
|
||
# Свежая write-сессия закоммитила РОВНО один раз и закрыта.
|
||
assert len(created) == 1
|
||
assert write_db.commits == 1
|
||
assert write_db.rollbacks == 0
|
||
assert write_db.closed is True
|
||
# Апсёрт ушёл в write-сессию, не в shared.
|
||
assert upserts == [_reg("Ц-1")]
|
||
# get_cached на SHARED db: cache-check (miss) + read-back (hit) = 2 чтения.
|
||
assert len(cache_reads) == 2
|
||
# Read-back вернул кэшированную форму (dict) для callers parcels.py / ird_analyze.py.
|
||
assert result == {"zone_index": "Ц-1"}
|
||
|
||
|
||
def test_cache_miss_table_missing_rolls_back_gracefully(monkeypatch: Any) -> None:
|
||
"""upsert вернул None (таблица недоступна) → fresh session rollback, shared не тронута."""
|
||
write_db = _WriteDB()
|
||
monkeypatch.setattr(zr, "SessionLocal", lambda: write_db)
|
||
# upsert_zone_regulation graceful-возврат None (OperationalError/ProgrammingError внутри).
|
||
monkeypatch.setattr(zr, "upsert_zone_regulation", lambda db, reg, **kw: None)
|
||
# Кэш недоступен и для read-back → None (форма как при недоступной таблице у callers).
|
||
monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None)
|
||
|
||
shared = _SharedDB()
|
||
client = _MissThenResolveClient("Ж-2")
|
||
result = zr.get_or_fetch_zone_regulation(shared, 60.6, 56.8, client=client) # type: ignore[arg-type]
|
||
|
||
# Write-сессия откатилась (нечего персистить), не коммитила, закрыта.
|
||
assert write_db.commits == 0
|
||
assert write_db.rollbacks == 1
|
||
assert write_db.closed is True
|
||
# SHARED транзакция вообще не тронута.
|
||
assert shared.commits == 0
|
||
assert shared.rollbacks == 0
|
||
# Функция отработала gracefully (read-back None при недоступном кэше).
|
||
assert result is None
|