|
1 | 1 | import os |
2 | 2 | import logging |
| 3 | +import threading |
3 | 4 | from itertools import chain |
4 | 5 | from typing import Union, Sequence |
5 | | -from sqlalchemy.engine import URL |
| 6 | +from sqlalchemy.engine import Engine, URL |
6 | 7 | from sqlmodel import create_engine, Session, SQLModel, select, text |
7 | 8 | from utils.core.models import ( |
8 | 9 | Account, |
|
28 | 29 | # --- Database connection functions --- |
29 | 30 |
|
30 | 31 |
|
| 32 | +# Cache by URL object (not str(url)): str(URL) masks passwords as "***", so |
| 33 | +# different credentials would collide on one cache key. Guarded by a lock: |
| 34 | +# sync dependencies run in FastAPI's threadpool, so concurrent first requests |
| 35 | +# would otherwise race and orphan an undisposed engine. |
| 36 | +_engines: dict[URL, Engine] = {} |
| 37 | +_engines_lock = threading.Lock() |
| 38 | + |
| 39 | + |
| 40 | +def get_engine() -> Engine: |
| 41 | + """Return a process-wide cached engine for the current connection URL. |
| 42 | +
|
| 43 | + Calling create_engine() per request leaks pooled connections until GC |
| 44 | + reclaims them. Cache one engine per URL so tests that swap DB_NAME still |
| 45 | + get a separate pool per database. Pool settings (DB_POOL_SIZE, |
| 46 | + DB_MAX_OVERFLOW) are read only when the engine for a URL is first |
| 47 | + created; later environment changes have no effect on a cached engine. |
| 48 | + """ |
| 49 | + url = get_connection_url() |
| 50 | + with _engines_lock: |
| 51 | + engine = _engines.get(url) |
| 52 | + if engine is None: |
| 53 | + engine = create_engine( |
| 54 | + url, |
| 55 | + pool_pre_ping=True, |
| 56 | + pool_size=int(os.getenv("DB_POOL_SIZE", "5")), |
| 57 | + max_overflow=int(os.getenv("DB_MAX_OVERFLOW", "5")), |
| 58 | + ) |
| 59 | + _engines[url] = engine |
| 60 | + return engine |
| 61 | + |
| 62 | + |
| 63 | +def clear_engine_cache() -> None: |
| 64 | + """Dispose and drop all cached engines (shutdown / tests).""" |
| 65 | + with _engines_lock: |
| 66 | + engines = list(_engines.values()) |
| 67 | + _engines.clear() |
| 68 | + for engine in engines: |
| 69 | + engine.dispose() |
| 70 | + |
| 71 | + |
31 | 72 | def ensure_database_exists(url: URL) -> None: |
32 | 73 | dbname = url.database |
33 | 74 | server_url = url.set(database="postgres") |
34 | 75 | engine = create_engine(server_url, isolation_level="AUTOCOMMIT") |
35 | | - with engine.connect() as conn: |
36 | | - exists = conn.execute( |
37 | | - text("SELECT 1 FROM pg_database WHERE datname = :n"), |
38 | | - {"n": dbname}, |
39 | | - ).scalar() |
40 | | - if not exists: |
41 | | - conn.execute(text(f'CREATE DATABASE "{dbname}"')) |
| 76 | + try: |
| 77 | + with engine.connect() as conn: |
| 78 | + exists = conn.execute( |
| 79 | + text("SELECT 1 FROM pg_database WHERE datname = :n"), |
| 80 | + {"n": dbname}, |
| 81 | + ).scalar() |
| 82 | + if not exists: |
| 83 | + conn.execute(text(f'CREATE DATABASE "{dbname}"')) |
| 84 | + finally: |
| 85 | + engine.dispose() |
42 | 86 |
|
43 | 87 |
|
44 | 88 | def get_connection_url() -> URL: |
@@ -259,28 +303,32 @@ def set_up_db(drop: bool = False) -> None: |
259 | 303 | drop (bool): If True, drops all existing tables before creating new ones. |
260 | 304 | """ |
261 | 305 | engine = create_engine(get_connection_url()) |
262 | | - if drop: |
263 | | - SQLModel.metadata.drop_all(engine) |
264 | | - # Ensure the private schema exists before creating tables |
265 | | - with engine.connect() as conn: |
266 | | - conn.execute(text("CREATE SCHEMA IF NOT EXISTS private")) |
267 | | - conn.commit() |
268 | | - SQLModel.metadata.create_all(engine) |
269 | | - # Create default permissions and seed account emails |
270 | | - with Session(engine) as session: |
271 | | - create_permissions(session) |
272 | | - session.commit() |
273 | | - seed_account_emails(session) |
274 | | - engine.dispose() |
| 306 | + try: |
| 307 | + if drop: |
| 308 | + SQLModel.metadata.drop_all(engine) |
| 309 | + # Ensure the private schema exists before creating tables |
| 310 | + with engine.connect() as conn: |
| 311 | + conn.execute(text("CREATE SCHEMA IF NOT EXISTS private")) |
| 312 | + conn.commit() |
| 313 | + SQLModel.metadata.create_all(engine) |
| 314 | + # Create default permissions and seed account emails |
| 315 | + with Session(engine) as session: |
| 316 | + create_permissions(session) |
| 317 | + session.commit() |
| 318 | + seed_account_emails(session) |
| 319 | + finally: |
| 320 | + engine.dispose() |
275 | 321 |
|
276 | 322 |
|
277 | 323 | def tear_down_db() -> None: |
278 | 324 | """ |
279 | 325 | Tears down the database by dropping all tables and the private schema. |
280 | 326 | """ |
281 | 327 | engine = create_engine(get_connection_url()) |
282 | | - SQLModel.metadata.drop_all(engine) |
283 | | - with engine.connect() as conn: |
284 | | - conn.execute(text("DROP SCHEMA IF EXISTS private CASCADE")) |
285 | | - conn.commit() |
286 | | - engine.dispose() |
| 328 | + try: |
| 329 | + SQLModel.metadata.drop_all(engine) |
| 330 | + with engine.connect() as conn: |
| 331 | + conn.execute(text("DROP SCHEMA IF EXISTS private CASCADE")) |
| 332 | + conn.commit() |
| 333 | + finally: |
| 334 | + engine.dispose() |
0 commit comments