fix(site_finder): SAVEPOINT-isolate gate-lookup db.execute swallows (#2464 Wave 2) #2468

Merged
bot-backend merged 1 commit from worktree-agent-ad881375834cdfd40 into main 2026-07-08 06:46:05 +00:00
16 changed files with 549 additions and 52 deletions

View file

@ -835,13 +835,15 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
compute_poi_weighted_top7. Нет геометрии / нет POI / сбой None (infra_fit unavailable).
"""
try:
coords = (
db.execute(
_PARCEL_CENTROID_SQL, {"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)}
with db.begin_nested():
coords = (
db.execute(
_PARCEL_CENTROID_SQL,
{"cad_num": cad_num, "quarter": _quarter_from_cad(cad_num)},
)
.mappings()
.first()
)
.mappings()
.first()
)
except Exception:
logger.exception(
"product_scoring: centroid lookup failed (cad_num=%s) → infra n/a", cad_num
@ -850,9 +852,10 @@ def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
if not coords or coords.get("lat") is None or coords.get("lon") is None:
return None
try:
response = compute_poi_weighted_top7(
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
)
with db.begin_nested():
response = compute_poi_weighted_top7(
db, cad_num, float(coords["lat"]), float(coords["lon"]), radius_m=_POI_RADIUS_M
)
except Exception:
logger.exception("product_scoring: poi_score failed (cad_num=%s) → infra n/a", cad_num)
return None

View file

@ -81,20 +81,22 @@ def _krt_at(db: Session, lon: float, lat: float) -> list[dict[str, Any]]:
[] при отсутствии пересечений или если таблица пуста/не существует.
"""
try:
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
return [
{
"num_oks_krt": row.num_oks_krt,
"status": row.status,
"full_name": row.full_name,
"area": float(row.area) if row.area is not None else None,
}
for row in rows
]
with db.begin_nested():
rows = db.execute(_KRT_AT_SQL, {"lon": lon, "lat": lat}).fetchall()
except Exception as exc: # таблица не существует / DB-сбой → graceful
logger.warning("ird_analyze: _krt_at DB query failed: %s", exc)
return []
return [
{
"num_oks_krt": row.num_oks_krt,
"status": row.status,
"full_name": row.full_name,
"area": float(row.area) if row.area is not None else None,
}
for row in rows
]
def _zone_regulation_at(
db: Session, client: EKBGeoportalClient, lon: float, lat: float

View file

@ -130,7 +130,8 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
return []
try:
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
with db.begin_nested():
rows = db.execute(_PLANNING_KRT_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_krt_requisites: таблица недоступна, skip: %s", exc)
return []
@ -173,9 +174,10 @@ def parcel_krt_requisites(db: Session, parcel_wkt: str | None) -> list[dict[str,
# Нет exact-матча — geom-only поиск по КРТ-топонимам
try:
no_match_rows = (
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
)
with db.begin_nested():
no_match_rows = (
db.execute(_PLANNING_NO_MATCH_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
)
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_krt_requisites: no-match fallback недоступен: %s", exc)
return []

View file

@ -263,9 +263,7 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
# ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала) ─
try:
metrics = compute_market_metrics(
db, district=district, window_months=_MARKET_WINDOW_MONTHS
)
metrics = compute_market_metrics(db, district=district, window_months=_MARKET_WINDOW_MONTHS)
# competition ← overstock_index (доступное/затоваренное конкурирующее
# предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
# velocity и инвертирован относительно «свободной конкуренции»).
@ -300,6 +298,15 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
logger.exception(
"location_indices: infra (POI) failed (district=%s) → infra None", district
)
# Plain rollback OK here (unlike begin_nested at the pass-2 upsert loop below):
# compute_location_indices только вызывается из refresh_locations, единственный
# caller которой — Celery-таск location_refresh.py, открывающий СВОЙ SessionLocal()
# (owned session, не shared request/report-scoped). На момент этого блока (pass 1,
# read-only) в транзакции ещё нет незакоммиченных write'ов — откатывать нечего,
# rollback() не сиротит внешний SessionTransaction (в отличие от velocity.py:170).
# Без rollback аборченная Postgres-транзакция валит СЛЕДУЮЩИЙ район в цикле pass 1
# (см. #2464) — как и все db.execute() в pass 2 upsert.
db.rollback()
logger.info(
"location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s",

View file

@ -74,10 +74,11 @@ def parcel_okn_objects(
if not parcel_wkt:
return []
try:
rows = db.execute(
_OKN_DWITHIN_SQL,
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
with db.begin_nested():
rows = db.execute(
_OKN_DWITHIN_SQL,
{"parcel_wkt": parcel_wkt, "radius_m": radius_m},
).fetchall()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_okn_objects: okn_objects недоступна, skip: %s", exc)
return []

View file

@ -109,7 +109,8 @@ def parcel_ppt_tep(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]:
if not parcel_wkt:
return []
try:
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
with db.begin_nested():
rows = db.execute(_PPT_TEP_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_ppt_tep: ekb_ppt_tep / planning_projects недоступна, skip: %s", exc)

View file

@ -137,11 +137,12 @@ def resolve_cad_for_domrf(
cad_num search).
"""
try:
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
with db.begin_nested():
row = (
db.execute(_RESOLVE_CAD_SQL, {"lat": float(lat), "lon": float(lon)})
.mappings()
.first()
)
except Exception:
# SQL/PostGIS-сбой — не наш профиль данных. Логируем и отдаём None
# (один конкурент без parking_ratio лучше, чем 500 на весь lazy-endpoint).

View file

@ -99,7 +99,8 @@ def parcel_riasurt_gate(
return dict(RIASURT_EMPTY_GATE)
try:
rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
with db.begin_nested():
rows = db.execute(_RIASURT_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError, DataError) as exc:
logger.warning("parcel_riasurt_gate: таблица/запрос недоступны, skip: %s", exc)
return dict(RIASURT_EMPTY_GATE)

View file

@ -40,6 +40,7 @@ from app.services.forecasting.product_scoring import (
_count_positive_usp,
_min_confidence,
_overlay_confidence,
_poi_weight_sum,
_quarter_from_cad,
_saturating,
_score_commercial,
@ -991,3 +992,98 @@ class TestMortgageSensitivityViaRegimeAdapter:
reg_mock.assert_not_called()
assert card.scores["mortgage_sensitivity"].value is None
assert card.scores["mortgage_sensitivity"].confidence == "low"
# ── #2464 cluster A: _poi_weight_sum SAVEPOINT-регрессия (session-poisoning) ────
# ОБА db.execute-сайта внутри _poi_weight_sum раньше ловили сбой БЕЗ SAVEPOINT/rollback:
# 1) центроид-lookup (db.execute(_PARCEL_CENTROID_SQL, ...));
# 2) compute_poi_weighted_top7(...) — poi_score.py сам делает raw db.execute БЕЗ своего
# try/except, поэтому сбой ПРОБРАСЫВАЕТСЯ наверх и глотается вторым except здесь.
# На реальном Postgres любой из них отравляет §22-report session (compute_score_card делит
# одну db-сессию с остальными §22-скорерами): следующий db.execute на ТОЙ ЖЕ сессии падал
# "current transaction is aborted". Фикс: ОБА сайта обёрнуты в db.begin_nested() (swallow
# site для poi — это второй except _poi_weight_sum, там и ставим savepoint).
class TestPoiWeightSumSavepoint:
def test_centroid_query_failure_returns_none(self) -> None:
"""DB-сбой на центроид-lookup → None (fallback → infra_fit unavailable)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("connection refused")
assert _poi_weight_sum(db, cad_num=_CAD) is None
def test_uses_begin_nested_savepoint(self) -> None:
"""Фикс: центроид-запрос обёрнут в db.begin_nested(), не голый db.execute."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = None
_poi_weight_sum(db, cad_num=_CAD)
assert db.begin_nested.call_count == 1
def test_session_usable_after_centroid_failure(self) -> None:
"""execute сбоит на 1-м вызове (центроид), db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует §22 shared report-session."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
assert _poi_weight_sum(db, cad_num=_CAD) is None
db.execute.side_effect = None
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_poi_call_failure_returns_none(self) -> None:
"""Второй сайт: центроид резолвится, но compute_poi_weighted_top7 бросает →
None (документированный fallback, infra_fit unavailable)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, side_effect=RuntimeError("poi_score raw db.execute failed")):
assert _poi_weight_sum(db, cad_num=_CAD) is None
def test_uses_begin_nested_for_poi_call(self) -> None:
"""Фикс второго сайта: вызов compute_poi_weighted_top7 обёрнут в db.begin_nested().
При успешном центроиде оба сайта под savepoint begin_nested вызван дважды."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, return_value=_poi_response_stub([0.01])):
_poi_weight_sum(db, cad_num=_CAD)
assert db.begin_nested.call_count == 2
def test_session_usable_after_poi_failure(self) -> None:
"""poi_score (второй сайт) бросает → db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует §22 shared report-session
(без savepoint здесь была бы 'current transaction is aborted' на следующем скоре)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, side_effect=RuntimeError("poi boom")):
assert _poi_weight_sum(db, cad_num=_CAD) is None
# begin_nested вызван для обоих сайтов (центроид успешен, poi сбоит внутри savepoint).
assert db.begin_nested.call_count == 2
# Следующий (не связанный) вызов на той же сессии отрабатывает — не отравлена.
next_result = MagicMock()
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
db.execute.return_value = next_result
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
def test_centroid_success_then_poi_computed(self) -> None:
"""Happy path: центроид резолвится → compute_poi_weighted_top7 вызван, Σ weight отдан.
Оба db-сайта под savepoint begin_nested вызван дважды."""
db = MagicMock()
db.execute.return_value.mappings.return_value.first.return_value = {
"lat": 56.8,
"lon": 60.6,
}
with patch(_P_POI, return_value=_poi_response_stub([0.03, 0.02])):
out = _poi_weight_sum(db, cad_num=_CAD)
assert out == pytest.approx(0.05)
assert db.begin_nested.call_count == 2

View file

@ -286,6 +286,71 @@ class TestComputeLocationIndices:
assert idx.raw_unit_velocity == 13.0
# ── 2b. infra/POI swallow site: session-poisoning regression (#2464 cluster A) ──
# _district_poi_score/_city_avg_poi_score (analytics_queries.py) do db.execute with no
# try/except of their own; compute_location_indices' bare `except Exception` used to
# swallow a failure WITHOUT rolling back. On real Postgres that leaves the transaction
# "aborted", poisoning every later db.execute on the SAME session — here that's the
# REMAINING districts in refresh_locations' pass-1 loop (all on one Session).
#
# Fix = plain db.rollback() (NOT begin_nested SAVEPOINT): compute_location_indices'
# only caller is refresh_locations, whose only caller (location_refresh.py Celery task)
# opens its OWN SessionLocal() — an owned, not request/report-shared, session. At this
# point in pass 1 no writes are pending yet (all UPSERTs happen later in pass 2), so
# rollback() has nothing to lose and doesn't orphan an outer SessionTransaction (unlike
# velocity.py:170). This is DIFFERENT from the pass-2 upsert loop below, which MUST use
# begin_nested (SAVEPOINT) — a plain rollback there would revert already-upserted rows
# from earlier districts in the same uncommitted transaction.
class TestInfraPoiRollbackRegression:
def test_infra_failure_rolls_back_and_falls_back_to_none(self) -> None:
db = MagicMock()
with (
patch(_MM, return_value=_metrics(0.5, 20.0)),
patch(_FSP, return_value=_fsp(0.4)),
patch(_DPOI, side_effect=RuntimeError("current transaction is aborted")),
patch(_CPOI, return_value=10.0),
):
idx = compute_location_indices(db, "Кировский")
# documented fallback: infra_index None, остальные индексы (isolated per-block)
# всё равно посчитаны.
assert idx.infra_index is None
assert idx.competition_index == 0.5
assert idx.future_supply_index == 0.4
db.rollback.assert_called_once()
# Pass-1 (read-only) — begin_nested здесь НЕ нужен (тот используется только
# в pass-2 upsert loop, где rollback() потерял бы предыдущие районы).
db.begin_nested.assert_not_called()
def test_session_usable_for_next_district_after_infra_failure(self) -> None:
# Симулирует refresh_locations pass 1: район 1 сбоит на infra, район 2 идёт
# следом на ТОЙ ЖЕ session. Без rollback (до фикса) реальный Postgres отдал бы
# "current transaction is aborted" на district 2 (session-poisoning, #2464).
db = MagicMock()
calls = {"n": 0}
def _dpoi_side_effect(_db: object, *, district_name: str) -> float:
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("db boom on district 1")
return 8.0
with (
patch(_MM, return_value=_metrics(0.5, 20.0)),
patch(_FSP, return_value=_fsp(0.4)),
patch(_DPOI, side_effect=_dpoi_side_effect),
patch(_CPOI, return_value=10.0),
):
idx1 = compute_location_indices(db, "Кировский")
idx2 = compute_location_indices(db, "Ленинский")
assert idx1.infra_index is None
db.rollback.assert_called_once()
# Второй район на той же сессии успешно считает infra (8/10 = 0.8) — session
# осталась usable после rollback (не отравлена).
assert idx2.infra_index == 0.8
# ── 3. refresh_locations: idempotent upsert + SAVEPOINT + counters ──────────────
@ -335,9 +400,7 @@ class TestRefreshLocations:
)
with (
patch(_ADMIN, return_value=districts),
patch(
"app.services.site_finder.locations.compute_location_indices", return_value=ns
),
patch("app.services.site_finder.locations.compute_location_indices", return_value=ns),
):
first = refresh_locations(_refresh_db())
second = refresh_locations(_refresh_db())
@ -469,9 +532,7 @@ class TestRefreshDemandCityRelative:
# ── Hard correctness edge case #1: ВСЕ velocities None → ВСЕ demand None ────
def test_all_velocities_none_yields_all_demand_none(self) -> None:
captured = self._run(
{"Кировский": None, "Ленинский": None, "Академический": None}
)
captured = self._run({"Кировский": None, "Ленинский": None, "Академический": None})
# Нет опоры → demand None у всех. НИКОГДА не подменяем 0.
for d in ("Кировский", "Ленинский", "Академический"):
assert captured[d]["demand_index"] is None
@ -480,9 +541,7 @@ class TestRefreshDemandCityRelative:
def test_mixed_none_and_present(self) -> None:
# Опора = MAX среди НЕ-None (= 200). Район без данных → demand None; районы с
# данными нормируются относительно 200, без учёта None в опоре.
captured = self._run(
{"Кировский": 200.0, "Ленинский": 50.0, "Академический": None}
)
captured = self._run({"Кировский": 200.0, "Ленинский": 50.0, "Академический": None})
assert captured["Академический"]["demand_index"] is None # нет выборки
assert captured["Кировский"]["demand_index"] == 1.0 # сам горячий (=опора)
assert captured["Ленинский"]["demand_index"] == 0.25 # 50 / 200
@ -502,8 +561,6 @@ class TestRefreshDemandCityRelative:
def test_all_equal_velocities_all_one(self) -> None:
# Все одинаково горячи → опора = их общее значение → 1.0 у каждого.
captured = self._run(
{"Кировский": 90.0, "Ленинский": 90.0, "Академический": 90.0}
)
captured = self._run({"Кировский": 90.0, "Ленинский": 90.0, "Академический": 90.0})
for d in ("Кировский", "Ленинский", "Академический"):
assert captured[d]["demand_index"] == 1.0

View file

@ -0,0 +1,107 @@
"""Тесты parcel_okn_objects (#1066, #1159) — ОКН-объекты рядом с участком.
БД не дёргается: фейковая сессия с .execute().fetchall() (позиционные колонки).
Проверяем happy path, graceful на отсутствие WKT/таблицы, и SAVEPOINT-регрессию
(#2464 cluster A): db.execute обёрнут в begin_nested, сбой не отравляет сессию.
"""
from __future__ import annotations
import contextlib
from typing import Any
from sqlalchemy.exc import OperationalError
from app.services.site_finder.okn_lookup import parcel_okn_objects
_WKT = "POLYGON((60.5 56.8, 60.6 56.8, 60.6 56.9, 60.5 56.9, 60.5 56.8))"
_OKN_ROW = (
"66-001", # source_id
"федеральный", # category
42, # distance_m
"Дом купца Иванова", # name
"ул. Ленина, 1", # address
)
class _FakeDB:
"""Фейковая сессия: execute().fetchall() (позиционные строки) + begin_nested()."""
def __init__(self, rows: list[tuple[Any, ...]] | Exception) -> None:
self._rows = rows
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
if isinstance(self._rows, Exception):
raise self._rows
class _Result:
def __init__(self, rows: list[tuple[Any, ...]]) -> None:
self._rows = rows
def fetchall(self) -> list[tuple[Any, ...]]:
return self._rows
return _Result(self._rows)
def test_returns_okn_objects() -> None:
"""Один ОКН-объект в радиусе → проброс полей."""
db = _FakeDB([_OKN_ROW])
out = parcel_okn_objects(db, _WKT)
assert len(out) == 1
r = out[0]
assert r["source_id"] == "66-001"
assert r["category"] == "федеральный"
assert r["distance_m"] == 42
assert r["name"] == "Дом купца Иванова"
assert r["address"] == "ул. Ленина, 1"
def test_empty_wkt_returns_empty() -> None:
"""parcel_wkt=None → [] без вызова DB."""
db = _FakeDB([_OKN_ROW])
assert parcel_okn_objects(db, None) == []
assert db.begin_nested_calls == 0
def test_no_objects_in_radius_returns_empty() -> None:
"""Нет объектов в радиусе → []."""
assert parcel_okn_objects(_FakeDB([]), _WKT) == []
def test_missing_table_is_graceful() -> None:
"""okn_objects ещё не задеплоена (OperationalError) → [] (analyze не падает)."""
db = _FakeDB(OperationalError("stmt", {}, Exception("relation okn_objects does not exist")))
assert parcel_okn_objects(db, _WKT) == []
def test_unexpected_exception_is_graceful() -> None:
"""Любая неожиданная ошибка БД → [] (analyze не падает)."""
db = _FakeDB(RuntimeError("connection refused"))
assert parcel_okn_objects(db, _WKT) == []
# ── #2464 cluster A: SAVEPOINT-регрессия (session-poisoning) ────────────────────
def test_uses_savepoint_and_session_reusable_after_failure() -> None:
"""db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не отравляет сессию:
follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не "current transaction is aborted")."""
db = _FakeDB(RuntimeError("connection refused"))
result = parcel_okn_objects(db, _WKT)
assert result == []
assert db.begin_nested_calls == 1
db._rows = [_OKN_ROW]
result2 = parcel_okn_objects(db, _WKT)
assert len(result2) == 1
assert result2[0]["source_id"] == "66-001"
assert db.begin_nested_calls == 2

View file

@ -7,11 +7,13 @@ happy path + graceful-фоллбэки (здание не найдено / не
from __future__ import annotations
import contextlib
from typing import Any
from unittest.mock import MagicMock, patch
from app.schemas.nspd_bulk import ObjectsListing
from app.services.scrapers.nspd_client import NspdLiteWafError
from app.services.site_finder.premises_lookup import resolve_cad_for_domrf
MODULE = "app.services.site_finder.premises_lookup"
@ -73,9 +75,7 @@ def test_get_building_premises_snake_case_objdoc() -> None:
patch(f"{MODULE}.NSPDClient") as mock_client_cls,
patch(f"{MODULE}.asyncio.run", side_effect=_run_returns(listing)),
):
mock_client_cls.return_value.search_by_cad.return_value = _search_result(
{"objdoc_id": 777}
)
mock_client_cls.return_value.search_by_cad.return_value = _search_result({"objdoc_id": 777})
out = get_building_premises("66:41:0106036:183")
assert out is listing
@ -148,3 +148,60 @@ def test_get_building_premises_waf_returns_none() -> None:
assert out is None
mock_run.assert_not_called()
# ── #2464 cluster A: resolve_cad_for_domrf SAVEPOINT-регрессия ──────────────────
# db.execute внутри resolve_cad_for_domrf раньше глотал сбой БЕЗ SAVEPOINT/rollback —
# на реальном Postgres это отравляет shared session (lazy-эндпоинт может дёргать
# resolve_cad_for_domrf на несколько конкурентов подряд, ОДНА и та же db-сессия):
# следующий db.execute падал "current transaction is aborted". Фикс: db.execute
# обёрнут в db.begin_nested().
class _ResolveCadFakeDB:
"""Фейковая сессия: execute().mappings().first() + begin_nested()."""
def __init__(self, row: dict[str, Any] | Exception | None) -> None:
self._row = row
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any]) -> Any:
if isinstance(self._row, Exception):
raise self._row
row = self._row
class _Result:
def mappings(self) -> _Result:
return self
def first(self) -> dict[str, Any] | None:
return row
return _Result()
def test_resolve_cad_for_domrf_db_failure_returns_none() -> None:
"""DB-сбой → None (документированный fallback), analyze не падает."""
db = _ResolveCadFakeDB(RuntimeError("connection refused"))
assert resolve_cad_for_domrf(db, lat=56.8, lon=60.6) is None # type: ignore[arg-type]
def test_resolve_cad_for_domrf_uses_savepoint_and_session_reusable_after_failure() -> None:
"""begin_nested обёрнут вокруг db.execute — сбой не отравляет сессию: follow-up
вызов resolve_cad_for_domrf на ТОЙ ЖЕ db-сессии успевает."""
db = _ResolveCadFakeDB(RuntimeError("boom"))
out = resolve_cad_for_domrf(db, lat=56.8, lon=60.6) # type: ignore[arg-type]
assert out is None
assert db.begin_nested_calls == 1
db._row = {"cad_num": "66:41:0106036:183", "objdoc_id": 40995027, "distance_m": 12.5}
out2 = resolve_cad_for_domrf(db, lat=56.8, lon=60.6) # type: ignore[arg-type]
assert out2 is not None
assert out2.cad_num == "66:41:0106036:183"
assert db.begin_nested_calls == 2

View file

@ -6,6 +6,7 @@
from __future__ import annotations
import contextlib
from typing import Any
from sqlalchemy.exc import ProgrammingError
@ -57,6 +58,13 @@ class _Result:
class _FakeDB:
def __init__(self, rows: list[_Row] | Exception) -> None:
self._rows = rows
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
if isinstance(self._rows, Exception):
@ -144,3 +152,18 @@ def test_gate_empty_wkt_returns_empty() -> None:
db = _FakeDB([])
gate = parcel_riasurt_gate(db, None, "66:35:0101001:5") # type: ignore[arg-type]
assert gate["applicable"] is False
def test_gate_uses_savepoint_and_session_reusable_after_failure() -> None:
"""#2464 cluster A: db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не
отравляет сессию. Follow-up вызов на ТОЙ ЖЕ db-инстансе успевает."""
db = _FakeDB(ProgrammingError("stmt", {}, Exception("no table")))
gate = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
assert gate == riasurt_lookup.RIASURT_EMPTY_GATE
assert db.begin_nested_calls == 1
# follow-up на той же сессии (таблица теперь "доступна").
db._rows = []
gate2 = parcel_riasurt_gate(db, _WKT, "66:35:0101001:5") # type: ignore[arg-type]
assert gate2["applicable"] is True
assert db.begin_nested_calls == 2

View file

@ -11,6 +11,8 @@ skip geoportal при отсутствии centroid.
from __future__ import annotations
import contextlib
from types import SimpleNamespace
from typing import Any
from app.services.site_finder import ird_analyze
@ -668,3 +670,63 @@ def test_geoportal_client_uses_settings_timeout(monkeypatch: Any) -> None:
assert constructed == [7]
# ---------------------------------------------------------------------------
# _krt_at SAVEPOINT-регрессия (#2464 cluster A, session-poisoning)
# ---------------------------------------------------------------------------
#
# db.execute внутри _krt_at раньше глотал сбой БЕЗ SAVEPOINT/rollback — на реальном
# Postgres это отравляет shared report-session (build_ird_analyze_block ходит по
# ОДНОЙ db-сессии через несколько lookup'ов): следующий db.execute на той же сессии
# падал "current transaction is aborted". Фикс: db.execute обёрнут в db.begin_nested().
class _KrtAtFetchResult:
def __init__(self, rows: list[SimpleNamespace]) -> None:
self._rows = rows
def fetchall(self) -> list[SimpleNamespace]:
return self._rows
class _KrtAtFakeDB:
"""Фейковая сессия для юнит-теста _krt_at: execute().fetchall() + begin_nested()."""
def __init__(self, rows_or_exc: list[SimpleNamespace] | Exception) -> None:
self._rows_or_exc = rows_or_exc
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any]) -> _KrtAtFetchResult:
if isinstance(self._rows_or_exc, Exception):
raise self._rows_or_exc
return _KrtAtFetchResult(self._rows_or_exc)
def test_krt_at_query_failure_returns_empty_list() -> None:
"""DB-сбой (таблица не задеплоена / QueryCanceled) → [] (documented fallback)."""
db = _KrtAtFakeDB(RuntimeError("relation ekb_krt_geometry does not exist"))
assert ird_analyze._krt_at(db, 60.6, 56.8) == []
def test_krt_at_uses_savepoint_and_session_reusable_after_failure() -> None:
"""begin_nested обёрнут вокруг db.execute — сбой не отравляет сессию: follow-up
вызов _krt_at на ТОЙ ЖЕ db-сессии успевает."""
db = _KrtAtFakeDB(RuntimeError("boom"))
result = ird_analyze._krt_at(db, 60.6, 56.8)
assert result == []
assert db.begin_nested_calls == 1
db._rows_or_exc = [
SimpleNamespace(num_oks_krt=7, status="В работе", full_name="КРТ ул. Тверитина", area=3.5)
]
result2 = ird_analyze._krt_at(db, 60.6, 56.8)
assert len(result2) == 1
assert result2[0]["num_oks_krt"] == 7
assert result2[0]["area"] == 3.5
assert db.begin_nested_calls == 2

View file

@ -11,6 +11,7 @@ DB/сеть не дёргаются: мокаем db.execute через FakeDB.
from __future__ import annotations
import contextlib
from decimal import Decimal
from typing import Any
@ -49,6 +50,13 @@ class _FakeDB:
self._second = second_rows or []
self._raise_first = raise_first
self._raise_second = raise_second
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _Result:
self._calls += 1
@ -174,3 +182,51 @@ def test_no_overlap_at_all_returns_empty() -> None:
db = _FakeDB(first_rows=[], second_rows=[])
result = parcel_krt_requisites(db, _WKT)
assert result == []
# ── #2464 cluster A: SAVEPOINT-регрессия (session-poisoning) ────────────────────
# Оба db.execute (exact-матч + no-match fallback) раньше глотали сбой БЕЗ SAVEPOINT/
# rollback — на реальном Postgres это отравляет shared session отчёта: следующий
# db.execute на ТОЙ ЖЕ сессии падает "current transaction is aborted". Фикс: оба
# сайта обёрнуты в db.begin_nested().
def test_first_query_uses_savepoint_and_session_reusable_after_failure() -> None:
"""Сбой на первом (exact-матч) запросе: begin_nested использован, [] отдан, и
follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не отравлен)."""
db = _FakeDB(raise_first=OperationalError("stmt", {}, Exception("relation does not exist")))
result = parcel_krt_requisites(db, _WKT)
assert result == []
assert db.begin_nested_calls == 1
# follow-up на той же сессии (первый запрос теперь отдаёт exact-матч). Сбрасываем
# _calls — _FakeDB считает execute() 1-based ОТ НАЧАЛА каждого прогона.
db._calls = 0
db._raise_first = None
db._first = [_EXACT_ROW]
result2 = parcel_krt_requisites(db, _WKT)
assert len(result2) == 1
assert result2[0]["matched"] is True
assert db.begin_nested_calls == 2
def test_second_query_uses_savepoint_and_session_reusable_after_failure() -> None:
"""Сбой на втором (no-match fallback) запросе: begin_nested использован, [] отдан,
и follow-up вызов на ТОЙ ЖЕ db-сессии успевает (не отравлен)."""
db = _FakeDB(
first_rows=[],
raise_second=OperationalError("stmt", {}, Exception("relation does not exist")),
)
result = parcel_krt_requisites(db, _WKT)
assert result == []
# begin_nested вызван для обоих запросов (первый успешен, второй сбоит).
assert db.begin_nested_calls == 2
# follow-up на той же сессии: и первый, и второй запрос теперь успешны.
db._calls = 0
db._raise_second = None
db._second = [{"pp_id": 42, "pp_full_name": "no-match", "dmd_actual_year": 2023}]
result2 = parcel_krt_requisites(db, _WKT)
assert len(result2) == 1
assert result2[0]["matched"] is False
assert db.begin_nested_calls == 4

View file

@ -6,6 +6,7 @@
from __future__ import annotations
import contextlib
from typing import Any
from sqlalchemy.exc import ProgrammingError
@ -27,6 +28,13 @@ class _Result:
class _DB:
def __init__(self, rows: list[dict[str, Any]] | Exception) -> None:
self._rows = rows
self.begin_nested_calls = 0
@contextlib.contextmanager
def begin_nested(self) -> Any:
"""Фейковый SAVEPOINT — не глотает исключение из execute (как реальный)."""
self.begin_nested_calls += 1
yield
def execute(self, sql: Any, params: dict[str, Any]) -> _Result:
if isinstance(self._rows, Exception):
@ -88,6 +96,19 @@ def test_unexpected_exception_is_graceful() -> None:
assert parcel_ppt_tep(db, "POINT(60 56)") == [] # type: ignore[arg-type]
def test_uses_savepoint_and_session_reusable_after_failure() -> None:
"""#2464 cluster A: db.execute обёрнут в begin_nested (SAVEPOINT) — сбой не
отравляет сессию: follow-up вызов на ТОЙ ЖЕ db-сессии успевает."""
db = _DB(RuntimeError("connection refused"))
assert parcel_ppt_tep(db, "POINT(60 56)") == [] # type: ignore[arg-type]
assert db.begin_nested_calls == 1
db._rows = [_PPT_TEP_ROW]
out = parcel_ppt_tep(db, "POINT(60 56)") # type: ignore[arg-type]
assert len(out) == 1
assert db.begin_nested_calls == 2
def test_multiple_rows_preserved() -> None:
"""Если match'ит несколько ППТ — все возвращаются."""
row2: dict[str, Any] = {