Skip to content

Commit e12d73d

Browse files
authored
Dispose engines on garbage collection (#78)
* Dispose engines on garbage collection
1 parent d9c338a commit e12d73d

File tree

4 files changed

+139
-20
lines changed

4 files changed

+139
-20
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Copyright (c) 2025 Federico Busetti <[email protected]>
2+
#
3+
# Permission is hereby granted, free of charge, to any person obtaining a
4+
# copy of this software and associated documentation files (the "Software"),
5+
# to deal in the Software without restriction, including without limitation
6+
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
7+
# and/or sell copies of the Software, and to permit persons to whom the
8+
# Software is furnished to do so, subject to the following conditions:
9+
#
10+
# The above copyright notice and this permission notice shall be included in
11+
# all copies or substantial portions of the Software.
12+
#
13+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16+
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19+
# DEALINGS IN THE SOFTWARE.
20+
import asyncio
21+
from typing import Coroutine
22+
23+
# Reference: https://docs.astral.sh/ruff/rules/asyncio-dangling-task/
24+
_background_asyncio_tasks = set()
25+
26+
27+
def run_async_from_sync(coro: Coroutine) -> None:
28+
try:
29+
loop = asyncio.get_event_loop()
30+
if loop.is_running():
31+
task = loop.create_task(coro)
32+
# Add task to the set. This creates a strong reference.
33+
_background_asyncio_tasks.add(task)
34+
35+
# To prevent keeping references to finished tasks forever,
36+
# make each task remove its own reference from the set after
37+
# completion:
38+
task.add_done_callback(_background_asyncio_tasks.discard)
39+
else:
40+
loop.run_until_complete(coro)
41+
except RuntimeError:
42+
asyncio.run(coro)

sqlalchemy_bind_manager/_bind_manager.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from sqlalchemy.orm import Session, sessionmaker
3333
from sqlalchemy.orm.decl_api import DeclarativeMeta, registry
3434

35+
from sqlalchemy_bind_manager._async_helpers import run_async_from_sync
3536
from sqlalchemy_bind_manager.exceptions import (
3637
InvalidConfigError,
3738
NotInitializedBindError,
@@ -87,6 +88,13 @@ def __init__(
8788
else:
8889
self.__init_bind(DEFAULT_BIND_NAME, config)
8990

91+
def __del__(self):
92+
for bind in self.__binds.values():
93+
if isinstance(bind, SQLAlchemyAsyncBind):
94+
run_async_from_sync(bind.engine.dispose())
95+
else:
96+
bind.engine.dispose()
97+
9098
def __init_bind(self, name: str, config: SQLAlchemyConfig):
9199
if not isinstance(config, SQLAlchemyConfig):
92100
raise InvalidConfigError(

sqlalchemy_bind_manager/_session_handler.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
)
2929
from sqlalchemy.orm import Session, scoped_session
3030

31+
from sqlalchemy_bind_manager._async_helpers import run_async_from_sync
3132
from sqlalchemy_bind_manager._bind_manager import (
3233
SQLAlchemyAsyncBind,
3334
SQLAlchemyBind,
@@ -73,10 +74,6 @@ def commit(self, session: Session) -> None:
7374
raise
7475

7576

76-
# Reference: https://docs.astral.sh/ruff/rules/asyncio-dangling-task/
77-
_background_asyncio_tasks = set()
78-
79-
8077
class AsyncSessionHandler:
8178
scoped_session: async_scoped_session
8279

@@ -91,22 +88,7 @@ def __init__(self, bind: SQLAlchemyAsyncBind):
9188
def __del__(self):
9289
if not getattr(self, "scoped_session", None):
9390
return
94-
95-
try:
96-
loop = asyncio.get_event_loop()
97-
if loop.is_running():
98-
task = loop.create_task(self.scoped_session.remove())
99-
# Add task to the set. This creates a strong reference.
100-
_background_asyncio_tasks.add(task)
101-
102-
# To prevent keeping references to finished tasks forever,
103-
# make each task remove its own reference from the set after
104-
# completion:
105-
task.add_done_callback(_background_asyncio_tasks.discard)
106-
else:
107-
loop.run_until_complete(self.scoped_session.remove())
108-
except RuntimeError:
109-
asyncio.run(self.scoped_session.remove())
91+
run_async_from_sync(self.scoped_session.remove())
11092

11193
@asynccontextmanager
11294
async def get_session(self, read_only: bool = False) -> AsyncIterator[AsyncSession]:

tests/test_sqlalchemy_bind_manager.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest.mock import patch
2+
13
import pytest
24
from sqlalchemy import MetaData
35
from sqlalchemy.ext.asyncio import AsyncSession
@@ -71,3 +73,88 @@ def test_multiple_binds(multiple_config):
7173
assert async_bind is not None
7274
assert isinstance(sa_manager.get_mapper("async"), registry)
7375
assert isinstance(sa_manager.get_session("async"), AsyncSession)
76+
77+
78+
async def test_engine_is_disposed_on_cleanup(multiple_config):
79+
sa_manager = SQLAlchemyBindManager(multiple_config)
80+
sync_engine = sa_manager.get_bind("default").engine
81+
async_engine = sa_manager.get_bind("async").engine
82+
83+
original_sync_dispose = sync_engine.dispose
84+
original_async_dispose = async_engine.dispose
85+
86+
with (
87+
patch.object(
88+
sync_engine,
89+
"dispose",
90+
wraps=original_sync_dispose,
91+
) as mocked_dispose,
92+
patch.object(
93+
type(async_engine),
94+
"dispose",
95+
wraps=original_async_dispose,
96+
) as mocked_async_dispose,
97+
):
98+
sa_manager = None
99+
100+
mocked_dispose.assert_called_once()
101+
mocked_async_dispose.assert_called()
102+
103+
104+
def test_engine_is_disposed_on_cleanup_even_if_no_loop(multiple_config):
105+
sa_manager = SQLAlchemyBindManager(multiple_config)
106+
sync_engine = sa_manager.get_bind("default").engine
107+
async_engine = sa_manager.get_bind("async").engine
108+
109+
original_sync_dispose = sync_engine.dispose
110+
original_async_dispose = async_engine.dispose
111+
112+
with (
113+
patch.object(
114+
sync_engine,
115+
"dispose",
116+
wraps=original_sync_dispose,
117+
) as mocked_dispose,
118+
patch.object(
119+
type(async_engine),
120+
"dispose",
121+
wraps=original_async_dispose,
122+
) as mocked_async_dispose,
123+
):
124+
sa_manager = None
125+
126+
mocked_dispose.assert_called_once()
127+
mocked_async_dispose.assert_called()
128+
129+
130+
def test_engine_is_disposed_on_cleanup_even_if_loop_search_errors_out(
131+
multiple_config,
132+
):
133+
sa_manager = SQLAlchemyBindManager(multiple_config)
134+
sync_engine = sa_manager.get_bind("default").engine
135+
async_engine = sa_manager.get_bind("async").engine
136+
137+
original_sync_dispose = sync_engine.dispose
138+
original_async_dispose = async_engine.dispose
139+
140+
with (
141+
patch.object(
142+
sync_engine,
143+
"dispose",
144+
wraps=original_sync_dispose,
145+
) as mocked_dispose,
146+
patch.object(
147+
type(async_engine),
148+
"dispose",
149+
wraps=original_async_dispose,
150+
) as mocked_async_dispose,
151+
patch(
152+
"asyncio.get_event_loop",
153+
side_effect=RuntimeError(),
154+
) as mocked_get_event_loop,
155+
):
156+
sa_manager = None
157+
158+
mocked_get_event_loop.assert_called_once()
159+
mocked_dispose.assert_called_once()
160+
mocked_async_dispose.assert_called()

0 commit comments

Comments
 (0)