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

230 statements  

1from abc import ABC, abstractmethod 

2from collections import OrderedDict 

3from dataclasses import dataclass 

4from enum import Enum 

5from typing import Any, List, Optional, Union 

6 

7 

8class CacheEntryStatus(Enum): 

9 VALID = "VALID" 

10 IN_PROGRESS = "IN_PROGRESS" 

11 

12 

13class EvictionPolicyType(Enum): 

14 time_based = "time_based" 

15 frequency_based = "frequency_based" 

16 

17 

18@dataclass(frozen=True) 

19class CacheKey: 

20 """ 

21 Represents a unique key for a cache entry. 

22 

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 """ 

31 

32 command: str 

33 redis_keys: tuple 

34 redis_args: tuple = () # Additional arguments for the Redis command; affects cache key uniqueness. 

35 

36 

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 

49 

50 def __hash__(self): 

51 return hash( 

52 (self.cache_key, self.cache_value, self.status, self.connection_ref) 

53 ) 

54 

55 def __eq__(self, other): 

56 return hash(self) == hash(other) 

57 

58 

59class EvictionPolicyInterface(ABC): 

60 @property 

61 @abstractmethod 

62 def cache(self): 

63 pass 

64 

65 @cache.setter 

66 @abstractmethod 

67 def cache(self, value): 

68 pass 

69 

70 @property 

71 @abstractmethod 

72 def type(self) -> EvictionPolicyType: 

73 pass 

74 

75 @abstractmethod 

76 def evict_next(self) -> CacheKey: 

77 pass 

78 

79 @abstractmethod 

80 def evict_many(self, count: int) -> List[CacheKey]: 

81 pass 

82 

83 @abstractmethod 

84 def touch(self, cache_key: CacheKey) -> None: 

85 pass 

86 

87 

88class CacheConfigurationInterface(ABC): 

89 @abstractmethod 

90 def get_cache_class(self): 

91 pass 

92 

93 @abstractmethod 

94 def get_max_size(self) -> int: 

95 pass 

96 

97 @abstractmethod 

98 def get_eviction_policy(self): 

99 pass 

100 

101 @abstractmethod 

102 def is_exceeds_max_size(self, count: int) -> bool: 

103 pass 

104 

105 @abstractmethod 

106 def is_allowed_to_cache(self, command: str) -> bool: 

107 pass 

108 

109 

110class CacheInterface(ABC): 

111 @property 

112 @abstractmethod 

113 def collection(self) -> OrderedDict: 

114 pass 

115 

116 @property 

117 @abstractmethod 

118 def config(self) -> CacheConfigurationInterface: 

119 pass 

120 

121 @property 

122 @abstractmethod 

123 def eviction_policy(self) -> EvictionPolicyInterface: 

124 pass 

125 

126 @property 

127 @abstractmethod 

128 def size(self) -> int: 

129 pass 

130 

131 @abstractmethod 

132 def get(self, key: CacheKey) -> Union[CacheEntry, None]: 

133 pass 

134 

135 @abstractmethod 

136 def set(self, entry: CacheEntry) -> bool: 

137 pass 

138 

139 @abstractmethod 

140 def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]: 

141 pass 

142 

143 @abstractmethod 

144 def delete_by_redis_keys(self, redis_keys: List[bytes]) -> List[bool]: 

145 pass 

146 

147 @abstractmethod 

148 def flush(self) -> int: 

149 pass 

150 

151 @abstractmethod 

152 def is_cachable(self, key: CacheKey) -> bool: 

153 pass 

154 

155 

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 

165 

166 @property 

167 def collection(self) -> OrderedDict: 

168 return self._cache 

169 

170 @property 

171 def config(self) -> CacheConfigurationInterface: 

172 return self._cache_config 

173 

174 @property 

175 def eviction_policy(self) -> EvictionPolicyInterface: 

176 return self._eviction_policy 

177 

178 @property 

179 def size(self) -> int: 

180 return len(self._cache) 

181 

182 def set(self, entry: CacheEntry) -> bool: 

