mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
feat(db): warn when the connection pool can outgrow the PostgreSQL server fix(mqtt): an unusable layer_num must not drop the printer connection test: patch settings.base_dir via monkeypatch so it unwinds on error test: restore the config module after reloading it
167 lines
6.6 KiB
Python
167 lines
6.6 KiB
Python
"""The pool must not silently be allowed to outgrow the PostgreSQL server.
|
|
|
|
``pool_size + max_overflow`` is the most connections one worker will open. When
|
|
that exceeds what the server permits, the pool never hits its own limit and so
|
|
never queues — it asks the server, which refuses with
|
|
``TooManyConnectionsError`` at whatever happened to need a connection next. In
|
|
the report behind this, that was the middle of a queue dispatch.
|
|
|
|
The check is diagnostic, not corrective: pool sizes are fixed at engine creation
|
|
(import time, before any connection exists to ask with), and the right ceiling
|
|
depends on the worker count and on other clients sharing the server. So the
|
|
contract under test is "says something accurate and loud, and never breaks
|
|
startup".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _engine_reporting(max_conn: int, reserved: int, in_use: int | None = 0) -> MagicMock:
|
|
"""An engine whose connection answers the three probe queries in order.
|
|
|
|
``in_use=None`` makes the third query fail, standing in for PostgreSQL < 10
|
|
where ``pg_stat_activity.backend_type`` does not exist.
|
|
"""
|
|
conn = MagicMock()
|
|
conn.execute = AsyncMock(
|
|
side_effect=[
|
|
MagicMock(scalar_one=MagicMock(return_value=max_conn)),
|
|
MagicMock(scalar_one=MagicMock(return_value=reserved)),
|
|
(
|
|
MagicMock(scalar_one=MagicMock(return_value=in_use))
|
|
if in_use is not None
|
|
else RuntimeError('column "backend_type" does not exist')
|
|
),
|
|
]
|
|
)
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=conn)
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
engine = MagicMock()
|
|
engine.connect = MagicMock(return_value=ctx)
|
|
return engine
|
|
|
|
|
|
async def _run_check(*, pool_size, max_overflow, max_conn, reserved, in_use=0, sqlite=False):
|
|
from backend.app.core import database
|
|
|
|
with (
|
|
patch.object(database, "is_sqlite", return_value=sqlite),
|
|
patch.object(database, "_pool_config", {"pool_size": pool_size, "max_overflow": max_overflow}),
|
|
patch.object(database, "engine", _engine_reporting(max_conn, reserved, in_use)),
|
|
patch.object(database, "_server_connection_limits", None),
|
|
):
|
|
await database.check_pool_fits_server()
|
|
return database._server_connection_limits
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_warns_when_the_ceiling_exceeds_what_the_server_allows(caplog):
|
|
"""Bambuddy's own PostgreSQL default against a stock server: 100 vs 100-3."""
|
|
with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
|
|
await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3)
|
|
|
|
assert any(r.levelno == logging.WARNING for r in caplog.records)
|
|
msg = caplog.text
|
|
# The numbers an operator needs, and the knobs to change.
|
|
for expected in ("100", "97", "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "max_connections"):
|
|
assert expected in msg, f"warning omits {expected!r}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_silent_when_the_pool_fits(caplog):
|
|
with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
|
|
await _run_check(pool_size=20, max_overflow=80, max_conn=500, reserved=3)
|
|
|
|
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_the_reserved_slots_count_against_the_budget(caplog):
|
|
"""Exactly at max_connections is still too many — reserved slots are not ours."""
|
|
with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
|
|
await _run_check(pool_size=10, max_overflow=90, max_conn=100, reserved=3)
|
|
|
|
assert [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_both_sides_are_recorded_for_the_support_bundle():
|
|
limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=41)
|
|
|
|
assert limits == {
|
|
"max_connections": 100,
|
|
"superuser_reserved_connections": 3,
|
|
"available_to_bambuddy": 97,
|
|
"client_backends_at_startup": 41,
|
|
"pool_ceiling_per_worker": 100,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_sqlite_is_skipped_entirely():
|
|
"""No such concept, and the probe SQL is PostgreSQL-only."""
|
|
limits = await _run_check(pool_size=20, max_overflow=200, max_conn=0, reserved=0, sqlite=True)
|
|
|
|
assert limits is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_a_probe_failure_cannot_break_startup(caplog):
|
|
"""A restricted role or an older server may refuse these queries."""
|
|
from backend.app.core import database
|
|
|
|
engine = MagicMock()
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("permission denied"))
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
engine.connect = MagicMock(return_value=ctx)
|
|
|
|
with (
|
|
patch.object(database, "is_sqlite", return_value=False),
|
|
patch.object(database, "_pool_config", {"pool_size": 20, "max_overflow": 80}),
|
|
patch.object(database, "engine", engine),
|
|
patch.object(database, "_server_connection_limits", None),
|
|
caplog.at_level(logging.WARNING, logger="backend.app.core.database"),
|
|
):
|
|
await database.check_pool_fits_server() # must not raise
|
|
|
|
assert database._server_connection_limits is None
|
|
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.unit
|
|
async def test_an_old_server_without_backend_type_still_gets_the_warning(caplog):
|
|
"""`pg_stat_activity.backend_type` is PostgreSQL 10+; the docs recommend 14+
|
|
but asyncpg reaches back to 9.5. Losing that count must not cost the
|
|
warning, which only needs the two settings."""
|
|
with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
|
|
limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=None)
|
|
|
|
assert [r for r in caplog.records if r.levelno == logging.WARNING], "warning was lost with the count"
|
|
assert "100" in caplog.text and "97" in caplog.text
|
|
# The sentence about other clients is dropped rather than rendered as None.
|
|
assert "None client" not in caplog.text
|
|
assert limits["client_backends_at_startup"] is None
|
|
assert limits["max_connections"] == 100
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_get_pool_status_exposes_the_server_limits_key():
|
|
"""The support bundle reads this; the key must exist even on SQLite."""
|
|
from backend.app.core.database import get_pool_status
|
|
|
|
assert "server_limits" in get_pool_status()
|