test_db_pool_and_auth_cache.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  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"):
  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. @pytest.mark.asyncio
  52. async def test_concurrent_checkouts_exceed_base_pool_size(self, tmp_path):
  53. """Regression (#2572): more concurrent sessions than pool_size must all
  54. complete by drawing from max_overflow — not deadlock or time out.
  55. This is the failure the farm hit: printer callbacks held every base
  56. connection, so unrelated requests waited on the pool. With headroom in
  57. max_overflow, concurrent checkouts beyond pool_size still succeed.
  58. Uses a file-based SQLite URL so it gets a real queue pool — the
  59. in-memory URL forces a single-connection StaticPool that ignores
  60. pool_size/max_overflow entirely.
  61. """
  62. db_file = tmp_path / "pool_regression.db"
  63. eng = create_async_engine(f"sqlite+aiosqlite:///{db_file}", pool_size=2, max_overflow=10)
  64. sm = async_sessionmaker(eng)
  65. async def _one():
  66. async with sm() as s:
  67. await s.execute(text("SELECT 1"))
  68. # Hold the checkout briefly so the calls genuinely overlap and
  69. # force the pool past its base size of 2.
  70. await asyncio.sleep(0.05)
  71. return (await s.execute(text("SELECT 1"))).scalar()
  72. try:
  73. results = await asyncio.gather(*[_one() for _ in range(12)])
  74. finally:
  75. await eng.dispose()
  76. assert results == [1] * 12
  77. class TestPoolStatus:
  78. """P3: diagnostics snapshot."""
  79. def test_get_pool_status_shape(self):
  80. from backend.app.core.database import get_pool_status
  81. status = get_pool_status()
  82. assert status["dialect"] in ("sqlite", "postgresql")
  83. for key in ("pool_size", "max_overflow", "pool_timeout", "pool_recycle", "pool_pre_ping"):
  84. assert key in status["config"]
  85. # Live gauges are present (values are ints on a QueuePool).
  86. for key in ("current_size", "checked_out", "checked_in", "overflow"):
  87. assert key in status
  88. class TestAuthEnabledCache:
  89. """P1: cache the auth-enabled probe, but only ever cache True."""
  90. class _Setting:
  91. def __init__(self, value):
  92. self.value = value
  93. class _Result:
  94. def __init__(self, setting):
  95. self._setting = setting
  96. def scalar_one_or_none(self):
  97. return self._setting
  98. class _CountingDB:
  99. def __init__(self, value):
  100. self._value = value
  101. self.calls = 0
  102. async def execute(self, *args, **kwargs):
  103. self.calls += 1
  104. setting = None if self._value is None else TestAuthEnabledCache._Setting(self._value)
  105. return TestAuthEnabledCache._Result(setting)
  106. @pytest.mark.asyncio
  107. async def test_enabled_true_is_cached(self):
  108. from backend.app.core import auth as auth_mod
  109. auth_mod.invalidate_auth_enabled_cache()
  110. db = self._CountingDB("true")
  111. assert await auth_mod.is_auth_enabled(db) is True
  112. assert db.calls == 1
  113. # Second probe served from cache — no new query.
  114. assert await auth_mod.is_auth_enabled(db) is True
  115. assert db.calls == 1
  116. # Invalidation forces a re-read (e.g. after set_auth_enabled).
  117. auth_mod.invalidate_auth_enabled_cache()
  118. assert await auth_mod.is_auth_enabled(db) is True
  119. assert db.calls == 2
  120. auth_mod.invalidate_auth_enabled_cache()
  121. @pytest.mark.asyncio
  122. async def test_disabled_is_never_cached(self):
  123. """SECURITY: a disabled result must never be cached, so staleness can
  124. only ever fail closed (require auth), never open."""
  125. from backend.app.core import auth as auth_mod
  126. auth_mod.invalidate_auth_enabled_cache()
  127. db = self._CountingDB("false")
  128. assert await auth_mod.is_auth_enabled(db) is False
  129. assert db.calls == 1
  130. # Every probe re-reads while disabled.
  131. assert await auth_mod.is_auth_enabled(db) is False
  132. assert db.calls == 2
  133. auth_mod.invalidate_auth_enabled_cache()
  134. @pytest.mark.asyncio
  135. async def test_unconfigured_returns_false_and_is_not_cached(self):
  136. from backend.app.core import auth as auth_mod
  137. auth_mod.invalidate_auth_enabled_cache()
  138. db = self._CountingDB(None)
  139. assert await auth_mod.is_auth_enabled(db) is False
  140. assert await auth_mod.is_auth_enabled(db) is False
  141. assert db.calls == 2
  142. auth_mod.invalidate_auth_enabled_cache()
  143. @pytest.mark.asyncio
  144. async def test_db_error_propagates_fail_closed(self):
  145. """A probe error must propagate (fail closed), not be swallowed."""
  146. from backend.app.core import auth as auth_mod
  147. auth_mod.invalidate_auth_enabled_cache()
  148. class _RaisingDB:
  149. async def execute(self, *args, **kwargs):
  150. raise RuntimeError("connection lost")
  151. with pytest.raises(RuntimeError):
  152. await auth_mod.is_auth_enabled(_RaisingDB())
  153. auth_mod.invalidate_auth_enabled_cache()