Skip to content

Commit 2dbe143

Browse files
committed
feat(redis): implement Redis client info reporting (#21)
Adds proper Redis client identification using SET_CLIENT_INFO for both synchronous and asynchronous clients. Includes graceful fallback to echo when the command is not available and comprehensive tests for both checkpoint and store components.
1 parent 58b23c2 commit 2dbe143

File tree

13 files changed

+643
-1
lines changed

13 files changed

+643
-1
lines changed

langgraph/checkpoint/redis/aio.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,10 @@ def create_indexes(self) -> None:
116116
async def __aenter__(self) -> AsyncRedisSaver:
117117
"""Async context manager enter."""
118118
await self.asetup()
119+
120+
# Set client info once Redis is set up
121+
await self.aset_client_info()
122+
119123
return self
120124

121125
async def __aexit__(

langgraph/checkpoint/redis/ashallow.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ def __init__(
117117
self.loop = asyncio.get_running_loop()
118118

119119
async def __aenter__(self) -> AsyncShallowRedisSaver:
120+
"""Async context manager enter."""
121+
await self.asetup()
122+
123+
# Set client info once Redis is set up
124+
await self.aset_client_info()
125+
120126
return self
121127

122128
async def __aexit__(

langgraph/checkpoint/redis/base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,41 @@ def configure_client(
132132
) -> None:
133133
"""Configure the Redis client."""
134134
pass
135+
136+
def set_client_info(self) -> None:
137+
"""Set client info for Redis monitoring."""
138+
from redis.exceptions import ResponseError
139+
from langgraph.checkpoint.redis.version import __full_lib_name__
140+
141+
try:
142+
# Try to use client_setinfo command if available
143+
self._redis.client_setinfo("LIB-NAME", __full_lib_name__) # type: ignore
144+
except (ResponseError, AttributeError):
145+
# Fall back to a simple echo if client_setinfo is not available
146+
try:
147+
self._redis.echo(__full_lib_name__)
148+
except Exception:
149+
# Silently fail if even echo doesn't work
150+
pass
151+
152+
async def aset_client_info(self) -> None:
153+
"""Set client info for Redis monitoring asynchronously."""
154+
from redis.exceptions import ResponseError
155+
from langgraph.checkpoint.redis.version import __full_lib_name__
156+
157+
try:
158+
# Try to use client_setinfo command if available
159+
await self._redis.client_setinfo("LIB-NAME", __full_lib_name__) # type: ignore
160+
except (ResponseError, AttributeError):
161+
# Fall back to a simple echo if client_setinfo is not available
162+
try:
163+
# Call with await to ensure it's an async call
164+
echo_result = self._redis.echo(__full_lib_name__)
165+
if hasattr(echo_result, "__await__"):
166+
await echo_result
167+
except Exception:
168+
# Silently fail if even echo doesn't work
169+
pass
135170

136171
def setup(self) -> None:
137172
"""Initialize the indices in Redis."""

langgraph/checkpoint/redis/shallow.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,9 @@ def configure_client(
386386
self._redis = redis_client or RedisConnectionFactory.get_redis_connection(
387387
redis_url, **connection_args
388388
)
389+
390+
# Set client info for Redis monitoring
391+
self.set_client_info()
389392

390393
def create_indexes(self) -> None:
391394
self.checkpoints_index = SearchIndex.from_dict(

langgraph/store/redis/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ def from_conn_string(
9898
client = None
9999
try:
100100
client = RedisConnectionFactory.get_redis_connection(conn_string)
101-
yield cls(client, index=index, ttl=ttl)
101+
store = cls(client, index=index, ttl=ttl)
102+
# Client info will already be set in __init__, but we set it up here
103+
# to make the method behavior consistent with AsyncRedisStore
104+
store.set_client_info()
105+
yield store
102106
finally:
103107
if client:
104108
client.close()

langgraph/store/redis/aio.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,8 @@ async def from_conn_string(
275275
"""Create store from Redis connection string."""
276276
async with cls(redis_url=conn_string, index=index, ttl=ttl) as store:
277277
await store.setup()
278+
# Set client information after setup
279+
await store.aset_client_info()
278280
yield store
279281

280282
def create_indexes(self) -> None:
@@ -289,6 +291,9 @@ def create_indexes(self) -> None:
289291

290292
async def __aenter__(self) -> AsyncRedisStore:
291293
"""Async context manager enter."""
294+
# Client info was already set in __init__,
295+
# but we'll set it again here to be consistent with checkpoint code
296+
await self.aset_client_info()
292297
return self
293298

294299
async def __aexit__(

langgraph/store/redis/base.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,44 @@ def __init__(
244244
self.vector_index = SearchIndex.from_dict(
245245
vector_schema, redis_client=self._redis
246246
)
247+
248+
# Set client information in Redis
249+
self.set_client_info()
250+
251+
def set_client_info(self) -> None:
252+
"""Set client info for Redis monitoring."""
253+
from redis.exceptions import ResponseError
254+
from langgraph.checkpoint.redis.version import __full_lib_name__
255+
256+
try:
257+
# Try to use client_setinfo command if available
258+
self._redis.client_setinfo("LIB-NAME", __full_lib_name__) # type: ignore
259+
except (ResponseError, AttributeError):
260+
# Fall back to a simple echo if client_setinfo is not available
261+
try:
262+
self._redis.echo(__full_lib_name__)
263+
except Exception:
264+
# Silently fail if even echo doesn't work
265+
pass
266+
267+
async def aset_client_info(self) -> None:
268+
"""Set client info for Redis monitoring asynchronously."""
269+
from redis.exceptions import ResponseError
270+
from langgraph.checkpoint.redis.version import __full_lib_name__
271+
272+
try:
273+
# Try to use client_setinfo command if available
274+
await self._redis.client_setinfo("LIB-NAME", __full_lib_name__) # type: ignore
275+
except (ResponseError, AttributeError):
276+
# Fall back to a simple echo if client_setinfo is not available
277+
try:
278+
# Call with await to ensure it's an async call
279+
echo_result = self._redis.echo(__full_lib_name__)
280+
if hasattr(echo_result, "__await__"):
281+
await echo_result
282+
except Exception:
283+
# Silently fail if even echo doesn't work
284+
pass
247285

248286
def _get_batch_GET_ops_queries(
249287
self,

tests/test_async.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,98 @@ async def test_from_conn_string_cleanup(redis_url: str) -> None:
278278
assert await ext_client.ping() # Should still work
279279
finally:
280280
await ext_client.aclose() # type: ignore[attr-defined]
281+
282+
283+
@pytest.mark.asyncio
284+
async def test_async_client_info_setting(redis_url: str, monkeypatch) -> None:
285+
"""Test that async client_setinfo is called with correct library information."""
286+
from langgraph.checkpoint.redis.version import __full_lib_name__
287+
288+
# Track if client_setinfo was called with the right parameters
289+
client_info_called = False
290+
291+
# Store the original method
292+
original_client_setinfo = Redis.client_setinfo
293+
294+
# Create a mock function for client_setinfo
295+
async def mock_client_setinfo(self, key, value):
296+
nonlocal client_info_called
297+
# Note: RedisVL might call this with its own lib name first
298+
# We only track calls with our full lib name
299+
if key == "LIB-NAME" and __full_lib_name__ in value:
300+
client_info_called = True
301+
# Call original method to ensure normal function
302+
return await original_client_setinfo(self, key, value)
303+
304+
# Apply the mock
305+
monkeypatch.setattr(Redis, "client_setinfo", mock_client_setinfo)
306+
307+
# Test client info setting when creating a new saver with async context manager
308+
async with AsyncRedisSaver.from_conn_string(redis_url) as saver:
309+
await saver.asetup()
310+
# __aenter__ should have called aset_client_info
311+
312+
# Verify client_setinfo was called with our library info
313+
assert client_info_called, "client_setinfo was not called with our library name"
314+
315+
316+
@pytest.mark.asyncio
317+
async def test_async_client_info_fallback_to_echo(redis_url: str, monkeypatch) -> None:
318+
"""Test that async client_setinfo falls back to echo when not available."""
319+
from langgraph.checkpoint.redis.version import __full_lib_name__
320+
from redis.exceptions import ResponseError
321+
322+
# Remove client_setinfo to simulate older Redis version
323+
async def mock_client_setinfo(self, key, value):
324+
raise ResponseError("ERR unknown command")
325+
326+
# Track if echo was called as fallback
327+
echo_called = False
328+
original_echo = Redis.echo
329+
330+
# Create mock for echo
331+
async def mock_echo(self, message):
332+
nonlocal echo_called
333+
echo_called = True
334+
assert message == __full_lib_name__
335+
return await original_echo(self, message)
336+
337+
# Apply the mocks
338+
monkeypatch.setattr(Redis, "client_setinfo", mock_client_setinfo)
339+
monkeypatch.setattr(Redis, "echo", mock_echo)
340+
341+
# Test client info setting with fallback
342+
async with AsyncRedisSaver.from_conn_string(redis_url) as saver:
343+
await saver.asetup()
344+
# __aenter__ should have called aset_client_info with fallback to echo
345+
346+
# Verify echo was called as fallback
347+
assert echo_called, "echo was not called as fallback when async client_setinfo failed"
348+
349+
350+
@pytest.mark.asyncio
351+
async def test_async_client_info_graceful_failure(redis_url: str, monkeypatch) -> None:
352+
"""Test that async client info setting fails gracefully when all methods fail."""
353+
from redis.exceptions import ResponseError
354+
355+
# Simulate failures for both methods
356+
async def mock_client_setinfo(self, key, value):
357+
raise ResponseError("ERR unknown command")
358+
359+
async def mock_echo(self, message):
360+
raise ResponseError("ERR connection broken")
361+
362+
# Apply the mocks
363+
monkeypatch.setattr(Redis, "client_setinfo", mock_client_setinfo)
364+
monkeypatch.setattr(Redis, "echo", mock_echo)
365+
366+
# Should not raise any exceptions when both methods fail
367+
try:
368+
async with AsyncRedisSaver.from_conn_string(redis_url) as saver:
369+
await saver.asetup()
370+
# __aenter__ should handle failures gracefully
371+
except Exception as e:
372+
assert False, f"aset_client_info did not handle failure gracefully: {e}"
281373

282374

283375
@pytest.mark.asyncio

tests/test_async_store.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,3 +464,95 @@ async def test_async_store_with_memory_persistence() -> None:
464464
Note: This test is skipped by default as it requires special setup.
465465
"""
466466
pytest.skip("Skipping in-memory Redis test")
467+
468+
469+
@pytest.mark.asyncio
470+
async def test_async_redis_store_client_info(redis_url: str, monkeypatch) -> None:
471+
"""Test that AsyncRedisStore sets client info correctly."""
472+
from redis.asyncio import Redis
473+
from langgraph.checkpoint.redis.version import __full_lib_name__
474+
475+
# Track if client_setinfo was called with the right parameters
476+
client_info_called = False
477+
478+
# Store the original method
479+
original_client_setinfo = Redis.client_setinfo
480+
481+
# Create a mock function for client_setinfo
482+
async def mock_client_setinfo(self, key, value):
483+
nonlocal client_info_called
484+
# Note: RedisVL might call this with its own lib name first
485+
# We only track calls with our full lib name
486+
if key == "LIB-NAME" and __full_lib_name__ in value:
487+
client_info_called = True
488+
# Call original method to ensure normal function
489+
return await original_client_setinfo(self, key, value)
490+
491+
# Apply the mock
492+
monkeypatch.setattr(Redis, "client_setinfo", mock_client_setinfo)
493+
494+
# Test client info setting when creating a new async store
495+
async with AsyncRedisStore.from_conn_string(redis_url) as store:
496+
await store.setup()
497+
498+
# Verify client_setinfo was called with our library info
499+
assert client_info_called, "client_setinfo was not called with our library name"
500+
501+
502+
@pytest.mark.asyncio
503+
async def test_async_redis_store_client_info_fallback(redis_url: str, monkeypatch) -> None:
504+
"""Test that AsyncRedisStore falls back to echo when client_setinfo is not available."""
505+
from redis.asyncio import Redis
506+
from redis.exceptions import ResponseError
507+
from langgraph.checkpoint.redis.version import __full_lib_name__
508+
509+
# Remove client_setinfo to simulate older Redis version
510+
async def mock_client_setinfo(self, key, value):
511+
raise ResponseError("ERR unknown command")
512+
513+
# Track if echo was called as fallback
514+
echo_called = False
515+
original_echo = Redis.echo
516+
517+
# Create mock for echo
518+
async def mock_echo(self, message):
519+
nonlocal echo_called
520+
echo_called = True
521+
assert message == __full_lib_name__
522+
return await original_echo(self, message)
523+
524+
# Apply the mocks
525+
monkeypatch.setattr(Redis, "client_setinfo", mock_client_setinfo)
526+
monkeypatch.setattr(Redis, "echo", mock_echo)
527+
528+
# Test client info setting with fallback
529+
async with AsyncRedisStore.from_conn_string(redis_url) as store:
530+
await store.setup()
531+
532+
# Verify echo was called as fallback
533+
assert echo_called, "echo was not called as fallback when client_setinfo failed in AsyncRedisStore"
534+
535+
536+
@pytest.mark.asyncio
537+
async def test_async_redis_store_graceful_failure(redis_url: str, monkeypatch) -> None:
538+
"""Test that async store client info setting fails gracefully when all methods fail."""
539+
from redis.asyncio import Redis
540+
from redis.exceptions import ResponseError
541+
542+
# Simulate failures for both methods
543+
async def mock_client_setinfo(self, key, value):
544+
raise ResponseError("ERR unknown command")
545+
546+
async def mock_echo(self, message):
547+
raise ResponseError("ERR connection broken")
548+
549+
# Apply the mocks
550+
monkeypatch.setattr(Redis, "client_setinfo", mock_client_setinfo)
551+
monkeypatch.setattr(Redis, "echo", mock_echo)
552+
553+
# Should not raise any exceptions when both methods fail
554+
try:
555+
async with AsyncRedisStore.from_conn_string(redis_url) as store:
556+
await store.setup()
557+
except Exception as e:
558+
assert False, f"aset_client_info did not handle failure gracefully: {e}"

0 commit comments

Comments
 (0)