Skip to content

Commit ce28eef

Browse files
authored
feat(#143): add Redis Sentinel support for async implementations (#166)
* feat(#143): add Redis Sentinel support for async configure_client() Replace AsyncRedis.from_url() with RedisConnectionFactory.get_async_redis_connection() in AsyncRedisSaver, AsyncShallowRedisSaver, and AsyncRedisStore. This enables redis+sentinel:// URLs for automatic master discovery via Redis Sentinel. Sync implementations already used RedisConnectionFactory and worked with Sentinel. Updated docstrings across all 6 classes to document Sentinel URL support. * test(#143): add Sentinel integration tests and Docker Compose infrastructure Add tests/sentinel/docker-compose.yml with Redis master (port 6399) and Sentinel (port 26399). Add 12 integration tests covering all saver/store types via Sentinel, cross-connection data verification, and configure_client factory delegation. Add --run-sentinel-tests CLI flag and `make test-sentinel` target. Sentinel tests run separately from the main suite to avoid Docker resource contention. * chore: migrate test infrastructure to redis:8 and fix test fixtures - Default test image from redis/redis-stack-server:latest to redis:8 - Fix test_key_registry_integration.py to use shared Docker Compose container instead of standalone RedisContainer (which timed out on startup) - Update error message assertions for RedisConnectionFactory's message format - Fix pyproject.toml warning filter for removed LangGraphDeprecatedSinceV10 class
1 parent a6b5c10 commit ce28eef

File tree

15 files changed

+616
-61
lines changed

15 files changed

+616
-61
lines changed

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: install format lint test test-all clean redis-start redis-stop check-types check
1+
.PHONY: install format lint test test-all test-sentinel clean redis-start redis-stop check-types check
22

33
install:
44
poetry install --all-extras
@@ -24,6 +24,9 @@ test:
2424
test-all:
2525
poetry run test-verbose --run-api-tests
2626

27+
test-sentinel:
28+
poetry run python -m pytest tests/test_sentinel_integration.py -vv -s --run-sentinel-tests
29+
2730
test-coverage:
2831
poetry run test-coverage
2932

langgraph/checkpoint/redis/__init__.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@
5454

5555

5656
class RedisSaver(BaseRedisSaver[Union[Redis, RedisCluster], SearchIndex]):
57-
"""Standard Redis implementation for checkpoint saving."""
57+
"""Standard Redis implementation for checkpoint saving.
58+
59+
Supports standard Redis URLs (redis://), SSL (rediss://), and
60+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
61+
"""
5862

5963
_redis: Union[Redis, RedisCluster] # Support both standalone and cluster clients
6064
# Whether to assume the Redis server is a cluster; None triggers auto-detection
@@ -94,7 +98,11 @@ def configure_client(
9498
redis_client: Optional[Union[Redis, RedisCluster]] = None,
9599
connection_args: Optional[Dict[str, Any]] = None,
96100
) -> None:
97-
"""Configure the Redis client."""
101+
"""Configure the Redis client.
102+
103+
Supports standard Redis URLs (redis://), SSL (rediss://), and
104+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
105+
"""
98106
from redis.exceptions import ResponseError
99107

100108
from langgraph.checkpoint.redis.version import __full_lib_name__

langgraph/checkpoint/redis/aio.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import asyncio
66
import json
77
import logging
8-
import os
98
from collections import defaultdict
109
from contextlib import asynccontextmanager
1110
from types import TracebackType
@@ -39,6 +38,7 @@
3938
from redisvl.index import AsyncSearchIndex
4039
from redisvl.query import FilterQuery
4140
from redisvl.query.filter import Num, Tag
41+
from redisvl.redis.connection import RedisConnectionFactory
4242
from ulid import ULID
4343

4444
from langgraph.checkpoint.redis.base import (
@@ -64,7 +64,11 @@
6464
class AsyncRedisSaver(
6565
BaseRedisSaver[Union[AsyncRedis, AsyncRedisCluster], AsyncSearchIndex]
6666
):
67-
"""Async Redis implementation for checkpoint saver."""
67+
"""Async Redis implementation for checkpoint saver.
68+
69+
Supports standard Redis URLs (redis://), SSL (rediss://), and
70+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
71+
"""
6872

6973
_redis_url: str
7074
checkpoints_index: AsyncSearchIndex
@@ -111,15 +115,17 @@ def configure_client(
111115
redis_client: Optional[Union[AsyncRedis, AsyncRedisCluster]] = None,
112116
connection_args: Optional[Dict[str, Any]] = None,
113117
) -> None:
114-
"""Configure the Redis client."""
118+
"""Configure the Redis client.
119+
120+
Supports standard Redis URLs (redis://), SSL (rediss://), and
121+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
122+
"""
115123
self._owns_its_client = redis_client is None
116124

117125
if redis_client is None:
118-
if not redis_url:
119-
redis_url = os.environ.get("REDIS_URL")
120-
if not redis_url:
121-
raise ValueError("REDIS_URL env var not set")
122-
self._redis = AsyncRedis.from_url(redis_url, **(connection_args or {}))
126+
self._redis = RedisConnectionFactory.get_async_redis_connection(
127+
redis_url, **(connection_args or {})
128+
)
123129
else:
124130
self._redis = redis_client
125131

langgraph/checkpoint/redis/ashallow.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
import asyncio
66
import json
7-
import os
87
import time
98
from contextlib import asynccontextmanager
109
from datetime import datetime
@@ -26,6 +25,7 @@
2625
from redisvl.index import AsyncSearchIndex
2726
from redisvl.query import FilterQuery
2827
from redisvl.query.filter import Num, Tag
28+
from redisvl.redis.connection import RedisConnectionFactory
2929
from ulid import ULID
3030

3131
from langgraph.checkpoint.redis.base import (
@@ -44,7 +44,11 @@
4444

4545

4646
class AsyncShallowRedisSaver(BaseRedisSaver[AsyncRedis, AsyncSearchIndex]):
47-
"""Async Redis implementation that only stores the most recent checkpoint."""
47+
"""Async Redis implementation that only stores the most recent checkpoint.
48+
49+
Supports standard Redis URLs (redis://), SSL (rediss://), and
50+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
51+
"""
4852

4953
_redis_url: str
5054
checkpoints_index: AsyncSearchIndex
@@ -674,15 +678,17 @@ def configure_client(
674678
redis_client: Optional[AsyncRedis] = None,
675679
connection_args: Optional[dict[str, Any]] = None,
676680
) -> None:
677-
"""Configure the Redis client."""
681+
"""Configure the Redis client.
682+
683+
Supports standard Redis URLs (redis://), SSL (rediss://), and
684+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
685+
"""
678686
self._owns_its_client = redis_client is None
679687

680688
if redis_client is None:
681-
if not redis_url:
682-
redis_url = os.environ.get("REDIS_URL")
683-
if not redis_url:
684-
raise ValueError("REDIS_URL env var not set")
685-
self._redis = AsyncRedis.from_url(redis_url, **(connection_args or {}))
689+
self._redis = RedisConnectionFactory.get_async_redis_connection(
690+
redis_url, **(connection_args or {})
691+
)
686692
else:
687693
self._redis = redis_client
688694

langgraph/checkpoint/redis/shallow.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,11 @@
4646

4747

4848
class ShallowRedisSaver(BaseRedisSaver[Redis, SearchIndex]):
49-
"""Redis implementation that only stores the most recent checkpoint."""
49+
"""Redis implementation that only stores the most recent checkpoint.
50+
51+
Supports standard Redis URLs (redis://), SSL (rediss://), and
52+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
53+
"""
5054

5155
# Default cache size limits
5256
DEFAULT_KEY_CACHE_MAX_SIZE = 1000

langgraph/store/redis/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ class RedisStore(BaseStore, BaseRedisStore[Redis, SearchIndex]):
7373
7474
Provides synchronous operations for storing and retrieving data with optional
7575
vector similarity search support.
76+
77+
Supports standard Redis URLs (redis://), SSL (rediss://), and
78+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
7679
"""
7780

7881
# Enable TTL support

langgraph/store/redis/aio.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import asyncio
44
import json
5-
import os
65
from contextlib import asynccontextmanager
76
from datetime import datetime, timedelta, timezone
87
from types import TracebackType
@@ -26,6 +25,7 @@
2625
from redis.commands.search.query import Query
2726
from redisvl.index import AsyncSearchIndex
2827
from redisvl.query import FilterQuery, VectorQuery
28+
from redisvl.redis.connection import RedisConnectionFactory
2929
from redisvl.utils.token_escaper import TokenEscaper
3030
from ulid import ULID
3131

@@ -54,7 +54,11 @@
5454
class AsyncRedisStore(
5555
BaseRedisStore[AsyncRedis, AsyncSearchIndex], AsyncBatchedBaseStore
5656
):
57-
"""Async Redis store with optional vector search."""
57+
"""Async Redis store with optional vector search.
58+
59+
Supports standard Redis URLs (redis://), SSL (rediss://), and
60+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
61+
"""
5862

5963
store_index: AsyncSearchIndex
6064
vector_index: AsyncSearchIndex
@@ -193,16 +197,17 @@ def configure_client(
193197
redis_client: Optional[AsyncRedis] = None,
194198
connection_args: Optional[dict[str, Any]] = None,
195199
) -> None:
196-
"""Configure the Redis client."""
200+
"""Configure the Redis client.
201+
202+
Supports standard Redis URLs (redis://), SSL (rediss://), and
203+
Sentinel URLs (redis+sentinel://host:26379/service_name/db).
204+
"""
197205
self._owns_its_client = redis_client is None
198206

199-
# Use direct AsyncRedis.from_url to avoid the deprecated get_async_redis_connection
200207
if redis_client is None:
201-
if not redis_url:
202-
redis_url = os.environ.get("REDIS_URL")
203-
if not redis_url:
204-
raise ValueError("REDIS_URL env var not set")
205-
self._redis = AsyncRedis.from_url(redis_url, **(connection_args or {}))
208+
self._redis = RedisConnectionFactory.get_async_redis_connection(
209+
redis_url, **(connection_args or {})
210+
)
206211
else:
207212
self._redis = redis_client
208213

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ filterwarnings = [
6262
"ignore::DeprecationWarning:testcontainers.core.waiting_utils",
6363
"ignore::DeprecationWarning:testcontainers.redis",
6464
# Ignore trustcall library's deprecated import (used by langmem)
65-
"ignore:Importing Send from langgraph.constants is deprecated.*:langgraph.errors.LangGraphDeprecatedSinceV10:trustcall._base",
65+
"ignore:Importing Send from langgraph.constants is deprecated.*:DeprecationWarning:trustcall._base",
6666
# Ignore PyTorch internal deprecation triggered by sentence-transformers during model loading
6767
"ignore::DeprecationWarning:torch.jit._script",
68-
# Ignore redisvl internal deprecation in SemanticCache (our code already uses AsyncRedis.from_url directly)
68+
# Ignore redisvl internal deprecation warning for get_async_redis_connection
6969
"ignore:get_async_redis_connection will become async:DeprecationWarning:redisvl.redis.connection",
7070
]
7171

tests/conftest.py

Lines changed: 105 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ def redis_container(request):
3030

3131
# Set the Compose project name so containers do not clash across workers
3232
os.environ["COMPOSE_PROJECT_NAME"] = f"redis_test_{worker_id}"
33-
os.environ.setdefault("REDIS_VERSION", "latest")
34-
os.environ.setdefault("REDIS_IMAGE", "redis/redis-stack-server:latest")
33+
os.environ.setdefault("REDIS_VERSION", "8")
34+
os.environ.setdefault("REDIS_IMAGE", "redis:8")
3535

3636
compose = DockerCompose(
3737
context="tests",
@@ -110,29 +110,124 @@ async def clear_redis(redis_url: str) -> None:
110110
pass
111111

112112

113+
@pytest.fixture(scope="session")
114+
def sentinel_container(request):
115+
"""Start Redis master + Sentinel via Docker Compose for sentinel tests."""
116+
if not request.config.getoption("--run-sentinel-tests"):
117+
pytest.skip("Sentinel tests require --run-sentinel-tests flag")
118+
119+
compose = DockerCompose(
120+
context="tests/sentinel",
121+
compose_file_name="docker-compose.yml",
122+
pull=True,
123+
)
124+
try:
125+
compose.start()
126+
except Exception as exc:
127+
pytest.fail(f"Failed to start Sentinel containers: {exc}")
128+
129+
yield compose
130+
131+
try:
132+
compose.stop()
133+
except Exception:
134+
pass
135+
136+
137+
@pytest.fixture(scope="session")
138+
def sentinel_master_url(sentinel_container):
139+
"""Direct connection URL to the Sentinel-monitored Redis master."""
140+
# The master is exposed on fixed port 6399
141+
host = "localhost"
142+
port = 6399
143+
144+
deadline = time.time() + 15
145+
while True:
146+
try:
147+
with socket.create_connection((host, port), timeout=1):
148+
break
149+
except OSError:
150+
if time.time() > deadline:
151+
pytest.skip("Redis master for Sentinel tests failed to become ready.")
152+
time.sleep(0.5)
153+
154+
return f"redis://{host}:{port}"
155+
156+
157+
@pytest.fixture(scope="session")
158+
def sentinel_info(sentinel_container):
159+
"""Return sentinel host/port after waiting for readiness.
160+
161+
Returns a tuple of (sentinel_host, sentinel_port, master_host, master_port)
162+
where master_host/port are the host-reachable mapped ports.
163+
"""
164+
sentinel_host = "localhost"
165+
sentinel_port = 26399
166+
# The master is port-mapped to localhost:6399
167+
master_host = "127.0.0.1"
168+
master_port = 6399
169+
170+
# Poll sentinel until it has discovered the master
171+
from redis import Redis as SyncRedis
172+
173+
deadline = time.time() + 30
174+
while True:
175+
try:
176+
client = SyncRedis(host=sentinel_host, port=sentinel_port)
177+
result = client.execute_command(
178+
"SENTINEL", "get-master-addr-by-name", "mymaster"
179+
)
180+
client.close()
181+
if result is not None:
182+
break
183+
except Exception:
184+
pass
185+
if time.time() > deadline:
186+
pytest.skip("Redis Sentinel failed to discover master.")
187+
time.sleep(0.5)
188+
189+
return sentinel_host, sentinel_port, master_host, master_port
190+
191+
113192
def pytest_addoption(parser: pytest.Parser) -> None:
114193
parser.addoption(
115194
"--run-api-tests",
116195
action="store_true",
117196
default=False,
118197
help="Run tests that require API keys",
119198
)
199+
parser.addoption(
200+
"--run-sentinel-tests",
201+
action="store_true",
202+
default=False,
203+
help="Run tests that require Redis Sentinel (extra containers)",
204+
)
120205

121206

122207
def pytest_configure(config: pytest.Config) -> None:
123208
config.addinivalue_line(
124209
"markers", "requires_api_keys: mark test as requiring API keys"
125210
)
211+
config.addinivalue_line(
212+
"markers", "sentinel: mark test as requiring Redis Sentinel"
213+
)
126214

127215

128216
def pytest_collection_modifyitems(
129217
config: pytest.Config, items: list[pytest.Item]
130218
) -> None:
131-
if config.getoption("--run-api-tests"):
132-
return
133-
skip_api = pytest.mark.skip(
134-
reason="Skipping test because API keys are not provided. Use --run-api-tests to run these tests."
135-
)
136-
for item in items:
137-
if item.get_closest_marker("requires_api_keys"):
138-
item.add_marker(skip_api)
219+
if not config.getoption("--run-api-tests"):
220+
skip_api = pytest.mark.skip(
221+
reason="Skipping test because API keys are not provided. Use --run-api-tests to run these tests."
222+
)
223+
for item in items:
224+
if item.get_closest_marker("requires_api_keys"):
225+
item.add_marker(skip_api)
226+
227+
if not config.getoption("--run-sentinel-tests"):
228+
skip_sentinel = pytest.mark.skip(
229+
reason="Skipping sentinel test. Use --run-sentinel-tests to run."
230+
)
231+
for item in items:
232+
if item.get_closest_marker("sentinel"):
233+
item.add_marker(skip_sentinel)

tests/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
version: "3.9"
22
services:
33
redis:
4-
image: "${REDIS_IMAGE}"
4+
image: "${REDIS_IMAGE:-redis:8}"
55
ports:
66
- target: 6379
77
published: 0

0 commit comments

Comments
 (0)