Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/redis/cache.py: 52%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from abc import ABC, abstractmethod
2from collections import OrderedDict
3from dataclasses import dataclass
4from enum import Enum
5from typing import Any, List, Optional, Union
8class CacheEntryStatus(Enum):
9 VALID = "VALID"
10 IN_PROGRESS = "IN_PROGRESS"
13class EvictionPolicyType(Enum):
14 time_based = "time_based"
15 frequency_based = "frequency_based"
18@dataclass(frozen=True)
19class CacheKey:
20 """
21 Represents a unique key for a cache entry.
23 Attributes:
24 command (str): The Redis command being cached.
25 redis_keys (tuple): The Redis keys involved in the command.
26 redis_args (tuple): Additional arguments for the Redis command.
27 This field is included in the cache key to ensure uniqueness
28 when commands have the same keys but different arguments.
29 Changing this field will affect cache key uniqueness.
30 """
32 command: str
33 redis_keys: tuple
34 redis_args: tuple = () # Additional arguments for the Redis command; affects cache key uniqueness.
37class CacheEntry:
38 def __init__(
39 self,
40 cache_key: CacheKey,
41 cache_value: bytes,
42 status: CacheEntryStatus,
43 connection_ref,
44 ):
45 self.cache_key = cache_key
46 self.cache_value = cache_value
47 self.status = status
48 self.connection_ref = connection_ref
50 def __hash__(self):
51 return hash(
52 (self.cache_key, self.cache_value, self.status, self.connection_ref)
53 )
55 def __eq__(self, other):
56 return hash(self) == hash(other)
59class EvictionPolicyInterface(ABC):
60 @property
61 @abstractmethod
62 def cache(self):
63 pass
65 @cache.setter
66 @abstractmethod
67 def cache(self, value):
68 pass
70 @property
71 @abstractmethod
72 def type(self) -> EvictionPolicyType:
73 pass
75 @abstractmethod
76 def evict_next(self) -> CacheKey:
77 pass
79 @abstractmethod
80 def evict_many(self, count: int) -> List[CacheKey]:
81 pass
83 @abstractmethod
84 def touch(self, cache_key: CacheKey) -> None:
85 pass
88class CacheConfigurationInterface(ABC):
89 @abstractmethod
90 def get_cache_class(self):
91 pass
93 @abstractmethod
94 def get_max_size(self) -> int:
95 pass
97 @abstractmethod
98 def get_eviction_policy(self):
99 pass
101 @abstractmethod
102 def is_exceeds_max_size(self, count: int) -> bool:
103 pass
105 @abstractmethod
106 def is_allowed_to_cache(self, command: str) -> bool:
107 pass
110class CacheInterface(ABC):
111 @property
112 @abstractmethod
113 def collection(self) -> OrderedDict:
114 pass
116 @property
117 @abstractmethod
118 def config(self) -> CacheConfigurationInterface:
119 pass
121 @property
122 @abstractmethod
123 def eviction_policy(self) -> EvictionPolicyInterface:
124 pass
126 @property
127 @abstractmethod
128 def size(self) -> int:
129 pass
131 @abstractmethod
132 def get(self, key: CacheKey) -> Union[CacheEntry, None]:
133 pass
135 @abstractmethod
136 def set(self, entry: CacheEntry) -> bool:
137 pass
139 @abstractmethod
140 def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]:
141 pass
143 @abstractmethod
144 def delete_by_redis_keys(self, redis_keys: List[bytes]) -> List[bool]:
145 pass
147 @abstractmethod
148 def flush(self) -> int:
149 pass
151 @abstractmethod
152 def is_cachable(self, key: CacheKey) -> bool:
153 pass
156class DefaultCache(CacheInterface):
157 def __init__(
158 self,
159 cache_config: CacheConfigurationInterface,
160 ) -> None:
161 self._cache = OrderedDict()
162 self._cache_config = cache_config
163 self._eviction_policy = self._cache_config.get_eviction_policy().value()
164 self._eviction_policy.cache = self
166 @property
167 def collection(self) -> OrderedDict:
168 return self._cache
170 @property
171 def config(self) -> CacheConfigurationInterface:
172 return self._cache_config
174 @property
175 def eviction_policy(self) -> EvictionPolicyInterface:
176 return self._eviction_policy
178 @property
179 def size(self) -> int:
180 return len(self._cache)
182 def set(self, entry: CacheEntry) -> bool:
183 if not self.is_cachable(entry.cache_key):
184 return False
186 self._cache[entry.cache_key] = entry
187 self._eviction_policy.touch(entry.cache_key)
189 if self._cache_config.is_exceeds_max_size(len(self._cache)):
190 self._eviction_policy.evict_next()
192 return True
194 def get(self, key: CacheKey) -> Union[CacheEntry, None]:
195 entry = self._cache.get(key, None)
197 if entry is None:
198 return None
200 self._eviction_policy.touch(key)
201 return entry
203 def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]:
204 response = []
206 for key in cache_keys:
207 if self.get(key) is not None:
208 self._cache.pop(key)
209 response.append(True)
210 else:
211 response.append(False)
213 return response
215 def delete_by_redis_keys(
216 self, redis_keys: Union[List[bytes], List[str]]
217 ) -> List[bool]:
218 response = []
219 keys_to_delete = []
221 for redis_key in redis_keys:
222 # Prepare both versions for lookup
223 candidates = [redis_key]
224 if isinstance(redis_key, str):
225 candidates.append(redis_key.encode("utf-8"))
226 elif isinstance(redis_key, bytes):
227 try:
228 candidates.append(redis_key.decode("utf-8"))
229 except UnicodeDecodeError:
230 pass # Non-UTF-8 bytes, skip str version
232 for cache_key in self._cache:
233 if any(candidate in cache_key.redis_keys for candidate in candidates):
234 keys_to_delete.append(cache_key)
235 response.append(True)
237 for key in keys_to_delete:
238 self._cache.pop(key)
240 return response
242 def flush(self) -> int:
243 elem_count = len(self._cache)
244 self._cache.clear()
245 return elem_count
247 def is_cachable(self, key: CacheKey) -> bool:
248 return self._cache_config.is_allowed_to_cache(key.command)
251class LRUPolicy(EvictionPolicyInterface):
252 def __init__(self):
253 self.cache = None
255 @property
256 def cache(self):
257 return self._cache
259 @cache.setter
260 def cache(self, cache: CacheInterface):
261 self._cache = cache
263 @property
264 def type(self) -> EvictionPolicyType:
265 return EvictionPolicyType.time_based
267 def evict_next(self) -> CacheKey:
268 self._assert_cache()
269 popped_entry = self._cache.collection.popitem(last=False)
270 return popped_entry[0]
272 def evict_many(self, count: int) -> List[CacheKey]:
273 self._assert_cache()
274 if count > len(self._cache.collection):
275 raise ValueError("Evictions count is above cache size")
277 popped_keys = []
279 for _ in range(count):
280 popped_entry = self._cache.collection.popitem(last=False)
281 popped_keys.append(popped_entry[0])
283 return popped_keys
285 def touch(self, cache_key: CacheKey) -> None:
286 self._assert_cache()
288 if self._cache.collection.get(cache_key) is None:
289 raise ValueError("Given entry does not belong to the cache")
291 self._cache.collection.move_to_end(cache_key)
293 def _assert_cache(self):
294 if self.cache is None or not isinstance(self.cache, CacheInterface):
295 raise ValueError("Eviction policy should be associated with valid cache.")
298class EvictionPolicy(Enum):
299 LRU = LRUPolicy
302class CacheConfig(CacheConfigurationInterface):
303 DEFAULT_CACHE_CLASS = DefaultCache
304 DEFAULT_EVICTION_POLICY = EvictionPolicy.LRU
305 DEFAULT_MAX_SIZE = 10000
307 DEFAULT_ALLOW_LIST = [
308 "BITCOUNT",
309 "BITFIELD_RO",
310 "BITPOS",
311 "EXISTS",
312 "GEODIST",
313 "GEOHASH",
314 "GEOPOS",
315 "GEORADIUSBYMEMBER_RO",
316 "GEORADIUS_RO",
317 "GEOSEARCH",
318 "GET",
319 "GETBIT",
320 "GETRANGE",
321 "HEXISTS",
322 "HGET",
323 "HGETALL",
324 "HKEYS",
325 "HLEN",
326 "HMGET",
327 "HSTRLEN",
328 "HVALS",
329 "JSON.ARRINDEX",
330 "JSON.ARRLEN",
331 "JSON.GET",
332 "JSON.MGET",
333 "JSON.OBJKEYS",
334 "JSON.OBJLEN",
335 "JSON.RESP",
336 "JSON.STRLEN",
337 "JSON.TYPE",
338 "LCS",
339 "LINDEX",
340 "LLEN",
341 "LPOS",
342 "LRANGE",
343 "MGET",
344 "SCARD",
345 "SDIFF",
346 "SINTER",
347 "SINTERCARD",
348 "SISMEMBER",
349 "SMEMBERS",
350 "SMISMEMBER",
351 "SORT_RO",
352 "STRLEN",
353 "SUBSTR",
354 "SUNION",
355 "TS.GET",
356 "TS.INFO",
357 "TS.RANGE",
358 "TS.REVRANGE",
359 "TYPE",
360 "XLEN",
361 "XPENDING",
362 "XRANGE",
363 "XREAD",
364 "XREVRANGE",
365 "ZCARD",
366 "ZCOUNT",
367 "ZDIFF",
368 "ZINTER",
369 "ZINTERCARD",
370 "ZLEXCOUNT",
371 "ZMSCORE",
372 "ZRANGE",
373 "ZRANGEBYLEX",
374 "ZRANGEBYSCORE",
375 "ZRANK",
376 "ZREVRANGE",
377 "ZREVRANGEBYLEX",
378 "ZREVRANGEBYSCORE",
379 "ZREVRANK",
380 "ZSCORE",
381 "ZUNION",
382 ]
384 def __init__(
385 self,
386 max_size: int = DEFAULT_MAX_SIZE,
387 cache_class: Any = DEFAULT_CACHE_CLASS,
388 eviction_policy: EvictionPolicy = DEFAULT_EVICTION_POLICY,
389 ):
390 self._cache_class = cache_class
391 self._max_size = max_size
392 self._eviction_policy = eviction_policy
394 def get_cache_class(self):
395 return self._cache_class
397 def get_max_size(self) -> int:
398 return self._max_size
400 def get_eviction_policy(self) -> EvictionPolicy:
401 return self._eviction_policy
403 def is_exceeds_max_size(self, count: int) -> bool:
404 return count > self._max_size
406 def is_allowed_to_cache(self, command: str) -> bool:
407 return command in self.DEFAULT_ALLOW_LIST
410class CacheFactoryInterface(ABC):
411 @abstractmethod
412 def get_cache(self) -> CacheInterface:
413 pass
416class CacheFactory(CacheFactoryInterface):
417 def __init__(self, cache_config: Optional[CacheConfig] = None):
418 self._config = cache_config
420 if self._config is None:
421 self._config = CacheConfig()
423 def get_cache(self) -> CacheInterface:
424 cache_class = self._config.get_cache_class()
425 return cache_class(cache_config=self._config)