fix(ptica): шесть lookup'ов /analyze глотали ошибку БД без SAVEPOINT (#2464) #2951
12 changed files with 183 additions and 7 deletions
|
|
@ -46,6 +46,12 @@ def parcel_functional_zones(db: Session, parcel_wkt: str | None) -> list[dict[st
|
||||||
if not parcel_wkt:
|
if not parcel_wkt:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
|
# #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()
|
rows = db.execute(_ZONE_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||||
except (OperationalError, ProgrammingError) as exc:
|
except (OperationalError, ProgrammingError) as exc:
|
||||||
# Таблица ещё не задеплоена или ошибка БД — graceful degrade (analyze не падает).
|
# Таблица ещё не задеплоена или ошибка БД — graceful degrade (analyze не падает).
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,12 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
||||||
if not parcel_wkt:
|
if not parcel_wkt:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
|
# #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()
|
rows = db.execute(_GRANDDOC_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||||
except (OperationalError, ProgrammingError) as exc:
|
except (OperationalError, ProgrammingError) as exc:
|
||||||
logger.warning("parcel_granddoc: planning_projects недоступна, skip: %s", exc)
|
logger.warning("parcel_granddoc: planning_projects недоступна, skip: %s", exc)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,12 @@ def parcel_ird_overlaps(db: Session, parcel_wkt: str | None) -> dict[str, Any]:
|
||||||
if not parcel_wkt:
|
if not parcel_wkt:
|
||||||
return {"ird_overlaps": [], "ird_by_kind": {}}
|
return {"ird_overlaps": [], "ird_by_kind": {}}
|
||||||
try:
|
try:
|
||||||
|
# #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()
|
rows = db.execute(_IRD_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||||
except (OperationalError, ProgrammingError, DataError) as exc:
|
except (OperationalError, ProgrammingError, DataError) as exc:
|
||||||
# OperationalError — БД недоступна / connection drop.
|
# OperationalError — БД недоступна / connection drop.
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ def parcel_planning_overlaps(db: Session, parcel_wkt: str | None) -> list[dict[s
|
||||||
if not parcel_wkt:
|
if not parcel_wkt:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
|
# #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()
|
rows = db.execute(_PLANNING_OVERLAP_SQL, {"parcel_wkt": parcel_wkt}).mappings().all()
|
||||||
except (OperationalError, ProgrammingError) as exc:
|
except (OperationalError, ProgrammingError) as exc:
|
||||||
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
|
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ def parcel_reservations(db: Session, cad_num: str | None) -> list[dict[str, obje
|
||||||
if not cad_num:
|
if not cad_num:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
|
# #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()
|
rows = db.execute(_LOOKUP_SQL, {"cad_num": cad_num}).mappings().all()
|
||||||
except (OperationalError, ProgrammingError) as exc:
|
except (OperationalError, ProgrammingError) as exc:
|
||||||
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
|
# Таблица ещё не задеплоена / БД-ошибка — graceful degrade.
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,13 @@ def get_cached_zone_regulation(
|
||||||
if not zone_index:
|
if not zone_index:
|
||||||
return None
|
return None
|
||||||
try:
|
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:
|
except (OperationalError, ProgrammingError) as exc:
|
||||||
logger.warning("get_cached_zone_regulation: cache недоступна, skip: %s", exc)
|
logger.warning("get_cached_zone_regulation: cache недоступна, skip: %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||||
|
|
@ -21,6 +22,15 @@ class _Result:
|
||||||
|
|
||||||
|
|
||||||
class _FakeDB:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
rows: list[dict[str, Any]] | None = None,
|
rows: list[dict[str, Any]] | None = None,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -24,6 +25,15 @@ class _Result:
|
||||||
|
|
||||||
|
|
||||||
class _FakeDB:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
rows: list[dict[str, Any]] | None = None,
|
rows: list[dict[str, Any]] | None = None,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -22,6 +23,15 @@ class _Result:
|
||||||
|
|
||||||
|
|
||||||
class _FakeDB:
|
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__(
|
def __init__(
|
||||||
self, rows: list[dict[str, Any]] | None = None, raise_exc: Exception | None = None
|
self, rows: list[dict[str, Any]] | None = None, raise_exc: Exception | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -111,7 +121,7 @@ def test_empty_when_no_overlap() -> None:
|
||||||
"operational-error: connection drop / БД недоступна",
|
"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 не задеплоен",
|
"programming-error: UndefinedTable — pre-migration / D9b не задеплоен",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.exc import ProgrammingError
|
from sqlalchemy.exc import ProgrammingError
|
||||||
|
|
@ -25,6 +26,15 @@ class _Result:
|
||||||
|
|
||||||
|
|
||||||
class _DB:
|
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:
|
def __init__(self, rows: list[dict[str, Any]] | Exception) -> None:
|
||||||
self._rows = rows
|
self._rows = rows
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -24,6 +25,15 @@ class _Result:
|
||||||
|
|
||||||
|
|
||||||
class _FakeDB:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
rows: list[dict[str, Any]] | None = None,
|
rows: list[dict[str, Any]] | None = None,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue