fix(ptica): job_settings не отравляет чужую сессию при сбое БД (#2464 кластер A) (#2937)
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m24s
Deploy / build-worker (push) Successful in 3m50s
Deploy / deploy (push) Successful in 1m30s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 10s
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 2m24s
Deploy / build-worker (push) Successful in 3m50s
Deploy / deploy (push) Successful in 1m30s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 10s
This commit is contained in:
parent
52349a39f6
commit
a2fbe4b400
2 changed files with 177 additions and 28 deletions
|
|
@ -124,22 +124,30 @@ def _fallback(job_type: str) -> dict[str, Any]:
|
|||
def get_all(db) -> list[dict[str, Any]]:
|
||||
"""Вернуть все строки job_settings. При ошибке БД — fallback на _DEFAULTS."""
|
||||
try:
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
|
||||
max_retries, max_concurrency, extra_config,
|
||||
updated_at, updated_by, description
|
||||
FROM job_settings
|
||||
ORDER BY job_type
|
||||
"""
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
|
||||
max_retries, max_concurrency, extra_config,
|
||||
updated_at, updated_by, description
|
||||
FROM job_settings
|
||||
ORDER BY job_type
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
except Exception as e:
|
||||
# #2464 cluster A: сессия ЧУЖАЯ — её отдаёт вызывающий (admin-ручка,
|
||||
# beat_schedule, get_setting_value из cadastre_fetch/nspd_geo). Ошибка
|
||||
# db.execute на Postgres оставляет транзакцию в aborted-состоянии, и все
|
||||
# последующие запросы этой же сессии падают с «current transaction is
|
||||
# aborted». Голый db.rollback() здесь НЕЛЬЗЯ: он снёс бы незакоммиченную
|
||||
# работу вызывающего. Поэтому SAVEPOINT вокруг самого execute (см.
|
||||
# developer_attribution.py:152, тот же кластер) — откатывается только он.
|
||||
logger.warning("get_all job_settings: БД недоступна — fallback. %s", e)
|
||||
return [_fallback(jt) for jt in _DEFAULTS]
|
||||
|
||||
|
|
@ -153,23 +161,25 @@ def get_all(db) -> list[dict[str, Any]]:
|
|||
def get_one(job_type: str, db) -> dict[str, Any]:
|
||||
"""Вернуть одну строку по job_type. При отсутствии — fallback с warning."""
|
||||
try:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
|
||||
max_retries, max_concurrency, extra_config,
|
||||
updated_at, updated_by, description
|
||||
FROM job_settings
|
||||
WHERE job_type = :jt
|
||||
"""
|
||||
),
|
||||
{"jt": job_type},
|
||||
with db.begin_nested():
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT job_type, enabled, queue_name, cron_schedule, rate_ms,
|
||||
max_retries, max_concurrency, extra_config,
|
||||
updated_at, updated_by, description
|
||||
FROM job_settings
|
||||
WHERE job_type = :jt
|
||||
"""
|
||||
),
|
||||
{"jt": job_type},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception as e:
|
||||
# См. get_all выше: SAVEPOINT, а не rollback — сессия принадлежит вызывающему.
|
||||
logger.warning("get_one job_settings '%s': БД недоступна — fallback. %s", job_type, e)
|
||||
return _fallback(job_type)
|
||||
|
||||
|
|
|
|||
139
backend/tests/services/test_2464a_job_settings_savepoint.py
Normal file
139
backend/tests/services/test_2464a_job_settings_savepoint.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""#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 вместо данных БД"
|
||||
Loading…
Add table
Reference in a new issue