Skip to content

Commit fd58646

Browse files
committed
add pool_pre_ping and dispose()
1 parent bddc3d2 commit fd58646

4 files changed

Lines changed: 112 additions & 19 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ BASE_URL=http://localhost:8000
77
# Database connection mode (0 = direct, 1 = pooled)
88
USE_POOL=0
99

10+
# SQLAlchemy pool (per process; keep small when using PgBouncer)
11+
# DB_POOL_SIZE=5
12+
# DB_MAX_OVERFLOW=5
13+
1014
# Shared database settings
1115
DB_HOST=127.0.0.1
1216
DB_SSLMODE=prefer

main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
RateLimitError,
4747
)
4848
from exceptions.exceptions import NeedsNewTokens
49-
from utils.core.db import set_up_db
49+
from utils.core.db import set_up_db, clear_engine_cache
5050

5151
logger = logging.getLogger("uvicorn.error")
5252
logger.setLevel(logging.DEBUG)
@@ -58,7 +58,7 @@ async def lifespan(app: FastAPI):
5858
load_dotenv()
5959
set_up_db()
6060
yield
61-
# Optional shutdown logic
61+
clear_engine_cache()
6262

6363

6464
# Initialize the FastAPI app

tests/utils/test_db.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from sqlalchemy import Engine
44
from utils.core.db import (
55
get_connection_url,
6+
get_engine,
7+
clear_engine_cache,
68
assign_permissions_to_role,
79
create_default_roles,
810
create_permissions,
@@ -146,6 +148,83 @@ def test_get_connection_url_missing_pool_vars(monkeypatch):
146148
get_connection_url()
147149

148150

151+
# --- Engine cache ---
152+
153+
154+
def _direct_db_env(monkeypatch, *, name: str = "testdb", password: str = "testpass"):
155+
for var in (
156+
"USE_POOL",
157+
"DB_HOST",
158+
"DB_PORT",
159+
"DB_NAME",
160+
"DB_USER",
161+
"DB_PASSWORD",
162+
"DB_POOL_PORT",
163+
"DB_POOL_NAME",
164+
"DB_APPUSER",
165+
"DB_APPUSER_PASSWORD",
166+
"DB_POOL_SIZE",
167+
"DB_MAX_OVERFLOW",
168+
):
169+
monkeypatch.delenv(var, raising=False)
170+
monkeypatch.setenv("DB_HOST", "localhost")
171+
monkeypatch.setenv("DB_PORT", "5432")
172+
monkeypatch.setenv("DB_NAME", name)
173+
monkeypatch.setenv("DB_USER", "testuser")
174+
monkeypatch.setenv("DB_PASSWORD", password)
175+
176+
177+
def test_get_engine_reuses_same_instance(monkeypatch):
178+
clear_engine_cache()
179+
_direct_db_env(monkeypatch)
180+
assert get_engine() is get_engine()
181+
clear_engine_cache()
182+
183+
184+
def test_get_engine_different_urls_get_different_engines(monkeypatch):
185+
clear_engine_cache()
186+
_direct_db_env(monkeypatch, name="db_a")
187+
engine_a = get_engine()
188+
_direct_db_env(monkeypatch, name="db_b")
189+
engine_b = get_engine()
190+
assert engine_a is not engine_b
191+
clear_engine_cache()
192+
193+
194+
def test_get_engine_not_keyed_by_masked_str_password(monkeypatch):
195+
"""str(URL) masks passwords; cache must still separate credentials."""
196+
clear_engine_cache()
197+
_direct_db_env(monkeypatch, password="secretA")
198+
engine_a = get_engine()
199+
assert "***" in str(get_connection_url())
200+
_direct_db_env(monkeypatch, password="secretB")
201+
engine_b = get_engine()
202+
assert engine_a is not engine_b
203+
clear_engine_cache()
204+
205+
206+
def test_clear_engine_cache_disposes_and_creates_new(monkeypatch):
207+
clear_engine_cache()
208+
_direct_db_env(monkeypatch)
209+
first = get_engine()
210+
clear_engine_cache()
211+
second = get_engine()
212+
assert first is not second
213+
clear_engine_cache()
214+
215+
216+
def test_get_engine_applies_pool_settings(monkeypatch):
217+
clear_engine_cache()
218+
_direct_db_env(monkeypatch)
219+
monkeypatch.setenv("DB_POOL_SIZE", "3")
220+
monkeypatch.setenv("DB_MAX_OVERFLOW", "2")
221+
engine = get_engine()
222+
assert engine.pool.size() == 3
223+
assert engine.pool._max_overflow == 2
224+
assert engine.pool._pre_ping is True
225+
clear_engine_cache()
226+
227+
149228
# --- Permission and Role Tests ---
150229

151230

utils/core/db.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import os
22
import logging
3-
from functools import lru_cache
43
from itertools import chain
54
from typing import Union, Sequence
65
from sqlalchemy.engine import Engine, URL
@@ -29,26 +28,36 @@
2928
# --- Database connection functions ---
3029

3130

32-
@lru_cache
33-
def _cached_engine(url: URL) -> Engine:
34-
# URL objects hash/compare on their real fields (including password), so
35-
# caching on the URL itself is safe. str(URL) masks the password as
36-
# "***" for safe logging — passing that to create_engine() instead would
37-
# make every connection attempt authenticate with the literal "***".
38-
return create_engine(url)
31+
# Cache by URL object (not str(url)): str(URL) masks passwords as "***", so
32+
# different credentials would collide on one cache key.
33+
_engines: dict[URL, Engine] = {}
3934

4035

4136
def get_engine() -> Engine:
42-
"""Returns a process-wide cached engine for the current connection URL.
43-
44-
Calling create_engine() per request/call leaks pooled connections until
45-
Python's GC reclaims them (engines aren't freed by simple refcounting),
46-
which can exhaust Postgres's max_connections under sustained load. Cache
47-
by URL rather than as a single global so tests that swap DB_NAME within
48-
the same process (e.g. browser vs. browser-csrf DBs) still get one
49-
engine per database instead of sharing a mismatched pool.
37+
"""Return a process-wide cached engine for the current connection URL.
38+
39+
Calling create_engine() per request leaks pooled connections until GC
40+
reclaims them. Cache one engine per URL so tests that swap DB_NAME still
41+
get a separate pool per database.
5042
"""
51-
return _cached_engine(get_connection_url())
43+
url = get_connection_url()
44+
engine = _engines.get(url)
45+
if engine is None:
46+
engine = create_engine(
47+
url,
48+
pool_pre_ping=True,
49+
pool_size=int(os.getenv("DB_POOL_SIZE", "5")),
50+
max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "5")),
51+
)
52+
_engines[url] = engine
53+
return engine
54+
55+
56+
def clear_engine_cache() -> None:
57+
"""Dispose and drop all cached engines (shutdown / tests)."""
58+
for engine in _engines.values():
59+
engine.dispose()
60+
_engines.clear()
5261

5362

5463
def ensure_database_exists(url: URL) -> None:
@@ -314,3 +323,4 @@ def tear_down_db() -> None:
314323
conn.commit()
315324
finally:
316325
engine.dispose()
326+
clear_engine_cache()

0 commit comments

Comments
 (0)