]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
Support aiosqlite 0.22.0+
authorFederico Caselli <cfederico87@gmail.com>
Wed, 24 Dec 2025 13:02:17 +0000 (14:02 +0100)
committerMike Bayer <mike_mp@zzzcomputing.com>
Thu, 1 Jan 2026 17:54:47 +0000 (12:54 -0500)
Fixed issue in the aiosqlite driver where SQLAlchemy's setting of
aiosqlite's worker thread to "daemon" stopped working because the aiosqlite
architecture moved the location of the worker thread in version 0.22.0.
This "daemon" flag is necessary so that a program is able to exit if the
SQLite connection itself was not explicitly closed, which is particularly
likely with SQLAlchemy as it maintains SQLite connections in a connection
pool.  While it's perfectly fine to call :meth:`.AsyncEngine.dispose`
before program exit, this is not historically or technically necessary for
any driver of any known backend, since a primary feature of relational
databases is durability.  The change also implements support for
"terminate" with aiosqlite when using version version 0.22.1 or greater,
which implements a sync ``.stop()`` method.

Fixes: #13039
Change-Id: I46efcbaab9dd028f673e113d5f6f2ceddfd133ca

doc/build/changelog/unreleased_20/13039.rst [new file with mode: 0644]
lib/sqlalchemy/dialects/sqlite/aiosqlite.py
test/ext/asyncio/test_engine.py

diff --git a/doc/build/changelog/unreleased_20/13039.rst b/doc/build/changelog/unreleased_20/13039.rst
new file mode 100644 (file)
index 0000000..296a8aa
--- /dev/null
@@ -0,0 +1,16 @@
+.. change::
+    :tags: bug, sqlite
+    :tickets: 13039
+
+    Fixed issue in the aiosqlite driver where SQLAlchemy's setting of
+    aiosqlite's worker thread to "daemon" stopped working because the aiosqlite
+    architecture moved the location of the worker thread in version 0.22.0.
+    This "daemon" flag is necessary so that a program is able to exit if the
+    SQLite connection itself was not explicitly closed, which is particularly
+    likely with SQLAlchemy as it maintains SQLite connections in a connection
+    pool.  While it's perfectly fine to call :meth:`.AsyncEngine.dispose`
+    before program exit, this is not historically or technically necessary for
+    any driver of any known backend, since a primary feature of relational
+    databases is durability.  The change also implements support for
+    "terminate" with aiosqlite when using version version 0.22.1 or greater,
+    which implements a sync ``.stop()`` method.
index 79b26d219f204c45f4f226c8e39951082fb6e3b5..e0028d6f6ac5c4bf31d07a0704fbbb6cd9df5e0b 100644 (file)
@@ -81,6 +81,7 @@ from __future__ import annotations
 
 import asyncio
 from functools import partial
+from threading import Thread
 from types import ModuleType
 from typing import Any
 from typing import cast
@@ -96,6 +97,7 @@ from ...connectors.asyncio import AsyncAdapt_dbapi_connection
 from ...connectors.asyncio import AsyncAdapt_dbapi_cursor
 from ...connectors.asyncio import AsyncAdapt_dbapi_module
 from ...connectors.asyncio import AsyncAdapt_dbapi_ss_cursor
+from ...connectors.asyncio import AsyncAdapt_terminate
 from ...util.concurrency import await_
 
 if TYPE_CHECKING:
@@ -115,7 +117,9 @@ class AsyncAdapt_aiosqlite_ss_cursor(AsyncAdapt_dbapi_ss_cursor):
     __slots__ = ()
 
 
-class AsyncAdapt_aiosqlite_connection(AsyncAdapt_dbapi_connection):
+class AsyncAdapt_aiosqlite_connection(
+    AsyncAdapt_terminate, AsyncAdapt_dbapi_connection
+):
     __slots__ = ()
 
     _cursor_cls = AsyncAdapt_aiosqlite_cursor
@@ -189,6 +193,24 @@ class AsyncAdapt_aiosqlite_connection(AsyncAdapt_dbapi_connection):
         else:
             super()._handle_exception_no_connection(dbapi, error)
 
+    async def _terminate_graceful_close(self) -> None:
+        """Try to close connection gracefully"""
+        await self._connection.close()
+
+    def _terminate_force_close(self) -> None:
+        """Terminate the connection"""
+
+        # this was added in aiosqlite 0.22.1.  if stop() is not present,
+        # the dialect should indicate has_terminate=False
+        try:
+            meth = self._connection.stop
+        except AttributeError as ae:
+            raise NotImplementedError(
+                "terminate_force_close() not implemented by this DBAPI shim"
+            ) from ae
+        else:
+            meth()
+
 
 class AsyncAdapt_aiosqlite_dbapi(AsyncAdapt_dbapi_module):
     def __init__(self, aiosqlite: ModuleType, sqlite: ModuleType):
