fix(site_finder): SAVEPOINT-isolate quarter-dump lookup db.execute (#2464 Wave 2) (#2467)
All checks were successful
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m42s
Deploy / build-worker (push) Successful in 3m53s
Deploy / deploy (push) Successful in 1m54s

This commit is contained in:
bot-backend 2026-07-08 06:30:32 +00:00
parent 82fdabccba
commit 22f3c44dc2
2 changed files with 545 additions and 310 deletions

View file

@ -355,6 +355,7 @@ def _get_zoning(
if layer_counts is not None and layer_counts.get("territorial_zones_count", 1) == 0:
return None
try:
with db.begin_nested():
row = db.execute(
text(
"""
@ -453,6 +454,7 @@ def _get_zouit_overlaps(
# geography для корректных м². Делитель — площадь участка; guard от деления на
# ноль через NULLIF(...,0) → NULL → coverage_pct None в Python. Zone-4326
# выражение совпадает с ST_Intersects ниже (transform 3857→4326).
with db.begin_nested():
rows = db.execute(
text(
"""
@ -547,6 +549,7 @@ def _get_cad_zouit_overlaps(db: Session, parcel_wkt: str) -> list[dict[str, Any]
# схлопывает в одну, оставляя наименьший id (ORDER BY reg_numb_border, id).
# reg_numb_border — TEXT NOT NULL (0 NULL на проде), так что рег-номер есть у
# каждой строки и дедуп идёт по реальному идентификатору зоны.
with db.begin_nested():
rows = db.execute(
text(
"""
@ -634,6 +637,7 @@ def _get_engineering_nearby(
if layer_counts is not None and layer_counts.get("engineering_count", 1) == 0:
return []
try:
with db.begin_nested():
rows = db.execute(
text(
"""
@ -750,6 +754,7 @@ def _get_risk_zones(
if layer_counts is not None and layer_counts.get("risks_count", 1) == 0:
return []
try:
with db.begin_nested():
rows = db.execute(
text(
"""
@ -870,6 +875,7 @@ def _get_opportunity_parcels(
if layer_counts is not None and layer_counts.get("opportunity_count", 1) == 0:
return []
try:
with db.begin_nested():
rows = db.execute(
text(
"""
@ -974,6 +980,7 @@ def _get_red_lines(
if layer_counts is not None and layer_counts.get("red_lines_count", 1) == 0:
return []
try:
with db.begin_nested():
rows = db.execute(
text(
"""
@ -1275,6 +1282,7 @@ def _get_engineering_structures_by_boundary(
"""
params = {"q": quarter, "wkt": parcel_wkt, "radius_m": radius_m}
try:
with db.begin_nested():
total_count = (
db.execute(
text(
@ -1287,6 +1295,7 @@ def _get_engineering_structures_by_boundary(
).scalar()
or 0
)
with db.begin_nested():
rows = db.execute(
text(
"""
@ -1466,6 +1475,7 @@ def _get_zouit_engineering_overlaps(
"""
params = {"q": quarter, "wkt": parcel_wkt}
try:
with db.begin_nested():
total_count = (
db.execute(
text(
@ -1478,6 +1488,7 @@ def _get_zouit_engineering_overlaps(
).scalar()
or 0
)
with db.begin_nested():
rows = db.execute(
text(
"""

View file

@ -0,0 +1,224 @@
"""SAVEPOINT-регрессия для quarter_dump_lookup.py (#2464 cluster A).
_get_zoning / _get_zouit_overlaps / _get_cad_zouit_overlaps / _get_engineering_nearby /
_get_risk_zones / _get_opportunity_parcels / _get_red_lines / _get_engineering_structures_
by_boundary когда-то ловили db.execute-сбой в bare ``except Exception`` (или
``except (OperationalError, ProgrammingError, DataError)``) БЕЗ SAVEPOINT/rollback на
реальном Postgres это отравляет db shared с caller'ом (`get_quarter_dump_data(db, ...)` →
вызывается из `/analyze` эндпоинта, `Depends(get_db)`): следующий `db.execute` в этом же
запросе (сиблинг `_get_*` хелпер, либо downstream `get_or_fetch_zone_regulation`, либо
`begin_nested` "9f cad_parcels" блок) падает с "current transaction is aborted" /
PendingRollbackError. Референс-корректный сиблинг `connection_capacity_lookup.py::
_query_nearby_network_zones` (уже был обёрнут в `db.begin_nested()`).
MagicMock-сессия не эмулирует реальный aborted-transaction Postgres тесты проверяют
(1) graceful fallback при сбое (документированный контракт функции не меняется),
(2) что begin_nested() реально вызван вокруг db.execute (SAVEPOINT-обёртка присутствует
в коде), (3) что db остаётся usable для следующего вызова на той же mock-сессии ошибка
не проглатывает control flow и не роняет остальную сессию.
Покрывает представительное подмножество (per #2464 audit): _get_zoning, _get_zouit_overlaps,
_get_engineering_structures_by_boundary и _get_zouit_engineering_overlaps (последние два
2 db.execute на функцию, каждый под своей savepoint, epic #2445 A1 honest-count + row-fetch).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from sqlalchemy.exc import OperationalError
from app.services.site_finder.quarter_dump_lookup import (
_get_engineering_structures_by_boundary,
_get_zoning,
_get_zouit_engineering_overlaps,
_get_zouit_overlaps,
)
def _op_error(msg: str) -> OperationalError:
"""OperationalError фабрика — единственный тип, который ловит except
(OperationalError, ProgrammingError, DataError) в _get_engineering_structures_
by_boundary (bare RuntimeError туда НЕ провалится это намеренно узкий except)."""
return OperationalError(msg, {}, Exception(msg))
_QUARTER = "66:41:0204016"
_WKT = "POLYGON((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.9, 60.6 56.8))"
class TestGetZoningSavepoint:
"""_get_zoning: db.execute().first(), except Exception → return None."""
def test_query_failure_degrades_to_none(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("nspd_quarter_dumps query failed")
assert _get_zoning(db, _QUARTER, _WKT) is None
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.first.return_value = None
_get_zoning(db, _QUARTER, _WKT)
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self) -> None:
"""db.execute сбоит один раз, но db остаётся usable для следующего
(не связанного) вызова на этой же сессии имитирует sibling _get_*
хелпер, вызванный сразу после в get_quarter_dump_data()."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
result = _get_zoning(db, _QUARTER, _WKT)
assert result is None
# Следующий вызов на том же db.execute отрабатывает (сессия не отравлена).
db.execute.side_effect = None
next_result = MagicMock()
next_result.first.return_value = ("marker",)
db.execute.return_value = next_result
assert db.execute("SELECT 1").first() == ("marker",)
class TestGetZouitOverlapsSavepoint:
"""_get_zouit_overlaps: db.execute().fetchall(), except Exception → return []."""
def test_query_failure_degrades_to_empty_list(self) -> None:
db = MagicMock()
db.execute.side_effect = RuntimeError("nspd zouit query failed")
result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert result == []
def test_uses_begin_nested_savepoint(self) -> None:
db = MagicMock()
db.execute.return_value.fetchall.return_value = []
_get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert db.begin_nested.call_count == 1
def test_session_usable_after_failure(self) -> None:
"""Сбой на dump-path (zouit_count>0) не отравляет сессию для следующего
sibling-запроса (например _get_engineering_nearby сразу после)."""
db = MagicMock()
db.execute.side_effect = RuntimeError("boom")
result = _get_zouit_overlaps(db, _QUARTER, _WKT, {"zouit_count": 3})
assert result == []
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]
class TestGetEngineeringStructuresByBoundarySavepoint:
"""_get_engineering_structures_by_boundary: 2 db.execute (COUNT + row-fetch) в одном
try, except (OperationalError, ProgrammingError, DataError) return ([], 0).
Каждый db.execute нуждается в СОБСТВЕННОЙ savepoint (RELEASE SAVEPOINT trap): если
COUNT сбоит, ROLLBACK TO SAVEPOINT должен откатить только COUNT, не всю функцию
и наоборот, если COUNT прошёл, а row-fetch сбоил, savepoint row-fetch'а изолирует
именно его сбой.
"""
def test_count_query_failure_degrades_to_empty(self) -> None:
db = MagicMock()
db.execute.side_effect = _op_error("COUNT(*) query failed")
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert items == []
assert total == 0
def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None:
"""COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 42
db.execute.side_effect = [count_result, _op_error("row-fetch failed")]
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert items == []
assert total == 0
def test_uses_two_separate_begin_nested_savepoints(self) -> None:
"""COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested)."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 0
rows_result = MagicMock()
rows_result.fetchall.return_value = []
db.execute.side_effect = [count_result, rows_result]
_get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert db.begin_nested.call_count == 2
def test_session_usable_after_count_failure(self) -> None:
"""COUNT сбоит → db остаётся usable для следующего sibling-запроса
(например _get_zouit_engineering_overlaps сразу после в get_connection_points)."""
db = MagicMock()
db.execute.side_effect = _op_error("boom")
items, total = _get_engineering_structures_by_boundary(db, _QUARTER, _WKT, 500)
assert (items, total) == ([], 0)
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]
class TestGetZouitEngineeringOverlapsSavepoint:
"""_get_zouit_engineering_overlaps: 2 db.execute (COUNT + row-fetch) в одном try,
except (OperationalError, ProgrammingError, DataError) return ([], 0).
Идентичная 2-запросная форма _get_engineering_structures_by_boundary вызывается
сразу после него в get_connection_points() на той же shared session, поэтому каждый
db.execute обёрнут в свою savepoint (RELEASE SAVEPOINT trap, epic #2445 A1).
"""
def test_count_query_failure_degrades_to_empty(self) -> None:
db = MagicMock()
db.execute.side_effect = _op_error("COUNT(*) query failed")
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert items == []
assert total == 0
def test_rowfetch_failure_after_successful_count_degrades_to_empty(self) -> None:
"""COUNT succeeds, row-fetch fails — fallback ([], 0), не total_count-протечка."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 137
db.execute.side_effect = [count_result, _op_error("row-fetch failed")]
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert items == []
assert total == 0
def test_uses_two_separate_begin_nested_savepoints(self) -> None:
"""COUNT и row-fetch — каждый под своей savepoint (2 отдельных begin_nested)."""
db = MagicMock()
count_result = MagicMock()
count_result.scalar.return_value = 0
rows_result = MagicMock()
rows_result.fetchall.return_value = []
db.execute.side_effect = [count_result, rows_result]
_get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert db.begin_nested.call_count == 2
def test_session_usable_after_failure(self) -> None:
"""Сбой не отравляет сессию — get_connection_points после этого хелпера ещё
строит summary и (в /analyze-контексте) сессия shared с downstream-запросами."""
db = MagicMock()
db.execute.side_effect = _op_error("boom")
items, total = _get_zouit_engineering_overlaps(db, _QUARTER, _WKT)
assert (items, total) == ([], 0)
db.execute.side_effect = None
next_result = MagicMock()
next_result.fetchall.return_value = [("marker",)]
db.execute.return_value = next_result
assert db.execute("SELECT 1").fetchall() == [("marker",)]