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
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:
parent
38163a1186
commit
c93d6cbde1
12 changed files with 183 additions and 7 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 не задеплоен",
|
||||
),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue