All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
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 2m17s
CI / backend-tests (pull_request) Successful in 17m21s
Мутационный прогон 19.08 показал, что у трёх закрытых пунктов кластера снятие
`with db.begin_nested():` не роняет НИ ОДНОГО теста:
saturation.py снят SAVEPOINT -> 465 passed
zone_regulation.py снят SAVEPOINT -> 195 passed
pat_lookup.py снят SAVEPOINT -> 21 passed
Код там правильный — беззащитны регрессии. Причина общая: существующие проверки
построены на MagicMock, у которого нет aborted-состояния, поэтому второй execute
проходит при любом коде. Такая проверка ловит только проглоченное исключение.
Добавлен двойник tests/support/pg_session.py, воспроизводящий семантику Postgres:
упавший запрос переводит транзакцию в aborted; следующий execute падает с тем же
текстом, что отдаёт Postgres; выход из begin_nested с исключением = ROLLBACK TO
SAVEPOINT и снимает aborted.
Проверяется СВОЙСТВО «сессия вызывающего осталась рабочей», а не наличие вызова
begin_nested: второе зафиксировало бы реализацию, первое переживёт любой
корректный способ починки. Плюс два контроля на сам двойник — без них проверки
были бы зелёными по построению.
Заодно закрыт пробел в connection_capacity_lookup. Его собственный тест краснеет
только при снятии ВСЕХ пяти SAVEPOINT, потому что проверяет
`begin_nested.call_count >= 1` — «хоть один был», а не «этот запрос обёрнут».
Снятие одного из пяти (обычный случай при рефакторинге одной функции) проходило
незамеченным. Новые проверки адресные: по одной на функцию.
Прицел важен: первая версия теста для _query_gas_city_grs роняла ПЕРВЫЙ execute,
а он принадлежит _query_gas_outlet_counts, которую эта функция зовёт внутри себя —
то есть тест сторожил соседа. Исправлено на fail_on=(2,).
Проверено мутацией: снятие SAVEPOINT в любом из трёх модулей роняет ровно свою
проверку; в connection_capacity_lookup поштучное снятие каждого из пяти — тоже
(5 из 5, до правки 4 из 5).
pytest tests/services: 3070 passed, 14 skipped, rc=0
99 lines
4.1 KiB
Python
99 lines
4.1 KiB
Python
"""Сессия-двойник с настоящей семантикой aborted-транзакции Postgres.
|
||
|
||
Зачем отдельный помощник, а не MagicMock: у мока нет состояния «транзакция
|
||
оборвана», поэтому второй `execute` в тесте проходит независимо от того, обёрнут
|
||
первый в SAVEPOINT или нет. Проверка на отравление сессии на таком моке зелена по
|
||
построению — она может поймать только проглоченное исключение.
|
||
|
||
Замер 19.08.2026 (мутационный прогон по кластеру A эпика #2464): у трёх мест из
|
||
одиннадцати снятие SAVEPOINT не роняет НИ ОДНОГО теста — saturation (465 passed),
|
||
zone_regulation (195), pat_lookup (21). Код там правильный; беззащитны регрессии.
|
||
|
||
Семантика, которую двойник воспроизводит:
|
||
* упавший `execute` переводит транзакцию в aborted;
|
||
* любой следующий `execute` в aborted-состоянии падает с тем же текстом, что
|
||
отдаёт Postgres, — до конца транзакции;
|
||
* выход из `begin_nested()` с исключением = ROLLBACK TO SAVEPOINT: снимает
|
||
aborted, внешняя транзакция остаётся рабочей.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from contextlib import contextmanager
|
||
from typing import Any
|
||
|
||
_ABORTED_MESSAGE = "current transaction is aborted, commands ignored until end of transaction block"
|
||
|
||
|
||
class AbortedTransactionError(RuntimeError):
|
||
"""Аналог psycopg InFailedSqlTransaction."""
|
||
|
||
|
||
class PostgresLikeSession:
|
||
"""Минимальная сессия: aborted-состояние + рабочий SAVEPOINT.
|
||
|
||
fail_on: номера вызовов `execute` (с единицы), которые должны упасть.
|
||
error: исключение, которым падать (важно — обработчики ловят конкретные типы
|
||
SQLAlchemy, а не голый Exception).
|
||
rows / row: что отдавать при успешном вызове.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
fail_on: tuple[int, ...] = (1,),
|
||
error: BaseException | None = None,
|
||
rows: list[Any] | None = None,
|
||
row: Any = None,
|
||
) -> None:
|
||
self.calls = 0
|
||
self.aborted = False
|
||
self.savepoints_entered = 0
|
||
self._fail_on = fail_on
|
||
self._error = error or RuntimeError("simulated DB failure")
|
||
self._rows = rows if rows is not None else []
|
||
self._row = row
|
||
|
||
@contextmanager
|
||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||
self.savepoints_entered += 1
|
||
try:
|
||
yield
|
||
except BaseException:
|
||
# ROLLBACK TO SAVEPOINT — внешняя транзакция снова пригодна.
|
||
self.aborted = False
|
||
raise
|
||
|
||
def execute(self, *_args: Any, **_kwargs: Any) -> _Result:
|
||
if self.aborted:
|
||
raise AbortedTransactionError(_ABORTED_MESSAGE)
|
||
self.calls += 1
|
||
if self.calls in self._fail_on:
|
||
self.aborted = True
|
||
raise self._error
|
||
return _Result(self._rows, self._row)
|
||
|
||
# ── прочее, что могут дёрнуть обработчики ────────────────────────────────
|
||
def commit(self) -> None:
|
||
if self.aborted:
|
||
raise AbortedTransactionError(_ABORTED_MESSAGE)
|
||
|
||
def rollback(self) -> None:
|
||
self.aborted = False
|
||
|
||
|
||
class _Result:
|
||
def __init__(self, rows: list[Any], row: Any) -> None:
|
||
self._rows, self._row = rows, row
|
||
|
||
def mappings(self) -> _Result:
|
||
return self
|
||
|
||
def all(self) -> list[Any]:
|
||
return self._rows
|
||
|
||
def first(self) -> Any:
|
||
return self._row
|
||
|
||
def scalar(self) -> Any:
|
||
return self._row
|