From e2dcfa46a18209fa0fd6110a17f93e36ec5c5120 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Fri, 15 Aug 2025 06:44:18 -0700 Subject: [PATCH] docs: add Azure Cache for Redis and Redis Enterprise configuration guide (#72) Fixes #72 - Document proper client configuration for Azure Cache for Redis and Redis Enterprise deployments that use proxy layers. Changes: - Add comprehensive Azure/Enterprise configuration section to README - Add test demonstrating correct client configuration approach - Clarify that standard Redis client should be used, not RedisCluster - Explain proxy architecture and why it requires this configuration The key is that Azure Cache for Redis and similar enterprise deployments use a proxy layer that makes clusters appear as single endpoints. This requires using a standard Redis client rather than a cluster-aware client to avoid detection issues and cross-slot errors. --- README.md | 56 +++++ langgraph/checkpoint/redis/aio.py | 4 +- tests/test_issue_72_azure_cluster.py | 350 +++++++++++++++++++++++++++ 3 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue_72_azure_cluster.py diff --git a/README.md b/README.md index e8b0f53..045cba3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,62 @@ If you're using a Redis version lower than 8.0, you'll need to ensure these modu Failure to have these modules available will result in errors during index creation and checkpoint operations. +### Azure Cache for Redis / Redis Enterprise Configuration + +If you're using **Azure Cache for Redis** (especially Enterprise tier) or **Redis Enterprise**, there are important configuration considerations: + +#### Client Configuration + +Azure Cache for Redis and Redis Enterprise use a **proxy layer** that makes the cluster appear as a single endpoint. This requires using a **standard Redis client**, not a cluster-aware client: + +```python +from redis import Redis +from langgraph.checkpoint.redis import RedisSaver + +# ✅ CORRECT: Use standard Redis client for Azure/Enterprise +client = Redis( + host="your-cache.redis.cache.windows.net", # or your Redis Enterprise endpoint + port=6379, # or 10000 for Azure Enterprise with TLS + password="your-access-key", + ssl=True, # Azure/Enterprise typically requires SSL + ssl_cert_reqs="required", # or "none" for self-signed certs + decode_responses=False # RedisSaver expects bytes +) + +# Pass the configured client to RedisSaver +saver = RedisSaver(redis_client=client) +saver.setup() + +# ❌ WRONG: Don't use RedisCluster client with Azure/Enterprise +# from redis.cluster import RedisCluster +# cluster_client = RedisCluster(...) # This will fail with proxy-based deployments +``` + +#### Why This Matters + +- **Proxy Architecture**: Azure Cache for Redis and Redis Enterprise use a proxy layer that handles cluster operations internally +- **Automatic Detection**: RedisSaver will correctly detect this as non-cluster mode when using the standard client +- **No Cross-Slot Errors**: The proxy handles key distribution, avoiding cross-slot errors + +#### Azure Cache for Redis Specific Settings + +For Azure Cache for Redis Enterprise tier: +- **Port**: Use port `10000` for Enterprise tier with TLS, or `6379` for standard +- **Modules**: Enterprise tier includes RediSearch and RedisJSON by default +- **SSL/TLS**: Always enabled, minimum TLS 1.2 for Enterprise + +Example for Azure Cache for Redis Enterprise: +```python +client = Redis( + host="your-cache.redisenterprise.cache.azure.net", + port=10000, # Enterprise TLS port + password="your-access-key", + ssl=True, + ssl_cert_reqs="required", + decode_responses=False +) +``` + ## Installation Install the library using pip: diff --git a/langgraph/checkpoint/redis/aio.py b/langgraph/checkpoint/redis/aio.py index 6dc12c8..20a6555 100644 --- a/langgraph/checkpoint/redis/aio.py +++ b/langgraph/checkpoint/redis/aio.py @@ -709,7 +709,9 @@ async def alist( if isinstance(checkpoint_data, dict) else orjson.loads(checkpoint_data) ) - channel_values = self._recursive_deserialize(checkpoint_dict.get("channel_values", {})) + channel_values = self._recursive_deserialize( + checkpoint_dict.get("channel_values", {}) + ) else: # If checkpoint data is missing, the document is corrupted # Set empty channel values rather than attempting a fallback diff --git a/tests/test_issue_72_azure_cluster.py b/tests/test_issue_72_azure_cluster.py new file mode 100644 index 0000000..175fbf3 --- /dev/null +++ b/tests/test_issue_72_azure_cluster.py @@ -0,0 +1,350 @@ +"""Test for issue #72: Azure Cache for Redis cluster mode compatibility. + +This test demonstrates the proper way to configure Redis clients for +Azure Cache for Redis and other enterprise/proxy environments. + +The key insight is that Azure Cache for Redis (and similar enterprise setups) +use a proxy layer that makes the cluster appear as a single endpoint. The +solution is to pass a properly configured Redis client, not to override +internal cluster detection. +""" + +from typing import Any, Dict, cast + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata +from redis import Redis +from redis.asyncio import Redis as AsyncRedis +from redis.asyncio.cluster import RedisCluster as AsyncRedisCluster +from redis.cluster import RedisCluster +from testcontainers.redis import RedisContainer + +from langgraph.checkpoint.redis import RedisSaver +from langgraph.checkpoint.redis.aio import AsyncRedisSaver + + +def test_azure_cache_with_standard_redis_client() -> None: + """Test using standard Redis client for Azure Cache (single endpoint). + + Azure Cache for Redis uses a proxy that exposes the cluster through + a single endpoint. In this case, you should use a standard Redis client, + not a RedisCluster client. + """ + with RedisContainer("redis:8") as redis_container: + redis_url = f"redis://{redis_container.get_container_host_ip()}:{redis_container.get_exposed_port(6379)}" + + # For Azure Cache, use standard Redis client (not RedisCluster) + # The proxy handles cluster routing internally + client = Redis.from_url(redis_url) + + # Pass the configured client to RedisSaver + saver = RedisSaver(redis_client=client) + saver.setup() # Initialize the saver + + # The saver will detect this as a non-cluster client + assert saver.cluster_mode == False + + # Test basic operations + config = cast( + RunnableConfig, + { + "configurable": { + "thread_id": "azure-test", + "checkpoint_ns": "", + "checkpoint_id": "checkpoint-1", + } + }, + ) + + checkpoint = cast( + Checkpoint, + { + "v": 1, + "ts": "2024-01-01T00:00:00+00:00", + "id": "checkpoint-1", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {}, + "pending_sends": [], + }, + ) + + metadata = cast( + CheckpointMetadata, {"source": "input", "step": 1, "writes": {}} + ) + + # Should work without cluster-related issues + saver.put(config, checkpoint, metadata, {}) + result = saver.get_tuple(config) + + assert result is not None + assert result.checkpoint["id"] == "checkpoint-1" + + +def test_redis_cluster_client_detection() -> None: + """Test that RedisCluster client is properly detected. + + When connecting to a real Redis cluster (not through a proxy), + use RedisCluster client which will be auto-detected. + """ + with RedisContainer("redis:8") as redis_container: + host = redis_container.get_container_host_ip() + port = redis_container.get_exposed_port(6379) + + # For demonstration - in real scenario, you'd have multiple nodes + # RedisCluster would be used for actual cluster deployments + try: + # This will fail with single node, but shows the pattern + cluster_client = RedisCluster( + host=host, + port=port, + skip_full_coverage_check=True, # For testing with single node + ) + + saver = RedisSaver(redis_client=cluster_client) + saver.setup() + # The saver should detect this as a cluster client + assert saver.cluster_mode == True + + except Exception: + # Expected - single node isn't a real cluster + # This just demonstrates the pattern + pass + + +@pytest.mark.asyncio +async def test_async_azure_cache_configuration() -> None: + """Test async configuration for Azure Cache for Redis.""" + redis_container = RedisContainer("redis:8") + redis_container.start() + + try: + redis_url = f"redis://{redis_container.get_container_host_ip()}:{redis_container.get_exposed_port(6379)}" + + # For Azure Cache, use standard async Redis client + client = AsyncRedis.from_url(redis_url) + + # Pass the configured client + async with AsyncRedisSaver(redis_client=client) as saver: + await saver.asetup() + + # Should detect as non-cluster + assert saver.cluster_mode == False + + config = cast( + RunnableConfig, + { + "configurable": { + "thread_id": "async-azure-test", + "checkpoint_ns": "", + "checkpoint_id": "async-checkpoint-1", + } + }, + ) + + checkpoint = cast( + Checkpoint, + { + "v": 1, + "ts": "2024-01-01T00:00:00+00:00", + "id": "async-checkpoint-1", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {}, + "pending_sends": [], + }, + ) + + metadata = cast( + CheckpointMetadata, {"source": "input", "step": 1, "writes": {}} + ) + + await saver.aput(config, checkpoint, metadata, {}) + result = await saver.aget_tuple(config) + + assert result is not None + assert result.checkpoint["id"] == "async-checkpoint-1" + + await client.aclose() + + finally: + redis_container.stop() + + +def test_workaround_manual_cluster_mode_override() -> None: + """Test manual override of cluster_mode after creation. + + This demonstrates a potential workaround if auto-detection fails, + though the proper solution is to use the correct client type. + """ + with RedisContainer("redis:8") as redis_container: + redis_url = f"redis://{redis_container.get_container_host_ip()}:{redis_container.get_exposed_port(6379)}" + + client = Redis.from_url(redis_url) + + saver = RedisSaver(redis_client=client) + saver.setup() + + # Auto-detection will set cluster_mode=False for standard Redis client + assert saver.cluster_mode == False + + # Manual override (workaround if needed) + # Note: This is NOT recommended - use proper client instead + saver.cluster_mode = True + + # Now it will use cluster-mode code paths + assert saver.cluster_mode == True + + # Operations will use cluster-mode logic + config = cast( + RunnableConfig, + { + "configurable": { + "thread_id": "override-test", + "checkpoint_ns": "", + "checkpoint_id": "checkpoint-1", + } + }, + ) + + checkpoint = cast( + Checkpoint, + { + "v": 1, + "ts": "2024-01-01T00:00:00+00:00", + "id": "checkpoint-1", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {}, + "pending_sends": [], + }, + ) + + metadata = cast( + CheckpointMetadata, {"source": "input", "step": 1, "writes": {}} + ) + + saver.put(config, checkpoint, metadata, {}) + result = saver.get_tuple(config) + + assert result is not None + + +def test_hash_tags_for_cluster_operations() -> None: + """Test using hash tags to ensure keys go to same slot in cluster. + + This is useful when you need to perform multi-key operations + in a Redis cluster environment. + """ + with RedisContainer("redis:8") as redis_container: + redis_url = f"redis://{redis_container.get_container_host_ip()}:{redis_container.get_exposed_port(6379)}" + + client = Redis.from_url(redis_url) + + saver = RedisSaver(redis_client=client) + saver.setup() + + # Use hash tags to force keys to same slot + # Keys with {tag} will hash to the same slot + config1 = cast( + RunnableConfig, + { + "configurable": { + "thread_id": "{user123}:thread-1", # Hash tag + "checkpoint_ns": "{user123}:ns", # Same hash tag + "checkpoint_id": "checkpoint-1", + } + }, + ) + + config2 = cast( + RunnableConfig, + { + "configurable": { + "thread_id": "{user123}:thread-2", # Same hash tag + "checkpoint_ns": "{user123}:ns", # Same hash tag + "checkpoint_id": "checkpoint-2", + } + }, + ) + + checkpoint = cast( + Checkpoint, + { + "v": 1, + "ts": "2024-01-01T00:00:00+00:00", + "id": "checkpoint-1", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {}, + "pending_sends": [], + }, + ) + + metadata = cast( + CheckpointMetadata, {"source": "input", "step": 1, "writes": {}} + ) + + # These will go to the same slot due to hash tags + saver.put(config1, checkpoint, metadata, {}) + + checkpoint["id"] = "checkpoint-2" + saver.put(config2, checkpoint, metadata, {}) + + # List operations will work efficiently with hash tags + results = list(saver.list(config1, limit=10)) + assert len(results) >= 1 + + +# Example documentation for users +def example_azure_cache_configuration() -> None: + """Example: How to configure for Azure Cache for Redis. + + Azure Cache for Redis uses a proxy layer that makes the cluster + appear as a single endpoint. Use a standard Redis client, not + RedisCluster. + """ + # For Azure Cache for Redis + azure_redis_url = "redis://your-cache.redis.cache.windows.net:6379" + azure_password = "your-access-key" + + # Use standard Redis client (not RedisCluster) + client = Redis.from_url( + azure_redis_url, + password=azure_password, + ssl=True, # Azure Cache uses SSL + ssl_cert_reqs=None, # Azure uses self-signed certs + ) + + # Pass the configured client to RedisSaver + saver = RedisSaver(redis_client=client) + saver.setup() + # Will auto-detect as non-cluster (correct for Azure proxy) + # Operations will work through Azure's proxy layer + + +def example_real_cluster_configuration() -> None: + """Example: How to configure for a real Redis Cluster. + + For actual Redis Cluster deployments (not behind a proxy), + use RedisCluster client. + """ + # For real Redis Cluster with multiple nodes + startup_nodes = [ + {"host": "node1.example.com", "port": 7000}, + {"host": "node2.example.com", "port": 7001}, + {"host": "node3.example.com", "port": 7002}, + ] + + # Use RedisCluster client for real clusters + cluster_client = RedisCluster( + startup_nodes=startup_nodes, + decode_responses=False, # RedisSaver expects bytes + skip_full_coverage_check=False, + ) + + # Pass the configured client + saver = RedisSaver(redis_client=cluster_client) + saver.setup() + # Will auto-detect as cluster (correct for real cluster)