183 if not self.is_cachable(entry.cache_key): 

184 return False 

185 

186 self._cache[entry.cache_key] = entry 

187 self._eviction_policy.touch(entry.cache_key) 

188 

189 if self._cache_config.is_exceeds_max_size(len(self._cache)): 

190 self._eviction_policy.evict_next() 

191 

192 return True 

193 

194 def get(self, key: CacheKey) -> Union[CacheEntry, None]: 

195 entry = self._cache.get(key, None) 

196 

197 if entry is None: 

198 return None 

199 

200 self._eviction_policy.touch(key) 

201 return entry 

202 

203 def delete_by_cache_keys(self, cache_keys: List[CacheKey]) -> List[bool]: 

204 response = [] 

205 

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) 

212 

213 return response 

214 

215 def delete_by_redis_keys( 

216 self, redis_keys: Union[List[bytes], List[str]] 

217 ) -> List[bool]: 

218 response = [] 

219 keys_to_delete = [] 

220 

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 

231 

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) 

236 

237 for key in keys_to_delete: 

238 self._cache.pop(key) 

239 

240 return response 

241 

242 def flush(self) -> int: 

243 elem_count = len(self._cache) 

244 self._cache.clear() 

245 return elem_count 

246 

247 def is_cachable(self, key: CacheKey) -> bool: 

248 return self._cache_config.is_allowed_to_cache(key.command) 

249 

250 

251class LRUPolicy(EvictionPolicyInterface): 

252 def __init__(self): 

253 self.cache = None 

254 

255 @property 

256 def cache(self): 

257 return self._cache 

258 

259 @cache.setter 

260 def cache(self, cache: CacheInterface): 

261 self._cache = cache 

262 

263 @property 

264 def type(self) -> EvictionPolicyType: 

265 return EvictionPolicyType.time_based 

266 

267 def evict_next(self) -> CacheKey: 

268 self._assert_cache() 

269 popped_entry = self._cache.collection.popitem(last=False) 

270 return popped_entry[0] 

271 

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") 

276 

277 popped_keys = [] 

278 

279 for _ in range(count): 

280 popped_entry = self._cache.collection.popitem(last=False) 

281 popped_keys.append(popped_entry[0]) 

282 

283 return popped_keys 

284 

285 def touch(self, cache_key: CacheKey) -> None: 

286 self._assert_cache() 

287 

288 if self._cache.collection.get(cache_key) is None: 

289 raise ValueError("Given entry does not belong to the cache") 

290 

291 self._cache.collection.move_to_end(cache_key) 

292 

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.") 

296 

297 

298class EvictionPolicy(Enum): 

299 LRU = LRUPolicy 

300 

301 

302class CacheConfig(CacheConfigurationInterface): 

303 DEFAULT_CACHE_CLASS = DefaultCache 

304 DEFAULT_EVICTION_POLICY = EvictionPolicy.LRU 

305 DEFAULT_MAX_SIZE = 10000 

306 

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 ] 

383 

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 

393 

394 def get_cache_class(self): 

395 return self._cache_class 

396 

397 def get_max_size(self) -> int: 

398 return self._max_size 

399 

400 def get_eviction_policy(self) -> EvictionPolicy: 

401 return self._eviction_policy 

402 

403 def is_exceeds_max_size(self, count: int) -> bool: 

404 return count > self._max_size 

405 

406 def is_allowed_to_cache(self, command: str) -> bool: 

407 return command in self.DEFAULT_ALLOW_LIST 

408 

409 

410class CacheFactoryInterface(ABC): 

411 @abstractmethod 

412 def get_cache(self) -> CacheInterface: 

413 pass 

414 

415 

416class CacheFactory(CacheFactoryInterface): 

417 def __init__(self, cache_config: Optional[CacheConfig] = None): 

418 self._config = cache_config 

419 

420 if self._config is None: 

421 self._config = CacheConfig() 

422 

423 def get_cache(self) -> CacheInterface: 

424 cache_class = self._config.get_cache_class() 

425 return cache_class(cache_config=self._config)