Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
b9381e15e0 fix(ptica): два блока analyze_parcel глотали ошибку БД без SAVEPOINT (#2464)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI / backend-tests (pull_request) Successful in 17m29s
Найдено поиском «правило соблюдено рядом — нарушено здесь». В самой функции
analyze_parcel двенадцать соседних блоков оборачивают свой db.execute в
db.begin_nested(), и у velocity-блока правило записано словами:

    «SAVEPOINT защищает outer transaction если velocity SQL падает — иначе
     следующие queries (_geotech_risk и пр.) крашатся с InFailedSqlTransaction»

Два блока из правила выпадали:

1. get_permits_nearby — вызов на общей сессии, своей защиты не имеет, ошибку
   глотает вызывающий. Разрыв с ближайшим честным соседом — 60 строк в ОДНОЙ
   функции.
2. _neighbors_summary — тот же класс, вызывается перед записью прогона.

Радиус поражения проверен по коду: после незащищённого блока разрешений идут
_geotech_risk (:4141), _neighbors_summary (:4145) и persist_analysis_run (~:4220).
Один упавший запрос к gisogd_permits оставлял транзакцию в aborted-состоянии и
уносил ВЕСЬ анализ, а не блок разрешений.

Тест использует двойник сессии из #2938 (tests/support/pg_session.py) —
MagicMock aborted-состояния не имеет, и на нём проверка «сессия осталась рабочей»
зелена независимо от наличия SAVEPOINT. Красный на origin/main:
`AbortedTransactionError: current transaction is aborted`.

ЧЕСТНО ПРО ПОКРЫТИЕ: прямой тест есть только на _neighbors_summary — это отдельная
функция. Блок get_permits_nearby лежит внутри обработчика, дотянуться можно лишь
через полный эндпоинт. Первая версия файла «покрывала» его тестом, который САМ
писал `with db.begin_nested():` вокруг вызова и проверял собственную обёртку —
такая проверка зелена независимо от боевого кода. Тест удалён, ограничение
записано в шапке: лучше честно отсутствующее покрытие, чем поддельное.

pytest tests/api/v1: 357 passed, 1 skipped, rc=0
2026-08-20 11:58:37 +05:00
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