@@ -196,6 +218,7 @@ class AsyncAdapt_aiosqlite_dbapi(AsyncAdapt_dbapi_module):
         self.aiosqlite = aiosqlite
         self.sqlite = sqlite
         self.paramstyle = "qmark"
+        self.has_stop = hasattr(aiosqlite.Connection, "stop")
         self._init_dbapi_attributes()
 
     def _init_dbapi_attributes(self) -> None:
@@ -223,13 +246,16 @@ class AsyncAdapt_aiosqlite_dbapi(AsyncAdapt_dbapi_module):
             connection = creator_fn(*arg, **kw)
         else:
             connection = self.aiosqlite.connect(*arg, **kw)
-            # it's a Thread.   you'll thank us later
-            connection.daemon = True
 
-        return AsyncAdapt_aiosqlite_connection(
-            self,
-            await_(connection),
-        )
+            # aiosqlite uses a Thread.   you'll thank us later
+            if isinstance(connection, Thread):
+                # Connection itself was a thread in version prior to 0.22
+                connection.daemon = True
+            else:
+                # in 0.22+ instead it contains a thread.
+                connection._thread.daemon = True
+
+        return AsyncAdapt_aiosqlite_connection(self, await_(connection))
 
 
 class SQLiteExecutionContext_aiosqlite(SQLiteExecutionContext):
@@ -242,11 +268,17 @@ class SQLiteDialect_aiosqlite(SQLiteDialect_pysqlite):
     supports_statement_cache = True
 
     is_async = True
+    has_terminate = True
 
     supports_server_side_cursors = True
 
     execution_ctx_cls = SQLiteExecutionContext_aiosqlite
 
+    def __init__(self, **kwargs: Any):
+        super().__init__(**kwargs)
+        if self.dbapi and not self.dbapi.has_stop:
+            self.has_terminate = False
+
     @classmethod
     def import_dbapi(cls) -> AsyncAdapt_aiosqlite_dbapi:
         return AsyncAdapt_aiosqlite_dbapi(
@@ -282,5 +314,8 @@ class SQLiteDialect_aiosqlite(SQLiteDialect_pysqlite):
     ) -> AsyncIODBAPIConnection:
         return connection._connection  # type: ignore[no-any-return]
 
+    def do_terminate(self, dbapi_connection: DBAPIConnection) -> None:
+        dbapi_connection.terminate()
+
 
 dialect = SQLiteDialect_aiosqlite
index ba31a6aa749d44aace3c21c61c9e81e1a08ab290..4082349ef785a4c6bb2a02486ea554d3cba4e4d1 100644 (file)
@@ -186,6 +186,10 @@ class EngineFixture(AsyncFixture, fixtures.TablesTest):
             asyncio=True, options={"sqlite_share_pool": True}
         )
 
+    @testing.fixture
+    def adhoc_async_engine(self):
+        return engines.testing_engine(asyncio=True)
+
     @testing.fixture
     def async_connection(self, async_engine):
         with async_engine.sync_engine.connect() as conn:
@@ -349,13 +353,13 @@ class AsyncEngineTest(EngineFixture):
 
     @testing.variation("simulate_gc", [True, False])
     def test_appropriate_warning_for_gced_connection(
-        self, async_engine, simulate_gc
+        self, adhoc_async_engine, simulate_gc
     ):
         """test #9237 which builds upon a not really complete solution
         added for #8419."""
 
         async def go():
-            conn = await async_engine.connect()
+            conn = await adhoc_async_engine.connect()
             await conn.begin()
             await conn.execute(select(1))
             pool_connection = await conn.get_raw_connection()
@@ -388,7 +392,7 @@ class AsyncEngineTest(EngineFixture):
                     None, rec, pool, ref, echo, transaction_was_reset=False
                 )
 
-            if async_engine.dialect.has_terminate:
+            if adhoc_async_engine.dialect.has_terminate:
                 expected_msg = (
                     "The garbage collector is trying to clean up.*which will "
                     "be terminated."
@@ -411,6 +415,15 @@ class AsyncEngineTest(EngineFixture):
 
             eq_(m.mock_calls, [])
 
+    @async_test
+    @testing.skip_if(lambda config: not config.db.dialect.has_terminate)
+    async def test_dbapi_terminate(self, adhoc_async_engine):
+
+        conn = await adhoc_async_engine.raw_connection()
+        dbapi_conn = conn.dbapi_connection
+        dbapi_conn.terminate()
+        conn.invalidate()
+
     @async_test
     async def test_statement_compile(self, async_engine):
         stmt = str(select(1).compile(async_engine))