Skip to content

Commit a55cdb1

Browse files
committed
Streamline client side caching API typing
Streamline the typing of the client side caching API. Some of the methods are defining commands of type `str`, while in reality tuples are being sent for those parameters. Add client side cache tests for Sentinels. In order to make this work, fix the sentinel configuration in the docker-compose stack. Add a test for client side caching with a truly custom cache, not just injecting our internal cache structure as custom. Add a test for client side caching where two different types of commands use the same key, to make sure they invalidate each others cached data.
1 parent 07fc339 commit a55cdb1

File tree

7 files changed

+282
-22
lines changed

7 files changed

+282
-22
lines changed

dev_requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
click==8.0.4
22
black==24.3.0
3+
cachetools
34
flake8==5.0.4
45
flake8-isort==6.0.0
56
flynt~=0.69.0

dockers/sentinel.conf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
sentinel monitor redis-py-test 127.0.0.1 6379 2
1+
sentinel resolve-hostnames yes
2+
sentinel monitor redis-py-test redis 6379 2
23
sentinel down-after-milliseconds redis-py-test 5000
34
sentinel failover-timeout redis-py-test 60000
45
sentinel parallel-syncs redis-py-test 1

redis/_cache.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@
44
from abc import ABC, abstractmethod
55
from collections import OrderedDict, defaultdict
66
from enum import Enum
7-
from typing import List
7+
from typing import Iterable, List, Union
88

99
from redis.typing import KeyT, ResponseT
1010

1111
DEFAULT_EVICTION_POLICY = "lru"
1212

13-
1413
DEFAULT_BLACKLIST = [
1514
"BF.CARD",
1615
"BF.DEBUG",
@@ -71,7 +70,6 @@
7170
"TTL",
7271
]
7372

