fix(metrics): развязать vidconf_pipeline_sessions с основным пулом БД

_refresh_pipeline_sessions_gauge и metrics_endpoint ходили через
Depends(get_session) — основной пул, разделяемый с API-запросами. В
инциденте 07.08 это дало 16 падений в api/metrics.py ровно тогда, когда
метрики были нужнее всего (пул исчерпан). db_up/db_pool_* уже были
развязаны в 0.0.32, эта метрика — нет (мешал тестовый харнесс).

Добавлен get_metrics_session (core/db.py) — отдельный движок с NullPool,
как у check_db_up, но с полноценной ORM-сессией для репозитория. Тестовый
харнесс (app-фикстура) подменяет её на ту же savepoint-сессию, что и
get_session, — иначе /metrics не видел бы данные теста.
This commit is contained in:
2026-08-10 16:09:09 +03:00
parent 0a13589612
commit c4485d43b1
3 changed files with 66 additions and 24 deletions

View File

@@ -14,17 +14,19 @@ Redis) можно опросить обычным `await` вместо реал
и вовсе синхронный — настройки уже в памяти процесса). и вовсе синхронный — настройки уже в памяти процесса).
🔴 Метрики о состоянии основного пула БД (`vidconf_db_up`, 🔴 Метрики о состоянии основного пула БД (`vidconf_db_up`,
`vidconf_db_pool_*`) обязаны читаться БЕЗ обращения к самому пулу — иначе `vidconf_db_pool_*`, `vidconf_pipeline_sessions`) обязаны читаться БЕЗ
в момент его исчерпания (см. `.forcc/session-results/32-loadtest-07-08-debug.md`) обращения к самому пулу — иначе в момент его исчерпания (см.
эндпоинт метрик падал бы вместе со всем остальным ровно тогда, когда нужнее `.forcc/session-results/32-loadtest-07-08-debug.md`) эндпоинт метрик падал бы
всего. `vidconf_db_pool_*` — синхронный снимок `engine.pool` (см. вместе со всем остальным ровно тогда, когда нужнее всего. `vidconf_db_pool_*`
`core/db.py::db_pool_stats`), `vidconf_db_up` — отдельное соединение вне — синхронный снимок `engine.pool` (см. `core/db.py::db_pool_stats`),
основного пула (`core/db.py::check_db_up`). `_refresh_pipeline_sessions_gauge` `vidconf_db_up` — отдельное соединение вне основного пула
по-прежнему ходит через основной пул (`Depends(get_session)`, тестовый (`core/db.py::check_db_up`). `_refresh_pipeline_sessions_gauge` с сессии 37
харнесс подменяет её на savepoint-сессию — см. `tests/conftest.py`; развести тоже читает через отдельный движок (`Depends(get_metrics_session)`,
полностью, как `vidconf_db_up`, значило бы переделывать харнесс ради того же `core/db.py`) — тестовый харнесс подменяет её на savepoint-сессию теста так
эффекта — цена не оправдана, см. прецедент `f7c4fb4`/session 32), но обёрнута же, как `get_session` (см. `tests/conftest.py`). До сессии 37 она ходила
таймаутом и try/except, чтобы её недоступность не роняла остальные метрики. через основной пул (`Depends(get_session)`) — прецедент `f7c4fb4`/session 32;
дополнительная обёртка таймаутом и try/except ниже осталась как вторая
линия обороны на случай, если сама БД (а не пул) не отвечает.
""" """
import asyncio import asyncio
@@ -37,7 +39,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match from starlette.routing import Match
from core.config import get_settings from core.config import get_settings
from core.db import check_db_up, db_pool_checked_out, get_session from core.db import check_db_up, db_pool_checked_out, get_metrics_session
from core.redis import redis_client from core.redis import redis_client
from models.session import PIPELINE_STATUSES from models.session import PIPELINE_STATUSES
from repositories.conferences import ConferenceSessionRepository from repositories.conferences import ConferenceSessionRepository
@@ -104,12 +106,12 @@ _PIPELINE_GAUGE_TIMEOUT_S = 2.0
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None: async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`. """Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.
Ходит через основной пул (`session` — из `Depends(get_session)`, см. `session` — из `Depends(get_metrics_session)` (отдельный от основного
докстринг модуля про ограничения тестового харнесса). Если пул занят пула движок, см. докстринг модуля и `core/db.py`). Таймаут и try/except
или БД недоступна, запрос не должен держать весь `/metrics` — таймаут ниже — вторая линия обороны на случай, если недоступна сама БД (а не
короче `db_pool_timeout`, ошибка гасится, gauge остаётся на прежнем только основной пул): запрос не должен держать весь `/metrics`, gauge
значении (не обнуляется — обнулять его при недоступности БД так же остаётся на прежнем значении (не обнуляется — обнулять его при
неверно, как считать сеансы пропавшими). недоступности БД так же неверно, как считать сеансы пропавшими).
""" """
try: try:
counts = await asyncio.wait_for( counts = await asyncio.wait_for(
@@ -249,7 +251,7 @@ def _refresh_host_info_gauge() -> None:
@router.get("/metrics") @router.get("/metrics")
async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Response: async def metrics_endpoint(session: AsyncSession = Depends(get_metrics_session)) -> Response:
"""Отдать метрики Prometheus в формате text exposition. """Отдать метрики Prometheus в формате text exposition.
Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим Gauge'и пересчитываются прямо здесь (а не по расписанию/периодическим
@@ -259,10 +261,10 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
редко (обычно раз в 1530с), нагрузка пренебрежимо мала. редко (обычно раз в 1530с), нагрузка пренебрежимо мала.
Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`, Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`,
`_refresh_db_pool_gauges`) считаются первыми и не зависят от самого пула `_refresh_db_pool_gauges`) считаются первыми — они гарантированно попадут в
(см. докстринг модуля) — они гарантированно попадут в ответ, даже если ответ, даже если следующая за ними `_refresh_pipeline_sessions_gauge`
следующий за ними `_refresh_pipeline_sessions_gauge` (основной пул) зависнет (отдельный движок, но всё ещё сама БД) зависнет или упадёт (таймаут/
или упадёт под нагрузкой. try-except внутри неё гасят это, не роняя остальные метрики).
""" """
await _refresh_db_up_gauge() await _refresh_db_up_gauge()
_refresh_db_pool_gauges() _refresh_db_pool_gauges()

View File

@@ -65,6 +65,38 @@ async def check_db_up() -> bool:
return True return True
# --- Сессия для метрик, читающих данные (не только «жив/мёртв»), вне основного
# пула (сессия 37, доделка session 32/33 — см. докстринг `api/metrics.py`) ----
#
# `check_db_up` выше обходится голым соединением ("SELECT 1"), но
# `vidconf_pipeline_sessions` нужна полноценная ORM-сессия (репозиторий,
# группировка по статусу) — `NullPool`, как и у `_probe_engine`: каждый вызов
# открывает новое соединение и сразу закрывает его, бюджет основного пула
# (`engine.pool`) не расходуется. `async_sessionmaker` — тот же паттерн, что
# `async_session_maker` выше, просто на другом движке.
_metrics_probe_engine: AsyncEngine = create_async_engine(
settings.database_url,
poolclass=NullPool,
connect_args={"timeout": settings.db_probe_timeout_s},
)
_metrics_session_maker = async_sessionmaker(_metrics_probe_engine, expire_on_commit=False)
async def get_metrics_session() -> AsyncGenerator[AsyncSession, None]:
"""Зависимость FastAPI для метрик, которым нужна БД, но не основной пул.
В отличие от `get_session()` (основной пул `engine.pool`, конкурирует за
те же 10+10×воркеров соединений, что и API-запросы), сессия здесь открыта
на `_metrics_probe_engine` — исчерпание основного пула эту метрику не
заденет, как и `vidconf_db_up`/`vidconf_db_pool_*`. Тестовый харнесс
(`tests/conftest.py`) подменяет и её на savepoint-сессию теста — так же,
как `get_session` — иначе тест `vidconf_pipeline_sessions` не видел бы
данные, ещё не закоммиченные за пределы savepoint.
"""
async with _metrics_session_maker() as session:
yield session
def db_pool_checked_out() -> int: def db_pool_checked_out() -> int:
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД. """Число соединений основного пула, занятых прямо сейчас — без обращения к БД.

View File

@@ -145,13 +145,21 @@ async def db_session(db_connection: AsyncConnection) -> AsyncGenerator[AsyncSess
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]: async def app(db_session: AsyncSession) -> AsyncGenerator[FastAPI, None]:
"""Экземпляр FastAPI-приложения с `get_session`, подменённым на тестовую (savepoint) сессию.""" """Экземпляр FastAPI-приложения с `get_session`/`get_metrics_session`,
подменёнными на тестовую (savepoint) сессию.
`get_metrics_session` (сессия 37, `core/db.py`) в проде — отдельный от
основного пула движок, но в тестах должен указывать на ТУ ЖЕ savepoint-
сессию, что и `get_session` — иначе `/metrics` не видел бы данные теста,
ещё не закоммиченные за пределы savepoint (см. `test_metrics_api.py`).
"""
application = create_app() application = create_app()
async def _override_get_session() -> AsyncGenerator[AsyncSession, None]: async def _override_get_session() -> AsyncGenerator[AsyncSession, None]:
yield db_session yield db_session
application.dependency_overrides[get_session] = _override_get_session application.dependency_overrides[get_session] = _override_get_session
application.dependency_overrides[get_metrics_session] = _override_get_session
yield application yield application