litellm/tests/unit/caching/test_redis_cluster_cache.py
Yassin Kortam ca2f99edac
fix(caching): make Redis Cluster commands wait for a pool slot instead of tripping the circuit breaker (#46488)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-14 15:27:00 -07:00

599 lines
22 KiB
Python

import asyncio
import gc
import json
import ssl
import weakref
from collections.abc import Awaitable, Callable
from importlib import import_module
from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from redis.asyncio import Redis, RedisCluster
from redis.asyncio.cluster import ClusterNode
from redis.asyncio.connection import SSLConnection
from litellm.caching.evicted_client_closer import EvictedClientCloser
from litellm.caching.llm_caching_handler import LLMClientCache
from litellm.caching.redis_cache import RedisCache
from litellm.caching.redis_cluster_cache import ClusterConnectionGate, RedisClusterCache
@patch("litellm._redis.init_redis_cluster")
def test_redis_cluster_batch_get(mock_init_redis_cluster):
"""
Test that RedisClusterCache uses mget_nonatomic instead of mget for batch operations
"""
# Create a mock Redis client
mock_redis = MagicMock()
mock_redis.mget_nonatomic.return_value = [None, None] # Simulate no cache hits
mock_init_redis_cluster.return_value = mock_redis
# Create RedisClusterCache instance with mock client
cache = RedisClusterCache(
startup_nodes=[{"host": "localhost", "port": 6379}],
password="hello",
)
# Test batch_get_cache
keys = ["key1", "key2"]
cache.batch_get_cache(keys)
# Verify mget_nonatomic was called instead of mget
mock_redis.mget_nonatomic.assert_called_once()
assert not mock_redis.mget.called
@pytest.mark.asyncio
@patch("litellm._redis.init_redis_cluster")
async def test_redis_cluster_async_batch_get(mock_init_redis_cluster):
"""
Test that RedisClusterCache uses mget_nonatomic instead of mget for async batch operations
"""
# Create a mock Redis client
mock_redis = MagicMock()
mock_redis.mget_nonatomic.return_value = [None, None] # Simulate no cache hits
# Create RedisClusterCache instance with mock client
cache = RedisClusterCache(
startup_nodes=[{"host": "localhost", "port": 6379}],
password="hello",
)
# Mock the init_async_client to return our mock redis client
cache.init_async_client = MagicMock(return_value=mock_redis)
# Test async_batch_get_cache
keys = ["key1", "key2"]
await cache.async_batch_get_cache(keys)
# Verify mget_nonatomic was called instead of mget
mock_redis.mget_nonatomic.assert_called_once()
assert not mock_redis.mget.called
@patch("litellm._redis.get_redis_connection_pool")
@patch("litellm._redis.get_redis_client")
@patch.object(import_module("litellm.caching.redis_cache").RedisCache, "_setup_health_pings")
def test_cache_init_creates_cluster_cache_from_env_var(mock_health, mock_get_client, mock_get_pool, monkeypatch):
"""
Test that Cache() creates RedisClusterCache when REDIS_CLUSTER_NODES env var is set.
Regression test for https://github.com/BerriAI/litellm/issues/22748
"""
from litellm.caching.caching import Cache
startup_nodes = [{"host": "127.0.0.1", "port": "7001"}]
monkeypatch.setenv("REDIS_CLUSTER_NODES", json.dumps(startup_nodes))
monkeypatch.delenv("REDIS_HOST", raising=False)
monkeypatch.delenv("REDIS_PORT", raising=False)
monkeypatch.delenv("REDIS_PASSWORD", raising=False)
monkeypatch.delenv("REDIS_URL", raising=False)
mock_get_client.return_value = MagicMock()
mock_get_pool.return_value = MagicMock()
cache = Cache(type="redis")
assert isinstance(cache.cache, RedisClusterCache)
@patch("litellm._redis.get_redis_connection_pool")
@patch("litellm._redis.get_redis_client")
@patch.object(import_module("litellm.caching.redis_cache").RedisCache, "_setup_health_pings")
def test_cache_init_creates_redis_cache_without_cluster_config(
mock_health, mock_get_client, mock_get_pool, monkeypatch
):
"""
Test that Cache() creates RedisCache when no cluster config is present.
Ensures backward compatibility: without REDIS_CLUSTER_NODES or
redis_startup_nodes, the standard RedisCache is still used.
"""
from litellm.caching.caching import Cache
monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
monkeypatch.setenv("REDIS_HOST", "localhost")
monkeypatch.setenv("REDIS_PORT", "6379")
monkeypatch.delenv("REDIS_URL", raising=False)
mock_get_client.return_value = MagicMock()
mock_get_pool.return_value = MagicMock()
cache = Cache(type="redis")
assert isinstance(cache.cache, RedisCache)
assert not isinstance(cache.cache, RedisClusterCache)
@pytest.mark.parametrize(
"startup_nodes, env_var, expected_cache_type",
[
pytest.param(
[dict(host="node1.localhost", port=6379)],
None,
RedisClusterCache,
id="cluster-via-explicit-startup-nodes",
),
pytest.param(
None,
'[{"host": "node1.localhost", "port": 6379}]',
RedisClusterCache,
id="cluster-via-env-var",
),
pytest.param(
None,
None,
RedisCache,
id="standard-redis-when-no-cluster-config",
),
pytest.param(
[dict(host="explicit-node.localhost", port=6379)],
'[{"host": "env-node.localhost", "port": 6379}]',
RedisClusterCache,
id="explicit-startup-nodes-takes-precedence-over-env-var",
),
],
)
def test_router_create_redis_cache_cluster_detection(startup_nodes, env_var, expected_cache_type, monkeypatch):
"""
Test that Router._create_redis_cache() creates RedisClusterCache when
either startup_nodes is in config or REDIS_CLUSTER_NODES env var is set.
Also verifies that explicit startup_nodes take precedence over env var.
Regression test for https://github.com/BerriAI/litellm/issues/22748
"""
from litellm import Router
cache_config = dict(
host="mockhost",
port=6379,
password="mock-password",
startup_nodes=startup_nodes,
)
if env_var is not None:
monkeypatch.setenv("REDIS_CLUSTER_NODES", env_var)
else:
monkeypatch.delenv("REDIS_CLUSTER_NODES", raising=False)
def _mock_redis_cache_init(*args, **kwargs): ...
with patch.object(RedisCache, "__init__", _mock_redis_cache_init):
redis_cache = Router._create_redis_cache(cache_config)
assert isinstance(redis_cache, expected_cache_type)
def _isolated_redis_cache(host: str) -> RedisCache:
"""RedisCache whose sync client and pool are stubbed out."""
with (
patch("litellm._redis.get_redis_client", return_value=MagicMock()),
patch("litellm._redis.get_redis_connection_pool", return_value=MagicMock()),
):
return RedisCache(host=host, port=6379)
def _cluster_for_pubsub(startup_node_host: str = "10.9.9.9") -> RedisCluster:
"""Uninitialized RedisCluster carrying the connection kwargs a real one would."""
return RedisCluster(
startup_nodes=[ClusterNode(host=startup_node_host, port=7000)],
password="cluster-secret",
socket_timeout=7.0,
)
def test_init_pubsub_client_derives_a_node_client_for_cluster_backend() -> None:
"""LIT-8543: a cluster-backed cache must return a pub/sub-capable client.
The derived client pins a plain Redis connection pool to the cluster's
default node, inheriting the connection kwargs minus cluster-only keys.
"""
cache = _isolated_redis_cache("cluster-pubsub-default-node")
cluster = _cluster_for_pubsub()
node = ClusterNode(host="10.1.2.3", port=7001)
cluster.nodes_manager.default_node = node
cache.init_async_client = MagicMock(return_value=cluster)
client = cache.init_pubsub_client()
assert isinstance(client, Redis) and not isinstance(client, RedisCluster)
kwargs = client.connection_pool.connection_kwargs
assert kwargs["host"] == "10.1.2.3"
assert kwargs["port"] == 7001
assert kwargs["password"] == "cluster-secret"
assert kwargs["socket_timeout"] == 7.0
assert "response_callbacks" not in kwargs
def test_init_pubsub_client_falls_back_to_first_startup_node() -> None:
"""Before cluster initialization there is no default node; the first
startup node is a valid pub/sub target."""
cache = _isolated_redis_cache("cluster-pubsub-startup-fallback")
cluster = _cluster_for_pubsub(startup_node_host="10.8.8.8")
cache.init_async_client = MagicMock(return_value=cluster)
client = cache.init_pubsub_client()
assert isinstance(client, Redis)
assert client.connection_pool.connection_kwargs["host"] == "10.8.8.8"
def test_init_pubsub_client_returns_the_same_cached_client_on_repeat_calls() -> None:
cache = _isolated_redis_cache("cluster-pubsub-caching")
cluster = _cluster_for_pubsub()
cluster.nodes_manager.default_node = ClusterNode(host="10.1.2.3", port=7001)
cache.init_async_client = MagicMock(return_value=cluster)
first = cache.init_pubsub_client()
second = cache.init_pubsub_client()
assert first is second
def test_init_pubsub_client_returns_the_shared_async_client_for_standalone() -> None:
cache = _isolated_redis_cache("standalone-pubsub")
standalone = Redis()
cache.init_async_client = MagicMock(return_value=standalone)
assert cache.init_pubsub_client() is standalone
def test_init_pubsub_client_preserves_tls_and_authentication() -> None:
cache = _isolated_redis_cache("cluster-pubsub-tls")
cluster = RedisCluster(
startup_nodes=[ClusterNode(host="redis.example.test", port=7000)],
ssl=True,
ssl_cert_reqs="required",
ssl_check_hostname=True,
username="pubsub-user",
password="test-password",
socket_connect_timeout=3.0,
socket_keepalive=True,
)
cache.init_async_client = MagicMock(return_value=cluster)
client = cache.init_pubsub_client()
connection = client.connection_pool.make_connection()
assert isinstance(connection, SSLConnection)
assert connection.ssl_context.cert_reqs == ssl.CERT_REQUIRED
assert connection.ssl_context.check_hostname is True
assert connection.username == "pubsub-user"
assert connection.password == "test-password"
assert connection.socket_connect_timeout == 3.0
assert connection.socket_keepalive is True
def test_init_pubsub_client_rejects_missing_nodes_and_can_retry() -> None:
cache = _isolated_redis_cache("cluster-pubsub-no-nodes")
cluster = _cluster_for_pubsub()
cluster.nodes_manager.startup_nodes = {}
cache.init_async_client = MagicMock(return_value=cluster)
with pytest.raises(ValueError, match="no default node and no startup nodes"):
cache.init_pubsub_client()
cluster.nodes_manager.default_node = ClusterNode(host="recovered.example.test", port=7001)
client = cache.init_pubsub_client()
assert client.connection_pool.connection_kwargs["host"] == "recovered.example.test"
@pytest.mark.parametrize("close_fails", [False, True])
@pytest.mark.parametrize("has_shared_pool", [False, True])
def test_disconnect_closes_derived_pubsub_connections_even_when_pool_close_fails(
close_fails: bool, has_shared_pool: bool
) -> None:
cache = _isolated_redis_cache(f"cluster-pubsub-close-{close_fails}-{has_shared_pool}")
cache.async_redis_conn_pool = AsyncMock() if has_shared_pool else None
cache.init_async_client = MagicMock(return_value=_cluster_for_pubsub())
async def exercise() -> None:
client = cache.init_pubsub_client()
connection = AsyncMock()
connection.disconnect.side_effect = ConnectionError("connection close failed") if close_fails else None
client.connection_pool._available_connections.append(connection)
await cache.disconnect()
connection.disconnect.assert_awaited_once()
if has_shared_pool:
cache.async_redis_conn_pool.disconnect.assert_awaited_once_with(inuse_connections=True)
cache.redis_client.close.assert_called_once()
asyncio.run(exercise())
def test_expired_pubsub_client_closes_connections_after_eviction() -> None:
cache = _isolated_redis_cache("cluster-pubsub-expired")
cache.init_async_client = MagicMock(return_value=_cluster_for_pubsub())
clients = LLMClientCache(evicted_client_closer=EvictedClientCloser(grace_seconds=0))
async def exercise() -> None:
client = cache.init_pubsub_client()
closed = asyncio.Event()
connection = AsyncMock()
connection.disconnect.side_effect = closed.set
client.connection_pool._available_connections.append(connection)
cache_key = clients.update_cache_key_with_event_loop(f"{cache._get_async_client_cache_key()}-pubsub")
clients.ttl_dict[cache_key] = 0
replacement = cache.init_pubsub_client()
await asyncio.wait_for(closed.wait(), timeout=1)
assert replacement is not client
connection.disconnect.assert_awaited_once()
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())
class _InFlightCountingClient:
"""Async cluster client double that records how many commands were in flight at once."""
def __init__(self, command_seconds: float) -> None:
self.command_seconds = command_seconds
self.in_flight = 0
self.peak_in_flight = 0
self.completed = 0
async def set(self, *args: object, **kwargs: object) -> bool:
self.in_flight += 1
self.peak_in_flight = max(self.peak_in_flight, self.in_flight)
await asyncio.sleep(self.command_seconds)
self.in_flight -= 1
self.completed += 1
return True
def _cluster_set_cache_with_client(
client: _InFlightCountingClient,
max_connections: int | None = None,
connection_gate: ClusterConnectionGate | None = None,
) -> Callable[[str, str], Awaitable[object]]:
with patch("litellm._redis.init_redis_cluster"):
cache = RedisClusterCache(
startup_nodes=[{"host": "localhost", "port": 7000}],
max_connections=max_connections,
connection_gate=connection_gate,
)
setattr(cache, "init_async_client", MagicMock(return_value=client))
return cast(
"Callable[[str, str], Awaitable[object]]", cache.async_set_cache
) # cast-ok: wrapped **kwargs are untyped
@pytest.mark.asyncio
async def test_cluster_cache_queues_commands_beyond_max_connections_instead_of_failing() -> None:
"""A burst larger than max_connections used to hand every command to the node at once, so redis-py raised
MaxConnectionsError on the overflow and the circuit breaker opened. The overflow now waits for a slot."""
client = _InFlightCountingClient(command_seconds=0.01)
set_cache = _cluster_set_cache_with_client(client, max_connections=3)
await asyncio.gather(*(set_cache(f"key-{i}", "value") for i in range(20)))
assert client.completed == 20
assert client.peak_in_flight <= 3
@pytest.mark.asyncio
async def test_cluster_cache_without_max_connections_does_not_throttle() -> None:
client = _InFlightCountingClient(command_seconds=0.01)
set_cache = _cluster_set_cache_with_client(client)
await asyncio.gather(*(set_cache(f"key-{i}", "value") for i in range(20)))
assert client.completed == 20
assert client.peak_in_flight == 20
@pytest.mark.asyncio
async def test_cluster_cache_sends_after_the_connection_wait_timeout() -> None:
"""A command that cannot get a slot within the pool timeout is still sent, so a saturated pool keeps
surfacing through the client's own errors and the breaker rather than stalling forever."""
client = _InFlightCountingClient(command_seconds=0.2)
set_cache = _cluster_set_cache_with_client(
client, connection_gate=ClusterConnectionGate(limit=1, wait_timeout=0.02)
)
await asyncio.wait_for(asyncio.gather(*(set_cache(f"key-{i}", "value") for i in range(3))), timeout=1.0)
assert client.completed == 3
assert client.peak_in_flight == 3
@pytest.mark.asyncio
async def test_cluster_caches_sharing_a_client_share_the_connection_cap() -> None:
"""init_async_client hands caches with identical settings the same client, so two caches must not each admit
a full max_connections worth of commands against that one pool."""
client = _InFlightCountingClient(command_seconds=0.01)
first = _cluster_set_cache_with_client(client, max_connections=3)
second = _cluster_set_cache_with_client(client, max_connections=3)
await asyncio.gather(
*(first(f"first-{i}", "value") for i in range(10)), *(second(f"second-{i}", "value") for i in range(10))
)
assert client.completed == 20
assert client.peak_in_flight <= 3
@pytest.mark.asyncio
async def test_guarded_command_nested_in_a_guarded_command_reuses_the_slot() -> None:
"""batch_cache_write flushes through async_set_cache_pipeline while it is itself admitted. With one slot the
inner command must run on the outer slot instead of waiting for the pool timeout."""
gate = ClusterConnectionGate(limit=1, wait_timeout=5.0)
async def inner() -> str:
return "flushed"
async def outer() -> str:
return await gate.run(inner, "client-a")
assert await asyncio.wait_for(gate.run(outer, "client-a"), timeout=1.0) == "flushed"
@pytest.mark.asyncio
async def test_nested_guarded_command_on_another_client_waits_for_that_clients_slot() -> None:
gate = ClusterConnectionGate(limit=1, wait_timeout=5.0)
client_b_busy = asyncio.Event()
release_b = asyncio.Event()
peak_b = 0
in_flight_b = 0
async def hold_b() -> None:
nonlocal peak_b, in_flight_b
in_flight_b += 1
peak_b = max(peak_b, in_flight_b)
client_b_busy.set()
await release_b.wait()
in_flight_b -= 1
async def touch_b() -> None:
nonlocal peak_b, in_flight_b
in_flight_b += 1
peak_b = max(peak_b, in_flight_b)
await asyncio.sleep(0)
in_flight_b -= 1
async def outer_on_a() -> None:
await client_b_busy.wait()
await gate.run(touch_b, "client-b")
holder = asyncio.create_task(gate.run(hold_b, "client-b"))
nested = asyncio.create_task(gate.run(outer_on_a, "client-a"))
await client_b_busy.wait()
await asyncio.sleep(0.05)
release_b.set()
await asyncio.gather(holder, nested)
assert peak_b == 1
@pytest.mark.asyncio
async def test_child_task_spawned_inside_a_guarded_command_does_not_inherit_the_slot() -> None:
gate = ClusterConnectionGate(limit=1, wait_timeout=5.0)
peak = 0
in_flight = 0
async def command() -> None:
nonlocal peak, in_flight
in_flight += 1
peak = max(peak, in_flight)
await asyncio.sleep(0.02)
in_flight -= 1
spawned: list[asyncio.Task[None]] = []
async def parent() -> None:
spawned.append(asyncio.create_task(gate.run(command, "client-a")))
await command()
await gate.run(parent, "client-a")
await asyncio.gather(*spawned)
assert peak == 1
def test_closed_event_loop_is_collected_after_contended_gate_use() -> None:
gate = ClusterConnectionGate(limit=1, wait_timeout=5.0)
async def command() -> None:
await asyncio.sleep(0.01)
async def contend() -> "weakref.ref[asyncio.AbstractEventLoop]":
await asyncio.gather(gate.run(command, "client-a"), gate.run(command, "client-a"))
return weakref.ref(asyncio.get_running_loop())
loop_ref = asyncio.run(contend())
gc.collect()
assert loop_ref() is None