Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
2b646bc7e5 fix(ptica): два запроса connection-capacity перестают травить общую сессию (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m2s
CI / backend-tests (pull_request) Successful in 17m28s
_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 <noreply@anthropic.com>
2026-08-20 14:30:29 +05: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]: def _query_power_points(db: Session, parcel_wkt: str, radius_m: int) -> list[dict]:
"""Центры питания в радиусе от центроида участка (ST_DWithin geography).""" """Центры питания в радиусе от центроида участка (ST_DWithin geography)."""
rows = ( # #2464: SAVEPOINT вокруг запроса. Сессия ОБЩАЯ с вызывающим — при сбое без
db.execute( # savepoint транзакция остаётся aborted, а исключение всплывает до
text(""" # _get_connection_capacity (full_report_pdf.py), где оно ГЛОТАЕТСЯ ради
SELECT sc_name, dzo_name, voltage_class, load_index, # деградации §3. Дальше падает уже следующий запрос — рыночная цена в
installed_capacity_mva, current_load_mva, reserve_mva, # _generate_concept_result — и концепция молча уходит в class_norm-фолбэк.
reserve_unit, district, reserve_asof, # Четыре соседних _query_* в этом же файле savepoint имеют.
ST_Distance( 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, geom::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
) AS distance_m, CAST(:radius_m AS float)
ST_Y(geom) AS lat, )
ST_X(geom) AS lon ORDER BY distance_m ASC
FROM power_supply_centers """),
WHERE geom IS NOT NULL {"wkt": parcel_wkt, "radius_m": radius_m},
AND ST_DWithin( )
geom::geography, .mappings()
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, .all()
CAST(:radius_m AS float)
)
ORDER BY distance_m ASC
"""),
{"wkt": parcel_wkt, "radius_m": radius_m},
) )
.mappings()
.all()
)
points: list[dict] = [] points: list[dict] = []
for r in rows: for r in rows:
@ -209,24 +216,31 @@ def _query_water_latest(db: Session) -> list[dict]:
опубликовать водоотведение на квартал позже водоснабжения глобальный MAX опубликовать водоотведение на квартал позже водоснабжения глобальный MAX
молча выкинул бы отстающий вид целиком. молча выкинул бы отстающий вид целиком.
""" """
rows = ( # #2464: SAVEPOINT вокруг запроса. Сессия ОБЩАЯ с вызывающим — при сбое без
db.execute( # savepoint транзакция остаётся aborted, а исключение всплывает до
text(""" # _get_connection_capacity (full_report_pdf.py), где оно ГЛОТАЕТСЯ ради
SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day, # деградации §3. Дальше падает уже следующий запрос — рыночная цена в
w.note, w.period # _generate_concept_result — и концепция молча уходит в class_norm-фолбэк.
FROM water_supply_reserves w # Четыре соседних _query_* в этом же файле savepoint имеют.
WHERE w.period IS NOT NULL with db.begin_nested():
AND w.period = ( rows = (
SELECT MAX(w2.period) FROM water_supply_reserves w2 db.execute(
WHERE w2.period IS NOT NULL text("""
AND w2.system_kind = w.system_kind SELECT w.system_kind, w.system_name, w.reserve_thousand_m3_day,
) w.note, w.period
ORDER BY w.system_kind, w.system_name 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 [ return [
{ {
"system_kind": r["system_kind"], "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")