Skip to content

Commit 3bd311c

Browse files
authored
Streamline client side caching API typing (#3216)
* 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. * Fixes after running tests against RE * More test cases * Fix async tests * Tests for raw commands * Change terminology for allow/deny lists * Add test for single connection * Make sure flushing the cache works everywhere * Reenable some tests for cluster too * Align cache typings at abstract level * Use Sequence instead of Iterable for types * Remove some exceptions in favor of ifs --------- Co-authored-by: Gabriel Erzse <[email protected]>
1 parent 64f291f commit 3bd311c

13 files changed

+861
-186
lines changed

dev_requirements.txt

+1
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

+2-1
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

+58-38
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
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 List, Sequence, Union
88

99
from redis.typing import KeyT, ResponseT
1010

11-
DEFAULT_EVICTION_POLICY = "lru"
1211

12+
class EvictionPolicy(Enum):
13+
LRU = "lru"
14+
LFU = "lfu"
15+
RANDOM = "random"
1316

14-
DEFAULT_BLACKLIST = [
17+
18+
DEFAULT_EVICTION_POLICY = EvictionPolicy.LRU
19+
20+
DEFAULT_DENY_LIST = [
1521
"BF.CARD",
1622
"BF.DEBUG",
1723
"BF.EXISTS",
@@ -71,8 +77,7 @@
7177
"TTL",
7278
]
7379

74-
75-
DEFAULT_WHITELIST = [
80+
DEFAULT_ALLOW_LIST = [
7681
"BITCOUNT",
7782
"BITFIELD_RO",
7883
"BITPOS",
@@ -155,32 +160,31 @@
155160
_ACCESS_COUNT = "access_count"
156161

157162

158-
class EvictionPolicy(Enum):
159-
LRU = "lru"
160-
LFU = "lfu"
161-
RANDOM = "random"
162-
163-
164163
class AbstractCache(ABC):
165164
"""
166165
An abstract base class for client caching implementations.
167166
If you want to implement your own cache you must support these methods.
168167
"""
169168

170169
@abstractmethod
171-
def set(self, command: str, response: ResponseT, keys_in_command: List[KeyT]):
170+
def set(
171+
self,
172+
command: Union[str, Sequence[str]],
173+
response: ResponseT,
174+
keys_in_command: List[KeyT],
175+
):
172176
pass
173177

174178
@abstractmethod
175-
def get(self, command: str) -> ResponseT:
179+
def get(self, command: Union[str, Sequence[str]]) -> ResponseT:
176180
pass
177181

178182
@abstractmethod
179-
def delete_command(self, command: str):
183+
def delete_command(self, command: Union[str, Sequence[str]]):
180184
pass
181185

182186
@abstractmethod
183-
def delete_many(self, commands):
187+
def delete_commands(self, commands: List[Union[str, Sequence[str]]]):
184188
pass
185189

186190
@abstractmethod
@@ -215,7 +219,6 @@ def __init__(
215219
max_size: int = 10000,
216220
ttl: int = 0,
217221
eviction_policy: EvictionPolicy = DEFAULT_EVICTION_POLICY,
218-
**kwargs,
219222
):
220223
self.max_size = max_size
221224
self.ttl = ttl
@@ -224,12 +227,17 @@ def __init__(
224227
self.key_commands_map = defaultdict(set)
225228
self.commands_ttl_list = []
226229

227-
def set(self, command: str, response: ResponseT, keys_in_command: List[KeyT]):
230+
def set(
231+
self,
232+
command: Union[str, Sequence[str]],
233+
response: ResponseT,
234+
keys_in_command: List[KeyT],
235+
):
228236
"""
229237
Set a redis command and its response in the cache.
230238
231239
Args:
232-
command (str): The redis command.
240+
command (Union[str, Sequence[str]]): The redis command.
233241
response (ResponseT): The response associated with the command.
234242
keys_in_command (List[KeyT]): The list of keys used in the command.
235243
"""
@@ -244,12 +252,12 @@ def set(self, command: str, response: ResponseT, keys_in_command: List[KeyT]):
244252
self._update_key_commands_map(keys_in_command, command)
245253
self.commands_ttl_list.append(command)
246254

247-
def get(self, command: str) -> ResponseT:
255+
def get(self, command: Union[str, Sequence[str]]) -> ResponseT:
248256
"""
249257
Get the response for a redis command from the cache.
250258
251259
Args:
252-
command (str): The redis command.
260+
command (Union[str, Sequence[str]]): The redis command.
253261
254262
Returns:
255263
ResponseT: The response associated with the command, or None if the command is not in the cache. # noqa
@@ -261,34 +269,42 @@ def get(self, command: str) -> ResponseT:
261269
self._update_access(command)
262270
return copy.deepcopy(self.cache[command]["response"])
263271

264-
def delete_command(self, command: str):
272+
def delete_command(self, command: Union[str, Sequence[str]]):
265273
"""
266274
Delete a redis command and its metadata from the cache.
267275
268276
Args:
269-
command (str): The redis command to be deleted.
277+
command (Union[str, Sequence[str]]): The redis command to be deleted.
270278
"""
271279
if command in self.cache:
272280
keys_in_command = self.cache[command].get("keys")
273281
self._del_key_commands_map(keys_in_command, command)
274282
self.commands_ttl_list.remove(command)
275283
del self.cache[command]
276284

277-
def delete_many(self, commands):
278-
pass
285+
def delete_commands(self, commands: List[Union[str, Sequence[str]]]):
286+
"""
287+
Delete multiple commands and their metadata from the cache.
288+
289+
Args:
290+
commands (List[Union[str, Sequence[str]]]): The list of commands to be
291+
deleted.
292+
"""
293+
for command in commands:
294+
self.delete_command(command)
279295

280296
def flush(self):
281297
"""Clear the entire cache, removing all redis commands and metadata."""
282298
self.cache.clear()
283299
self.key_commands_map.clear()
284300
self.commands_ttl_list = []
285301

286-
def _is_expired(self, command: str) -> bool:
302+
def _is_expired(self, command: Union[str, Sequence[str]]) -> bool:
287303
"""
288304
Check if a redis command has expired based on its time-to-live.
289305
290306
Args:
291-
command (str): The redis command.
307+
command (Union[str, Sequence[str]]): The redis command.
292308
293309
Returns:
294310
bool: True if the command has expired, False otherwise.
@@ -297,56 +313,60 @@ def _is_expired(self, command: str) -> bool:
297313
return False
298314
return time.monotonic() - self.cache[command]["ctime"] > self.ttl
299315

300-
def _update_access(self, command: str):
316+
def _update_access(self, command: Union[str, Sequence[str]]):
301317
"""
302318
Update the access information for a redis command based on the eviction policy.
303319
304320
Args:
305-
command (str): The redis command.
321+
command (Union[str, Sequence[str]]): The redis command.
306322
"""
307-
if self.eviction_policy == EvictionPolicy.LRU.value:
323+
if self.eviction_policy == EvictionPolicy.LRU:
308324
self.cache.move_to_end(command)
309-
elif self.eviction_policy == EvictionPolicy.LFU.value:
325+
elif self.eviction_policy == EvictionPolicy.LFU:
310326
self.cache[command]["access_count"] = (
311327
self.cache.get(command, {}).get("access_count", 0) + 1
312328
)
313329
self.cache.move_to_end(command)
314-
elif self.eviction_policy == EvictionPolicy.RANDOM.value:
330+
elif self.eviction_policy == EvictionPolicy.RANDOM:
315331
pass # Random eviction doesn't require updates
316332

317333
def _evict(self):
318334
"""Evict a redis command from the cache based on the eviction policy."""
319335
if self._is_expired(self.commands_ttl_list[0]):
320336
self.delete_command(self.commands_ttl_list[0])
321-
elif self.eviction_policy == EvictionPolicy.LRU.value:
337+
elif self.eviction_policy == EvictionPolicy.LRU:
322338
self.cache.popitem(last=False)
323-
elif self.eviction_policy == EvictionPolicy.LFU.value:
339+
elif self.eviction_policy == EvictionPolicy.LFU:
324340
min_access_command = min(
325341
self.cache, key=lambda k: self.cache[k].get("access_count", 0)
326342
)
327343
self.cache.pop(min_access_command)
328-
elif self.eviction_policy == EvictionPolicy.RANDOM.value:
344+
elif self.eviction_policy == EvictionPolicy.RANDOM:
329345
random_command = random.choice(list(self.cache.keys()))
330346
self.cache.pop(random_command)
331347

332-
def _update_key_commands_map(self, keys: List[KeyT], command: str):
348+
def _update_key_commands_map(
349+
self, keys: List[KeyT], command: Union[str, Sequence[str]]
350+
):
333351
"""
334352
Update the key_commands_map with command that uses the keys.
335353
336354
Args:
337355
keys (List[KeyT]): The list of keys used in the command.
338-
command (str): The redis command.
356+
command (Union[str, Sequence[str]]): The redis command.
339357
"""
340358
for key in keys:
341359
self.key_commands_map[key].add(command)
342360

343-
def _del_key_commands_map(self, keys: List[KeyT], command: str):
361+
def _del_key_commands_map(
362+
self, keys: List[KeyT], command: Union[str, Sequence[str]]
363+
):
344364
"""
345365
Remove a redis command from the key_commands_map.
346366
347367
Args:
348368
keys (List[KeyT]): The list of keys used in the redis command.
349-
command (str): The redis command.
369+
command (Union[str, Sequence[str]]): The redis command.
350370
"""
351371
for key in keys:
352372
self.key_commands_map[key].remove(command)

redis/asyncio/client.py

+20-28
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
)
2828

2929
from redis._cache import (
30-
DEFAULT_BLACKLIST,
30+
DEFAULT_ALLOW_LIST,
31+
DEFAULT_DENY_LIST,
3132
DEFAULT_EVICTION_POLICY,
32-
DEFAULT_WHITELIST,
3333
AbstractCache,
3434
)
3535
from redis._parsers.helpers import (
@@ -243,8 +243,8 @@ def __init__(
243243
cache_max_size: int = 100,
244244
cache_ttl: int = 0,
245245
cache_policy: str = DEFAULT_EVICTION_POLICY,
246-
cache_blacklist: List[str] = DEFAULT_BLACKLIST,
247-
cache_whitelist: List[str] = DEFAULT_WHITELIST,
246+
cache_deny_list: List[str] = DEFAULT_DENY_LIST,
247+
cache_allow_list: List[str] = DEFAULT_ALLOW_LIST,
248248
):
249249
"""
250250
Initialize a new Redis client.
@@ -299,8 +299,8 @@ def __init__(
299299
"cache_max_size": cache_max_size,
300300
"cache_ttl": cache_ttl,
301301
"cache_policy": cache_policy,
302-
"cache_blacklist": cache_blacklist,
303-
"cache_whitelist": cache_whitelist,
302+
"cache_deny_list": cache_deny_list,
303+
"cache_allow_list": cache_allow_list,
304304
}
305305
# based on input, setup appropriate connection args
306306
if unix_socket_path is not None:
@@ -640,7 +640,8 @@ async def execute_command(self, *args, **options):
640640
),
641641
lambda error: self._disconnect_raise(conn, error),
642642
)
643-
conn._add_to_local_cache(args, response, keys)
643+
if keys:
644+
conn._add_to_local_cache(args, response, keys)
644645
return response
645646
finally:
646647
if self.single_connection_client:
@@ -675,31 +676,22 @@ async def parse_response(
675676
return response
676677

677678
def flush_cache(self):
678-
try:
679-
if self.connection:
680-
self.connection.client_cache.flush()
681-
else:
682-
self.connection_pool.flush_cache()
683-
except AttributeError:
684-
pass
679+
if self.connection:
680+
self.connection.flush_cache()
681+
else:
682+
self.connection_pool.flush_cache()
685683

686684
def delete_command_from_cache(self, command):
687-
try:
688-
if self.connection:
689-
self.connection.client_cache.delete_command(command)
690-
else:
691-
self.connection_pool.delete_command_from_cache(command)
692-
except AttributeError:
693-
pass
685+
if self.connection:
686+
self.connection.delete_command_from_cache(command)
687+
else:
688+
self.connection_pool.delete_command_from_cache(command)
694689

695690
def invalidate_key_from_cache(self, key):
696-
try:
697-
if self.connection:
698-
self.connection.client_cache.invalidate_key(key)
699-
else:
700-
self.connection_pool.invalidate_key_from_cache(key)
701-
except AttributeError:
702-
pass
691+
if self.connection:
692+
self.connection.invalidate_key_from_cache(key)
693+
else:
694+
self.connection_pool.invalidate_key_from_cache(key)
703695

704696

705697
StrictRedis = Redis

0 commit comments

Comments
 (0)