mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
* feat(redis): route the router, rate limiters and auth through the native store families * style(redis): satisfy the python lint gates Format the rate limit store, sort its __all__ and give the routing read batch and two tests concrete list and dict types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(router): give the registry's default usage store a memory-only handle RouterUsageStore now takes a handle, so the default passed None and any usage read before RustCore swapped in its store raised AttributeError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(redis): build stores from native handles and wait for router tasks Tests still used the pre-refactor store constructors (Store(None), handle= factories, NativeIncrement, tuple scopes). The router outcome tests counted 20 loop ticks, too few now that the allowed-fails increment resolves on the native runtime, so settle() waits for the scheduled tasks instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
574 lines
22 KiB
Python
574 lines
22 KiB
Python
"""RedisBatch: independent operations share one pipeline, each keeps its own result and failure."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Awaitable, Callable, Sequence
|
|
from dataclasses import dataclass, field
|
|
from datetime import timedelta
|
|
from typing import Any, Generic, TypeVar
|
|
|
|
import pytest
|
|
from redis.exceptions import NoScriptError
|
|
|
|
from litellm._internal_context import current_service_target, service_target
|
|
from litellm._service_logger import ServiceLogging
|
|
from litellm.caching.redis_batch import (
|
|
MIXED_PIPELINE_TARGET,
|
|
RedisBatch,
|
|
active_request_redis_batch,
|
|
request_redis_batch_scope,
|
|
)
|
|
from litellm.caching.redis_cache import (
|
|
RedisCache,
|
|
RedisCircuitBreaker,
|
|
_get_call_stack_info, # pyright: ignore[reportPrivateUsage] # the chain the service hook reports
|
|
)
|
|
from litellm.caching.redis_cluster_cache import RedisClusterCache
|
|
from litellm.redis.store_settings import RedisStoreSettings, breaker_settings
|
|
|
|
SCRIPT = "return redis.call('GET', KEYS[1])"
|
|
_Answer = TypeVar("_Answer")
|
|
SHA = hashlib.sha1(SCRIPT.encode()).hexdigest() # noqa: S324
|
|
|
|
|
|
class FakePipeline:
|
|
def __init__(self, reply_for: Callable[[tuple[object, ...]], object], fail: Exception | None) -> None:
|
|
self.commands: list[tuple[Any, ...]] = []
|
|
self.reply_for = reply_for
|
|
self.fail = fail
|
|
self.executed = False
|
|
|
|
async def __aenter__(self) -> FakePipeline:
|
|
return self
|
|
|
|
async def __aexit__(self, *exc: object) -> None:
|
|
return None
|
|
|
|
def mget(self, keys: Sequence[str]) -> FakePipeline:
|
|
self.commands.append(("MGET", *keys))
|
|
return self
|
|
|
|
def evalsha(self, sha: str, numkeys: int, *keys_and_args: object) -> FakePipeline:
|
|
self.commands.append(("EVALSHA", sha, numkeys, *keys_and_args))
|
|
return self
|
|
|
|
def incrbyfloat(self, name: str, amount: float) -> FakePipeline:
|
|
self.commands.append(("INCRBYFLOAT", name, amount))
|
|
return self
|
|
|
|
def expire(self, name: str, time: timedelta) -> FakePipeline:
|
|
self.commands.append(("EXPIRE", name, int(time.total_seconds())))
|
|
return self
|
|
|
|
def set(self, name: str, value: str, ex: timedelta | None = None) -> FakePipeline:
|
|
self.commands.append(("SET", name, value, None if ex is None else int(ex.total_seconds())))
|
|
return self
|
|
|
|
def delete(self, *names: str) -> FakePipeline:
|
|
self.commands.append(("DEL", *names))
|
|
return self
|
|
|
|
async def execute(self, raise_on_error: bool = True) -> list[Any]:
|
|
assert raise_on_error is False
|
|
self.executed = True
|
|
if self.fail is not None:
|
|
raise self.fail
|
|
return [self.reply_for(command) for command in self.commands]
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self, reply_for: Callable[[tuple[object, ...]], object], fail: Exception | None = None) -> None:
|
|
self.pipelines: list[FakePipeline] = []
|
|
self.reply_for = reply_for
|
|
self.fail = fail
|
|
|
|
def pipeline(self, transaction: bool = True) -> FakePipeline:
|
|
assert transaction is False
|
|
pipe = FakePipeline(self.reply_for, self.fail)
|
|
self.pipelines.append(pipe)
|
|
return pipe
|
|
|
|
|
|
class FakeRedisCache(RedisCache):
|
|
def __init__(self, client: FakeClient, namespace: str | None = None) -> None: # super().__init__ needs a server
|
|
self.client = client
|
|
self.namespace = namespace
|
|
self.redis_kwargs = {"host": "127.0.0.1", "port": 1, "socket_timeout": 0.2, "db": id(self) % 1_000_000}
|
|
self.store_settings = RedisStoreSettings(
|
|
redis_kwargs=self.redis_kwargs, namespace=namespace, default_ttl=60, breaker=breaker_settings()
|
|
)
|
|
self._circuit_breaker = RedisCircuitBreaker(failure_threshold=5, recovery_timeout=30)
|
|
self.service_logger_obj = ServiceLogging()
|
|
self.default_ttl = None
|
|
self.alone: list[tuple[str, Any]] = []
|
|
self.store: dict[str, Any] = {}
|
|
|
|
def init_async_client(self) -> FakeClient: # pyright: ignore[reportIncompatibleMethodOverride] # fake client, no server
|
|
return self.client
|
|
|
|
async def async_batch_get_cache(self, key_list: Sequence[str], **kwargs: object) -> dict[str, Any]: # pyright: ignore[reportIncompatibleMethodOverride] # records the direct read
|
|
self.alone.append(("MGET", tuple(key_list)))
|
|
return {key: self.store.get(key) for key in key_list}
|
|
|
|
async def async_increment(self, key: str, value: float, ttl: int | None = None, **kwargs: object) -> float: # pyright: ignore[reportIncompatibleMethodOverride] # records the direct write
|
|
self.alone.append(("INCRBYFLOAT", key, value))
|
|
self.store[key] = float(self.store.get(key, 0.0)) + value
|
|
return self.store[key]
|
|
|
|
async def async_set_cache(self, key: str, value: object, **kwargs: object) -> None: # pyright: ignore[reportIncompatibleMethodOverride] # fake, no server
|
|
self.alone.append(("SET", key, value))
|
|
self.store[key] = value
|
|
|
|
async def async_delete_cache(self, key: str) -> None: # pyright: ignore[reportIncompatibleMethodOverride] # records the direct delete
|
|
self.alone.append(("DEL", key))
|
|
self.store.pop(key, None)
|
|
|
|
async def async_set_cache_pipeline_with_ttls(self, cache_list: Sequence[tuple[str, object, float | None]]) -> None:
|
|
self.alone.append(("SET_PIPELINE", tuple(cache_list)))
|
|
for key, value, _ttl in cache_list:
|
|
self.store[key] = value
|
|
|
|
|
|
class FakeClusterCache(RedisClusterCache, FakeRedisCache):
|
|
def __init__(self, client: FakeClient) -> None: # super().__init__ needs a server
|
|
FakeRedisCache.__init__(self, client)
|
|
|
|
|
|
def replies(command: tuple[Any, ...]) -> Any:
|
|
match command[0]:
|
|
case "MGET":
|
|
return [json.dumps({"k": key}) if key.endswith("hit") else None for key in command[1:]]
|
|
case "EVALSHA":
|
|
return [1, 2]
|
|
case "INCRBYFLOAT":
|
|
return b"3.5"
|
|
case "EXPIRE":
|
|
return 1
|
|
case "SET":
|
|
return True
|
|
case "DEL":
|
|
return 1
|
|
raise AssertionError(command)
|
|
|
|
|
|
@dataclass
|
|
class FakeNativeRequest:
|
|
"""Stands in for ``NativeRequestRedis``: records each flush in ``events`` next to what the test's pendings log."""
|
|
|
|
post_call_deadline: float
|
|
events: list[str] = field(default_factory=list)
|
|
|
|
async def flush_pre(self) -> None:
|
|
self.events.append("flush_pre")
|
|
|
|
async def flush_post(self) -> None:
|
|
self.events.append("flush_post")
|
|
|
|
|
|
@dataclass
|
|
class FakeNatives:
|
|
built: list[FakeNativeRequest] = field(default_factory=list)
|
|
|
|
def __call__(self, post_call_deadline: float) -> FakeNativeRequest:
|
|
self.built.append(FakeNativeRequest(post_call_deadline))
|
|
return self.built[-1]
|
|
|
|
|
|
@dataclass
|
|
class FakeNativePending(Generic[_Answer]):
|
|
"""A read declared on a ``FakeNativeRequest``; it only answers once that request's pre-call batch was flushed."""
|
|
|
|
request: FakeNativeRequest
|
|
answer: Callable[[], _Answer]
|
|
|
|
async def resolve(self) -> _Answer:
|
|
if "flush_pre" not in self.request.events:
|
|
raise ConnectionError("resolved before its batch was flushed")
|
|
self.request.events.append("resolve")
|
|
return self.answer()
|
|
|
|
|
|
@dataclass
|
|
class ReadyPending(Generic[_Answer]):
|
|
"""An op declared with ``NativeSend.Now()``: it answers whenever it is resolved."""
|
|
|
|
answer: Callable[[], _Answer]
|
|
|
|
async def resolve(self) -> _Answer:
|
|
return self.answer()
|
|
|
|
|
|
def fake_send(_phase: object, request: FakeNativeRequest) -> FakeNativeRequest:
|
|
"""A ``native_send_factory`` for ``FakeNatives``: the fake request stands in for its ``NativeSend``."""
|
|
return request
|
|
|
|
|
|
def fake_pending(send: object, answer: Callable[[], _Answer]) -> FakeNativePending[_Answer] | ReadyPending[_Answer]:
|
|
"""What a fake handle declares for ``send``: on the fake request's batch, or on its own for ``Now()``."""
|
|
return FakeNativePending(send, answer) if isinstance(send, FakeNativeRequest) else ReadyPending(answer)
|
|
|
|
|
|
def make(fail: Exception | None = None, namespace: str | None = None) -> tuple[FakeRedisCache, FakeClient]:
|
|
client = FakeClient(replies, fail)
|
|
return FakeRedisCache(client, namespace), client
|
|
|
|
|
|
async def run_alone_script(keys: Sequence[str], args: Sequence[Any]) -> object:
|
|
return ["alone", *keys, *args]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_flush_reports_its_name_as_the_call_type_and_the_op_count_as_metadata() -> None:
|
|
"""The service event is ``request_redis_batch`` with ``op_count`` on the metadata, not
|
|
``request_redis_batch[3]``: the span renders as ``redis.pipeline`` and the metrics label
|
|
stays one value per batch name instead of one per batch size."""
|
|
cache, _client = make()
|
|
events: list[dict[str, Any]] = []
|
|
|
|
async def record(**kwargs: Any) -> None:
|
|
events.append(kwargs)
|
|
|
|
cache.service_logger_obj.async_service_success_hook = record # pyright: ignore[reportAttributeAccessIssue] # fake, records the hook call
|
|
batch = RedisBatch(cache, name="request_redis_batch")
|
|
got = batch.mget(["a:hit"])
|
|
incr = batch.increment("cnt", 1)
|
|
await got
|
|
await incr
|
|
await asyncio.gather(*(t for t in asyncio.all_tasks() if t is not asyncio.current_task()))
|
|
|
|
(event,) = events
|
|
assert event["call_type"] == "request_redis_batch"
|
|
assert event["event_metadata"] == {"op_count": 2}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_one_pipeline_carries_every_declared_operation_and_awaiting_one_flushes_all() -> None:
|
|
cache, client = make(namespace="ns")
|
|
batch = RedisBatch(cache)
|
|
got = batch.mget(["a:hit", "b", "a:hit"])
|
|
script = batch.script(SCRIPT, run_alone_script, ["w"], [7, "x"])
|
|
incr = batch.increment("cnt", 2.5, ttl=60)
|
|
plain = batch.increment("cnt2", 1)
|
|
assert client.pipelines == []
|
|
|
|
assert await got == {"a:hit": {"k": "ns:a:hit"}, "b": None}
|
|
assert script.done and incr.done and plain.done
|
|
assert await script == [1, 2]
|
|
assert await incr == 3.5
|
|
assert await plain == 3.5
|
|
assert batch.flushes == 1
|
|
assert [pipe.commands for pipe in client.pipelines] == [
|
|
[
|
|
("MGET", "ns:a:hit", "ns:b"),
|
|
("EVALSHA", SHA, 1, "ns:w", 7, "x"),
|
|
("INCRBYFLOAT", "ns:cnt", 2.5),
|
|
("EXPIRE", "ns:cnt", 60),
|
|
("INCRBYFLOAT", "ns:cnt2", 1),
|
|
]
|
|
]
|
|
assert cache.alone == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_operations_declared_after_a_flush_go_out_in_the_next_pipeline() -> None:
|
|
cache, client = make()
|
|
batch = RedisBatch(cache)
|
|
await batch.mget(["a"])
|
|
later = batch.increment("cnt", 1)
|
|
assert not later.done
|
|
assert await later == 3.5
|
|
assert batch.flushes == 2
|
|
assert [pipe.commands for pipe in client.pipelines] == [[("MGET", "a")], [("INCRBYFLOAT", "cnt", 1)]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_failing_reply_fails_only_its_own_operation() -> None:
|
|
def reply_for(command: tuple[Any, ...]) -> Any:
|
|
if command[0] == "EVALSHA":
|
|
return ValueError("script blew up")
|
|
return replies(command)
|
|
|
|
client = FakeClient(reply_for)
|
|
cache = FakeRedisCache(client)
|
|
batch = RedisBatch(cache)
|
|
got = batch.mget(["a:hit"])
|
|
script = batch.script(SCRIPT, run_alone_script, ["w"], [])
|
|
assert await got == {"a:hit": {"k": "a:hit"}}
|
|
with pytest.raises(ValueError, match="script blew up"):
|
|
await script
|
|
assert cache.alone == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_reply_an_operation_cannot_decode_fails_only_that_operation() -> None:
|
|
def reply_for(command: tuple[Any, ...]) -> Any:
|
|
if command[0] == "MGET":
|
|
return "not-a-list"
|
|
return replies(command)
|
|
|
|
client = FakeClient(reply_for)
|
|
cache = FakeRedisCache(client)
|
|
batch = RedisBatch(cache)
|
|
got = batch.mget(["a:hit"])
|
|
written = batch.set("w", {"k": 1})
|
|
script = batch.script(SCRIPT, run_alone_script, ["w"], [])
|
|
with pytest.raises(TypeError, match="MGET reply is not a list"):
|
|
await got
|
|
assert await written is None
|
|
assert await script == [1, 2]
|
|
assert len(client.pipelines) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_failure_fails_every_operation_and_trips_the_breaker() -> None:
|
|
cache, _client = make(fail=ConnectionError("redis down"))
|
|
batch = RedisBatch(cache)
|
|
got = batch.mget(["a"])
|
|
incr = batch.increment("cnt", 1)
|
|
with pytest.raises(ConnectionError):
|
|
await got
|
|
with pytest.raises(ConnectionError):
|
|
await incr
|
|
assert cache._circuit_breaker._failure_count == 1 # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_noscript_reply_reruns_that_script_through_the_registered_executor() -> None:
|
|
def reply_for(command: tuple[Any, ...]) -> Any:
|
|
if command[0] == "EVALSHA":
|
|
return NoScriptError("NOSCRIPT No matching script")
|
|
return replies(command)
|
|
|
|
client = FakeClient(reply_for)
|
|
cache = FakeRedisCache(client)
|
|
batch = RedisBatch(cache)
|
|
script = batch.script(SCRIPT, run_alone_script, ["w"], [1])
|
|
incr = batch.increment("cnt", 1)
|
|
assert await script == ["alone", "w", 1]
|
|
assert await incr == 3.5
|
|
assert batch.flushes == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cluster_cache_runs_each_operation_on_its_own_path() -> None:
|
|
client = FakeClient(replies)
|
|
cache = FakeClusterCache(client)
|
|
cache.store["a"] = 4
|
|
batch = RedisBatch(cache)
|
|
got = batch.mget(["a", "b"])
|
|
incr = batch.increment("cnt", 2)
|
|
assert await got == {"a": 4, "b": None}
|
|
assert await incr == 2.0
|
|
assert client.pipelines == []
|
|
assert cache.alone == [("MGET", ("a", "b")), ("INCRBYFLOAT", "cnt", 2)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flush_hook_lets_a_lazy_reader_join_the_pipeline_that_is_going_out() -> None:
|
|
cache, client = make()
|
|
batch = RedisBatch(cache)
|
|
joined: list[Any] = []
|
|
batch.add_flush_hook(lambda: joined.append(batch.mget(["late"])))
|
|
await batch.mget(["early"])
|
|
assert len(joined) == 1 and joined[0].done
|
|
assert await joined[0] == {"late": None}
|
|
assert [pipe.commands for pipe in client.pipelines] == [[("MGET", "early"), ("MGET", "late")]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_awaiters_share_one_flush() -> None:
|
|
cache, client = make()
|
|
batch = RedisBatch(cache)
|
|
first = batch.mget(["a"])
|
|
second = batch.mget(["b"])
|
|
results = await asyncio.gather(first._wait(), second._wait()) # pyright: ignore[reportPrivateUsage]
|
|
assert results == [{"a": None}, {"b": None}]
|
|
assert batch.flushes == 1
|
|
assert len(client.pipelines) == 1
|
|
|
|
|
|
def test_request_scope_hands_out_one_batch_per_backend_and_nests() -> None:
|
|
cache_a, _ = make()
|
|
cache_b, _ = make()
|
|
assert active_request_redis_batch(cache_a) is None
|
|
with request_redis_batch_scope() as batches:
|
|
first = active_request_redis_batch(cache_a)
|
|
assert first is not None
|
|
assert active_request_redis_batch(cache_a) is first
|
|
assert active_request_redis_batch(cache_b) is not first
|
|
with request_redis_batch_scope() as inner:
|
|
assert inner is batches
|
|
assert active_request_redis_batch(cache_a) is first
|
|
assert active_request_redis_batch(cache_a) is first
|
|
assert len(batches.batches) == 2
|
|
assert active_request_redis_batch(cache_a) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_key_an_mget_read_as_absent_stays_known_missing_until_something_sets_it() -> None:
|
|
cache, client = make()
|
|
batch = RedisBatch(cache)
|
|
values = await batch.mget(["a-hit", "b-miss"])
|
|
assert values == {"a-hit": {"k": "a-hit"}, "b-miss": None}
|
|
assert batch.read_as_missing("b-miss") is True
|
|
assert batch.read_as_missing("a-hit") is False
|
|
assert batch.read_as_missing("never-read") is False
|
|
batch.set("b-miss", "now-present")
|
|
assert batch.read_as_missing("b-miss") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_delete_rides_the_pipeline_under_the_namespace_and_reads_as_missing_afterwards() -> None:
|
|
cache, client = make(namespace="ns")
|
|
batch = RedisBatch(cache)
|
|
gone = batch.delete("team_alias:x")
|
|
got = batch.mget(["a-hit"])
|
|
assert await gone is None
|
|
assert await got == {"a-hit": {"k": "ns:a-hit"}}
|
|
assert len(client.pipelines) == 1
|
|
assert client.pipelines[0].commands[0] == ("DEL", "ns:team_alias:x")
|
|
assert batch.read_as_missing("team_alias:x") is True
|
|
assert cache.alone == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_delete_on_a_cluster_cache_runs_as_its_own_del() -> None:
|
|
client = FakeClient(replies)
|
|
cache = FakeClusterCache(client)
|
|
cache.store["team_alias:x"] = "stale"
|
|
batch = RedisBatch(cache)
|
|
assert await batch.delete("team_alias:x") is None
|
|
assert cache.alone == [("DEL", "team_alias:x")]
|
|
assert "team_alias:x" not in cache.store
|
|
assert client.pipelines == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_failed_mget_marks_nothing_as_missing() -> None:
|
|
cache, client = make(fail=ConnectionError("down"))
|
|
batch = RedisBatch(cache)
|
|
with pytest.raises(ConnectionError):
|
|
await batch.mget(["b-miss"])
|
|
assert batch.read_as_missing("b-miss") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_operation_retried_alone_keeps_the_target_it_was_declared_under() -> None:
|
|
"""The retry runs on the flush, outside the declaring caller's block, so the op carries
|
|
the target it was declared under and the retried call is still named by its purpose."""
|
|
seen: list[str | None] = []
|
|
|
|
async def record_target(keys: Sequence[str], args: Sequence[Any]) -> object:
|
|
seen.append(current_service_target())
|
|
return ["alone", *keys]
|
|
|
|
def reply_for(command: tuple[Any, ...]) -> Any:
|
|
if command[0] == "EVALSHA":
|
|
return NoScriptError("NOSCRIPT")
|
|
return replies(command)
|
|
|
|
cache = FakeRedisCache(FakeClient(reply_for))
|
|
batch = RedisBatch(cache)
|
|
with service_target("spend_counters"):
|
|
script = batch.script(SCRIPT, record_target, ["w"], [])
|
|
assert current_service_target() is None
|
|
assert await script == ["alone", "w"]
|
|
assert seen == ["spend_counters"]
|
|
assert current_service_target() is None
|
|
|
|
|
|
class CallerRecordingClusterCache(FakeClusterCache):
|
|
def __init__(self, client: FakeClient) -> None:
|
|
super().__init__(client)
|
|
self.callers: list[str] = []
|
|
|
|
async def async_batch_get_cache(self, key_list: Sequence[str], **kwargs: object) -> dict[str, Any]: # pyright: ignore[reportIncompatibleMethodOverride] # records what the service hook would report
|
|
self.callers.append(_get_call_stack_info())
|
|
return await super().async_batch_get_cache(key_list, **kwargs)
|
|
|
|
|
|
def _prefetch_auth_objects(batch: RedisBatch) -> Awaitable[Sequence[Any]]:
|
|
return batch.mget(["team", "user"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_cluster_op_names_the_code_that_declared_it_not_its_wrappers() -> None:
|
|
"""On a cluster client every op runs alone, in a task driven by the flush, so above its
|
|
wrappers there is only the event loop. Production reported ``_run_under_circuit_breaker <-
|
|
wrapper``; the op carries the chain captured where it was declared and reports that."""
|
|
cache = CallerRecordingClusterCache(FakeClient(replies))
|
|
batch = RedisBatch(cache)
|
|
with service_target("auth_objects"):
|
|
pending = _prefetch_auth_objects(batch)
|
|
assert await pending == {"team": None, "user": None}
|
|
assert cache.callers == [
|
|
"_prefetch_auth_objects <- test_a_cluster_op_names_the_code_that_declared_it_not_its_wrappers"
|
|
]
|
|
|
|
|
|
async def _flush_and_record_service_events(
|
|
cache: FakeRedisCache, *results: Awaitable[object]
|
|
) -> list[dict[str, object]]:
|
|
events: list[dict[str, object]] = [] # mutable-ok: filled by the recording hooks
|
|
|
|
async def record(**kwargs: object) -> None:
|
|
events.append({**kwargs, "target": current_service_target()})
|
|
|
|
cache.service_logger_obj.async_service_success_hook = record # pyright: ignore[reportAttributeAccessIssue] # fake, records the hook call
|
|
cache.service_logger_obj.async_service_failure_hook = record # pyright: ignore[reportAttributeAccessIssue] # fake, records the hook call
|
|
await asyncio.gather(*results, return_exceptions=True)
|
|
await asyncio.gather(*(t for t in asyncio.all_tasks() if t is not asyncio.current_task()))
|
|
return events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_of_one_key_family_is_targeted_by_that_family() -> None:
|
|
"""Every op in the flush was declared under ``auth_objects``, so the span is
|
|
``redis.pipeline auth_objects`` and carries only the op count."""
|
|
cache, _client = make()
|
|
batch = RedisBatch(cache, name="request_redis_batch")
|
|
with service_target("auth_objects"):
|
|
first = batch.mget(["a:hit"])
|
|
second = batch.mget(["b:hit"])
|
|
|
|
(event,) = await _flush_and_record_service_events(cache, first, second)
|
|
assert (event["target"], event["event_metadata"]) == ("auth_objects", {"op_count": 2})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_of_several_key_families_is_mixed_and_lists_the_families_sorted() -> None:
|
|
"""Owners of different families sharing one round trip render as ``redis.pipeline mixed``
|
|
with the sorted family list beside the op count, never as a bare ``redis.pipeline``."""
|
|
cache, _client = make()
|
|
batch = RedisBatch(cache, name="request_redis_batch")
|
|
with service_target("spend_counters"):
|
|
incr = batch.increment("cnt", 1)
|
|
with service_target("auth_objects"):
|
|
auth = batch.mget(["a:hit"])
|
|
with service_target("router_cooldowns"):
|
|
cooldown = batch.mget(["c:hit"])
|
|
|
|
(event,) = await _flush_and_record_service_events(cache, incr, auth, cooldown)
|
|
assert event["target"] == MIXED_PIPELINE_TARGET
|
|
assert event["event_metadata"] == {"op_count": 3, "families": "auth_objects,router_cooldowns,spend_counters"}
|
|
assert current_service_target() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_pipeline_reports_the_same_family_target_as_a_successful_one() -> None:
|
|
"""The failure event names the pipeline the same way, so the error span lines up with the
|
|
success spans of the same flush shape in a trace search."""
|
|
cache, _client = make(fail=ConnectionError("redis down"))
|
|
batch = RedisBatch(cache, name="post_call_redis_batch")
|
|
with service_target("spend_counters"):
|
|
incr = batch.increment("cnt", 1)
|
|
with service_target("auth_objects"):
|
|
auth = batch.mget(["a:hit"])
|
|
|
|
(event,) = await _flush_and_record_service_events(cache, incr, auth)
|
|
assert isinstance(event["error"], ConnectionError)
|
|
assert (event["call_type"], event["target"]) == ("post_call_redis_batch", MIXED_PIPELINE_TARGET)
|
|
assert event["event_metadata"] == {"op_count": 2, "families": "auth_objects,spend_counters"}
|