fix(ptica): два запроса connection-capacity перестают травить общую сессию (#2464) #2964

Merged
bot-backend merged 1 commit from fix/2464-capacity-savepoints into main 2026-08-20 09:53:35 +00:00
2 changed files with 140 additions and 41 deletions

View file

@ -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"],

View file

@ -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")