fix(ptica): два блока analyze_parcel глотали ошибку БД без SAVEPOINT (#2464) (#2949)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 3m13s
Deploy / build-worker (push) Successful in 3m29s
Deploy / deploy (push) Successful in 1m25s
Deploy / deploy-status (push) Successful in 0s
Deploy / perimeter-smoke (push) Successful in 10s

This commit is contained in:
bot-backend 2026-08-20 07:19:49 +00:00
parent 8db357ef30
commit 38163a1186
2 changed files with 96 additions and 8 deletions

View file

@ -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)

View file

@ -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