All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
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 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после #3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки на нетронутых строках (длинные assert-сообщения и т.п.). Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего, кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих проектах, повторный `format --check` — 1001 файл без изменений (идемпотентно), compileall чистый. Refs #2864
139 lines
5.7 KiB
Python
139 lines
5.7 KiB
Python
"""#2464 кластер A: job_settings не должен отравлять ЧУЖУЮ сессию.
|
||
|
||
`get_all`/`get_one` глотают ошибку БД и возвращают fallback. Сессию им отдаёт
|
||
вызывающий — admin-ручка, `beat_schedule`, либо `get_setting_value` из
|
||
`cadastre_fetch`/`nspd_geo`. На Postgres упавший `db.execute` оставляет транзакцию
|
||
в aborted-состоянии, и ВСЕ последующие запросы этой же сессии падают с
|
||
«current transaction is aborted, commands ignored until end of transaction block» —
|
||
падает не тот, кто виноват.
|
||
|
||
Голый `db.rollback()` здесь запрещён: он снёс бы незакоммиченную работу
|
||
вызывающего. Правильное средство — SAVEPOINT вокруг самого execute
|
||
(`developer_attribution.py:152`, тот же кластер).
|
||
|
||
О МОКЕ
|
||
──────
|
||
Мок ниже ВОСПРОИЗВОДИТ семантику Postgres: после упавшего execute сессия помечается
|
||
aborted и дальнейшие запросы падают, пока откат SAVEPOINT её не восстановит.
|
||
|
||
Это существенно. Обычный «мок с пустым begin_nested» (см. tests/test_saturation.py)
|
||
такую проверку не даёт: у него нет aborted-состояния, поэтому второй execute
|
||
проходит в любом случае — и тест зелёный независимо от того, есть SAVEPOINT в коде
|
||
или нет. Проверка, которую нельзя уронить, сняв защиту, защиты не проверяет.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from contextlib import contextmanager
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.services.job_settings import get_all, get_one
|
||
|
||
|
||
class AbortedTransactionError(RuntimeError):
|
||
"""Аналог psycopg InFailedSqlTransaction."""
|
||
|
||
|
||
class _PostgresLikeDb:
|
||
"""Сессия с aborted-состоянием и настоящей семантикой SAVEPOINT."""
|
||
|
||
def __init__(self, *, fail_first: bool = True) -> None:
|
||
self.calls = 0
|
||
self.aborted = False
|
||
self._fail_first = fail_first
|
||
self._savepoint_depth = 0
|
||
|
||
@contextmanager
|
||
def begin_nested(self): # type: ignore[no-untyped-def]
|
||
self._savepoint_depth += 1
|
||
try:
|
||
yield
|
||
except Exception:
|
||
# Откат SAVEPOINT: снимаем aborted, внешняя транзакция цела.
|
||
self.aborted = False
|
||
raise
|
||
finally:
|
||
self._savepoint_depth -= 1
|
||
|
||
def execute(self, *_args: Any, **_kwargs: Any) -> Any:
|
||
if self.aborted:
|
||
raise AbortedTransactionError(
|
||
"current transaction is aborted, commands ignored until end of transaction block"
|
||
)
|
||
self.calls += 1
|
||
if self.calls == 1 and self._fail_first:
|
||
# Ошибка внутри транзакции переводит её в aborted.
|
||
self.aborted = True
|
||
raise RuntimeError("simulated DB failure")
|
||
return _Result()
|
||
|
||
|
||
# Строка в форме, которую ждёт _row_to_dict: одиннадцать колонок SELECT'а.
|
||
_DB_ROW: dict[str, Any] = {
|
||
"job_type": "scrape_kn",
|
||
"enabled": True,
|
||
"queue_name": "celery",
|
||
"cron_schedule": "0 3 * * *",
|
||
"rate_ms": 1000,
|
||
"max_retries": 3,
|
||
"max_concurrency": 1,
|
||
"extra_config": {"marker": "из-БД"},
|
||
"updated_at": None,
|
||
"updated_by": None,
|
||
"description": "тестовая строка",
|
||
}
|
||
|
||
|
||
class _Result:
|
||
def mappings(self) -> _Result:
|
||
return self
|
||
|
||
def all(self) -> list[dict[str, Any]]:
|
||
return [_DB_ROW]
|
||
|
||
def first(self) -> dict[str, Any]:
|
||
return _DB_ROW
|
||
|
||
|
||
def test_get_all_leaves_the_caller_session_usable() -> None:
|
||
"""После сбоя внутри get_all следующий запрос вызывающего должен пройти."""
|
||
db = _PostgresLikeDb()
|
||
|
||
rows = get_all(db)
|
||
assert rows, "fallback не вернулся — сломано само graceful-поведение"
|
||
|
||
# Это и есть проверка: на origin/main здесь AbortedTransaction.
|
||
assert db.execute("SELECT 1").mappings().first() == _DB_ROW
|
||
|
||
|
||
def test_get_one_leaves_the_caller_session_usable() -> None:
|
||
db = _PostgresLikeDb()
|
||
|
||
row = get_one("scrape_kn", db)
|
||
assert row, "fallback не вернулся"
|
||
|
||
assert db.execute("SELECT 1").mappings().first() == _DB_ROW
|
||
|
||
|
||
def test_mock_actually_poisons_without_a_savepoint() -> None:
|
||
"""Контроль на сам мок: без SAVEPOINT он ОБЯЗАН отравляться.
|
||
|
||
Без этой проверки тесты выше были бы зелёными по построению — ровно та ловушка,
|
||
из-за которой существующий мок в test_saturation.py ничего не проверяет.
|
||
"""
|
||
db = _PostgresLikeDb()
|
||
with pytest.raises(RuntimeError):
|
||
db.execute("boom") # первый вызов падает и переводит в aborted
|
||
with pytest.raises(AbortedTransactionError):
|
||
db.execute("SELECT 1")
|
||
|
||
|
||
def test_healthy_session_is_not_disturbed() -> None:
|
||
"""Контроль: без сбоя поведение прежнее — данные из БД, а не fallback."""
|
||
db = _PostgresLikeDb(fail_first=False)
|
||
rows = get_all(db)
|
||
assert len(rows) == 1
|
||
assert rows[0]["job_type"] == "scrape_kn"
|
||
assert rows[0]["extra_config"] == {"marker": "из-БД"}, "вернулся fallback вместо данных БД"
|