test_pool_fits_server.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  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. """A 100-connection ceiling 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_the_postgres_default_fits_a_stock_server(caplog, monkeypatch):
  64. """Without DB_POOL_* set, a stock server (100, 3 reserved) must not warn.
  65. The default was 20 + 80, one over the 97 a stock server leaves, so every
  66. PostgreSQL install that didn't size its pool logged this at each start.
  67. """
  68. from backend.app.core import database
  69. for attr in ("db_pool_size", "db_max_overflow", "db_pool_timeout", "db_pool_recycle"):
  70. monkeypatch.setattr(database.settings, attr, None, raising=False)
  71. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  72. kwargs = database._resolve_pool_kwargs()
  73. with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
  74. await _run_check(pool_size=kwargs["pool_size"], max_overflow=kwargs["max_overflow"], max_conn=100, reserved=3)
  75. assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
  76. @pytest.mark.asyncio
  77. @pytest.mark.unit
  78. async def test_silent_when_the_pool_fits(caplog):
  79. with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
  80. await _run_check(pool_size=20, max_overflow=80, max_conn=500, reserved=3)
  81. assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
  82. @pytest.mark.asyncio
  83. @pytest.mark.unit
  84. async def test_the_reserved_slots_count_against_the_budget(caplog):
  85. """Exactly at max_connections is still too many — reserved slots are not ours."""
  86. with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
  87. await _run_check(pool_size=10, max_overflow=90, max_conn=100, reserved=3)
  88. assert [r for r in caplog.records if r.levelno == logging.WARNING]
  89. @pytest.mark.asyncio
  90. @pytest.mark.unit
  91. async def test_both_sides_are_recorded_for_the_support_bundle():
  92. limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=41)
  93. assert limits == {
  94. "max_connections": 100,
  95. "superuser_reserved_connections": 3,
  96. "available_to_bambuddy": 97,
  97. "client_backends_at_startup": 41,
  98. "pool_ceiling_per_worker": 100,
  99. }
  100. @pytest.mark.asyncio
  101. @pytest.mark.unit
  102. async def test_sqlite_is_skipped_entirely():
  103. """No such concept, and the probe SQL is PostgreSQL-only."""
  104. limits = await _run_check(pool_size=20, max_overflow=200, max_conn=0, reserved=0, sqlite=True)
  105. assert limits is None
  106. @pytest.mark.asyncio
  107. @pytest.mark.unit
  108. async def test_a_probe_failure_cannot_break_startup(caplog):
  109. """A restricted role or an older server may refuse these queries."""
  110. from backend.app.core import database
  111. engine = MagicMock()
  112. ctx = MagicMock()
  113. ctx.__aenter__ = AsyncMock(side_effect=RuntimeError("permission denied"))
  114. ctx.__aexit__ = AsyncMock(return_value=False)
  115. engine.connect = MagicMock(return_value=ctx)
  116. with (
  117. patch.object(database, "is_sqlite", return_value=False),
  118. patch.object(database, "_pool_config", {"pool_size": 20, "max_overflow": 80}),
  119. patch.object(database, "engine", engine),
  120. patch.object(database, "_server_connection_limits", None),
  121. caplog.at_level(logging.WARNING, logger="backend.app.core.database"),
  122. ):
  123. await database.check_pool_fits_server() # must not raise
  124. assert database._server_connection_limits is None
  125. assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
  126. @pytest.mark.asyncio
  127. @pytest.mark.unit
  128. async def test_an_old_server_without_backend_type_still_gets_the_warning(caplog):
  129. """`pg_stat_activity.backend_type` is PostgreSQL 10+; the docs recommend 14+
  130. but asyncpg reaches back to 9.5. Losing that count must not cost the
  131. warning, which only needs the two settings."""
  132. with caplog.at_level(logging.WARNING, logger="backend.app.core.database"):
  133. limits = await _run_check(pool_size=20, max_overflow=80, max_conn=100, reserved=3, in_use=None)
  134. assert [r for r in caplog.records if r.levelno == logging.WARNING], "warning was lost with the count"
  135. assert "100" in caplog.text and "97" in caplog.text
  136. # The sentence about other clients is dropped rather than rendered as None.
  137. assert "None client" not in caplog.text
  138. assert limits["client_backends_at_startup"] is None
  139. assert limits["max_connections"] == 100
  140. @pytest.mark.unit
  141. def test_get_pool_status_exposes_the_server_limits_key():
  142. """The support bundle reads this; the key must exist even on SQLite."""
  143. from backend.app.core.database import get_pool_status
  144. assert "server_limits" in get_pool_status()