test_db_pool_and_auth_cache.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. """Tests for the DB connection-pool sizing/diagnostics and the auth-enabled
  2. cache added for large printer farms (issue #2572)."""
  3. import asyncio
  4. import pytest
  5. from sqlalchemy import text
  6. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  7. class TestPoolConfiguration:
  8. """P0: env-configurable, dialect-aware pool sizing."""
  9. def test_sqlite_defaults_when_unset(self, monkeypatch):
  10. """SQLite defaults are 10 + 90 when no env override is set (#2883).
  11. WAL keeps a closed connection's db fd open until the last connection
  12. closes, so the pool's fds stay at its peak: ~201 open / ~101 parked
  13. here against ~441 / ~221 at the old 20 + 200. That default dates from
  14. b8fa2df36, a 100+ printer SQLite farm before #2572 made authenticated
  15. requests a single checkout; such a farm can raise DB_MAX_OVERFLOW or
  16. move to PostgreSQL."""
  17. from backend.app.core import database
  18. for attr in ("db_pool_size", "db_max_overflow", "db_pool_timeout", "db_pool_recycle"):
  19. monkeypatch.setattr(database.settings, attr, None, raising=False)
  20. monkeypatch.setattr(database, "is_sqlite", lambda: True)
  21. kwargs = database._resolve_pool_kwargs()
  22. assert kwargs["pool_size"] == 10
  23. assert kwargs["max_overflow"] == 90
  24. # No server-socket recycle/pre-ping for a local file.
  25. assert "pool_pre_ping" not in kwargs
  26. assert "pool_recycle" not in kwargs
  27. def test_postgres_defaults_raise_the_old_limits(self, monkeypatch):
  28. """Postgres default is 20 + 60 (was 10 + 20) with pre-ping + recycle."""
  29. from backend.app.core import database
  30. for attr in ("db_pool_size", "db_max_overflow", "db_pool_timeout", "db_pool_recycle", "db_pool_pre_ping"):
  31. monkeypatch.setattr(database.settings, attr, None, raising=False)
  32. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  33. kwargs = database._resolve_pool_kwargs()
  34. assert kwargs["pool_size"] == 20
  35. assert kwargs["max_overflow"] == 60
  36. assert kwargs["pool_pre_ping"] is True
  37. assert kwargs["pool_recycle"] == 1800
  38. def test_env_overrides_win_on_postgres(self, monkeypatch):
  39. """DB_POOL_* overrides replace the dialect defaults."""
  40. from backend.app.core import database
  41. monkeypatch.setattr(database.settings, "db_pool_size", 100, raising=False)
  42. monkeypatch.setattr(database.settings, "db_max_overflow", 200, raising=False)
  43. monkeypatch.setattr(database.settings, "db_pool_timeout", 45, raising=False)
  44. monkeypatch.setattr(database.settings, "db_pool_recycle", 600, raising=False)
  45. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  46. kwargs = database._resolve_pool_kwargs()
  47. assert kwargs["pool_size"] == 100
  48. assert kwargs["max_overflow"] == 200
  49. assert kwargs["pool_timeout"] == 45
  50. assert kwargs["pool_recycle"] == 600
  51. def test_pre_ping_can_be_disabled_on_postgres(self, monkeypatch):
  52. """DB_POOL_PRE_PING=false drops pool_pre_ping; the other defaults stay."""
  53. from backend.app.core import database
  54. monkeypatch.setattr(database.settings, "db_pool_pre_ping", False, raising=False)
  55. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  56. kwargs = database._resolve_pool_kwargs()
  57. assert kwargs["pool_pre_ping"] is False
  58. assert kwargs["pool_recycle"] == 1800
  59. def test_pre_ping_env_string_is_parsed(self, monkeypatch):
  60. """The real DB_POOL_PRE_PING env strings reach the setting as booleans."""
  61. from backend.app.core.config import Settings
  62. for raw, expected in (("false", False), ("0", False), ("true", True), ("1", True)):
  63. monkeypatch.setenv("DB_POOL_PRE_PING", raw)
  64. assert Settings().db_pool_pre_ping is expected, raw
  65. def test_pre_ping_explicit_true_is_honoured(self, monkeypatch):
  66. from backend.app.core import database
  67. monkeypatch.setattr(database.settings, "db_pool_pre_ping", True, raising=False)
  68. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  69. assert database._resolve_pool_kwargs()["pool_pre_ping"] is True
  70. def test_pre_ping_override_is_a_no_op_on_sqlite(self, monkeypatch):
  71. from backend.app.core import database
  72. monkeypatch.setattr(database.settings, "db_pool_pre_ping", False, raising=False)
  73. monkeypatch.setattr(database, "is_sqlite", lambda: True)
  74. assert "pool_pre_ping" not in database._resolve_pool_kwargs()
  75. @pytest.mark.asyncio
  76. async def test_concurrent_checkouts_exceed_base_pool_size(self, tmp_path):
  77. """Regression (#2572): more concurrent sessions than pool_size must all
  78. complete by drawing from max_overflow — not deadlock or time out.
  79. This is the failure the farm hit: printer callbacks held every base
  80. connection, so unrelated requests waited on the pool. With headroom in
  81. max_overflow, concurrent checkouts beyond pool_size still succeed.
  82. Uses a file-based SQLite URL so it gets a real queue pool — the
  83. in-memory URL forces a single-connection StaticPool that ignores
  84. pool_size/max_overflow entirely.
  85. """
  86. db_file = tmp_path / "pool_regression.db"
  87. eng = create_async_engine(f"sqlite+aiosqlite:///{db_file}", pool_size=2, max_overflow=10)
  88. sm = async_sessionmaker(eng)
  89. async def _one():
  90. async with sm() as s:
  91. await s.execute(text("SELECT 1"))
  92. # Hold the checkout briefly so the calls genuinely overlap and
  93. # force the pool past its base size of 2.
  94. await asyncio.sleep(0.05)
  95. return (await s.execute(text("SELECT 1"))).scalar()
  96. try:
  97. results = await asyncio.gather(*[_one() for _ in range(12)])
  98. finally:
  99. await eng.dispose()
  100. assert results == [1] * 12
  101. class TestPoolStatus:
  102. """P3: diagnostics snapshot."""
  103. def test_get_pool_status_shape(self):
  104. from backend.app.core.database import get_pool_status
  105. status = get_pool_status()
  106. assert status["dialect"] in ("sqlite", "postgresql")
  107. for key in ("pool_size", "max_overflow", "pool_timeout", "pool_recycle", "pool_pre_ping"):
  108. assert key in status["config"]
  109. # Live gauges are present (values are ints on a QueuePool).
  110. for key in ("current_size", "checked_out", "checked_in", "overflow"):
  111. assert key in status
  112. class TestAuthEnabledCache:
  113. """P1: cache the auth-enabled probe, but only ever cache True."""
  114. class _Setting:
  115. def __init__(self, value):
  116. self.value = value
  117. class _Result:
  118. def __init__(self, setting):
  119. self._setting = setting
  120. def scalar_one_or_none(self):
  121. return self._setting
  122. class _CountingDB:
  123. def __init__(self, value):
  124. self._value = value
  125. self.calls = 0
  126. async def execute(self, *args, **kwargs):
  127. self.calls += 1
  128. setting = None if self._value is None else TestAuthEnabledCache._Setting(self._value)
  129. return TestAuthEnabledCache._Result(setting)
  130. @pytest.mark.asyncio
  131. async def test_enabled_true_is_cached(self):
  132. from backend.app.core import auth as auth_mod
  133. auth_mod.invalidate_auth_enabled_cache()
  134. db = self._CountingDB("true")
  135. assert await auth_mod.is_auth_enabled(db) is True
  136. assert db.calls == 1
  137. # Second probe served from cache — no new query.
  138. assert await auth_mod.is_auth_enabled(db) is True
  139. assert db.calls == 1
  140. # Invalidation forces a re-read (e.g. after set_auth_enabled).
  141. auth_mod.invalidate_auth_enabled_cache()
  142. assert await auth_mod.is_auth_enabled(db) is True
  143. assert db.calls == 2
  144. auth_mod.invalidate_auth_enabled_cache()
  145. @pytest.mark.asyncio
  146. async def test_disabled_is_never_cached(self):
  147. """SECURITY: a disabled result must never be cached, so staleness can
  148. only ever fail closed (require auth), never open."""
  149. from backend.app.core import auth as auth_mod
  150. auth_mod.invalidate_auth_enabled_cache()
  151. db = self._CountingDB("false")
  152. assert await auth_mod.is_auth_enabled(db) is False
  153. assert db.calls == 1
  154. # Every probe re-reads while disabled.
  155. assert await auth_mod.is_auth_enabled(db) is False
  156. assert db.calls == 2
  157. auth_mod.invalidate_auth_enabled_cache()
  158. @pytest.mark.asyncio
  159. async def test_unconfigured_returns_false_and_is_not_cached(self):
  160. from backend.app.core import auth as auth_mod
  161. auth_mod.invalidate_auth_enabled_cache()
  162. db = self._CountingDB(None)
  163. assert await auth_mod.is_auth_enabled(db) is False
  164. assert await auth_mod.is_auth_enabled(db) is False
  165. assert db.calls == 2
  166. auth_mod.invalidate_auth_enabled_cache()
  167. @pytest.mark.asyncio
  168. async def test_db_error_propagates_fail_closed(self):
  169. """A probe error must propagate (fail closed), not be swallowed."""
  170. from backend.app.core import auth as auth_mod
  171. auth_mod.invalidate_auth_enabled_cache()
  172. class _RaisingDB:
  173. async def execute(self, *args, **kwargs):
  174. raise RuntimeError("connection lost")
  175. with pytest.raises(RuntimeError):
  176. await auth_mod.is_auth_enabled(_RaisingDB())
  177. auth_mod.invalidate_auth_enabled_cache()