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)
This commit is contained in:
parent
b615156192
commit
f8156df6f5
6 changed files with 112 additions and 6 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue