From 0e569607149e4873a1a076ba319116d28310a5c5 Mon Sep 17 00:00:00 2001 From: Max Ronzhin Date: Sun, 9 Aug 2026 02:39:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(metrics):=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=91=D0=94=20=D0=B8=20=D0=B7=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D0=BE=D1=81=D1=82=D0=B8=20=D0=BF=D1=83=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=91=D0=94/Redis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vidconf_db_up проверяется отдельным от основного пула соединением (NullPool, короткий таймаут) — иначе в момент исчерпания пула проверка сама встала бы в очередь и не отличила бы «БД лежит» от «пул занят». vidconf_db_pool_* читаются синхронно из engine.pool, без единого запроса к БД. metrics_endpoint больше не виснет и не падает при недоступном основном пуле: критичные gauge'и считаются первыми и не зависят от него, а vidconf_pipeline_sessions (по-прежнему через Depends(get_session) — тестовый харнесс подменяет её на savepoint-сессию) обёрнут таймаутом и try/except. --- backend/api/metrics.py | 116 ++++++++++++++++++++++++++++-- backend/core/config.py | 7 ++ backend/core/db.py | 45 ++++++++++++ backend/tests/test_metrics_api.py | 72 +++++++++++++++++++ deploy/monitoring/prometheus.yml | 3 +- 5 files changed, 238 insertions(+), 5 deletions(-) diff --git a/backend/api/metrics.py b/backend/api/metrics.py index 9a59349..af84c19 100644 --- a/backend/api/metrics.py +++ b/backend/api/metrics.py @@ -1,4 +1,4 @@ -"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей и железа. +"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей, БД и железа. `GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops): @@ -12,8 +12,22 @@ Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth`/ Redis) можно опросить обычным `await` вместо реализации синхронного `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 from collections.abc import Awaitable, Callable @@ -23,7 +37,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from starlette.routing import Match 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 models.session import PIPELINE_STATUSES 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: - """Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.""" - counts = await ConferenceSessionRepository(session).count_by_pipeline_status() + """Пересчитать `vidconf_pipeline_sessions` по всем статусам `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: 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) +# --- Доступность БД и занятость основного пула (сессия 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) --------------- HOST_INFO = Gauge( @@ -158,7 +257,16 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит редко (обычно раз в 15–30с), нагрузка пренебрежимо мала. + + Порядок важен: метрики о состоянии основного пула БД (`_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_celery_queue_depth_gauge() _refresh_host_info_gauge() diff --git a/backend/core/config.py b/backend/core/config.py index d6c535a..55e63aa 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -38,6 +38,13 @@ class Settings(BaseSettings): # и показывает проблему, а не висит полминуты, делая вид, что всё живо. 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 --- # Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты # (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока diff --git a/backend/core/db.py b/backend/core/db.py index c790c50..bcf7d52 100644 --- a/backend/core/db.py +++ b/backend/core/db.py @@ -1,13 +1,16 @@ """Настройка асинхронного движка SQLAlchemy и сеанса.""" from collections.abc import AsyncGenerator +from typing import cast +from sqlalchemy import text from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine, ) +from sqlalchemy.pool import NullPool, QueuePool from core.config import get_settings @@ -31,3 +34,45 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: """Зависимость FastAPI, возвращающая `AsyncSession`.""" async with async_session_maker() as 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() diff --git a/backend/tests/test_metrics_api.py b/backend/tests/test_metrics_api.py index 47eb33a..f34c07f 100644 --- a/backend/tests/test_metrics_api.py +++ b/backend/tests/test_metrics_api.py @@ -59,6 +59,12 @@ async def test_metrics_endpoint_returns_prometheus_exposition_format( assert "vidconf_pipeline_sessions" in families assert "vidconf_celery_queue_depth" 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( @@ -129,6 +135,72 @@ async def test_metrics_pipeline_sessions_gauge_reflects_new_session( 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( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/deploy/monitoring/prometheus.yml b/deploy/monitoring/prometheus.yml index 9355baa..a6162c2 100644 --- a/deploy/monitoring/prometheus.yml +++ b/deploy/monitoring/prometheus.yml @@ -4,7 +4,8 @@ # сервис `prometheus`). # # Имена метрик 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 поправить оба файла одновременно. global: