From 7cde3b8eaa4e3b3831392511dcdf20338291ac30 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 12:18:59 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D1=88=D0=B5=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?lookup'=D0=BE=D0=B2=20/analyze=20=D0=B3=D0=BB=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D1=83=20=D0=91?= =?UTF-8?q?=D0=94=20=D0=B1=D0=B5=D0=B7=20SAVEPOINT=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шесть функций в site_finder работают на ОБЩЕЙ с analyze_parcel сессии и глотают (OperationalError, ProgrammingError) вокруг db.execute. Без SAVEPOINT проглоченная ошибка оставляет транзакцию в aborted-состоянии: сам lookup возвращает пустой результат «штатно», а падает следующий за ним запрос — в другом месте отчёта и с другой причиной в логе. Соседний ppt_tep_lookup — та же семья, та же таблица planning_projects — SAVEPOINT имел. Правка сводит шесть отставших к нему: planning_lookup, granddoc_lookup, ird_overlay_lookup, functional_zone_lookup, reservation_lookup — по одному db.execute zone_regulation — upsert был защищён, SELECT нет Тест параметризован по всем шести именам, плюс два контроля: «без SAVEPOINT сессия травится» и «здоровый путь по-прежнему читает строки». На origin/main шесть падают с AbortedTransactionError, оба контроля зелёные. Стабы _FakeDB/_DB в пяти существующих тестах получили no-op begin_nested — без него 28 тестов падали на AttributeError. В шапке каждого стаба указано, где SAVEPOINT проверяется по-настоящему, чтобы no-op не читался как покрытие. Co-Authored-By: Claude Opus 5 --- .../site_finder/functional_zone_lookup.py | 8 +- .../services/site_finder/granddoc_lookup.py | 8 +- .../site_finder/ird_overlay_lookup.py | 8 +- .../services/site_finder/planning_lookup.py | 8 +- .../site_finder/reservation_lookup.py | 8 +- .../services/site_finder/zone_regulation.py | 8 +- .../test_2464_ird_lookups_savepoint.py | 90 +++++++++++++++++++ .../services/test_functional_zone_lookup.py | 10 +++ .../tests/services/test_granddoc_lookup.py | 10 +++ .../tests/services/test_ird_overlay_lookup.py | 12 ++- .../tests/services/test_planning_lookup.py | 10 +++ .../tests/services/test_reservation_lookup.py | 10 +++ 12 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 backend/tests/services/site_finder/test_2464_ird_lookups_savepoint.py diff --git a/backend/app/services/site_finder/functional_zone_lookup.py b/backend/app/services/site_finder/functional_zone_lookup.py index d06601ad..dc5b6d27 100644 --- a/backend/app/services/site_finder/functional_zone_lookup.py +++ b/backend/app/services/site_finder/functional_zone_lookup.py @@ -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( diff --git a/backend/app/services/site_finder/granddoc_lookup.py b/backend/app/services/site_finder/granddoc_lookup.py index 6b71b9c0..aa2ec85b 100644 --- a/backend/app/services/site_finder/granddoc_lookup.py +++ b/backend/app/services/site_finder/granddoc_lookup.py @@ -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 [] diff --git a/backend/app/services/site_finder/ird_overlay_lookup.py b/backend/app/services/site_finder/ird_overlay_lookup.py index 3d7681e8..d5db0dda 100644 --- a/backend/app/services/site_finder/ird_overlay_lookup.py +++ b/backend/app/services/site_finder/ird_overlay_lookup.py @@ -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). diff --git a/backend/app/services/site_finder/planning_lookup.py b/backend/app/services/site_finder/planning_lookup.py index 26141f8d..43edde3f 100644 --- a/backend/app/services/site_finder/planning_lookup.py +++ b/backend/app/services/site_finder/planning_lookup.py @@ -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) diff --git a/backend/app/services/site_finder/reservation_lookup.py b/backend/app/services/site_finder/reservation_lookup.py index 6771911b..fb22a7fd 100644 --- a/backend/app/services/site_finder/reservation_lookup.py +++ b/backend/app/services/site_finder/reservation_lookup.py @@ -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) diff --git a/backend/app/services/site_finder/zone_regulation.py b/backend/app/services/site_finder/zone_regulation.py index cf5fdb6d..88951ba4 100644 --- a/backend/app/services/site_finder/zone_regulation.py +++ b/backend/app/services/site_finder/zone_regulation.py @@ -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 diff --git a/backend/tests/services/site_finder/test_2464_ird_lookups_savepoint.py b/backend/tests/services/site_finder/test_2464_ird_lookups_savepoint.py new file mode 100644 index 00000000..ad9039ff --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_ird_lookups_savepoint.py @@ -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" diff --git a/backend/tests/services/test_functional_zone_lookup.py b/backend/tests/services/test_functional_zone_lookup.py index e7301728..2cc44131 100644 --- a/backend/tests/services/test_functional_zone_lookup.py +++ b/backend/tests/services/test_functional_zone_lookup.py @@ -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, diff --git a/backend/tests/services/test_granddoc_lookup.py b/backend/tests/services/test_granddoc_lookup.py index 9eda6beb..dc029ed3 100644 --- a/backend/tests/services/test_granddoc_lookup.py +++ b/backend/tests/services/test_granddoc_lookup.py @@ -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, diff --git a/backend/tests/services/test_ird_overlay_lookup.py b/backend/tests/services/test_ird_overlay_lookup.py index 3e9898c6..0ed3b958 100644 --- a/backend/tests/services/test_ird_overlay_lookup.py +++ b/backend/tests/services/test_ird_overlay_lookup.py @@ -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 не задеплоен", ), ( diff --git a/backend/tests/services/test_planning_lookup.py b/backend/tests/services/test_planning_lookup.py index 3207f5d6..8ec8a37e 100644 --- a/backend/tests/services/test_planning_lookup.py +++ b/backend/tests/services/test_planning_lookup.py @@ -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 diff --git a/backend/tests/services/test_reservation_lookup.py b/backend/tests/services/test_reservation_lookup.py index 644402de..3cfbbe47 100644 --- a/backend/tests/services/test_reservation_lookup.py +++ b/backend/tests/services/test_reservation_lookup.py @@ -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.45.3