fix(ptica): шесть lookup'ов /analyze глотали ошибку БД без SAVEPOINT (#2464) (#2951)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m15s
Deploy / build-worker (push) Successful in 3m14s
Deploy / deploy (push) Successful in 1m28s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 10s

This commit is contained in:
bot-backend 2026-08-20 07:38:23 +00:00
parent 38163a1186
commit c93d6cbde1
12 changed files with 183 additions and 7 deletions

View file

@ -46,7 +46,13 @@ def parcel_functional_zones(db: Session, parcel_wkt: str | None) -> list[dict[st
if not parcel_wkt:
return []
try:
rows = db.execute(_ZONE_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_ZONE_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена или ошибка БД — graceful degrade (analyze не падает).
logger.warning(

View file

@ -88,7 +88,13 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
if not parcel_wkt:
return []
try:
rows = db.execute(_GRANDDOC_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_GRANDDOC_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
logger.warning("parcel_granddoc: planning_projects недоступна, skip: %s", exc)
return []

View file

@ -58,7 +58,13 @@ def parcel_ird_overlaps(db: Session, parcel_wkt: str | None) -> dict[str, Any]:
if not parcel_wkt:
return {"ird_overlaps": [], "ird_by_kind": {}}
try:
rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError, DataError) as exc:
# OperationalError — БД недоступна / connection drop.
# ProgrammingError — ird_overlays ещё не задеплоена (UndefinedTable, pre-migration).

View file

@ -45,7 +45,13 @@ def parcel_planning_overlaps(db: Session, parcel_wkt: str | None) -> list[dict[s
if not parcel_wkt:
return []
try:
rows = db.execute(_PLANNING_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_PLANNING_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_planning_overlaps: planning_projects недоступна, skip: %s", exc)

View file

@ -45,7 +45,13 @@ def parcel_reservations(db: Session, cad_num: str | None) -> list[dict[str, obje
if not cad_num:
return []
try:
rows = db.execute(_LOOKUP_SQL, {"cad_num": cad_num}).mappings().all()
# #2464: SAVEPOINT перед проглатыванием ошибки. Сессия ОБЩАЯ с analyze_parcel
# (build_ird_analyze_block зовёт шесть таких lookup'ов подряд в одном словаре),
# и на Postgres упавший запрос оставляет транзакцию в aborted-состоянии —
# падают все следующие, включая запись прогона. Образец рядом:
# ppt_tep_lookup.py делает ровно так же.
with db.begin_nested():
rows = db.execute(_LOOKUP_SQL, {"cad_num": cad_num}).mappings().all()
except (OperationalError, ProgrammingError) as exc:
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
logger.warning("parcel_reservations: land_reservation недоступна, skip: %s", exc)

View file

@ -299,7 +299,13 @@ def get_cached_zone_regulation(
if not zone_index:
return None
try:
row = db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first()
# #2464: SAVEPOINT — как у upsert_zone_regulation двадцатью строками выше в
# ЭТОМ ЖЕ файле. Сессия общая с analyze_parcel; без savepoint'а упавший SELECT
# оставляет транзакцию в aborted-состоянии, и падает всё, что идёт следом.
with db.begin_nested():
row = (
db.execute(_SELECT_SQL, {"city": city, "zone_index": zone_index}).mappings().first()
)
except (OperationalError, ProgrammingError) as exc:
logger.warning("get_cached_zone_regulation: cache недоступна, skip: %s", exc)
return None

View file

@ -0,0 +1,90 @@
"""#2464: шесть lookup'ов ИРД глотали ошибку БД без SAVEPOINT.
`build_ird_analyze_block` зовёт их подряд, В ОДНОМ словарном литерале, на ОБЩЕЙ
сессии `/analyze`. Каждый ловит `(OperationalError, ProgrammingError)` и возвращает
пустой результат «таблица ещё не задеплоена, деградируем мягко».
На Postgres упавший запрос оставляет транзакцию в aborted-состоянии. Значит первый
же сбой ронял не свой блок, а все следующие lookup'ы, `_geotech_risk`,
`_neighbors_summary` и запись прогона то есть весь анализ.
Правильный образец лежал рядом: `ppt_tep_lookup` (та же семья, читает ту же
`planning_projects`) оборачивает свой `db.execute` в `db.begin_nested()`. В
`zone_regulation` образец вообще в том же файле: `upsert_zone_regulation` обёрнут,
а `get_cached_zone_regulation` двадцатью строками ниже нет.
Двойник сессии воспроизводит семантику Postgres: у `MagicMock` aborted-состояния
нет, и проверка «сессия осталась рабочей» на нём зелена независимо от SAVEPOINT.
"""
from __future__ import annotations
from typing import Any
import pytest
from sqlalchemy.exc import OperationalError
from tests.support.pg_session import AbortedTransactionError, PostgresLikeSession
_WKT = "POLYGON((60.6 56.83, 60.61 56.83, 60.61 56.84, 60.6 56.84, 60.6 56.83))"
def _sqla_error() -> OperationalError:
"""Обработчики ловят конкретные типы SQLAlchemy, а не голый Exception."""
return OperationalError("SELECT 1", {}, Exception("relation does not exist"))
def _call(name: str, db: Any) -> Any:
from app.services.site_finder import (
functional_zone_lookup,
granddoc_lookup,
ird_overlay_lookup,
planning_lookup,
reservation_lookup,
zone_regulation,
)
return {
"planning": lambda: planning_lookup.parcel_planning_overlaps(db, _WKT),
"granddoc": lambda: granddoc_lookup.parcel_granddoc(db, _WKT),
"functional_zone": lambda: functional_zone_lookup.parcel_functional_zones(db, _WKT),
"reservation": lambda: reservation_lookup.parcel_reservations(db, _WKT),
"ird_overlay": lambda: ird_overlay_lookup.parcel_ird_overlaps(db, _WKT),
"zone_regulation": lambda: zone_regulation.get_cached_zone_regulation(db, "Ж-5"),
}[name]()
@pytest.mark.parametrize(
"name",
["planning", "granddoc", "functional_zone", "reservation", "ird_overlay", "zone_regulation"],
)
def test_lookup_failure_leaves_shared_session_usable(name: str) -> None:
"""Сбой одного lookup'а не должен уносить остальные пять и запись прогона."""
db = PostgresLikeSession(error=_sqla_error())
_call(name, db) # деградирует мягко, как и обещает докстринг
db.execute("SELECT 1") # следующий блок анализа
assert db.calls == 2, f"{name}: сессия не восстановилась после отката SAVEPOINT"
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_healthy_lookup_still_reads_rows() -> None:
"""Контроль: без сбоя поведение прежнее — строки читаются, не пустой sentinel."""
from app.services.site_finder import zone_regulation
db = PostgresLikeSession(fail_on=(), row={"zone_index": "Ж-5", "max_far": 2.4})
out = zone_regulation.get_cached_zone_regulation(db, "Ж-5")
assert out is not None and out["zone_index"] == "Ж-5"

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from sqlalchemy.exc import OperationalError, ProgrammingError
@ -21,6 +22,15 @@ class _Result:
class _FakeDB:
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
"""#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с
analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про
логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию,
проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на
двойнике с настоящей семантикой Postgres."""
yield
def __init__(
self,
rows: list[dict[str, Any]] | None = None,

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import contextmanager
from datetime import date
from typing import Any
@ -24,6 +25,15 @@ class _Result:
class _FakeDB:
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
"""#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с
analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про
логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию,
проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на
двойнике с настоящей семантикой Postgres."""
yield
def __init__(
self,
rows: list[dict[str, Any]] | None = None,

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
import pytest
@ -22,6 +23,15 @@ class _Result:
class _FakeDB:
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
"""#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с
analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про
логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию,
проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на
двойнике с настоящей семантикой Postgres."""
yield
def __init__(
self, rows: list[dict[str, Any]] | None = None, raise_exc: Exception | None = None
) -> None:
@ -111,7 +121,7 @@ def test_empty_when_no_overlap() -> None:
"operational-error: connection drop / БД недоступна",
),
(
ProgrammingError("stmt", {}, Exception("relation \"ird_overlays\" does not exist")),
ProgrammingError("stmt", {}, Exception('relation "ird_overlays" does not exist')),
"programming-error: UndefinedTable — pre-migration / D9b не задеплоен",
),
(

View file

@ -6,6 +6,7 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Any
from sqlalchemy.exc import ProgrammingError
@ -25,6 +26,15 @@ class _Result:
class _DB:
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
"""#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с
analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про
логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию,
проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на
двойнике с настоящей семантикой Postgres."""
yield
def __init__(self, rows: list[dict[str, Any]] | Exception) -> None:
self._rows = rows

View file

@ -2,6 +2,7 @@
from __future__ import annotations
from contextlib import contextmanager
from datetime import date
from typing import Any
@ -24,6 +25,15 @@ class _Result:
class _FakeDB:
@contextmanager
def begin_nested(self): # type: ignore[no-untyped-def]
"""#2464: lookup оборачивает свой db.execute в SAVEPOINT (сессия общая с
analyze_parcel). Здесь достаточно пустого контекст-менеджера: эти тесты про
логику самого lookup'а. Что SAVEPOINT РЕАЛЬНО откатывает aborted-транзакцию,
проверяет tests/services/site_finder/test_2464_ird_lookups_savepoint.py на
двойнике с настоящей семантикой Postgres."""
yield
def __init__(
self,
rows: list[dict[str, Any]] | None = None,