Skip to content

Commit 428d609

Browse files
Vivanov98Viktor Ivanov
and
Viktor Ivanov
authored
Fix issue 2540: Synchronise concurrent command calls to single-client mode. (#2568)
Co-authored-by: Viktor Ivanov <[email protected]>
1 parent 9e6a9b5 commit 428d609

File tree

2 files changed

+58
-2
lines changed

2 files changed

+58
-2
lines changed

redis/asyncio/client.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,15 +253,22 @@ def __init__(
253253

254254
self.response_callbacks = CaseInsensitiveDict(self.__class__.RESPONSE_CALLBACKS)
255255

256+
# If using a single connection client, we need to lock creation-of and use-of
257+
# the client in order to avoid race conditions such as using asyncio.gather
258+
# on a set of redis commands
259+
self._single_conn_lock = asyncio.Lock()
260+
256261
def __repr__(self):
257262
return f"{self.__class__.__name__}<{self.connection_pool!r}>"
258263

259264
def __await__(self):
260265
return self.initialize().__await__()
261266

262267
async def initialize(self: _RedisT) -> _RedisT:
263-
if self.single_connection_client and self.connection is None:
264-
self.connection = await self.connection_pool.get_connection("_")
268+
if self.single_connection_client:
269+
async with self._single_conn_lock:
270+
if self.connection is None:
271+
self.connection = await self.connection_pool.get_connection("_")
265272
return self
266273

267274
def set_response_callback(self, command: str, callback: ResponseCallbackT):
@@ -501,6 +508,8 @@ async def execute_command(self, *args, **options):
501508
command_name = args[0]
502509
conn = self.connection or await pool.get_connection(command_name, **options)
503510

511+
if self.single_connection_client:
512+
await self._single_conn_lock.acquire()
504513
try:
505514
return await conn.retry.call_with_retry(
506515
lambda: self._send_command_parse_response(
@@ -509,6 +518,8 @@ async def execute_command(self, *args, **options):
509518
lambda error: self._disconnect_raise(conn, error),
510519
)
511520
finally:
521+
if self.single_connection_client:
522+
self._single_conn_lock.release()
512523
if not self.connection:
513524
await pool.release(conn)
514525

tests/test_asyncio/test_connection.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
import redis
9+
from redis.asyncio import Redis
910
from redis.asyncio.connection import (
1011
BaseParser,
1112
Connection,
@@ -41,6 +42,50 @@ async def test_invalid_response(create_redis):
4142
await r.connection.disconnect()
4243

4344

45+
@pytest.mark.onlynoncluster
46+
async def test_single_connection():
47+
"""Test that concurrent requests on a single client are synchronised."""
48+
r = Redis(single_connection_client=True)
49+
50+
init_call_count = 0
51+
command_call_count = 0
52+
in_use = False
53+
54+
class Retry_:
55+
async def call_with_retry(self, _, __):
56+
# If we remove the single-client lock, this error gets raised as two
57+
# coroutines will be vying for the `in_use` flag due to the two
58+
# asymmetric sleep calls
59+
nonlocal command_call_count
60+
nonlocal in_use
61+
if in_use is True:
62+
raise ValueError("Commands should be executed one at a time.")
63+
in_use = True
64+
await asyncio.sleep(0.01)
65+
command_call_count += 1
66+
await asyncio.sleep(0.03)
67+
in_use = False
68+
return "foo"
69+
70+
mock_conn = mock.MagicMock()
71+
mock_conn.retry = Retry_()
72+
73+
async def get_conn(_):
74+
# Validate only one client is created in single-client mode when
75+
# concurrent requests are made
76+
nonlocal init_call_count
77+
await asyncio.sleep(0.01)
78+
init_call_count += 1
79+
return mock_conn
80+
81+
with mock.patch.object(r.connection_pool, "get_connection", get_conn):
82+
with mock.patch.object(r.connection_pool, "release"):
83+
await asyncio.gather(r.set("a", "b"), r.set("c", "d"))
84+
85+
assert init_call_count == 1
86+
assert command_call_count == 2
87+
88+
4489
@skip_if_server_version_lt("4.0.0")
4590
@pytest.mark.redismod
4691
@pytest.mark.onlynoncluster

0 commit comments

Comments
 (0)