test_db_pool_and_auth_cache.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  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 keeps 20 + 200 when no env override is set."""
  11. from backend.app.core import database
  12. for attr in ("db_pool_size", "db_max_overflow", "db_pool_timeout", "db_pool_recycle"):
  13. monkeypatch.setattr(database.settings, attr, None, raising=False)
  14. monkeypatch.setattr(database, "is_sqlite", lambda: True)
  15. kwargs = database._resolve_pool_kwargs()
  16. assert kwargs["pool_size"] == 20
  17. assert kwargs["max_overflow"] == 200
  18. # No server-socket recycle/pre-ping for a local file.
  19. assert "pool_pre_ping" not in kwargs
  20. assert "pool_recycle" not in kwargs
  21. def test_postgres_defaults_raise_the_old_limits(self, monkeypatch):
  22. """Postgres default is now 20 + 80 (was 10 + 20) with pre-ping + recycle."""
  23. from backend.app.core import database
  24. for attr in ("db_pool_size", "db_max_overflow", "db_pool_timeout", "db_pool_recycle"):
  25. monkeypatch.setattr(database.settings, attr, None, raising=False)
  26. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  27. kwargs = database._resolve_pool_kwargs()
  28. assert kwargs["pool_size"] == 20
  29. assert kwargs["max_overflow"] == 80
  30. assert kwargs["pool_pre_ping"] is True
  31. assert kwargs["pool_recycle"] == 1800
  32. def test_env_overrides_win_on_postgres(self, monkeypatch):
  33. """DB_POOL_* overrides replace the dialect defaults."""
  34. from backend.app.core import database
  35. monkeypatch.setattr(database.settings, "db_pool_size", 100, raising=False)
  36. monkeypatch.setattr(database.settings, "db_max_overflow", 200, raising=False)
  37. monkeypatch.setattr(database.settings, "db_pool_timeout", 45, raising=False)
  38. monkeypatch.setattr(database.settings, "db_pool_recycle", 600, raising=False)
  39. monkeypatch.setattr(database, "is_sqlite", lambda: False)
  40. kwargs = database._resolve_pool_kwargs()
  41. assert kwargs["pool_size"] == 100
  42. assert kwargs["max_overflow"] == 200
  43. assert kwargs["pool_timeout"] == 45
  44. assert kwargs["pool_recycle"] == 600
  45. @pytest.mark.asyncio
  46. async def test_concurrent_checkouts_exceed_base_pool_size(self, tmp_path):
  47. """Regression (#2572): more concurrent sessions than pool_size must all
  48. complete by drawing from max_overflow — not deadlock or time out.
  49. This is the failure the farm hit: printer callbacks held every base
  50. connection, so unrelated requests waited on the pool. With headroom in
  51. max_overflow, concurrent checkouts beyond pool_size still succeed.
  52. Uses a file-based SQLite URL so it gets a real queue pool — the
  53. in-memory URL forces a single-connection StaticPool that ignores
  54. pool_size/max_overflow entirely.
  55. """
  56. db_file = tmp_path / "pool_regression.db"
  57. eng = create_async_engine(f"sqlite+aiosqlite:///{db_file}", pool_size=2, max_overflow=10)
  58. sm = async_sessionmaker(eng)
  59. async def _one():
  60. async with sm() as s:
  61. await s.execute(text("SELECT 1"))
  62. # Hold the checkout briefly so the calls genuinely overlap and
  63. # force the pool past its base size of 2.
  64. await asyncio.sleep(0.05)
  65. return (await s.execute(text("SELECT 1"))).scalar()
  66. try:
  67. results = await asyncio.gather(*[_one() for _ in range(12)])
  68. finally:
  69. await eng.dispose()
  70. assert results == [1] * 12
  71. class TestPoolStatus:
  72. """P3: diagnostics snapshot."""
  73. def test_get_pool_status_shape(self):
  74. from backend.app.core.database import get_pool_status
  75. status = get_pool_status()
  76. assert status["dialect"] in ("sqlite", "postgresql")
  77. for key in ("pool_size", "max_overflow", "pool_timeout", "pool_recycle", "pool_pre_ping"):
  78. assert key in status["config"]
  79. # Live gauges are present (values are ints on a QueuePool).
  80. for key in ("current_size", "checked_out", "checked_in", "overflow"):
  81. assert key in status
  82. class TestAuthEnabledCache:
  83. """P1: cache the auth-enabled probe, but only ever cache True."""
  84. class _Setting:
  85. def __init__(self, value):
  86. self.value = value
  87. class _Result:
  88. def __init__(self, setting):
  89. self._setting = setting
  90. def scalar_one_or_none(self):
  91. return self._setting
  92. class _CountingDB:
  93. def __init__(self, value):
  94. self._value = value
  95. self.calls = 0
  96. async def execute(self, *args, **kwargs):
  97. self.calls += 1
  98. setting = None if self._value is None else TestAuthEnabledCache._Setting(self._value)
  99. return TestAuthEnabledCache._Result(setting)
  100. @pytest.mark.asyncio
  101. async def test_enabled_true_is_cached(self):
  102. from backend.app.core import auth as auth_mod
  103. auth_mod.invalidate_auth_enabled_cache()
  104. db = self._CountingDB("true")
  105. assert await auth_mod.is_auth_enabled(db) is True
  106. assert db.calls == 1
  107. # Second probe served from cache — no new query.
  108. assert await auth_mod.is_auth_enabled(db) is True
  109. assert db.calls == 1
  110. # Invalidation forces a re-read (e.g. after set_auth_enabled).
  111. auth_mod.invalidate_auth_enabled_cache()
  112. assert await auth_mod.is_auth_enabled(db) is True
  113. assert db.calls == 2
  114. auth_mod.invalidate_auth_enabled_cache()
  115. @pytest.mark.asyncio
  116. async def test_disabled_is_never_cached(self):
  117. """SECURITY: a disabled result must never be cached, so staleness can
  118. only ever fail closed (require auth), never open."""
  119. from backend.app.core import auth as auth_mod
  120. auth_mod.invalidate_auth_enabled_cache()
  121. db = self._CountingDB("false")
  122. assert await auth_mod.is_auth_enabled(db) is False
  123. assert db.calls == 1
  124. # Every probe re-reads while disabled.
  125. assert await auth_mod.is_auth_enabled(db) is False
  126. assert db.calls == 2
  127. auth_mod.invalidate_auth_enabled_cache()
  128. @pytest.mark.asyncio
  129. async def test_unconfigured_returns_false_and_is_not_cached(self):
  130. from backend.app.core import auth as auth_mod
  131. auth_mod.invalidate_auth_enabled_cache()
  132. db = self._CountingDB(None)
  133. assert await auth_mod.is_auth_enabled(db) is False
  134. assert await auth_mod.is_auth_enabled(db) is False
  135. assert db.calls == 2
  136. auth_mod.invalidate_auth_enabled_cache()
  137. @pytest.mark.asyncio
  138. async def test_db_error_propagates_fail_closed(self):
  139. """A probe error must propagate (fail closed), not be swallowed."""
  140. from backend.app.core import auth as auth_mod
  141. auth_mod.invalidate_auth_enabled_cache()
  142. class _RaisingDB:
  143. async def execute(self, *args, **kwargs):
  144. raise RuntimeError("connection lost")
  145. with pytest.raises(RuntimeError):
  146. await auth_mod.is_auth_enabled(_RaisingDB())
  147. auth_mod.invalidate_auth_enabled_cache()