From 38163a1186fadb9f4c8bdac6e26573e375ded528 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 07:19:49 +0000 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=B4=D0=B2=D0=B0=20=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=20analyze=5Fparcel=20=D0=B3=D0=BB=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D1=83?= =?UTF-8?q?=20=D0=91=D0=94=20=D0=B1=D0=B5=D0=B7=20SAVEPOINT=20(#2464)=20(#?= =?UTF-8?q?2949)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/parcels.py | 26 +++++-- .../v1/test_2464_parcels_savepoint_pair.py | 78 +++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 backend/tests/api/v1/test_2464_parcels_savepoint_pair.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 88f8f648..fe5325a8 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -925,14 +925,17 @@ def _neighbors_summary(db: Session, geom_wkt: str, our_cad_num: str) -> dict[str integration EXPLAIN-gate, см. `test_analyze_parcels_sql.py`). """ try: - row = ( - db.execute( - _NEIGHBORS_SUMMARY_SQL, - {"wkt": geom_wkt, "our_cad": our_cad_num}, + # #2464: SAVEPOINT — сессия общая с analyze_parcel, ошибку глотаем ниже. Без него + # aborted-транзакция дошла бы до persist_analysis_run, и анализ не сохранился бы. + with db.begin_nested(): + row = ( + db.execute( + _NEIGHBORS_SUMMARY_SQL, + {"wkt": geom_wkt, "our_cad": our_cad_num}, + ) + .mappings() + .first() ) - .mappings() - .first() - ) neighbor_rows: list[dict[str, Any]] = list(row["neighbors"]) if row else [] overlap_row: list[dict[str, Any]] = list(row["overlap_rows"]) if row else [] # #2464 cluster B: честный total из neighbors_total CTE (БЕЗ LIMIT 30) — @@ -3750,7 +3753,14 @@ def analyze_parcel( "source": "gisogd66", } try: - permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500) + # #2464: SAVEPOINT, как у соседних блоков этой же функции (ближайший — разрешения + # 10d-pre2 шестьюдесятью строками выше, где приём применён явно). get_permits_nearby + # делает db.execute на ЭТОЙ сессии и своей защиты не имеет; ошибку глотаем здесь. + # Без savepoint'а упавший запрос оставляет транзакцию в aborted-состоянии, и дальше + # по обработчику падают _geotech_risk (:4141), _neighbors_summary (:4145) и запись + # прогона — то есть теряется весь анализ, а не блок разрешений. + with db.begin_nested(): + permits_nearby_data = get_permits_nearby(db, geom_wkt, radius_m=500) except Exception as e: logger.warning("gisogd permits_nearby query failed for %s: %s", cad_num, e) diff --git a/backend/tests/api/v1/test_2464_parcels_savepoint_pair.py b/backend/tests/api/v1/test_2464_parcels_savepoint_pair.py new file mode 100644 index 00000000..268133ce --- /dev/null +++ b/backend/tests/api/v1/test_2464_parcels_savepoint_pair.py @@ -0,0 +1,78 @@ +"""#2464: два места analyze_parcel глотали ошибку БД без SAVEPOINT. + +Найдено поиском «правило соблюдено рядом — нарушено здесь»: в самой функции +`analyze_parcel` двенадцать соседних блоков оборачивают свой `db.execute` в +`db.begin_nested()`, и у velocity-блока правило записано словами — + + «SAVEPOINT защищает outer transaction если velocity SQL падает — иначе + следующие queries (_geotech_risk и пр.) крашатся с InFailedSqlTransaction» + +Два блока из правила выпадали: + +* `get_permits_nearby` (вызов на общей сессии, своей защиты не имеет, ошибку + глотает вызывающий) — а после него идут `_geotech_risk`, `_neighbors_summary` + и запись прогона: терялся бы весь анализ, а не блок разрешений; +* `_neighbors_summary` — вызывается перед `persist_analysis_run`. + +Двойник сессии (`tests/support/pg_session.py`) воспроизводит семантику Postgres: +обычный MagicMock aborted-состояния не имеет, и проверка «сессия осталась рабочей» +на нём зелена независимо от наличия SAVEPOINT. + +ЧТО ЗДЕСЬ НЕ ПОКРЫТО И ПОЧЕМУ +───────────────────────────── +Прямой тест есть только на `_neighbors_summary` — это отдельная функция, её можно +вызвать. Блок `get_permits_nearby` лежит ВНУТРИ обработчика `analyze_parcel`, и +дотянуться до него можно лишь через полный эндпоинт, чей мок-двойник сессии должен +уметь отвечать на десятки разных запросов. + +Первая версия этого файла «покрывала» его так: тест сам писал +`with db.begin_nested():` вокруг вызова и проверял, что сессия выжила. Такая +проверка зелена независимо от боевого кода — она проверяет собственную обёртку. +Убрана: лучше честно отсутствующее покрытие, чем поддельное. Правка там та же +самая и по тому же доводу, но подтверждена чтением, а не тестом. +""" + +from __future__ import annotations + +import pytest + +from tests.support.pg_session import AbortedTransactionError, PostgresLikeSession + + +def test_neighbors_summary_failure_leaves_session_usable() -> None: + """Сбой запроса соседей не должен помешать сохранить прогон.""" + from app.api.v1 import parcels + + db = PostgresLikeSession() + wkt = "POLYGON((60.6 56.83, 60.61 56.83, 60.61 56.84, 60.6 56.84, 60.6 56.83))" + + out = parcels._neighbors_summary(db, wkt, "66:41:0402029:25") + + assert out["data_available"] is False, "деградация должна остаться graceful" + db.execute("SELECT 1") # на origin/main здесь AbortedTransactionError + assert db.calls == 2 + + +def test_double_still_poisons_without_a_savepoint() -> None: + """Контроль на инструмент: без SAVEPOINT двойник ОБЯЗАН отравляться. + + Без этой проверки обе проверки выше были бы зелёными по построению. + """ + db = PostgresLikeSession() + with pytest.raises(RuntimeError): + db.execute("boom") + with pytest.raises(AbortedTransactionError): + db.execute("SELECT 1") + + +def test_neighbors_summary_healthy_path_unchanged() -> None: + """Контроль: без сбоя поведение прежнее — данные читаются, не fallback.""" + from app.api.v1 import parcels + + db = PostgresLikeSession( + fail_on=(), + row={"neighbors": [], "overlap_rows": [], "neighbors_total_count": 0}, + ) + out = parcels._neighbors_summary(db, "POLYGON EMPTY", "66:41:0402029:25") + + assert out.get("data_available") is not False