Skip to content

Commit 14be2da

Browse files
Better deal with "lost" connections for async Redis (#2999)
* Allow tracking/reporting and closing of "lost" connections. ConnectionPool keeps a WeakSet of in_use connections, allowing lost ones to be collected. Collection produces a warning and closes the underlying transport. * Add tests for the __del__ handlers of async Redis and Connection objects * capture expected warnings in the test * lint
1 parent 05124de commit 14be2da

File tree

3 files changed

+72
-2
lines changed

3 files changed

+72
-2
lines changed

redis/asyncio/client.py

+1
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ def __del__(
546546
_grl().call_exception_handler(context)
547547
except RuntimeError:
548548
pass
549+
self.connection._close()
549550

550551
async def aclose(self, close_connection_pool: Optional[bool] = None) -> None:
551552
"""

redis/asyncio/connection.py

+20-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import socket
66
import ssl
77
import sys
8+
import warnings
89
import weakref
910
from abc import abstractmethod
1011
from itertools import chain
@@ -204,6 +205,24 @@ def __init__(
204205
raise ConnectionError("protocol must be either 2 or 3")
205206
self.protocol = protocol
206207

208+
def __del__(self, _warnings: Any = warnings):
209+
# For some reason, the individual streams don't get properly garbage
210+
# collected and therefore produce no resource warnings. We add one
211+
# here, in the same style as those from the stdlib.
212+
if getattr(self, "_writer", None):
213+
_warnings.warn(
214+
f"unclosed Connection {self!r}", ResourceWarning, source=self
215+
)
216+
self._close()
217+
218+
def _close(self):
219+
"""
220+
Internal method to silently close the connection without waiting
221+
"""
222+
if self._writer:
223+
self._writer.close()
224+
self._writer = self._reader = None
225+
207226
def __repr__(self):
208227
repr_args = ",".join((f"{k}={v}" for k, v in self.repr_pieces()))
209228
return f"{self.__class__.__name__}<{repr_args}>"
@@ -1017,7 +1036,7 @@ def __repr__(self):
10171036

10181037
def reset(self):
10191038
self._available_connections = []
1020-
self._in_use_connections = set()
1039+
self._in_use_connections = weakref.WeakSet()
10211040

10221041
def can_get_connection(self) -> bool:
10231042
"""Return True if a connection can be retrieved from the pool."""

tests/test_asyncio/test_connection.py

+51-1
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,8 @@ async def mock_aclose(self):
320320
url: str = request.config.getoption("--redis-url")
321321
r1 = await Redis.from_url(url)
322322
with patch.object(r1, "aclose", mock_aclose):
323-
await r1.close()
323+
with pytest.deprecated_call():
324+
await r1.close()
324325
assert calls == 1
325326

326327
with pytest.deprecated_call():
@@ -440,3 +441,52 @@ async def mock_disconnect(_):
440441

441442
assert called == 0
442443
await pool.disconnect()
444+
445+
446+
async def test_client_garbage_collection(request):
447+
"""
448+
Test that a Redis client will call _close() on any
449+
connection that it holds at time of destruction
450+
"""
451+
452+
url: str = request.config.getoption("--redis-url")
453+
pool = ConnectionPool.from_url(url)
454+
455+
# create a client with a connection from the pool
456+
client = Redis(connection_pool=pool, single_connection_client=True)
457+
await client.initialize()
458+
with mock.patch.object(client, "connection") as a:
459+
# we cannot, in unittests, or from asyncio, reliably trigger garbage collection
460+
# so we must just invoke the handler
461+
with pytest.warns(ResourceWarning):
462+
client.__del__()
463+
assert a._close.called
464+
465+
await client.aclose()
466+
await pool.aclose()
467+
468+
469+
async def test_connection_garbage_collection(request):
470+
"""
471+
Test that a Connection object will call close() on the
472+
stream that it holds.
473+
"""
474+
475+
url: str = request.config.getoption("--redis-url")
476+
pool = ConnectionPool.from_url(url)
477+
478+
# create a client with a connection from the pool
479+
client = Redis(connection_pool=pool, single_connection_client=True)
480+
await client.initialize()
481+
conn = client.connection
482+
483+
with mock.patch.object(conn, "_reader"):
484+
with mock.patch.object(conn, "_writer") as a:
485+
# we cannot, in unittests, or from asyncio, reliably trigger
486+
# garbage collection so we must just invoke the handler
487+
with pytest.warns(ResourceWarning):
488+
conn.__del__()
489+
assert a.close.called
490+
491+
await client.aclose()
492+
await pool.aclose()

0 commit comments

Comments
 (0)