Skip to content

Commit 91b7c8e

Browse files
committed
fix: use AsyncSentinel for async Sentinel connections
Fixes #465 - Async Sentinel connections were using sync SentinelConnectionPool The _redis_sentinel_client() method was only using the sync Sentinel class, which caused AsyncRedis clients to be backed by sync SentinelConnectionPool. This resulted in runtime failures when async operations were attempted. Changes: - Import AsyncSentinel from redis.asyncio.sentinel - Branch on redis_class to use AsyncSentinel for async clients - Sync clients continue to use the sync Sentinel class - Added comprehensive docstrings for _redis_sentinel_client() and _parse_sentinel_url() - Improved type hints for _parse_sentinel_url() return type - Added edge case tests for Sentinel URL parsing - Added module docstring to test file - Updated docs with async Sentinel example - Fixed Python 3.9 compatibility in tests
1 parent 2bbe8b2 commit 91b7c8e

3 files changed

Lines changed: 248 additions & 12 deletions

File tree

docs/user_guide/installation.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,25 @@ If you are considering a self-hosted Redis Enterprise deployment on Kubernetes,
149149

150150
### Redis Sentinel
151151

152-
For high availability deployments, RedisVL supports connecting to Redis through Sentinel. Use the `redis+sentinel://` URL scheme to connect:
152+
For high availability deployments, RedisVL supports connecting to Redis through Sentinel. Use the `redis+sentinel://` URL scheme to connect. Both sync and async connections are fully supported.
153153