74-
7573
DEFAULT_WHITELIST = [
7674
"BITCOUNT",
7775
"BITFIELD_RO",
@@ -215,7 +213,6 @@ def __init__(
215213
max_size: int = 10000,
216214
ttl: int = 0,
217215
eviction_policy: EvictionPolicy = DEFAULT_EVICTION_POLICY,
218-
**kwargs,
219216
):
220217
self.max_size = max_size
221218
self.ttl = ttl
@@ -224,12 +221,17 @@ def __init__(
224221
self.key_commands_map = defaultdict(set)
225222
self.commands_ttl_list = []
226223

227-
def set(self, command: str, response: ResponseT, keys_in_command: List[KeyT]):
224+
def set(
225+
self,
226+
command: Union[str, Iterable[str]],
227+
response: ResponseT,
228+
keys_in_command: List[KeyT],
229+
):
228230
"""
229231
Set a redis command and its response in the cache.
230232
231233
Args:
232-
command (str): The redis command.
234+
command (Union[str, Iterable[str]]): The redis command.
233235
response (ResponseT): The response associated with the command.
234236
keys_in_command (List[KeyT]): The list of keys used in the command.
235237
"""
@@ -244,12 +246,12 @@ def set(self, command: str, response: ResponseT, keys_in_command: List[KeyT]):
244246
self._update_key_commands_map(keys_in_command, command)
245247
self.commands_ttl_list.append(command)
246248

247-
def get(self, command: str) -> ResponseT:
249+
def get(self, command: Union[str, Iterable[str]]) -> ResponseT:
248250
"""
249251
Get the response for a redis command from the cache.
250252
251253
Args:
252-
command (str): The redis command.
254+
command (Union[str, Iterable[str]]): The redis command.
253255
254256
Returns:
255257
ResponseT: The response associated with the command, or None if the command is not in the cache. # noqa
@@ -261,34 +263,42 @@ def get(self, command: str) -> ResponseT:
261263
self._update_access(command)
262264
return copy.deepcopy(self.cache[command]["response"])
263265

264-
def delete_command(self, command: str):
266+
def delete_command(self, command: Union[str, Iterable[str]]):
265267
"""
266268
Delete a redis command and its metadata from the cache.
267269
268270
Args:
269-
command (str): The redis command to be deleted.
271+
command (Union[str, Iterable[str]]): The redis command to be deleted.
270272
"""
271273
if command in self.cache:
272274
keys_in_command = self.cache[command].get("keys")
273275
self._del_key_commands_map(keys_in_command, command)
274276
self.commands_ttl_list.remove(command)
275277
del self.cache[command]
276278

277-
def delete_many(self, commands):
278-
pass
279+
def delete_many(self, commands: List[Union[str, Iterable[str]]]):
280+
"""
281+
Delete multiple commands and their metadata from the cache.
282+
283+
Args:
284+
commands (List[Union[str, Iterable[str]]]): The list of commands to be
285+
deleted.
286+
"""
287+
for command in commands:
288+
self.delete_command(command)
279289

280290
def flush(self):
281291
"""Clear the entire cache, removing all redis commands and metadata."""
282292
self.cache.clear()
283293
self.key_commands_map.clear()
284294
self.commands_ttl_list = []
285295

286-
def _is_expired(self, command: str) -> bool:
296+
def _is_expired(self, command: Union[str, Iterable[str]]) -> bool:
287297
"""
288298
Check if a redis command has expired based on its time-to-live.
289299
290300
Args:
291-
command (str): The redis command.
301+
command (Union[str, Iterable[str]]): The redis command.
292302
293303
Returns:
294304
bool: True if the command has expired, False otherwise.
@@ -297,12 +307,12 @@ def _is_expired(self, command: str) -> bool:
297307
return False
298308
return time.monotonic() - self.cache[command]["ctime"] > self.ttl
299309

300-
def _update_access(self, command: str):
310+
def _update_access(self, command: Union[str, Iterable[str]]):
301311
"""
302312
Update the access information for a redis command based on the eviction policy.
303313
304314
Args:
305-
command (str): The redis command.
315+
command (Union[str, Iterable[str]]): The redis command.
306316
"""
307317
if self.eviction_policy == EvictionPolicy.LRU.value:
308318
self.cache.move_to_end(command)
@@ -329,24 +339,28 @@ def _evict(self):
329339
random_command = random.choice(list(self.cache.keys()))
330340
self.cache.pop(random_command)
331341

332-
def _update_key_commands_map(self, keys: List[KeyT], command: str):
342+
def _update_key_commands_map(
343+
self, keys: List[KeyT], command: Union[str, Iterable[str]]
344+
):
333345
"""
334346
Update the key_commands_map with command that uses the keys.
335347
336348
Args:
337349
keys (List[KeyT]): The list of keys used in the command.
338-
command (str): The redis command.
350+
command (Union[str, Iterable[str]]): The redis command.
339351
"""
340352
for key in keys:
341353
self.key_commands_map[key].add(command)
342354

343-
def _del_key_commands_map(self, keys: List[KeyT], command: str):
355+
def _del_key_commands_map(
356+
self, keys: List[KeyT], command: Union[str, Iterable[str]]
357+
):
344358
"""
345359
Remove a redis command from the key_commands_map.
346360
347361
Args:
348362
keys (List[KeyT]): The list of keys used in the redis command.
349-
command (str): The redis command.
363+
command (Union[str, Iterable[str]]): The redis command.
350364
"""
351365
for key in keys:
352366
self.key_commands_map[key].remove(command)

tests/conftest.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010
import redis
1111
from packaging.version import Version
12+
from redis import Sentinel
1213
from redis.backoff import NoBackoff
1314
from redis.connection import Connection, parse_url
1415
from redis.exceptions import RedisClusterException
@@ -105,6 +106,19 @@ def pytest_addoption(parser):
105106
"--uvloop", action=BooleanOptionalAction, help="Run tests with uvloop"
106107
)
107108

109+
parser.addoption(
110+
"--sentinels",
111+
action="store",
112+
default="localhost:26379,localhost:26380,localhost:26381",
113+
help="Comma-separated list of sentinel IPs and ports",
114+
)
115+
parser.addoption(
116+
"--master-service",
117+
action="store",
118+
default="redis-py-test",
119+
help="Name of the Redis master service that the sentinels are monitoring",
120+
)
121+
108122

109123
def _get_info(redis_url):
110124
client = redis.Redis.from_url(redis_url)
@@ -352,6 +366,34 @@ def sslclient(request):
352366
yield client
353367

354368

369+
@pytest.fixture()
370+
def sentinel_setup(local_cache, request):
371+
sentinel_ips = request.config.getoption("--sentinels")
372+
sentinel_endpoints = [
373+
(ip.strip(), int(port.strip()))
374+
for ip, port in (endpoint.split(":") for endpoint in sentinel_ips.split(","))
375+
]
376+
kwargs = request.param.get("kwargs", {}) if hasattr(request, "param") else {}
377+
sentinel = Sentinel(
378+
sentinel_endpoints,
379+
socket_timeout=0.1,
380+
client_cache=local_cache,
381+
protocol=3,
382+
**kwargs,
383+
)
384+
yield sentinel
385+
for s in sentinel.sentinels:
386+
s.close()
387+
388+
389+
@pytest.fixture()
390+
def master(request, sentinel_setup):
391+
master_service = request.config.getoption("--master-service")
392+
master = sentinel_setup.master_for(master_service)
393+
yield master
394+
master.close()
395+
396+
355397
def _gen_cluster_mock_resp(r, response):
356398
connection = Mock(spec=Connection)
357399
connection.retry = Retry(NoBackoff(), 0)

tests/test_asyncio/conftest.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import redis.asyncio as redis
88
from packaging.version import Version
99
from redis._parsers import _AsyncHiredisParser, _AsyncRESP2Parser
10+
from redis.asyncio import Sentinel
1011
from redis.asyncio.client import Monitor
1112
from redis.asyncio.connection import Connection, parse_url
1213
from redis.asyncio.retry import Retry
@@ -136,6 +137,34 @@ async def decoded_r(create_redis):
136137
return await create_redis(decode_responses=True)
137138

138139

140+
@pytest_asyncio.fixture()
141+
async def sentinel_setup(local_cache, request):
142+
sentinel_ips = request.config.getoption("--sentinels")
143+
sentinel_endpoints = [
144+
(ip.strip(), int(port.strip()))
145+
for ip, port in (endpoint.split(":") for endpoint in sentinel_ips.split(","))
146+
]
147+
kwargs = request.param.get("kwargs", {}) if hasattr(request, "param") else {}
148+
sentinel = Sentinel(
149+
sentinel_endpoints,
150+
socket_timeout=0.1,
151+
client_cache=local_cache,
152+
protocol=3,
153+
**kwargs,
154+
)
155+
yield sentinel
156+
for s in sentinel.sentinels:
157+
await s.close()
158+
159+
160+
@pytest_asyncio.fixture()
161+
async def master(request, sentinel_setup):
162+
master_service = request.config.getoption("--master-service")
163+
master = sentinel_setup.master_for(master_service)
164+
yield master
165+
await master.close()
166+
167+
139168
def _gen_cluster_mock_resp(r, response):
140169
connection = mock.AsyncMock(spec=Connection)
141170
connection.retry = Retry(NoBackoff(), 0)

tests/test_asyncio/test_cache.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ async def r(request, create_redis):
1414
yield r, cache
1515

1616

17+
@pytest_asyncio.fixture()
18+
async def local_cache():
19+
yield _LocalCache()
20+
21+
1722
@pytest.mark.skipif(HIREDIS_AVAILABLE, reason="PythonParser only")
1823
class TestLocalCache:
1924
@pytest.mark.onlynoncluster
@@ -228,3 +233,43 @@ async def test_cache_decode_response(self, r):
228233
assert cache.get(("GET", "foo")) is None
229234
# get key from redis
230235
assert await r.get("foo") == "barbar"
236+
237+
238+
@pytest.mark.skipif(HIREDIS_AVAILABLE, reason="PythonParser only")
239+
@pytest.mark.onlynoncluster
240+
class TestSentinelLocalCache:
241+
242+
async def test_get_from_cache(self, local_cache, master):
243+
await master.set("foo", "bar")
244+
# get key from redis and save in local cache
245+
assert await master.get("foo") == b"bar"
246+
# get key from local cache
247+
assert local_cache.get(("GET", "foo")) == b"bar"
248+
# change key in redis (cause invalidation)
249+
await master.set("foo", "barbar")
250+
# send any command to redis (process invalidation in background)
251+
await master.ping()
252+
# the command is not in the local cache anymore
253+
assert local_cache.get(("GET", "foo")) is None
254+
# get key from redis
255+
assert await master.get("foo") == b"barbar"
256+
257+
@pytest.mark.parametrize(
258+
"sentinel_setup",
259+
[{"kwargs": {"decode_responses": True}}],
260+
indirect=True,
261+
)
262+
async def test_cache_decode_response(self, local_cache, sentinel_setup, master):
263+
await master.set("foo", "bar")
264+
# get key from redis and save in local cache
265+
assert await master.get("foo") == "bar"
266+
# get key from local cache
267+
assert local_cache.get(("GET", "foo")) == "bar"
268+
# change key in redis (cause invalidation)
269+
await master.set("foo", "barbar")
270+
# send any command to redis (process invalidation in background)
271+
await master.ping()
272+
# the command is not in the local cache anymore
273+
assert local_cache.get(("GET", "foo")) is None
274+
# get key from redis
275+
assert await master.get("foo") == "barbar"

0 commit comments

Comments
 (0)