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:
@@ -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
|
|||||||
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
редко (обычно раз в 15–30с), нагрузка пренебрежимо мала.
|
||||||
|
|
||||||
Порядок важен: метрики о состоянии основного пула БД (`_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()
|
||||||
|
|||||||
@@ -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:
|
||||||
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
|
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user