Explorar el Código

feat(db): DB_POOL_PRE_PING env override for the PostgreSQL pool (#3396) (#3413)

oh-forf hace 2 días
padre
commit
ffb43e8922

+ 8 - 0
backend/app/core/config.py

@@ -91,6 +91,14 @@ class Settings(BaseSettings):
     # overflow connections age out via pool_recycle instead of churning the whole
     # pool. Override with DB_POOL_USE_LIFO. No effect on SQLite. (#2572)
     db_pool_use_lifo: bool | None = Field(default=None)
+    # pool_pre_ping (PostgreSQL default on): every checkout first runs a liveness
+    # ping, which SQLAlchemy's asyncpg dialect wraps in its own transaction, so
+    # each pooled session costs an extra ``BEGIN; ROLLBACK`` round-trip that
+    # shows up in ``pg_stat_database.xact_rollback``. Set DB_POOL_PRE_PING=false
+    # to skip it; ``pool_recycle`` then bounds how long a dead connection can
+    # survive, but a connection dropped by the server or a proxy in between is
+    # no longer replaced transparently. No effect on SQLite.
+    db_pool_pre_ping: bool | None = Field(default=None)
 
     # Logging
     log_level: str = "INFO"  # Override with LOG_LEVEL env var or DEBUG=true

+ 3 - 2
backend/app/core/database.py

@@ -40,7 +40,8 @@ def _resolve_pool_kwargs() -> dict:
 
     Dialect-aware defaults, each overridable via env (``DB_POOL_SIZE`` etc.):
       - PostgreSQL: pool_size 20 + max_overflow 60, ``pool_pre_ping`` (recover
-        server-dropped connections instead of erroring the request) and
+        server-dropped connections instead of erroring the request;
+        ``DB_POOL_PRE_PING=false`` turns it off) and
         ``pool_recycle`` 1800s. The old hard-coded 10 + 20 exhausted on large
         farms while printer callbacks held connections. The 80-connection
         ceiling fits a stock server (max_connections 100, 3 reserved for
@@ -86,7 +87,7 @@ def _resolve_pool_kwargs() -> dict:
         kwargs = {
             "pool_size": pool_size,
             "max_overflow": max_overflow,
-            "pool_pre_ping": True,
+            "pool_pre_ping": settings.db_pool_pre_ping if settings.db_pool_pre_ping is not None else True,
             "pool_recycle": settings.db_pool_recycle if settings.db_pool_recycle is not None else 1800,
             # LIFO checkout keeps a bursty farm on a small hot connection set and
             # lets overflow connections recycle out during quiet spells (#2572).

+ 36 - 1
backend/tests/unit/test_db_pool_and_auth_cache.py

@@ -37,7 +37,7 @@ class TestPoolConfiguration:
         """Postgres default is 20 + 60 (was 10 + 20) with pre-ping + recycle."""
         from backend.app.core import database
 
-        for attr in ("db_pool_size", "db_max_overflow", "db_pool_timeout", "db_pool_recycle"):
+        for attr in ("db_pool_size", "db_max_overflow", "db_pool_timeout", "db_pool_recycle", "db_pool_pre_ping"):
             monkeypatch.setattr(database.settings, attr, None, raising=False)
         monkeypatch.setattr(database, "is_sqlite", lambda: False)
 
@@ -63,6 +63,41 @@ class TestPoolConfiguration:
         assert kwargs["pool_timeout"] == 45
         assert kwargs["pool_recycle"] == 600
 
+    def test_pre_ping_can_be_disabled_on_postgres(self, monkeypatch):
+        """DB_POOL_PRE_PING=false drops pool_pre_ping; the other defaults stay."""
+        from backend.app.core import database
+
+        monkeypatch.setattr(database.settings, "db_pool_pre_ping", False, raising=False)
+        monkeypatch.setattr(database, "is_sqlite", lambda: False)
+
+        kwargs = database._resolve_pool_kwargs()
+        assert kwargs["pool_pre_ping"] is False
+        assert kwargs["pool_recycle"] == 1800
+
+    def test_pre_ping_env_string_is_parsed(self, monkeypatch):
+        """The real DB_POOL_PRE_PING env strings reach the setting as booleans."""
+        from backend.app.core.config import Settings
+
+        for raw, expected in (("false", False), ("0", False), ("true", True), ("1", True)):
+            monkeypatch.setenv("DB_POOL_PRE_PING", raw)
+            assert Settings().db_pool_pre_ping is expected, raw
+
+    def test_pre_ping_explicit_true_is_honoured(self, monkeypatch):
+        from backend.app.core import database
+
+        monkeypatch.setattr(database.settings, "db_pool_pre_ping", True, raising=False)
+        monkeypatch.setattr(database, "is_sqlite", lambda: False)
+
+        assert database._resolve_pool_kwargs()["pool_pre_ping"] is True
+
+    def test_pre_ping_override_is_a_no_op_on_sqlite(self, monkeypatch):
+        from backend.app.core import database
+
+        monkeypatch.setattr(database.settings, "db_pool_pre_ping", False, raising=False)
+        monkeypatch.setattr(database, "is_sqlite", lambda: True)
+
+        assert "pool_pre_ping" not in database._resolve_pool_kwargs()
+
     @pytest.mark.asyncio
     async def test_concurrent_checkouts_exceed_base_pool_size(self, tmp_path):
         """Regression (#2572): more concurrent sessions than pool_size must all