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
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>
This commit is contained in:
parent
1470bc26de
commit
2b646bc7e5
2 changed files with 140 additions and 41 deletions
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
Loading…
Add table
Reference in a new issue