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