154154
```python
155-
from redisvl.index import SearchIndex
155+
from redisvl.index import SearchIndex, AsyncSearchIndex
156156

157-
# Connect via Sentinel
157+
# Sync connection via Sentinel
158158
# Format: redis+sentinel://[username:password@]host1:port1,host2:port2/service_name[/db]
159159
index = SearchIndex.from_yaml(
160160
"schema.yaml",
161161
redis_url="redis+sentinel://sentinel1:26379,sentinel2:26379/mymaster"
162162
)
163163

164-
# With authentication
164+
# Async connection via Sentinel
165+
async_index = AsyncSearchIndex.from_yaml(
166+
"schema.yaml",
167+
redis_url="redis+sentinel://sentinel1:26379,sentinel2:26379/mymaster"
168+
)
169+
170+
# With authentication and database selection
165171
index = SearchIndex.from_yaml(
166172
"schema.yaml",
167173
redis_url="redis+sentinel://user:pass@sentinel1:26379,sentinel2:26379/mymaster/0"
@@ -172,5 +178,6 @@ The Sentinel URL format supports:
172178

173179
- Multiple sentinel hosts (comma-separated)
174180
- Optional authentication (username:password)
175-
- Service name (required - the name of the Redis master)
181+
- Service name (defaults to `mymaster` if not specified)
176182
- Optional database number (defaults to 0)
183+
- Both sync (`SearchIndex`) and async (`AsyncSearchIndex`) connections

redisvl/redis/connection.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from redis.asyncio.connection import AbstractConnection as AsyncAbstractConnection
1111
from redis.asyncio.connection import Connection as AsyncConnection
1212
from redis.asyncio.connection import SSLConnection as AsyncSSLConnection
13+
from redis.asyncio.sentinel import Sentinel as AsyncSentinel
1314
from redis.connection import SSLConnection
1415
from redis.exceptions import ResponseError
1516
from redis.sentinel import Sentinel
@@ -744,11 +745,33 @@ def _redis_sentinel_client(
744745
def _redis_sentinel_client(
745746
redis_url: str, redis_class: Union[type[Redis], type[AsyncRedis]], **kwargs: Any
746747
) -> Union[Redis, AsyncRedis]:
748+
"""Create a Redis client connected via Sentinel for high availability.
749+
750+
Parses a Sentinel URL and creates a Redis client connected to the
751+
master instance discovered by Sentinel. Supports both sync and async
752+
clients by using the appropriate Sentinel class.
753+
754+
Args:
755+
redis_url: Sentinel URL in the format:
756+
``redis+sentinel://[user:pass@]host1:port1[,host2:port2,...][/service][/db]``
757+
Service name defaults to "mymaster" if not specified.
758+
redis_class: The Redis client class to use (Redis or AsyncRedis).
759+
**kwargs: Additional arguments passed to Sentinel and master_for().
760+
761+
Returns:
762+
A Redis client (sync or async) connected to the Sentinel-managed master.
763+
764+
Example:
765+
>>> client = RedisConnectionFactory._redis_sentinel_client(
766+
... "redis+sentinel://sentinel1:26379,sentinel2:26379/mymaster",
767+
... Redis
768+
... )
769+
"""
747770
sentinel_list, service_name, db, username, password = (
748771
RedisConnectionFactory._parse_sentinel_url(redis_url)
749772
)
750773

751-
sentinel_kwargs = {}
774+
sentinel_kwargs: Dict[str, Any] = {}
752775
if username:
753776
sentinel_kwargs["username"] = username
754777
kwargs["username"] = username
@@ -758,11 +781,46 @@ def _redis_sentinel_client(
758781
if db:
759782
kwargs["db"] = db
760783

761-
sentinel = Sentinel(sentinel_list, sentinel_kwargs=sentinel_kwargs, **kwargs)
762-
return sentinel.master_for(service_name, redis_class=redis_class, **kwargs)
784+
# Use AsyncSentinel for async clients, Sentinel for sync clients
785+
if redis_class == AsyncRedis:
786+
async_sentinel = AsyncSentinel(
787+
sentinel_list, sentinel_kwargs=sentinel_kwargs, **kwargs
788+
)
789+
return async_sentinel.master_for(
790+
service_name, redis_class=redis_class, **kwargs # type: ignore[arg-type]
791+
)
792+
else:
793+
sync_sentinel = Sentinel(
794+
sentinel_list, sentinel_kwargs=sentinel_kwargs, **kwargs
795+
)
796+
return sync_sentinel.master_for(
797+
service_name, redis_class=redis_class, **kwargs
798+
)
763799

764800
@staticmethod
765-
def _parse_sentinel_url(url: str) -> tuple:
801+
def _parse_sentinel_url(
802+
url: str,
803+
) -> Tuple[List[Tuple[str, int]], str, Optional[str], Optional[str], Optional[str]]:
804+
"""Parse a Redis Sentinel URL into its components.
805+
806+
Args:
807+
url: Sentinel URL in the format:
808+
``redis+sentinel://[user:pass@]host1:port1[,host2:port2,...]/service[/db]``
809+
810+
Returns:
811+
A tuple containing:
812+
- sentinel_list: List of (host, port) tuples for Sentinel nodes
813+
- service_name: The Sentinel service name (defaults to "mymaster")
814+
- db: The database number (or None if not specified)
815+
- username: The username for authentication (or None)
816+
- password: The password for authentication (or None)
817+
818+
Example:
819+
>>> RedisConnectionFactory._parse_sentinel_url(
820+
... "redis+sentinel://user:pass@host1:26379,host2:26380/mymaster/0"
821+
... )
822+
([('host1', 26379), ('host2', 26380)], 'mymaster', '0', 'user', 'pass')
823+
"""
766824
parsed_url = urlparse(url)
767825
hosts_part = parsed_url.netloc.split("@")[-1]
768826
sentinel_hosts = hosts_part.split(",")

tests/unit/test_sentinel_url.py

Lines changed: 174 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
"""Tests for Redis Sentinel URL connection handling.
2+
3+
This module tests the RedisConnectionFactory's ability to create Redis clients
4+
from Sentinel URLs (redis+sentinel://...). It verifies:
5+
6+
1. Correct Sentinel class selection (AsyncSentinel for async, Sentinel for sync)
7+
2. URL parsing (hosts, ports, service name, database, authentication)
8+
3. Proper kwargs passthrough to Sentinel and master_for()
9+
4. Error handling for connection failures
10+
11+
These tests use mocking to avoid requiring a real Sentinel deployment.
12+
13+
Related: GitHub Issue #465 - Async Sentinel connections were incorrectly using
14+
the sync SentinelConnectionPool, causing runtime failures.
15+
"""
16+
117
from unittest.mock import MagicMock, patch
218

319
import pytest
@@ -12,7 +28,14 @@ def test_sentinel_url_connection(use_async):
1228
"redis+sentinel://username:password@host1:26379,host2:26380/mymaster/0"
1329
)
1430

15-
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
31+
# Use appropriate Sentinel class based on sync/async
32+
sentinel_patch_target = (
33+
"redisvl.redis.connection.AsyncSentinel"
34+
if use_async
35+
else "redisvl.redis.connection.Sentinel"
36+
)
37+
38+
with patch(sentinel_patch_target) as mock_sentinel:
1639
mock_master = MagicMock()
1740
mock_sentinel.return_value.master_for.return_value = mock_master
1841

@@ -42,7 +65,14 @@ def test_sentinel_url_connection(use_async):
4265
def test_sentinel_url_connection_no_auth_no_db(use_async):
4366
sentinel_url = "redis+sentinel://host1:26379,host2:26380/mymaster"
4467

45-
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
68+
# Use appropriate Sentinel class based on sync/async
69+
sentinel_patch_target = (
70+
"redisvl.redis.connection.AsyncSentinel"
71+
if use_async
72+
else "redisvl.redis.connection.Sentinel"
73+
)
74+
75+
with patch(sentinel_patch_target) as mock_sentinel:
4676
mock_master = MagicMock()
4777
mock_sentinel.return_value.master_for.return_value = mock_master
4878

@@ -72,7 +102,14 @@ def test_sentinel_url_connection_no_auth_no_db(use_async):
72102
def test_sentinel_url_connection_error(use_async):
73103
sentinel_url = "redis+sentinel://host1:26379,host2:26380/mymaster"
74104

75-
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
105+
# Use appropriate Sentinel class based on sync/async
106+
sentinel_patch_target = (
107+
"redisvl.redis.connection.AsyncSentinel"
108+
if use_async
109+
else "redisvl.redis.connection.Sentinel"
110+
)
111+
112+
with patch(sentinel_patch_target) as mock_sentinel:
76113
mock_sentinel.return_value.master_for.side_effect = ConnectionError(
77114
"Test connection error"
78115
)
@@ -85,3 +122,137 @@ def test_sentinel_url_connection_error(use_async):
85122
RedisConnectionFactory.get_redis_connection(sentinel_url)
86123

87124
mock_sentinel.assert_called_once()
125+
126+
127+
def test_async_sentinel_uses_async_sentinel_class():
128+
"""Test that async connections use AsyncSentinel (fix for issue #465)."""
129+
sentinel_url = "redis+sentinel://host1:26379/mymaster"
130+
131+
# Track which Sentinel class is called
132+
sync_sentinel_called = False
133+
async_sentinel_called = False
134+
135+
def track_sync_sentinel(*args, **kwargs):
136+
nonlocal sync_sentinel_called
137+
sync_sentinel_called = True
138+
mock = MagicMock()
139+
mock.master_for.return_value = MagicMock()
140+
return mock
141+
142+
def track_async_sentinel(*args, **kwargs):
143+
nonlocal async_sentinel_called
144+
async_sentinel_called = True
145+
mock = MagicMock()
146+
mock.master_for.return_value = MagicMock()
147+
return mock
148+
149+
with (
150+
patch("redisvl.redis.connection.Sentinel", side_effect=track_sync_sentinel),
151+
patch(
152+
"redisvl.redis.connection.AsyncSentinel", side_effect=track_async_sentinel
153+
),
154+
):
155+
with pytest.warns(DeprecationWarning):
156+
RedisConnectionFactory.get_async_redis_connection(sentinel_url)
157+
158+
# Verify AsyncSentinel was called, not sync Sentinel
159+
assert async_sentinel_called, "AsyncSentinel should be called for async connections"
160+
assert (
161+
not sync_sentinel_called
162+
), "Sync Sentinel should NOT be called for async connections"
163+
164+
165+
def test_sync_sentinel_uses_sync_sentinel_class():
166+
"""Test that sync connections use sync Sentinel."""
167+
sentinel_url = "redis+sentinel://host1:26379/mymaster"
168+
169+
# Track which Sentinel class is called
170+
sync_sentinel_called = False
171+
async_sentinel_called = False
172+
173+
def track_sync_sentinel(*args, **kwargs):
174+
nonlocal sync_sentinel_called
175+
sync_sentinel_called = True
176+
mock = MagicMock()
177+
mock.master_for.return_value = MagicMock()
178+
return mock
179+
180+
def track_async_sentinel(*args, **kwargs):
181+
nonlocal async_sentinel_called
182+
async_sentinel_called = True
183+
mock = MagicMock()
184+
mock.master_for.return_value = MagicMock()
185+
return mock
186+
187+
with (
188+
patch("redisvl.redis.connection.Sentinel", side_effect=track_sync_sentinel),
189+
patch(
190+
"redisvl.redis.connection.AsyncSentinel", side_effect=track_async_sentinel
191+
),
192+
):
193+
RedisConnectionFactory.get_redis_connection(sentinel_url)
194+
195+
# Verify sync Sentinel was called, not AsyncSentinel
196+
assert sync_sentinel_called, "Sync Sentinel should be called for sync connections"
197+
assert (
198+
not async_sentinel_called
199+
), "AsyncSentinel should NOT be called for sync connections"
200+
201+
202+
# =============================================================================
203+
# Additional Edge Case Tests for Sentinel URL Parsing
204+
# =============================================================================
205+
206+
207+
class TestSentinelUrlParsingEdgeCases:
208+
"""Tests for Sentinel URL parsing edge cases not covered by main tests."""
209+
210+
def test_sentinel_url_default_port_when_not_specified(self):
211+
"""Verify default port 26379 is used when port is omitted."""
212+
sentinel_url = "redis+sentinel://host1/mymaster"
213+
214+
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
215+
mock_sentinel.return_value.master_for.return_value = MagicMock()
216+
RedisConnectionFactory.get_redis_connection(sentinel_url)
217+
218+
call_args = mock_sentinel.call_args
219+
assert call_args[0][0] == [("host1", 26379)]
220+
221+
def test_sentinel_url_default_service_name_when_path_empty(self):
222+
"""Verify default service name 'mymaster' when path is empty."""
223+
sentinel_url = "redis+sentinel://host1:26379"
224+
225+
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
226+
mock_sentinel.return_value.master_for.return_value = MagicMock()
227+
RedisConnectionFactory.get_redis_connection(sentinel_url)
228+
229+
master_for_args = mock_sentinel.return_value.master_for.call_args
230+
assert master_for_args[0][0] == "mymaster"
231+
232+
def test_sentinel_url_password_only_auth(self):
233+
"""Verify password-only auth works (empty username)."""
234+
sentinel_url = "redis+sentinel://:secretpass@host1:26379/mymaster"
235+
236+
with patch("redisvl.redis.connection.Sentinel") as mock_sentinel:
237+
mock_sentinel.return_value.master_for.return_value = MagicMock()
238+
RedisConnectionFactory.get_redis_connection(sentinel_url)
239+
240+
call_kwargs = mock_sentinel.call_args[1]
241+
assert call_kwargs["sentinel_kwargs"]["password"] == "secretpass"
242+
assert call_kwargs["password"] == "secretpass"
243+
244+
def test_sentinel_custom_kwargs_passed_to_master_for(self):
245+
"""Verify custom kwargs are passed through to master_for call."""
246+
sentinel_url = "redis+sentinel://host1:26379/mymaster"
247+
248+
with patch("redisvl.redis.connection.AsyncSentinel") as mock_async_sentinel:
249+
mock_async_sentinel.return_value.master_for.return_value = MagicMock()
250+
251+
with pytest.warns(DeprecationWarning):
252+
RedisConnectionFactory.get_async_redis_connection(
253+
sentinel_url, decode_responses=True, socket_timeout=5.0
254+
)
255+
256+
master_for_kwargs = mock_async_sentinel.return_value.master_for.call_args[1]
257+
assert master_for_kwargs["decode_responses"] is True
258+
assert master_for_kwargs["socket_timeout"] == 5.0

0 commit comments

Comments
 (0)