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

@@ -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: