test_pool_fits_server.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """The pool must not silently be allowed to outgrow the PostgreSQL server.
  2. ``pool_size + max_overflow`` is the most connections one worker will open. When
  3. that exceeds what the server permits, the pool never hits its own limit and so
  4. never queues — it asks the server, which refuses with
  5. ``TooManyConnectionsError`` at whatever happened to need a connection next. In
  6. the report behind this, that was the middle of a queue dispatch.
  7. The check is diagnostic, not corrective: pool sizes are fixed at engine creation
  8. (import time, before any connection exists to ask with), and the right ceiling
  9. depends on the worker count and on other clients sharing the server. So the
  10. contract under test is "says something accurate and loud, and never breaks
  11. startup".
  12. """
  13. from __future__ import annotations
  14. import logging
  15. from unittest.mock import AsyncMock, MagicMock, patch
  16. import pytest
  17. def _engine_reporting(max_conn: int, reserved: int, in_use: int | None = 0) -> MagicMock:
  18. """An engine whose connection answers the three probe queries in order.
  19. ``in_use=None`` makes the third query fail, standing in for PostgreSQL < 10
  20. where ``pg_stat_activity.backend_type`` does not exist.
  21. """
  22. conn = MagicMock()
  23. conn.execute = AsyncMock(
  24. side_effect=[
  25. MagicMock(scalar_one=MagicMock(return_value=max_conn)),
  26. MagicMock(scalar_one=MagicMock(return_value=reserved)),
  27. (
  28. MagicMock(scalar_one=MagicMock(return_value=in_use))
  29. if in_use is not None
  30. else RuntimeError('column "backend_type" does not exist')
  31. ),
  32. ]
  33. )
  34. ctx = MagicMock()
  35. ctx.__aenter__ = AsyncMock(return_value=conn)
  36. ctx.__aexit__ = AsyncMock(return_value=False)
  37. engine = MagicMock()
  38. engine.connect = MagicMock(return_value=ctx)
  39. return engine
  40. async def _run_check(*, pool_size, max_overflow, max_conn, reserved, in_use=0, sqlite=False):
  41. from backend.app.core import database
  42. with (
  43. patch.object(database, "is_sqlite", return_value=sqlite),
  44. patch.object(database, "_pool_config", {"pool_size": pool_size, "max_overflow": max_overflow}),
  45. patch.object(database, "engine", _engine_reporting(max_conn, reserved, in_use)),
  46. patch.object(database, "_server_connection_limits", None),
  47. ):
  48. await database.check_pool_fits_server()
  49. return database._server_connection_limits
  50. @pytest.mark.asyncio
  51. @pytest.mark.unit
  52. async def test_warns_when_the_ceiling_exceeds_what_the_server_allows(caplog):
  53. """Bambuddy's own PostgreSQL default against a stock server: 100 vs 100-3."""
  54. with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
  55. await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3)
  56. assert any(r.levelno == logging.WARNING for r in caplog.records)
  57. msg = caplog.text
  58. # The numbers an operator needs, and the knobs to change.
  59. for expected in ("100", "97", "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "max_connections"):
  60. assert expected in msg, f"warning omits {expected!r}"
  61. @pytest.mark.asyncio
  62. @pytest.mark.unit
  63. async def test_silent_when_the_pool_fits(caplog):
  64. with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
  65. await _run_check(pool_size=20, max_overflow=80, max_conn=500, reserved=3)
  66. assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
  67. @pytest.mark.asyncio
  68. @pytest.mark.unit
  69. async def test_the_reserved_slots_count_against_the_budget(caplog):
  70. """Exactly at max_connections is still too many — reserved slots are not ours."""
  71. with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
  72. await _run_check(pool_size=10, max_overflow=90, max_conn=100, reserved=3)
  73. assert [r for r in caplog.records if r.levelno == logging.WARNING]
  74. @pytest.mark.asyncio
  75. @pytest.mark.unit
  76. async def test_both_sides_are_recorded_for_the_support_bundle():
  77. limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=41)
  78. assert limits == {
  79. "max_connections": 100,
  80. "superuser_reserved_connections": 3,
  81. "available_to_bambuddy": 97,
  82. "client_backends_at_startup": 41,
  83. "pool_ceiling_per_worker": 100,
  84. }
  85. @pytest.mark.asyncio
  86. @pytest.mark.unit
  87. async def test_sqlite_is_skipped_entirely():
  88. """No such concept, and the probe SQL is PostgreSQL-only."""
  89. limits = await _run_check(pool_size=20, max_overflow=200, max_conn=0, reserved=0, sqlite=True)
  90. assert limits is None
  91. @pytest.mark.asyncio
  92. @pytest.mark.unit
  93. async def test_a_probe_failure_cannot_break_startup(caplog):
  94. """A restricted role or an older server may refuse these queries."""
  95. from backend.app.core import database
  96. engine = MagicMock()
  97. ctx = MagicMock()
  98. ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("permission denied"))
  99. ctx.__aexit__ = AsyncMock(return_value=False)
  100. engine.connect = MagicMock(return_value=ctx)
  101. with (
  102. patch.object(database, "is_sqlite", return_value=False),
  103. patch.object(database, "_pool_config", {"pool_size": 20, "max_overflow": 80}),
  104. patch.object(database, "engine", engine),
  105. patch.object(database, "_server_connection_limits", None),
  106. caplog.at_level(logging.WARNING, logger="backend.app.core.database"),
  107. ):
  108. await database.check_pool_fits_server() # must not raise
  109. assert database._server_connection_limits is None
  110. assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
  111. @pytest.mark.asyncio
  112. @pytest.mark.unit
  113. async def test_an_old_server_without_backend_type_still_gets_the_warning(caplog):
  114. """`pg_stat_activity.backend_type` is PostgreSQL 10+; the docs recommend 14+
  115. but asyncpg reaches back to 9.5. Losing that count must not cost the
  116. warning, which only needs the two settings."""
  117. with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
  118. limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=None)
  119. assert [r for r in caplog.records if r.levelno == logging.WARNING], "warning was lost with the count"
  120. assert "100" in caplog.text and "97" in caplog.text
  121. # The sentence about other clients is dropped rather than rendered as None.
  122. assert "None client" not in caplog.text
  123. assert limits["client_backends_at_startup"] is None
  124. assert limits["max_connections"] == 100
  125. @pytest.mark.unit
  126. def test_get_pool_status_exposes_the_server_limits_key():
  127. """The support bundle reads this; the key must exist even on SQLite."""
  128. from backend.app.core.database import get_pool_status
  129. assert "server_limits" in get_pool_status()