From cb73bad48a41d374c48d9bd9e8c7243fb8256a07 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Sat, 10 Oct 2026 12:23:45 -0700 Subject: [PATCH] fix(caching): give RedisClusterCache one async client per event loop (#45784) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/caching/redis_cache.py | 8 ++- litellm/caching/redis_cluster_cache.py | 18 +---- .../unit/caching/test_redis_cluster_cache.py | 70 +++++++++++++++++++ 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index 4874495a6c9..3a847044e3b 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -797,6 +797,8 @@ class RedisCache(BaseCache): def init_async_client( self, ) -> async_redis_client | async_redis_cluster_client: + from redis.asyncio import RedisCluster + from litellm import in_memory_llm_clients_cache from .._redis import get_redis_async_client, get_redis_connection_pool @@ -809,7 +811,11 @@ class RedisCache(BaseCache): # Create new connection pool and client for current event loop self.async_redis_conn_pool = get_redis_connection_pool(**self.redis_kwargs) redis_async_client = get_redis_async_client(connection_pool=self.async_redis_conn_pool, **self.redis_kwargs) - in_memory_llm_clients_cache.set_cache(key=cache_key, value=redis_async_client) + in_memory_llm_clients_cache.set_cache( + key=cache_key, + value=redis_async_client, + litellm_owned_client=isinstance(redis_async_client, RedisCluster), + ) self.redis_async_client = redis_async_client return redis_async_client diff --git a/litellm/caching/redis_cluster_cache.py b/litellm/caching/redis_cluster_cache.py index 12d285ca5a8..d0f3c11bf10 100644 --- a/litellm/caching/redis_cluster_cache.py +++ b/litellm/caching/redis_cluster_cache.py @@ -11,7 +11,7 @@ from litellm.caching.redis_cache import RedisCache if TYPE_CHECKING: from opentelemetry.trace import Span as _Span - from redis.asyncio import Redis, RedisCluster + from redis.asyncio import Redis from redis.asyncio.client import Pipeline pipeline = Pipeline @@ -26,22 +26,6 @@ else: class RedisClusterCache(RedisCache): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.redis_async_redis_cluster_client: RedisCluster | None = None - self.redis_sync_redis_cluster_client: RedisCluster | None = None - - def init_async_client(self): - from redis.asyncio import RedisCluster - - from .._redis import get_redis_async_client - - if self.redis_async_redis_cluster_client: - return self.redis_async_redis_cluster_client - - _redis_client: Final = get_redis_async_client(connection_pool=self.async_redis_conn_pool, **self.redis_kwargs) - if isinstance(_redis_client, RedisCluster): - self.redis_async_redis_cluster_client = _redis_client - - return _redis_client def _run_redis_mget_operation(self, keys: list[str]) -> list[Any]: """ diff --git a/tests/unit/caching/test_redis_cluster_cache.py b/tests/unit/caching/test_redis_cluster_cache.py index ba1deabd0e1..68260bc6870 100644 --- a/tests/unit/caching/test_redis_cluster_cache.py +++ b/tests/unit/caching/test_redis_cluster_cache.py @@ -346,3 +346,73 @@ def test_expired_pubsub_client_closes_connections_after_eviction() -> None: with patch("litellm.in_memory_llm_clients_cache", clients): asyncio.run(exercise()) + + +def test_init_async_client_returns_one_cluster_client_per_event_loop() -> None: + clients = LLMClientCache() + with ( + patch("litellm.in_memory_llm_clients_cache", clients), + patch("redis.RedisCluster", return_value=MagicMock()), + patch("redis.Redis", return_value=MagicMock()), + ): + cache = RedisClusterCache(startup_nodes=[{"host": "127.0.0.1", "port": 7000}]) + loop_a = asyncio.new_event_loop() + loop_b = asyncio.new_event_loop() + + async def get_client() -> RedisCluster: + return cache.init_async_client() + + async def get_client_pair() -> tuple[RedisCluster, RedisCluster]: + return cache.init_async_client(), cache.init_async_client() + + try: + client_a, client_a_again = loop_a.run_until_complete(get_client_pair()) + client_b, client_b_again = loop_b.run_until_complete(get_client_pair()) + + assert isinstance(client_a, RedisCluster) + assert isinstance(client_b, RedisCluster) + assert client_a is client_a_again + assert client_b is client_b_again + assert client_a is not client_b + + loop_a.close() + assert loop_b.run_until_complete(get_client()) is client_b + finally: + loop_a.close() + loop_b.close() + + +@pytest.mark.parametrize("cluster", [True, False]) +def test_evicted_async_client_is_closed_only_for_cluster(cluster: bool) -> None: + clients = LLMClientCache(evicted_client_closer=EvictedClientCloser(grace_seconds=0)) + with ( + patch("litellm.in_memory_llm_clients_cache", clients), + patch("redis.RedisCluster", return_value=MagicMock()), + patch("redis.Redis", return_value=MagicMock()), + ): + cache = ( + RedisClusterCache(startup_nodes=[{"host": "127.0.0.1", "port": 7000}]) + if cluster + else RedisCache(host="standalone-evicted-client", port=6379) + ) + + async def exercise() -> None: + client = cache.init_async_client() + closed = asyncio.Event() + client.aclose = AsyncMock(side_effect=closed.set) + cache_key = clients.update_cache_key_with_event_loop(cache._get_async_client_cache_key()) + clients.ttl_dict[cache_key] = 0 + + replacement = cache.init_async_client() + + assert replacement is not client + if cluster: + await asyncio.wait_for(closed.wait(), timeout=1) + client.aclose.assert_awaited_once() + else: + await asyncio.sleep(0) + await asyncio.sleep(0) + await asyncio.sleep(0) + client.aclose.assert_not_awaited() + + asyncio.run(exercise())