All checks were successful
Deploy / changes (push) Successful in 14s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 1m2s
Deploy / build-worker (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m17s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
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
|