From f8156df6f5d73993ea4c12917b0d0693503ee1bb Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Fri, 20 Mar 2026 06:08:54 +0000 Subject: [PATCH] fix: configure SQLAlchemy connection pool and dispose engines on shutdown - Add configurable pool settings via environment variables: DB_POOL_SIZE, DB_MAX_OVERFLOW, DB_POOL_TIMEOUT, DB_POOL_RECYCLE, DB_POOL_PRE_PING (defaults: 5, 10, 30s, 1800s, true) - Enable pool_pre_ping by default to detect and recycle stale connections - Add dispose_engines() called during FastAPI lifespan shutdown to release all connections back to the database - Skip pool configuration for SQLite (uses file-lock, not connection pools) - Apply changes to both servers/ and electron/ FastAPI instances Fixes #453 (stale connections exhausting pool) Fixes #454 (missing pool configuration) --- electron/servers/fastapi/api/lifespan.py | 4 ++- electron/servers/fastapi/services/database.py | 21 ++++++++++-- electron/servers/fastapi/utils/db_utils.py | 34 +++++++++++++++++++ servers/fastapi/api/lifespan.py | 4 ++- servers/fastapi/services/database.py | 21 ++++++++++-- servers/fastapi/utils/db_utils.py | 34 +++++++++++++++++++ 6 files changed, 112 insertions(+), 6 deletions(-) diff --git a/electron/servers/fastapi/api/lifespan.py b/electron/servers/fastapi/api/lifespan.py index 6fe4e6c4..1ce3e26f 100644 --- a/electron/servers/fastapi/api/lifespan.py +++ b/electron/servers/fastapi/api/lifespan.py @@ -4,7 +4,7 @@ import os from fastapi import FastAPI from migrations import migrate_database_on_startup -from services.database import create_db_and_tables +from services.database import create_db_and_tables, dispose_engines from utils.get_env import get_app_data_directory_env from utils.model_availability import ( check_llm_and_image_provider_api_or_model_availability, @@ -24,3 +24,5 @@ async def app_lifespan(_: FastAPI): await create_db_and_tables() await check_llm_and_image_provider_api_or_model_availability() yield + # Shutdown: release all database connections to prevent stale/leaked pools. + await dispose_engines() diff --git a/electron/servers/fastapi/services/database.py b/electron/servers/fastapi/services/database.py index 6149447a..b1fa842a 100644 --- a/electron/servers/fastapi/services/database.py +++ b/electron/servers/fastapi/services/database.py @@ -19,13 +19,19 @@ from models.sql.slide import SlideModel from models.sql.presentation_layout_code import PresentationLayoutCodeModel from models.sql.template import TemplateModel from models.sql.webhook_subscription import WebhookSubscription -from utils.db_utils import get_database_url_and_connect_args +from utils.db_utils import get_database_url_and_connect_args, get_pool_kwargs from utils.get_env import get_app_data_directory_env database_url, connect_args = get_database_url_and_connect_args() -sql_engine: AsyncEngine = create_async_engine(database_url, connect_args=connect_args) +# Apply connection-pool settings for server-class databases (PostgreSQL, MySQL). +# SQLite uses a file-lock model and ignores pool configuration, so we skip it. +_pool_kwargs = get_pool_kwargs() if "sqlite" not in database_url else {} + +sql_engine: AsyncEngine = create_async_engine( + database_url, connect_args=connect_args, **_pool_kwargs +) async_session_maker = async_sessionmaker(sql_engine, expire_on_commit=False) @@ -76,3 +82,14 @@ async def create_db_and_tables(): tables=[OllamaPullStatus.__table__], ) ) + + +async def dispose_engines(): + """Dispose all engine connection pools. + + Call this during application shutdown (e.g. in a FastAPI ``shutdown`` + event or lifespan context) to release every connection back to the + database and prevent stale / leaked connections. + """ + await sql_engine.dispose() + await container_db_engine.dispose() diff --git a/electron/servers/fastapi/utils/db_utils.py b/electron/servers/fastapi/utils/db_utils.py index 60b521fb..8976eb8b 100644 --- a/electron/servers/fastapi/utils/db_utils.py +++ b/electron/servers/fastapi/utils/db_utils.py @@ -4,6 +4,40 @@ from urllib.parse import urlsplit, urlunsplit, parse_qsl import ssl +def _int_env(name: str, default: int) -> int: + """Read an integer from an environment variable, falling back to *default*.""" + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def get_pool_kwargs() -> dict: + """Build SQLAlchemy engine pool keyword arguments from environment variables. + + Supported variables (all optional): + DB_POOL_SIZE – max persistent connections (default 5) + DB_MAX_OVERFLOW – extra connections above pool_size (default 10) + DB_POOL_TIMEOUT – seconds to wait for a connection (default 30) + DB_POOL_RECYCLE – seconds before a connection is recycled (default 1800) + DB_POOL_PRE_PING – enable connection liveness check (default true) + + For SQLite the pool settings are not applicable and an empty dict is + returned, since SQLite uses ``StaticPool`` / ``NullPool`` by default. + """ + return { + "pool_size": _int_env("DB_POOL_SIZE", 5), + "max_overflow": _int_env("DB_MAX_OVERFLOW", 10), + "pool_timeout": _int_env("DB_POOL_TIMEOUT", 30), + "pool_recycle": _int_env("DB_POOL_RECYCLE", 1800), + "pool_pre_ping": os.getenv("DB_POOL_PRE_PING", "true").lower() + not in ("false", "0", "no"), + } + + def _ensure_sqlite_parent_dir(database_url: str) -> None: if not database_url.startswith("sqlite://"): return diff --git a/servers/fastapi/api/lifespan.py b/servers/fastapi/api/lifespan.py index 6fe4e6c4..1ce3e26f 100644 --- a/servers/fastapi/api/lifespan.py +++ b/servers/fastapi/api/lifespan.py @@ -4,7 +4,7 @@ import os from fastapi import FastAPI from migrations import migrate_database_on_startup -from services.database import create_db_and_tables +from services.database import create_db_and_tables, dispose_engines from utils.get_env import get_app_data_directory_env from utils.model_availability import ( check_llm_and_image_provider_api_or_model_availability, @@ -24,3 +24,5 @@ async def app_lifespan(_: FastAPI): await create_db_and_tables() await check_llm_and_image_provider_api_or_model_availability() yield + # Shutdown: release all database connections to prevent stale/leaked pools. + await dispose_engines() diff --git a/servers/fastapi/services/database.py b/servers/fastapi/services/database.py index 5557451a..39d8fd13 100644 --- a/servers/fastapi/services/database.py +++ b/servers/fastapi/services/database.py @@ -20,12 +20,18 @@ from models.sql.slide import SlideModel from models.sql.presentation_layout_code import PresentationLayoutCodeModel from models.sql.template import TemplateModel from models.sql.webhook_subscription import WebhookSubscription -from utils.db_utils import get_database_url_and_connect_args +from utils.db_utils import get_database_url_and_connect_args, get_pool_kwargs database_url, connect_args = get_database_url_and_connect_args() -sql_engine: AsyncEngine = create_async_engine(database_url, connect_args=connect_args) +# Apply connection-pool settings for server-class databases (PostgreSQL, MySQL). +# SQLite uses a file-lock model and ignores pool configuration, so we skip it. +_pool_kwargs = get_pool_kwargs() if "sqlite" not in database_url else {} + +sql_engine: AsyncEngine = create_async_engine( + database_url, connect_args=connect_args, **_pool_kwargs +) async_session_maker = async_sessionmaker(sql_engine, expire_on_commit=False) @@ -81,3 +87,14 @@ async def create_db_and_tables(): tables=[OllamaPullStatus.__table__], ) ) + + +async def dispose_engines(): + """Dispose all engine connection pools. + + Call this during application shutdown (e.g. in a FastAPI ``shutdown`` + event or lifespan context) to release every connection back to the + database and prevent stale / leaked connections. + """ + await sql_engine.dispose() + await container_db_engine.dispose() diff --git a/servers/fastapi/utils/db_utils.py b/servers/fastapi/utils/db_utils.py index 368740f5..1789a1a7 100644 --- a/servers/fastapi/utils/db_utils.py +++ b/servers/fastapi/utils/db_utils.py @@ -4,6 +4,40 @@ from urllib.parse import urlsplit, urlunsplit, parse_qsl import ssl +def _int_env(name: str, default: int) -> int: + """Read an integer from an environment variable, falling back to *default*.""" + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def get_pool_kwargs() -> dict: + """Build SQLAlchemy engine pool keyword arguments from environment variables. + + Supported variables (all optional): + DB_POOL_SIZE – max persistent connections (default 5) + DB_MAX_OVERFLOW – extra connections above pool_size (default 10) + DB_POOL_TIMEOUT – seconds to wait for a connection (default 30) + DB_POOL_RECYCLE – seconds before a connection is recycled (default 1800) + DB_POOL_PRE_PING – enable connection liveness check (default true) + + For SQLite the pool settings are not applicable and an empty dict is + returned, since SQLite uses ``StaticPool`` / ``NullPool`` by default. + """ + return { + "pool_size": _int_env("DB_POOL_SIZE", 5), + "max_overflow": _int_env("DB_MAX_OVERFLOW", 10), + "pool_timeout": _int_env("DB_POOL_TIMEOUT", 30), + "pool_recycle": _int_env("DB_POOL_RECYCLE", 1800), + "pool_pre_ping": os.getenv("DB_POOL_PRE_PING", "true").lower() + not in ("false", "0", "no"), + } + + def get_database_url_and_connect_args() -> tuple[str, dict]: database_url = get_database_url_env() or "sqlite:///" + os.path.join( get_app_data_directory_env() or "/tmp/presenton", "fastapi.db"