fix(site_finder): SAVEPOINT-isolate gate-lookup db.execute swallows (#2464 Wave 2) #2468
16 changed files with 549 additions and 52 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
107
backend/tests/services/site_finder/test_okn_lookup.py
Normal file
107
backend/tests/services/site_finder/test_okn_lookup.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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] = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue