mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-28 01:31:28 +00:00
SQLAlchemy 2.0.36+ added a `terminate_force_close()` path on its
async DB-API adapters, used by the connection pool when a connection
must be invalidated immediately (typically when an in-flight query
was cancelled — request aborted by client, timeout, etc.).
For the aiosqlite dialect, `_terminate_force_close` unconditionally
accesses `self._connection.stop`. That attribute existed on
`aiosqlite.Connection` up to 0.18 (where Connection sub-classed
`threading.Thread`) and was removed in 0.20+ when the worker model
was refactored.
On the version pin Open WebUI ships (sqlalchemy 2.0.48 + aiosqlite
0.21.0) every cancelled aiosqlite call therefore produces a
multi-page
NotImplementedError: terminate_force_close() not implemented by this DBAPI shim
ERROR traceback, drowning real errors in noise even though the
underlying connection is correctly torn down by aiosqlite's own
worker on the next loop tick.
Add `internal/_aiosqlite_compat.install()` which monkey-patches the
shim to understand both APIs:
* aiosqlite ≤ 0.18 — call `Connection.stop()` as before.
* aiosqlite ≥ 0.20 — flip the worker thread's `_running` flag so it
exits on the next iteration (the same end-state the original code
achieved on the old API).
* Anything else — log at debug and fall back to GC.
The patch is idempotent, runs once at db.py import time before the
async engine is created, and is a no-op on installations whose
SQLAlchemy doesn't ship the affected symbol — so it won't break
future upstream rewrites.
https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8
291 lines
10 KiB
Python
291 lines
10 KiB
Python
import os
|
|
import json
|
|
import logging
|
|
from contextlib import asynccontextmanager, contextmanager
|
|
from typing import Any, Optional
|
|
|
|
from open_webui.internal.wrappers import register_connection
|
|
from open_webui.env import (
|
|
OPEN_WEBUI_DIR,
|
|
DATABASE_URL,
|
|
DATABASE_SCHEMA,
|
|
DATABASE_POOL_MAX_OVERFLOW,
|
|
DATABASE_POOL_RECYCLE,
|
|
DATABASE_POOL_SIZE,
|
|
DATABASE_POOL_TIMEOUT,
|
|
DATABASE_ENABLE_SQLITE_WAL,
|
|
DATABASE_ENABLE_SESSION_SHARING,
|
|
ENABLE_DB_MIGRATIONS,
|
|
)
|
|
from peewee_migrate import Router
|
|
from sqlalchemy import Dialect, create_engine, MetaData, event, types
|
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
|
from sqlalchemy.ext.declarative import declarative_base
|
|
from sqlalchemy.orm import scoped_session, sessionmaker, Session
|
|
from sqlalchemy.pool import QueuePool, NullPool
|
|
from sqlalchemy.sql.type_api import _T
|
|
from typing_extensions import Self
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class JSONField(types.TypeDecorator):
|
|
impl = types.Text
|
|
cache_ok = True
|
|
|
|
def process_bind_param(self, value: Optional[_T], dialect: Dialect) -> Any:
|
|
return json.dumps(value)
|
|
|
|
def process_result_value(self, value: Optional[_T], dialect: Dialect) -> Any:
|
|
if value is not None:
|
|
return json.loads(value)
|
|
|
|
def copy(self, **kw: Any) -> Self:
|
|
return JSONField(self.impl.length)
|
|
|
|
def db_value(self, value):
|
|
return json.dumps(value)
|
|
|
|
def python_value(self, value):
|
|
if value is not None:
|
|
return json.loads(value)
|
|
|
|
|
|
# Workaround to handle the peewee migration
|
|
# This is required to ensure the peewee migration is handled before the alembic migration
|
|
def handle_peewee_migration(DATABASE_URL):
|
|
# db = None
|
|
try:
|
|
# Replace the postgresql:// with postgres:// to handle the peewee migration
|
|
db = register_connection(DATABASE_URL.replace('postgresql://', 'postgres://'))
|
|
migrate_dir = OPEN_WEBUI_DIR / 'internal' / 'migrations'
|
|
router = Router(db, logger=log, migrate_dir=migrate_dir)
|
|
router.run()
|
|
db.close()
|
|
|
|
except Exception as e:
|
|
log.error(f'Failed to initialize the database connection: {e}')
|
|
log.warning('Hint: If your database password contains special characters, you may need to URL-encode it.')
|
|
raise
|
|
finally:
|
|
# Properly closing the database connection
|
|
if db and not db.is_closed():
|
|
db.close()
|
|
|
|
# Assert if db connection has been closed
|
|
assert db.is_closed(), 'Database connection is still open.'
|
|
|
|
|
|
if ENABLE_DB_MIGRATIONS:
|
|
handle_peewee_migration(DATABASE_URL)
|
|
|
|
|
|
SQLALCHEMY_DATABASE_URL = DATABASE_URL
|
|
|
|
|
|
def _make_async_url(url: str) -> str:
|
|
"""Convert a sync database URL to its async driver equivalent."""
|
|
if url.startswith('sqlite+sqlcipher://'):
|
|
# SQLCipher has no async driver — not supported for async
|
|
raise ValueError(
|
|
'sqlite+sqlcipher:// URLs are not supported with async engine. '
|
|
'Use standard sqlite:// or postgresql:// instead.'
|
|
)
|
|
if url.startswith('sqlite:///') or url.startswith('sqlite://'):
|
|
return url.replace('sqlite://', 'sqlite+aiosqlite://', 1)
|
|
if url.startswith('postgresql+psycopg2://'):
|
|
return url.replace('postgresql+psycopg2://', 'postgresql+asyncpg://', 1)
|
|
if url.startswith('postgresql://'):
|
|
return url.replace('postgresql://', 'postgresql+asyncpg://', 1)
|
|
if url.startswith('postgres://'):
|
|
return url.replace('postgres://', 'postgresql+asyncpg://', 1)
|
|
# For other dialects, return as-is and let SQLAlchemy handle it
|
|
return url
|
|
|
|
|
|
# ============================================================
|
|
# SYNC ENGINE (used only for: startup migrations, config loading,
|
|
# Alembic, peewee migration, health checks)
|
|
# ============================================================
|
|
|
|
# Handle SQLCipher URLs
|
|
if SQLALCHEMY_DATABASE_URL.startswith('sqlite+sqlcipher://'):
|
|
database_password = os.environ.get('DATABASE_PASSWORD')
|
|
if not database_password or database_password.strip() == '':
|
|
raise ValueError('DATABASE_PASSWORD is required when using sqlite+sqlcipher:// URLs')
|
|
|
|
# Extract database path from SQLCipher URL
|
|
db_path = SQLALCHEMY_DATABASE_URL.replace('sqlite+sqlcipher://', '')
|
|
|
|
# Create a custom creator function that uses sqlcipher3
|
|
def create_sqlcipher_connection():
|
|
import sqlcipher3
|
|
|
|
conn = sqlcipher3.connect(db_path, check_same_thread=False)
|
|
conn.execute(f"PRAGMA key = '{database_password}'")
|
|
return conn
|
|
|
|
# The dummy "sqlite://" URL would cause SQLAlchemy to auto-select
|
|
# SingletonThreadPool, which non-deterministically closes in-use
|
|
# connections when thread count exceeds pool_size, leading to segfaults
|
|
# in the native sqlcipher3 C library. Use NullPool by default for safety,
|
|
# or QueuePool if DATABASE_POOL_SIZE is explicitly configured.
|
|
if isinstance(DATABASE_POOL_SIZE, int) and DATABASE_POOL_SIZE > 0:
|
|
engine = create_engine(
|
|
'sqlite://',
|
|
creator=create_sqlcipher_connection,
|
|
pool_size=DATABASE_POOL_SIZE,
|
|
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
|
pool_timeout=DATABASE_POOL_TIMEOUT,
|
|
pool_recycle=DATABASE_POOL_RECYCLE,
|
|
pool_pre_ping=True,
|
|
poolclass=QueuePool,
|
|
echo=False,
|
|
)
|
|
else:
|
|
engine = create_engine(
|
|
'sqlite://',
|
|
creator=create_sqlcipher_connection,
|
|
poolclass=NullPool,
|
|
echo=False,
|
|
)
|
|
|
|
log.info('Connected to encrypted SQLite database using SQLCipher')
|
|
|
|
elif 'sqlite' in SQLALCHEMY_DATABASE_URL:
|
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={'check_same_thread': False})
|
|
|
|
def on_connect(dbapi_connection, connection_record):
|
|
cursor = dbapi_connection.cursor()
|
|
if DATABASE_ENABLE_SQLITE_WAL:
|
|
cursor.execute('PRAGMA journal_mode=WAL')
|
|
else:
|
|
cursor.execute('PRAGMA journal_mode=DELETE')
|
|
cursor.close()
|
|
|
|
event.listen(engine, 'connect', on_connect)
|
|
else:
|
|
if isinstance(DATABASE_POOL_SIZE, int):
|
|
if DATABASE_POOL_SIZE > 0:
|
|
engine = create_engine(
|
|
SQLALCHEMY_DATABASE_URL,
|
|
pool_size=DATABASE_POOL_SIZE,
|
|
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
|
pool_timeout=DATABASE_POOL_TIMEOUT,
|
|
pool_recycle=DATABASE_POOL_RECYCLE,
|
|
pool_pre_ping=True,
|
|
poolclass=QueuePool,
|
|
)
|
|
else:
|
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True, poolclass=NullPool)
|
|
else:
|
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
|
|
|
|
|
# Sync session — used ONLY for startup config loading (config.py runs at import time)
|
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
|
|
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
|
|
Base = declarative_base(metadata=metadata_obj)
|
|
ScopedSession = scoped_session(SessionLocal)
|
|
|
|
|
|
def get_session():
|
|
"""Sync session generator — used ONLY for startup/config operations."""
|
|
db = SessionLocal()
|
|
try:
|
|
yield db
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
get_db = contextmanager(get_session)
|
|
|
|
|
|
# ============================================================
|
|
# ASYNC ENGINE (used for ALL runtime database operations)
|
|
# ============================================================
|
|
|
|
# Patch SQLAlchemy's aiosqlite connector before any async engine is
|
|
# created. Without this, every cancelled aiosqlite query produces a
|
|
# multi-page `terminate_force_close() not implemented` ERROR traceback
|
|
# because SQLAlchemy 2.0.x's shim references `Connection.stop`, which
|
|
# aiosqlite removed in 0.20+. See `_aiosqlite_compat` for details.
|
|
from open_webui.internal._aiosqlite_compat import install as _install_aiosqlite_compat
|
|
|
|
_install_aiosqlite_compat()
|
|
|
|
ASYNC_SQLALCHEMY_DATABASE_URL = _make_async_url(SQLALCHEMY_DATABASE_URL)
|
|
|
|
if 'sqlite' in ASYNC_SQLALCHEMY_DATABASE_URL:
|
|
async_engine = create_async_engine(
|
|
ASYNC_SQLALCHEMY_DATABASE_URL,
|
|
connect_args={'check_same_thread': False},
|
|
)
|
|
|
|
if DATABASE_ENABLE_SQLITE_WAL:
|
|
|
|
@event.listens_for(async_engine.sync_engine, 'connect')
|
|
def _set_sqlite_wal(dbapi_connection, connection_record):
|
|
cursor = dbapi_connection.cursor()
|
|
cursor.execute('PRAGMA journal_mode=WAL')
|
|
cursor.close()
|
|
else:
|
|
if isinstance(DATABASE_POOL_SIZE, int):
|
|
if DATABASE_POOL_SIZE > 0:
|
|
async_engine = create_async_engine(
|
|
ASYNC_SQLALCHEMY_DATABASE_URL,
|
|
pool_size=DATABASE_POOL_SIZE,
|
|
max_overflow=DATABASE_POOL_MAX_OVERFLOW,
|
|
pool_timeout=DATABASE_POOL_TIMEOUT,
|
|
pool_recycle=DATABASE_POOL_RECYCLE,
|
|
pool_pre_ping=True,
|
|
)
|
|
else:
|
|
async_engine = create_async_engine(
|
|
ASYNC_SQLALCHEMY_DATABASE_URL,
|
|
pool_pre_ping=True,
|
|
poolclass=NullPool,
|
|
)
|
|
else:
|
|
async_engine = create_async_engine(
|
|
ASYNC_SQLALCHEMY_DATABASE_URL,
|
|
pool_pre_ping=True,
|
|
)
|
|
|
|
|
|
AsyncSessionLocal = async_sessionmaker(
|
|
bind=async_engine,
|
|
class_=AsyncSession,
|
|
autocommit=False,
|
|
autoflush=False,
|
|
expire_on_commit=False,
|
|
)
|
|
|
|
|
|
async def get_async_session():
|
|
"""Async session generator for FastAPI Depends()."""
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
yield db
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def get_async_db():
|
|
"""Async context manager for use outside of FastAPI dependency injection."""
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
yield db
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def get_async_db_context(db: Optional[AsyncSession] = None):
|
|
"""Async context manager that reuses an existing session if provided and session sharing is enabled."""
|
|
if isinstance(db, AsyncSession) and DATABASE_ENABLE_SESSION_SHARING:
|
|
yield db
|
|
else:
|
|
async with get_async_db() as session:
|
|
yield session
|