From 2b646bc7e59f312dab2cc757d57f8374c693a45c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 14:30:29 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20=D0=B4=D0=B2=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=B0=20connection-capacity=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=8E=D1=82=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1=D1=89=D1=83=D1=8E?= =?UTF-8?q?=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D1=8E=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _query_power_points и _query_water_latest делали db.execute без SAVEPOINT, тогда как четыре соседних _query_* в том же файле его имеют. Пункт эпика указывал на другое место — отсутствие db.rollback() в except-блоке _get_connection_capacity (full_report_pdf.py:217). Но плоский rollback на ОБЩЕЙ сессии запрещён: он снёс бы работу вызывающего. Чинить надо там, где сбой происходит, — как во всей волне #2464. Цепочка целиком: сбой в _query_power_points → исключение всплывает (в get_connection_capacity try/except нет) → его глотает _get_connection_capacity ради деградации §3 → транзакция остаётся aborted → падает уже СЛЕДУЮЩИЙ запрос — рыночная цена в _generate_concept_result → концепция молча уходит в class_norm-фолбэк То есть отказ проявляется не там, где случился, и выглядит как «нет рыночных данных». Ровно этот класс и описывает эпик. Третье место (SET LOCAL statement_timeout в _query_nearby_network_zones) при беглом поиске выглядит незащищённым, но лежит ВНУТРИ savepoint'а — проверено по отступам, трогать не нужно. Тест на PostgresLikeSession (tests/support/pg_session.py) — двойнике с настоящей семантикой aborted-транзакции. На MagicMock он был бы зелёным по построению. Против origin/main: следующий запрос после проглоченного сбоя → AbortedTransactionError, ×2 savepoints_entered = 0 вместо >=1 → падает, ×2 двойник сам воспроизводит отравление — контроль, зелёный с обеих сторон Последний контроль не для симметрии: без него зелёные тесты нельзя отличить от «двойник ничего не моделирует». Прогоны: tests/services/site_finder + tests/services/exporters — 868 passed rc=0. Co-Authored-By: Claude Opus 5 --- .../site_finder/connection_capacity_lookup.py | 96 +++++++++++-------- .../test_2464_capacity_savepoints.py | 85 ++++++++++++++++ 2 files changed, 140 insertions(+), 41 deletions(-) create mode 100644 backend/tests/services/site_finder/test_2464_capacity_savepoints.py diff --git a/backend/app/services/site_finder/connection_capacity_lookup.py b/backend/app/services/site_finder/connection_capacity_lookup.py index 2c43e552..40fba5d8 100644 --- a/backend/app/services/site_finder/connection_capacity_lookup.py +++ b/backend/app/services/site_finder/connection_capacity_lookup.py @@ -113,32 +113,39 @@ def get_connection_capacity( def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]: """Центры питания в радиусе от центроида участка (ST_DWithin geography).""" - rows = ( - db.execute( - text(""" - SELECT sc_name, dzo_name, voltage_class, load_index, - installed_capacity_mva, current_load_mva, reserve_mva, - reserve_unit, district, reserve_asof, - ST_Distance( + # #2464: SAVEPOINT вокруг запроса. Сессия ОБЩАЯ с вызывающим — при сбое без + # savepoint транзакция остаётся aborted, а исключение всплывает до + # _get_connection_capacity (full_report_pdf.py), где оно ГЛОТАЕТСЯ ради + # деградации §3. Дальше падает уже следующий запрос — рыночная цена в + # _generate_concept_result — и концепция молча уходит в class_norm-фолбэк. + # Четыре соседних _query_* в этом же файле savepoint имеют. + with db.begin_nested(): + rows = ( + db.execute( + text(""" + SELECT sc_name, dzo_name, voltage_class, load_index, + installed_capacity_mva, current_load_mva, reserve_mva, + reserve_unit, district, reserve_asof, + ST_Distance( + geom::geography, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography + ) AS distance_m, + ST_Y(geom) AS lat, + ST_X(geom) AS lon + FROM power_supply_centers + WHERE geom IS NOT NULL + AND ST_DWithin( geom::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography - ) AS distance_m, - ST_Y(geom) AS lat, - ST_X(geom) AS lon - FROM power_supply_centers - WHERE geom IS NOT NULL - AND ST_DWithin( - geom::geography, - ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, - CAST(:radius_m AS float) - ) - ORDER BY distance_m ASC - """), - {"wkt": parcel_wkt, "radius_m": radius_m}, + ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, + CAST(:radius_m AS float) + ) + ORDER BY distance_m ASC + """), + {"wkt": parcel_wkt, "radius_m": radius_m}, + ) + .mappings() + .all() ) - .mappings() - .all() - ) points: list[dict] = [] for r in rows: @@ -209,24 +216,31 @@ def _query_water_latest(db: Session) -> list[dict]: опубликовать водоотведение на квартал позже водоснабжения — глобальный MAX молча выкинул бы отстающий вид целиком. """ - rows = ( - db.execute( - text(""" - SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day, - w.note, w.period - FROM water_supply_reserves w - WHERE w.period IS NOT NULL - AND w.period = ( - SELECT MAX(w2.period) FROM water_supply_reserves w2 - WHERE w2.period IS NOT NULL - AND w2.system_kind = w.system_kind - ) - ORDER BY w.system_kind, w.system_name - """) + # #2464: SAVEPOINT вокруг запроса. Сессия ОБЩАЯ с вызывающим — при сбое без + # savepoint транзакция остаётся aborted, а исключение всплывает до + # _get_connection_capacity (full_report_pdf.py), где оно ГЛОТАЕТСЯ ради + # деградации §3. Дальше падает уже следующий запрос — рыночная цена в + # _generate_concept_result — и концепция молча уходит в class_norm-фолбэк. + # Четыре соседних _query_* в этом же файле savepoint имеют. + with db.begin_nested(): + rows = ( + db.execute( + text(""" + SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day, + w.note, w.period + FROM water_supply_reserves w + WHERE w.period IS NOT NULL + AND w.period = ( + SELECT MAX(w2.period) FROM water_supply_reserves w2 + WHERE w2.period IS NOT NULL + AND w2.system_kind = w.system_kind + ) + ORDER BY w.system_kind, w.system_name + """) + ) + .mappings() + .all() ) - .mappings() - .all() - ) return [ { "system_kind": r["system_kind"], diff --git a/backend/tests/services/site_finder/test_2464_capacity_savepoints.py b/backend/tests/services/site_finder/test_2464_capacity_savepoints.py new file mode 100644 index 00000000..c43e1d77 --- /dev/null +++ b/backend/tests/services/site_finder/test_2464_capacity_savepoints.py @@ -0,0 +1,85 @@ +"""Сбой в connection-capacity не должен травить общую сессию (#2464). + +`_query_power_points` и `_query_water_latest` делали `db.execute` без SAVEPOINT, тогда как +четыре соседних `_query_*` в том же файле его имеют. Сессия здесь ОБЩАЯ с вызывающим, и +цепочка выходила такая: + + сбой в _query_power_points + → исключение всплывает (в get_connection_capacity try/except нет) + → его ГЛОТАЕТ _get_connection_capacity в full_report_pdf.py ради деградации §3 + → транзакция остаётся aborted + → падает уже СЛЕДУЮЩИЙ запрос — рыночная цена в _generate_concept_result + → концепция молча уходит в class_norm-фолбэк + +То есть отказ проявляется не там, где случился, и выглядит как «нет рыночных данных». + +Проверяется на `PostgresLikeSession` (tests/support/pg_session.py) — двойнике с настоящей +семантикой aborted-транзакции. На `MagicMock` такой тест был бы зелёным по построению: +у него нет aborted-состояния, и любой следующий execute «успешен». +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from tests.support.pg_session import AbortedTransactionError, PostgresLikeSession + +_QUERIES = ("_query_power_points", "_query_water_latest") + + +def _call(name: str, db: PostgresLikeSession): + from app.services.site_finder import connection_capacity_lookup as mod + + fn = getattr(mod, name) + if name == "_query_power_points": + return fn(db, "POLYGON((0 0,0 1,1 1,1 0,0 0))", 5000) + return fn(db) + + +@pytest.mark.parametrize("name", _QUERIES) +def test_failure_leaves_session_usable(name: str) -> None: + """После проглоченного вызывающим сбоя следующий запрос обязан пройти. + + На origin/main второй execute падает с AbortedTransactionError — ровно та + отложенная поломка, что уводит концепцию в class_norm. + """ + db = PostgresLikeSession(fail_on=(1,)) + + with pytest.raises(RuntimeError): # сам сбой пробрасывается, это ожидаемо + _call(name, db) + + # Вызывающий (full_report_pdf._get_connection_capacity) исключение глотает, + # и дальше по коду идёт СЛЕДУЮЩИЙ запрос на той же сессии. + db.execute("SELECT 1") # на origin/main здесь AbortedTransactionError + + +@pytest.mark.parametrize("name", _QUERIES) +def test_savepoint_is_actually_entered(name: str) -> None: + """Запрос действительно идёт внутри SAVEPOINT, а не просто «не падает». + + Отдельно от предыдущего: тест выше прошёл бы и если бы кто-то «починил» + функцию, убрав из неё запрос целиком. + """ + db = PostgresLikeSession(fail_on=(), rows=[]) # здоровый путь + _call(name, db) + assert ( + db.savepoints_entered >= 1 + ), f"{name}: запрос выполнен вне SAVEPOINT — при сбое сессия останется aborted" + assert db.calls >= 1, f"{name}: запрос вообще не выполнялся" + + +def test_double_reproduces_poisoning_without_savepoint() -> None: + """Контроль на сам двойник: без SAVEPOINT он ДОЛЖЕН травить сессию. + + Без этой проверки зелёные тесты выше нельзя отличить от «двойник ничего не + моделирует» — та же ловушка, что с MagicMock. + """ + db = PostgresLikeSession(fail_on=(1,)) + with pytest.raises(RuntimeError): + db.execute("BOOM") + with pytest.raises(AbortedTransactionError): + db.execute("SELECT 1")