From 01360e9c3dc1eb9b4bc08990f1035f24cec35e9b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 8 Jul 2026 06:46:03 +0000 Subject: [PATCH] fix(site_finder): SAVEPOINT-isolate gate-lookup db.execute swallows (#2464 Wave 2) (#2468) --- .../services/forecasting/product_scoring.py | 21 ++-- .../app/services/site_finder/ird_analyze.py | 22 ++-- .../app/services/site_finder/krt_lookup.py | 10 +- backend/app/services/site_finder/locations.py | 13 ++- .../app/services/site_finder/okn_lookup.py | 9 +- .../services/site_finder/ppt_tep_lookup.py | 3 +- .../services/site_finder/premises_lookup.py | 11 +- .../services/site_finder/riasurt_lookup.py | 3 +- .../forecasting/test_product_scoring.py | 96 ++++++++++++++++ .../services/site_finder/test_locations.py | 81 +++++++++++-- .../services/site_finder/test_okn_lookup.py | 107 ++++++++++++++++++ .../site_finder/test_premises_lookup.py | 63 ++++++++++- .../site_finder/test_riasurt_lookup.py | 23 ++++ backend/tests/services/test_ird_analyze.py | 62 ++++++++++ backend/tests/services/test_krt_lookup.py | 56 +++++++++ backend/tests/services/test_ppt_tep_lookup.py | 21 ++++ 16 files changed, 549 insertions(+), 52 deletions(-) create mode 100644 backend/tests/services/site_finder/test_okn_lookup.py diff --git a/backend/app/services/forecasting/product_scoring.py b/backend/app/services/forecasting/product_scoring.py index aa19d5d5..d1b14919 100644 --- a/backend/app/services/forecasting/product_scoring.py +++ b/backend/app/services/forecasting/product_scoring.py @@ -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 diff --git a/backend/app/services/site_finder/ird_analyze.py b/backend/app/services/site_finder/ird_analyze.py index f9e0cc7d..940eee55 100644 --- a/backend/app/services/site_finder/ird_analyze.py +++ b/backend/app/services/site_finder/ird_analyze.py @@ -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 diff --git a/backend/app/services/site_finder/krt_lookup.py b/backend/app/services/site_finder/krt_lookup.py index 054af74b..0d2c2825 100644 --- a/backend/app/services/site_finder/krt_lookup.py +++ b/backend/app/services/site_finder/krt_lookup.py @@ -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 [] diff --git a/backend/app/services/site_finder/locations.py b/backend/app/services/site_finder/locations.py index 17117c58..03b8f6f0 100644 --- a/backend/app/services/site_finder/locations.py +++ b/backend/app/services/site_finder/locations.py @@ -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", diff --git a/backend/app/services/site_finder/okn_lookup.py b/backend/app/services/site_finder/okn_lookup.py index 42eeca62..23398cc6 100644 --- a/backend/app/services/site_finder/okn_lookup.py +++ b/backend/app/services/site_finder/okn_lookup.py @@ -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 [] diff --git a/backend/app/services/site_finder/ppt_tep_lookup.py b/backend/app/services/site_finder/ppt_tep_lookup.py index b499970e..1de47e22 100644 --- a/backend/app/services/site_finder/ppt_tep_lookup.py +++ b/backend/app/services/site_finder/ppt_tep_lookup.py @@ -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) diff --git a/backend/app/services/site_finder/premises_lookup.py b/backend/app/services/site_finder/premises_lookup.py index dfdb476f..8029a550 100644 --- a/backend/app/services/site_finder/premises_lookup.py +++ b/backend/app/services/site_finder/premises_lookup.py @@ -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). diff --git a/backend/app/services/site_finder/riasurt_lookup.py b/backend/app/services/site_finder/riasurt_lookup.py index 0c380e9e..d947067b 100644 --- a/backend/app/services/site_finder/riasurt_lookup.py +++ b/backend/app/services/site_finder/riasurt_lookup.py @@ -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) diff --git a/backend/tests/services/forecasting/test_product_scoring.py b/backend/tests/services/forecasting/test_product_scoring.py index 99e889c2..4bc9e271 100644 --- a/backend/tests/services/forecasting/test_product_scoring.py +++ b/backend/tests/services/forecasting/test_product_scoring.py @@ -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 diff --git a/backend/tests/services/site_finder/test_locations.py b/backend/tests/services/site_finder/test_locations.py index 75eea3bf..6443000b 100644 --- a/backend/tests/services/site_finder/test_locations.py +++ b/backend/tests/services/site_finder/test_locations.py @@ -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 diff --git a/backend/tests/services/site_finder/test_okn_lookup.py b/backend/tests/services/site_finder/test_okn_lookup.py new file mode 100644 index 00000000..2078fe78 --- /dev/null +++ b/backend/tests/services/site_finder/test_okn_lookup.py @@ -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 diff --git a/backend/tests/services/site_finder/test_premises_lookup.py b/backend/tests/services/site_finder/test_premises_lookup.py index 5ef64877..c66de376 100644 --- a/backend/tests/services/site_finder/test_premises_lookup.py +++ b/backend/tests/services/site_finder/test_premises_lookup.py @@ -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 diff --git a/backend/tests/services/site_finder/test_riasurt_lookup.py b/backend/tests/services/site_finder/test_riasurt_lookup.py index 24e648cc..e09fe9fc 100644 --- a/backend/tests/services/site_finder/test_riasurt_lookup.py +++ b/backend/tests/services/site_finder/test_riasurt_lookup.py @@ -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 diff --git a/backend/tests/services/test_ird_analyze.py b/backend/tests/services/test_ird_analyze.py index 46aed2dd..65c46033 100644 --- a/backend/tests/services/test_ird_analyze.py +++ b/backend/tests/services/test_ird_analyze.py @@ -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 diff --git a/backend/tests/services/test_krt_lookup.py b/backend/tests/services/test_krt_lookup.py index fedd03e6..622d58eb 100644 --- a/backend/tests/services/test_krt_lookup.py +++ b/backend/tests/services/test_krt_lookup.py @@ -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 diff --git a/backend/tests/services/test_ppt_tep_lookup.py b/backend/tests/services/test_ppt_tep_lookup.py index 7a8e92c6..e4da5e63 100644 --- a/backend/tests/services/test_ppt_tep_lookup.py +++ b/backend/tests/services/test_ppt_tep_lookup.py @@ -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] = {