fix(db): patch SQLAlchemy ↔ aiosqlite ≥0.20 terminate_force_close incompatibility

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
This commit is contained in:
Claude 2026-04-14 12:35:19 +00:00
parent 37658fd541
commit 6bda95d3bd
No known key found for this signature in database
2 changed files with 119 additions and 0 deletions

View file

@ -0,0 +1,110 @@
"""
Compatibility shim for SQLAlchemy ↔ aiosqlite ≥ 0.20.
Background
----------
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 because the operation it
was running was cancelled (for example, a request was aborted by the
client mid-query).
For the aiosqlite dialect this implementation lives at
`sqlalchemy.dialects.sqlite.aiosqlite.AsyncAdapt_aiosqlite_connection
._terminate_force_close` and unconditionally accesses
`self._connection.stop`. That attribute existed on
`aiosqlite.Connection` in 0.18 and earlier (where Connection
sub-classed `threading.Thread` and exposed a `stop()` method). It was
removed in aiosqlite 0.20 when the worker model was refactored.
The result, on the version combo Open WebUI pins
(SQLAlchemy 2.0.48 + aiosqlite 0.21.0), is that *every* cancellation
mid-DB-call produces a multi-page
NotImplementedError: terminate_force_close() not implemented by this DBAPI shim
at ERROR level — drowning real errors in noise even though the
underlying connection is being torn down correctly by aiosqlite's own
worker on the next iteration.
Fix
---
Replace `_terminate_force_close` with one that understands both the
old `Connection.stop()` API and the modern internal `_running` flag.
Setting `_running = False` causes the aiosqlite worker thread to
break out of its loop on the next tick, which is the same end-state
the original code was after.
Apply this patch once, at import time, before any async engine is
created. Idempotent and safe to import on systems where the affected
SQLAlchemy/aiosqlite versions are not in use — it bails out silently
if the symbol is missing.
"""
from __future__ import annotations
import logging
log = logging.getLogger(__name__)
def install() -> None:
try:
from sqlalchemy.dialects.sqlite import aiosqlite as _sa_aiosqlite
except ImportError:
# SQLAlchemy installed without sqlite+aiosqlite — nothing to patch.
return
target_cls = getattr(_sa_aiosqlite, 'AsyncAdapt_aiosqlite_connection', None)
if target_cls is None or not hasattr(target_cls, '_terminate_force_close'):
# Either an older SQLAlchemy without the terminate path, or an
# upstream rewrite that no longer needs this shim.
return
if getattr(target_cls._terminate_force_close, '__open_webui_patched__', False):
return # Idempotent — already applied.
def _terminate_force_close(self) -> None:
conn = getattr(self, '_connection', None)
if conn is None:
return
# aiosqlite ≤ 0.18 — original API.
stop = getattr(conn, 'stop', None)
if callable(stop):
try:
stop()
except Exception:
log.debug(
'aiosqlite Connection.stop() raised during force-close; '
'connection will be cleaned up by garbage collection.',
exc_info=True,
)
return
# aiosqlite ≥ 0.20 — the worker thread observes its own
# `_running` flag and exits on the next loop tick, releasing
# the underlying sqlite3 connection.
if hasattr(conn, '_running'):
try:
conn._running = False
except Exception:
log.debug(
'Could not flip aiosqlite Connection._running during '
'force-close; connection will be cleaned up by garbage '
'collection.',
exc_info=True,
)
return
# Unknown aiosqlite internals — fall back to leaving the
# connection to garbage collection. Do *not* re-raise: the pool
# has already removed the connection record and there is
# nothing useful for the caller to do.
log.debug(
'aiosqlite Connection has neither stop() nor _running; relying '
'on garbage collection to release the underlying sqlite3 handle.'
)
_terminate_force_close.__open_webui_patched__ = True # type: ignore[attr-defined]
target_cls._terminate_force_close = _terminate_force_close

View file

@ -205,6 +205,15 @@ 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: