feat(metrics): метрики доступности БД и занятости пулов БД/Redis

vidconf_db_up проверяется отдельным от основного пула соединением
(NullPool, короткий таймаут) — иначе в момент исчерпания пула проверка
сама встала бы в очередь и не отличила бы «БД лежит» от «пул занят».
vidconf_db_pool_* читаются синхронно из engine.pool, без единого запроса
к БД. metrics_endpoint больше не виснет и не падает при недоступном
основном пуле: критичные gauge'и считаются первыми и не зависят от него,
а vidconf_pipeline_sessions (по-прежнему через Depends(get_session) —
тестовый харнесс подменяет её на savepoint-сессию) обёрнут таймаутом
и try/except.
This commit is contained in:
2026-08-09 02:39:59 +03:00
parent c906c97cb8
commit 0e56960714
5 changed files with 238 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей и железа. """Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей, БД и железа.
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне `GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops): периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
@@ -12,8 +12,22 @@ Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth`/
Redis) можно опросить обычным `await` вместо реализации синхронного Redis) можно опросить обычным `await` вместо реализации синхронного
`prometheus_client.registry.Collector` (у `vidconf_host_info` источник `prometheus_client.registry.Collector` (у `vidconf_host_info` источник
и вовсе синхронный — настройки уже в памяти процесса). и вовсе синхронный — настройки уже в памяти процесса).
🔴 Метрики о состоянии основного пула БД (`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, чтобы её недоступность не роняла остальные метрики.
""" """
import asyncio
import time import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
@@ -23,7 +37,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 get_session from core.db import check_db_up, db_pool_checked_out, get_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
@@ -80,9 +94,30 @@ PIPELINE_SESSIONS = Gauge(
) )
# Сколько ждать основной пул под этой конкретной метрикой, прежде чем
# сдаться и оставить прежнее значение gauge. Меньше `db_pool_timeout` (10с,
# `core/config.py`) — Prometheus скрейпит раз в 15с, и эта метрика не должна
# в одиночку съедать бюджет всего окна scrape.
_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`.
counts = await ConferenceSessionRepository(session).count_by_pipeline_status()
Ходит через основной пул (`session` — из `Depends(get_session)`, см.
докстринг модуля про ограничения тестового харнесса). Если пул занят
или БД недоступна, запрос не должен держать весь `/metrics` — таймаут
короче `db_pool_timeout`, ошибка гасится, gauge остаётся на прежнем
значении (не обнуляется — обнулять его при недоступности БД так же
неверно, как считать сеансы пропавшими).
"""
try:
counts = await asyncio.wait_for(
ConferenceSessionRepository(session).count_by_pipeline_status(),
timeout=_PIPELINE_GAUGE_TIMEOUT_S,
)
except Exception: # noqa: BLE001
return
for status in PIPELINE_STATUSES: for status in PIPELINE_STATUSES:
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0)) PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
@@ -113,6 +148,70 @@ async def _refresh_celery_queue_depth_gauge() -> None:
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth) CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
# --- Доступность БД и занятость основного пула (сессия 33) -----------------
#
# Ранний сигнал важнее самого факта отказа: в инциденте 07.08 пул заполнялся
# постепенно (`idle in transaction` 3→8→16→26→35→39→40 участников) —
# `vidconf_db_pool_checked_out` показал бы это задолго до первого 500.
# Обе метрики читаются без обращения к основному пулу (см. докстринг модуля
# и `core/db.py`), поэтому доступны и в момент, когда сам пул исчерпан.
DB_UP = Gauge(
"vidconf_db_up",
"Доступность БД (1/0) — проверяется отдельным соединением вне основного пула",
)
DB_POOL_SIZE = Gauge(
"vidconf_db_pool_size",
"Настроенный размер основного пула БД без overflow (db_pool_size)",
)
DB_POOL_MAX_OVERFLOW = Gauge(
"vidconf_db_pool_max_overflow",
"Настроенный максимум overflow-соединений сверх db_pool_size (db_max_overflow)",
)
DB_POOL_CHECKED_OUT = Gauge(
"vidconf_db_pool_checked_out",
"Число соединений основного пула БД, занятых прямо сейчас (в пуле + overflow)",
)
async def _refresh_db_up_gauge() -> None:
"""Пересчитать `vidconf_db_up` отдельным от основного пула соединением."""
DB_UP.set(1 if await check_db_up() else 0)
def _refresh_db_pool_gauges() -> None:
"""Пересчитать gauge'и занятости основного пула — синхронно, без I/O."""
settings = get_settings()
DB_POOL_SIZE.set(settings.db_pool_size)
DB_POOL_MAX_OVERFLOW.set(settings.db_max_overflow)
DB_POOL_CHECKED_OUT.set(db_pool_checked_out())
# --- Занятость пула Redis (сессия 33, второй потолок из session 32) --------
#
# Тот же класс отказа, что и у пула БД: каждое WS-подключение комнаты держит
# pub/sub-соединение всё время, пока участник в конференции (`core/redis.py`,
# `redis_max_connections`). Снимок — синхронный (атрибуты пула в памяти
# процесса redis-py), Redis для этого спрашивать не нужно.
REDIS_POOL_IN_USE = Gauge(
"vidconf_redis_pool_in_use",
"Число занятых соединений пула Redis прямо сейчас",
)
REDIS_POOL_MAX = Gauge(
"vidconf_redis_pool_max_connections",
"Настроенный максимум соединений пула Redis (redis_max_connections)",
)
def _refresh_redis_pool_gauges() -> None:
"""Пересчитать gauge'и занятости пула Redis — синхронно, без I/O."""
pool = redis_client.connection_pool
REDIS_POOL_IN_USE.set(len(pool._in_use_connections)) # noqa: SLF001
REDIS_POOL_MAX.set(pool.max_connections)
# --- Info-метрика обнаруженного железа (install.sh, ADR-004) --------------- # --- Info-метрика обнаруженного железа (install.sh, ADR-004) ---------------
HOST_INFO = Gauge( HOST_INFO = Gauge(
@@ -158,7 +257,16 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
редко (обычно раз в 1530с), нагрузка пренебрежимо мала. редко (обычно раз в 1530с), нагрузка пренебрежимо мала.
Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`,
`_refresh_db_pool_gauges`) считаются первыми и не зависят от самого пула
(см. докстринг модуля) — они гарантированно попадут в ответ, даже если
следующий за ними `_refresh_pipeline_sessions_gauge` (основной пул) зависнет
или упадёт под нагрузкой.
""" """
await _refresh_db_up_gauge()
_refresh_db_pool_gauges()
_refresh_redis_pool_gauges()
await _refresh_pipeline_sessions_gauge(session) await _refresh_pipeline_sessions_gauge(session)
await _refresh_celery_queue_depth_gauge() await _refresh_celery_queue_depth_gauge()
_refresh_host_info_gauge() _refresh_host_info_gauge()

View File

@@ -38,6 +38,13 @@ class Settings(BaseSettings):
# и показывает проблему, а не висит полминуты, делая вид, что всё живо. # и показывает проблему, а не висит полминуты, делая вид, что всё живо.
db_pool_timeout: int = 10 db_pool_timeout: int = 10
# --- Проверка доступности БД вне основного пула (`core/db.py::check_db_up`) ---
# Таймаут TCP/auth отдельного соединения-пробы (не путать с
# `db_pool_timeout` выше — тот про очередь на основной пул). Дефолт
# asyncpg — 60с, для сигнала мониторинга это неприемлемо долго: пусть
# `vidconf_db_up` станет 0 за секунды, а не через минуту.
db_probe_timeout_s: float = 3.0
# --- Пул соединений с Redis --- # --- Пул соединений с Redis ---
# Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты # Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты
# (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока # (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока

View File

@@ -1,13 +1,16 @@
"""Настройка асинхронного движка SQLAlchemy и сеанса.""" """Настройка асинхронного движка SQLAlchemy и сеанса."""
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import cast
from sqlalchemy import text
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncEngine, AsyncEngine,
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
create_async_engine, create_async_engine,
) )
from sqlalchemy.pool import NullPool, QueuePool
from core.config import get_settings from core.config import get_settings
@@ -31,3 +34,45 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Зависимость FastAPI, возвращающая `AsyncSession`.""" """Зависимость FastAPI, возвращающая `AsyncSession`."""
async with async_session_maker() as session: async with async_session_maker() as session:
yield session yield session
# --- Проверка доступности БД вне основного пула (сессия 33) ----------------
#
# Отдельный движок с `NullPool`: каждый вызов открывает новое соединение и
# закрывает его сразу после — бюджет соединений не пересекается с
# `engine.pool` (10 + 10 overflow × число воркеров uvicorn). Это единственный
# способ отличить «БД лежит» от «основной пул занят под нагрузкой»: проверка
# через `get_session()` в момент исчерпания пула сама встала бы в очередь на
# `db_pool_timeout` и не смогла бы ответить, пока не появится случайно
# освободившееся место — то есть не отличила бы два принципиально разных
# состояния. Короткий `timeout` на соединение (не путать с `db_pool_timeout`
# основного пула) — чтобы зависший, а не оборванный TCP (Postgres отвечает,
# но не может продвинуться) не держал проверку до дефолтных 60 секунд asyncpg.
_probe_engine: AsyncEngine = create_async_engine(
settings.database_url,
poolclass=NullPool,
connect_args={"timeout": settings.db_probe_timeout_s},
)
async def check_db_up() -> bool:
"""`True`, если БД отвечает на `SELECT 1` по отдельному от основного пула соединению."""
try:
async with _probe_engine.connect() as connection:
await connection.execute(text("SELECT 1"))
except Exception: # noqa: BLE001
return False
return True
def db_pool_checked_out() -> int:
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
SQLAlchemy держит счётчик в памяти самого объекта пула (`engine.pool`),
поэтому его можно прочитать в любой момент, даже когда все соединения
заняты или БД недоступна — именно это нужно алерту на исчерпание пула
(метрика не должна зависеть от того, что измеряет). Размер и лимит
overflow — конфигурация (`Settings.db_pool_size`/`db_max_overflow`),
их не нужно снимать с объекта пула отдельно.
"""
return cast(QueuePool, engine.pool).checkedout()

View File

@@ -59,6 +59,12 @@ async def test_metrics_endpoint_returns_prometheus_exposition_format(
assert "vidconf_pipeline_sessions" in families assert "vidconf_pipeline_sessions" in families
assert "vidconf_celery_queue_depth" in families assert "vidconf_celery_queue_depth" in families
assert "vidconf_host_info" in families assert "vidconf_host_info" in families
assert "vidconf_db_up" in families
assert "vidconf_db_pool_size" in families
assert "vidconf_db_pool_max_overflow" in families
assert "vidconf_db_pool_checked_out" in families
assert "vidconf_redis_pool_in_use" in families
assert "vidconf_redis_pool_max_connections" in families
async def test_metrics_host_info_gauge_reflects_settings( async def test_metrics_host_info_gauge_reflects_settings(
@@ -129,6 +135,72 @@ async def test_metrics_pipeline_sessions_gauge_reflects_new_session(
assert after == before + 1 assert after == before + 1
async def test_metrics_db_up_gauge_reflects_real_connectivity(client: httpx.AsyncClient) -> None:
"""Против реального тестового Postgres (см. докстринг conftest) `vidconf_db_up` == 1."""
response = await client.get("/metrics")
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert value == 1
async def test_metrics_db_up_gauge_reports_down_without_crashing_endpoint(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Недоступность БД (проверка вне пула не удалась) не роняет `/metrics` — отдаёт 0, не 500."""
async def _fail() -> bool:
return False
monkeypatch.setattr(metrics_module, "check_db_up", _fail)
response = await client.get("/metrics")
assert response.status_code == 200
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert value == 0
async def test_metrics_db_pool_gauges_reflect_settings_not_usage(
client: httpx.AsyncClient,
) -> None:
"""`vidconf_db_pool_size`/`_max_overflow` — конфигурация из `Settings`, не текущая занятость."""
settings = get_settings()
response = await client.get("/metrics")
samples_size = _samples(response.text, "vidconf_db_pool_size")
samples_overflow = _samples(response.text, "vidconf_db_pool_max_overflow")
size = _sample_value(samples_size, suffix="vidconf_db_pool_size")
max_overflow = _sample_value(samples_overflow, suffix="vidconf_db_pool_max_overflow")
assert size == settings.db_pool_size
assert max_overflow == settings.db_max_overflow
async def test_metrics_endpoint_survives_pipeline_gauge_failure(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Падение/таймаут основного пула на одном gauge не роняет весь `/metrics`.
Симулирует ровно ситуацию инцидента 07.08 (`api/metrics.py` падал вместе
со всем остальным при исчерпанном пуле): `count_by_pipeline_status`
поднимает исключение — `vidconf_db_up`/`vidconf_db_pool_*` (не зависящие
от основного пула) при этом всё равно приходят в ответе.
"""
async def _raise(*args: object, **kwargs: object) -> dict[str, int]:
raise TimeoutError("основной пул занят (симуляция теста)")
monkeypatch.setattr(
"repositories.conferences.ConferenceSessionRepository.count_by_pipeline_status",
_raise,
)
response = await client.get("/metrics")
assert response.status_code == 200
db_up = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert db_up == 1
async def test_metrics_celery_queue_depth_gauge( async def test_metrics_celery_queue_depth_gauge(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View File

@@ -4,7 +4,8 @@
# сервис `prometheus`). # сервис `prometheus`).
# #
# Имена метрик backend (`vidconf_http_request_duration_seconds`, # Имена метрик backend (`vidconf_http_request_duration_seconds`,
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с # `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`, `vidconf_db_up`,
# `vidconf_db_pool_*`, `vidconf_redis_pool_*`) — КОНТРАКТ с
# `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально — # `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально —
# при переименовании метрик в backend поправить оба файла одновременно. # при переименовании метрик в backend поправить оба файла одновременно.
global: global: