Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/redis/cache.py: 46%

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

362 statements  

1import threading 

2import warnings 

3from abc import ABC, abstractmethod 

4from collections import OrderedDict 

5from collections.abc import Callable 

6from dataclasses import dataclass 

7from enum import Enum 

8from typing import Any 

9 

10from redis.commands.metadata import MetadataResolver, StaticMetadataResolver 

11from redis.observability.attributes import CSCReason 

12 

13 

14class CacheEntryStatus(Enum): 

15 VALID = "VALID" 

16 IN_PROGRESS = "IN_PROGRESS" 

17 

18 

19class EvictionPolicyType(Enum): 

20 time_based = "time_based" 

21 frequency_based = "frequency_based" 

22 

23 

24class TrackingMode(Enum): 

25 """ 

26 How a cache-managed connection enables server-side tracking. 

27 

28 The mode is a connection-setup property: the server refuses to switch a live connection 

29 between ``OPTIN`` and ``OPTOUT``, so a configuration change applies to new connections 

30 only. 

31 

32 ``PLAIN`` is the default, and was the only mode before 8.2, which added ``OPTIN`` and 

33 ``OPTOUT``. 

34 

35 The two other modes reduce what the server has to remember, from the two ends, 

36 by pairing one ``CLIENT CACHING YES|NO`` immediately in front of a single read. 

37 """ 

38 

39 PLAIN = "plain" 

40 """``CLIENT TRACKING ON`` - every trackable read is tracked.""" 

41 

42 OPTIN = "optin" 

43 """``CLIENT TRACKING ON OPTIN`` - tracked only after ``CLIENT CACHING YES``.""" 

44 

45 OPTOUT = "optout" 

46 """``CLIENT TRACKING ON OPTOUT`` - tracked unless ``CLIENT CACHING NO``.""" 

47 

48 

49CachePredicate = Callable[[str, tuple], bool] 

50"""Decides whether the application wants an eligible reply cached. 

51 

52Receives the command name as the command method spells it (``"GET"``, ``"FT.SEARCH"``) and the 

53key tuple exactly as the invocation supplied it in ``keys=``, so its elements are ``str`` or 

54``bytes`` depending on what the caller passed. Consulted under ``optin`` and ``optout`` only, 

55and only for a command that already passed eligibility and carried keys. 

56""" 

57 

58 

59@dataclass(frozen=True) 

60class CacheKey: 

61 """ 

62 Represents a unique key for a cache entry. 

63 

64 Attributes: 

65 command (str): The Redis command being cached. 

66 redis_keys (tuple): The Redis keys involved in the command. 

67 redis_args (tuple): Additional arguments for the Redis command. 

68 This field is included in the cache key to ensure uniqueness 

69 when commands have the same keys but different arguments. 

70 Changing this field will affect cache key uniqueness. 

71 """ 

72 

73 command: str 

74 redis_keys: tuple 

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

76 

77 

78class CacheEntry: 

79 def __init__( 

80 self, 

81 cache_key: CacheKey, 

82 cache_value: bytes, 

83 status: CacheEntryStatus, 

84 connection_ref, 

85 ): 

86 self.cache_key = cache_key 

87 self.cache_value = cache_value 

88 self.status = status 

89 self.connection_ref = connection_ref 

90 

91 def __hash__(self): 

92 return hash( 

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

94 ) 

95 

96 def __eq__(self, other): 

97 return hash(self) == hash(other) 

98 

99 

100class EvictionPolicyInterface(ABC): 

101 @property 

102 @abstractmethod 

103 def cache(self): 

104 pass 

105 

106 @cache.setter 

107 @abstractmethod 

108 def cache(self, value): 

109 pass 

110 

111 @property 

112 @abstractmethod 

113 def type(self) -> EvictionPolicyType: 

114 pass 

115 

116 @abstractmethod 

117 def evict_next(self) -> CacheKey: 

118 pass 

119 

120 @abstractmethod 

121 def evict_many(self, count: int) -> list[CacheKey]: 

122 pass 

123 

124 @abstractmethod 

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

126 pass 

127 

128 

129class CacheConfigurationInterface(ABC): 

130 @abstractmethod 

131 def get_cache_class(self): 

132 pass 

133 

134 @abstractmethod 

135 def get_max_size(self) -> int: 

136 pass 

137 

138 @abstractmethod 

139 def get_eviction_policy(self): 

140 pass 

141 

142 @abstractmethod 

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

144 pass 

145 

146 @abstractmethod 

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

148 pass 

149 

150 # The three tracking-mode decisions are concrete, not abstract: this ABC is public and 

151 # implemented by third parties, so a configuration written against the previous version of 

152 # it must keep working. The defaults reproduce the existing behaviour exactly - plain tracking, 

153 # every eligible reply stored, no ``CLIENT CACHING NO`` ever paired. Same reasoning as 

154 # ``CacheConfig.set_metadata_resolver``, which was deliberately kept off this ABC. 

155 

156 def get_tracking_mode(self) -> TrackingMode: 

157 return TrackingMode.PLAIN 

158 

159 def should_cache(self, command: str, keys: tuple) -> bool: 

160 return True 

161 

162 def is_trackable_read(self, command: str) -> bool: 

163 return False 

164 

165 

166class CacheInterface(ABC): 

167 @property 

168 @abstractmethod 

169 def collection(self) -> OrderedDict: 

170 pass 

171 

172 @property 

173 @abstractmethod 

174 def config(self) -> CacheConfigurationInterface: 

175 pass 

176 

177 @property 

178 @abstractmethod 

179 def eviction_policy(self) -> EvictionPolicyInterface: 

180 pass 

181 

182 @property 

183 @abstractmethod 

184 def size(self) -> int: 

185 pass 

186 

187 @abstractmethod 

188 def get(self, key: CacheKey) -> CacheEntry | None: 

189 pass 

190 

191 @abstractmethod 

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

193 pass 

194 

195 @abstractmethod 

196 def delete_by_cache_keys(self, cache_keys: list[CacheKey]) -> list[bool]: 

197 pass 

198 

199 @abstractmethod 

200 def delete_by_redis_keys(self, redis_keys: list[bytes]) -> list[bool]: 

201 pass 

202 

203 @abstractmethod 

204 def flush(self) -> int: 

205 pass 

206 

207 @abstractmethod 

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

209 pass 

210 

211 

212class _IndexedCacheEntries(OrderedDict): 

213 """ 

214 The cache's entry map, carrying a reverse index from Redis key to the entries holding it. 

215 

216 Invalidation is the hot path: the server names a key and the cache has to find every entry 

217 whose invocation touched it. Doing that by scanning the whole map costs O(entries) per 

218 message, under the lock, for every message - and opt-in only reduces how many messages 

219 arrive, not what each one costs. 

220 

221 The index lives on the mapping rather than in :class:`DefaultCache` because entries do not 

222 only leave through that class's methods: an eviction policy pops straight off 

223 ``cache.collection`` (see :meth:`LRUPolicy.evict_next`), so an index maintained one level 

224 up would go stale on every eviction, and a stale index is the one thing it must never be - 

225 a missed entry is a reply that is never invalidated. Overriding the mutating methods here 

226 means every insertion and removal path keeps it exact, whoever calls it. 

227 

228 Most Redis keys have exactly one holder, and a ``set`` costs over 200 bytes, so a lone 

229 holder is stored bare and promoted to a ``set`` only when a second one arrives - and 

230 demoted back when it drops to one again. That trades an ``isinstance`` check on each 

231 index update for most of the index's memory. 

232 

233 Every mutation holds ``_lock`` across both the map update and its index update. The map is 

234 shared by a whole pool, while each :class:`~redis.connection.CacheProxyConnection` guards it 

235 with a lock of its own, so two connections can mutate it at once - and an index update is a 

236 read-modify-write that would otherwise lose a holder, leaving an entry no invalidation can 

237 find. The lock is a leaf: nothing is acquired under it, so it cannot join a lock cycle with 

238 the connection and pool locks. Plain reads of the map - the cache-hit path - do not take it. 

239 """ 

240 

241 def __init__(self, *args, **kwargs) -> None: 

242 # Assigned before delegating: ``OrderedDict.__init__`` may populate, which routes 

243 # through the ``__setitem__`` below. The lock is re-entrant in case an ``OrderedDict`` 

244 # implementation (PyPy's, say) routes one overridden method through another. 

245 self._lock = threading.RLock() 

246 self._by_redis_key: dict[Any, CacheKey | set[CacheKey]] = {} 

247 super().__init__(*args, **kwargs) 

248 

249 def __setitem__(self, key: CacheKey, value: "CacheEntry") -> None: 

250 with self._lock: 

251 index = self._by_redis_key 

252 for redis_key in key.redis_keys: 

253 holders = index.get(redis_key) 

254 if holders is None: 

255 index[redis_key] = key 

256 elif isinstance(holders, set): 

257 holders.add(key) 

258 elif holders != key: 

259 index[redis_key] = {holders, key} 

260 super().__setitem__(key, value) 

261 

262 def __delitem__(self, key: CacheKey) -> None: 

263 with self._lock: 

264 super().__delitem__(key) 

265 self._unindex(key) 

266 

267 def pop(self, key: CacheKey, *args): 

268 with self._lock: 

269 value = super().pop(key, *args) 

270 self._unindex(key) 

271 return value 

272 

273 def popitem(self, last: bool = True): 

274 with self._lock: 

275 key, value = super().popitem(last=last) 

276 self._unindex(key) 

277 return key, value 

278 

279 def clear(self) -> None: 

280 with self._lock: 

281 super().clear() 

282 self._by_redis_key.clear() 

283 

284 def holders_of(self, redis_key) -> frozenset: 

285 """ 

286 The cache keys of every entry whose invocation named ``redis_key``. 

287 

288 Returned as a snapshot, because the caller deletes what it finds. 

289 """ 

290 with self._lock: 

291 holders = self._by_redis_key.get(redis_key) 

292 if holders is None: 

293 return frozenset() 

294 if isinstance(holders, set): 

295 return frozenset(holders) 

296 return frozenset((holders,)) 

297 

298 def _unindex(self, key: CacheKey) -> None: 

299 # Called with ``_lock`` held. 

300 index = self._by_redis_key 

301 for redis_key in key.redis_keys: 

302 holders = index.get(redis_key) 

303 if holders is None: 

304 continue 

305 if isinstance(holders, set): 

306 holders.discard(key) 

307 if len(holders) == 1: 

308 index[redis_key] = next(iter(holders)) 

309 elif not holders: 

310 del index[redis_key] 

311 elif holders == key: 

312 del index[redis_key] 

313 

314 

315class DefaultCache(CacheInterface): 

316 def __init__( 

317 self, 

318 cache_config: CacheConfigurationInterface, 

319 ) -> None: 

320 self._cache = _IndexedCacheEntries() 

321 self._cache_config = cache_config 

322 self._eviction_policy = self._cache_config.get_eviction_policy().value() 

323 self._eviction_policy.cache = self 

324 

325 @property 

326 def collection(self) -> OrderedDict: 

327 return self._cache 

328 

329 @property 

330 def config(self) -> CacheConfigurationInterface: 

331 return self._cache_config 

332 

333 @property 

334 def eviction_policy(self) -> EvictionPolicyInterface: 

335 return self._eviction_policy 

336 

337 @property 

338 def size(self) -> int: 

339 return len(self._cache) 

340 

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

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

343 return False 

344 

345 self._cache[entry.cache_key] = entry 

346 self._eviction_policy.touch(entry.cache_key) 

347 

348 return True 

349 

350 def get(self, key: CacheKey) -> CacheEntry | None: 

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

352 

353 if entry is None: 

354 return None 

355 

356 self._eviction_policy.touch(key) 

357 return entry 

358 

359 def delete_by_cache_keys(self, cache_keys: list[CacheKey]) -> list[bool]: 

360 response = [] 

361 

362 for key in cache_keys: 

363 if self.get(key) is not None: 

364 self._cache.pop(key) 

365 response.append(True) 

366 else: 

367 response.append(False) 

368 

369 return response 

370 

371 def delete_by_redis_keys(self, redis_keys: list[bytes] | list[str]) -> list[bool]: 

372 response = [] 

373 keys_to_delete = [] 

374 

375 for redis_key in redis_keys: 

376 # Prepare both versions for lookup 

377 candidates = [redis_key] 

378 if isinstance(redis_key, str): 

379 candidates.append(redis_key.encode("utf-8")) 

380 elif isinstance(redis_key, bytes): 

381 try: 

382 candidates.append(redis_key.decode("utf-8")) 

383 except UnicodeDecodeError: 

384 pass # Non-UTF-8 bytes, skip str version 

385 

386 # The reverse index answers this without walking the map. Both spellings are 

387 # looked up because an entry is indexed under its keys exactly as the invocation 

388 # supplied them, while the server names them in its own encoding. The two results 

389 # are unioned, so an entry indexed under both spellings of this one key is 

390 # collected once. 

391 holders: set[CacheKey] = set() 

392 for candidate in candidates: 

393 holders |= self._cache.holders_of(candidate) 

394 

395 # An invalidation message never carries more than one key, so an entry holding 

396 # several keys (MGET) cannot be collected twice by one call. A duplicate pop for 

397 # a multi-key batch is not a reachable case - do not "fix" it. 

398 for cache_key in holders: 

399 keys_to_delete.append(cache_key) 

400 response.append(True) 

401 

402 for key in keys_to_delete: 

403 self._cache.pop(key) 

404 

405 return response 

406 

407 def flush(self) -> int: 

408 elem_count = len(self._cache) 

409 self._cache.clear() 

410 return elem_count 

411 

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

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

414 

415 

416class CacheProxy(CacheInterface): 

417 """ 

418 Proxy object that wraps cache implementations to enable additional logic on top 

419 """ 

420 

421 def __init__(self, cache: CacheInterface): 

422 self._cache = cache 

423 

424 @property 

425 def collection(self) -> OrderedDict: 

426 return self._cache.collection 

427 

428 @property 

429 def config(self) -> CacheConfigurationInterface: 

430 return self._cache.config 

431 

432 @property 

433 def eviction_policy(self) -> EvictionPolicyInterface: 

434 return self._cache.eviction_policy 

435 

436 @property 

437 def size(self) -> int: 

438 return self._cache.size 

439 

440 def get(self, key: CacheKey) -> CacheEntry | None: 

441 return self._cache.get(key) 

442 

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

444 is_set = self._cache.set(entry) 

445 

446 if self.config.is_exceeds_max_size(self.size): 

447 # Lazy import to avoid circular dependency 

448 from redis.observability.recorder import record_csc_eviction 

449 

450 record_csc_eviction( 

451 count=1, 

452 reason=CSCReason.FULL, 

453 ) 

454 self.eviction_policy.evict_next() 

455 

456 return is_set 

457 

458 def delete_by_cache_keys(self, cache_keys: list[CacheKey]) -> list[bool]: 

459 return self._cache.delete_by_cache_keys(cache_keys) 

460 

461 def delete_by_redis_keys(self, redis_keys: list[bytes]) -> list[bool]: 

462 return self._cache.delete_by_redis_keys(redis_keys) 

463 

464 def flush(self) -> int: 

465 return self._cache.flush() 

466 

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

468 return self._cache.is_cachable(key) 

469 

470 

471class LRUPolicy(EvictionPolicyInterface): 

472 def __init__(self): 

473 self.cache = None 

474 

475 @property 

476 def cache(self): 

477 return self._cache 

478 

479 @cache.setter 

480 def cache(self, cache: CacheInterface): 

481 self._cache = cache 

482 

483 @property 

484 def type(self) -> EvictionPolicyType: 

485 return EvictionPolicyType.time_based 

486 

487 def evict_next(self) -> CacheKey: 

488 self._assert_cache() 

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

490 return popped_entry[0] 

491 

492 def evict_many(self, count: int) -> list[CacheKey]: 

493 self._assert_cache() 

494 if count > len(self._cache.collection): 

495 raise ValueError("Evictions count is above cache size") 

496 

497 popped_keys = [] 

498 

499 for _ in range(count): 

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

501 popped_keys.append(popped_entry[0]) 

502 

503 return popped_keys 

504 

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

506 self._assert_cache() 

507 

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

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

510 

511 self._cache.collection.move_to_end(cache_key) 

512 

513 def _assert_cache(self): 

514 if self.cache is None or not isinstance(self.cache, CacheInterface): 

515 raise ValueError("Eviction policy should be associated with valid cache.") 

516 

517 

518class EvictionPolicy(Enum): 

519 LRU = LRUPolicy 

520 

521 

522class CacheConfig(CacheConfigurationInterface): 

523 DEFAULT_CACHE_CLASS = DefaultCache 

524 DEFAULT_EVICTION_POLICY = EvictionPolicy.LRU 

525 DEFAULT_MAX_SIZE = 10000 

526 

527 # DEPRECATED - no longer consulted, and it will be removed in a future release. 

528 # 

529 # Command eligibility is now decided from command metadata by the metadata resolver this 

530 # config holds, so this list no longer describes what gets cached. It is kept as a public 

531 # attribute only so an external caller reading it keeps working; editing it changes 

532 # nothing. The effective set it is replaced by differs from it by ``+FT.SUGGET``, 

533 # ``+FT.SUGLEN``, ``+DIGEST``, ``+EXPIRETIME``, ``+PEXPIRETIME``, ``+HEXPIRETIME``, 

534 # ``+HPEXPIRETIME``, ``+SDIFFCARD``, ``+SUNIONCARD`` and ``-XPENDING``, ``-TS.INFO``, 

535 # ``-XREAD`` - the three removals being commands the server itself reports as not cacheable. 

536 # 

537 # To change eligibility, edit ``redis.commands.metadata._STATIC_COMMAND_METADATA`` or pass 

538 # a ``metadata_resolver`` to the client. Nothing here. 

539 DEFAULT_ALLOW_LIST = [ 

540 "BITCOUNT", 

541 "BITFIELD_RO", 

542 "BITPOS", 

543 "EXISTS", 

544 "GEODIST", 

545 "GEOHASH", 

546 "GEOPOS", 

547 "GEORADIUSBYMEMBER_RO", 

548 "GEORADIUS_RO", 

549 "GEOSEARCH", 

550 "GET", 

551 "GETBIT", 

552 "GETRANGE", 

553 "HEXISTS", 

554 "HGET", 

555 "HGETALL", 

556 "HKEYS", 

557 "HLEN", 

558 "HMGET", 

559 "HSTRLEN", 

560 "HVALS", 

561 "JSON.ARRINDEX", 

562 "JSON.ARRLEN", 

563 "JSON.GET", 

564 "JSON.MGET", 

565 "JSON.OBJKEYS", 

566 "JSON.OBJLEN", 

567 "JSON.RESP", 

568 "JSON.STRLEN", 

569 "JSON.TYPE", 

570 "LCS", 

571 "LINDEX", 

572 "LLEN", 

573 "LPOS", 

574 "LRANGE", 

575 "MGET", 

576 "SCARD", 

577 "SDIFF", 

578 "SINTER", 

579 "SINTERCARD", 

580 "SISMEMBER", 

581 "SMEMBERS", 

582 "SMISMEMBER", 

583 "SORT_RO", 

584 "STRLEN", 

585 "SUBSTR", 

586 "SUNION", 

587 "TS.GET", 

588 "TS.INFO", 

589 "TS.RANGE", 

590 "TS.REVRANGE", 

591 "TYPE", 

592 "XLEN", 

593 "XPENDING", 

594 "XRANGE", 

595 "XREAD", 

596 "XREVRANGE", 

597 "ZCARD", 

598 "ZCOUNT", 

599 "ZDIFF", 

600 "ZINTER", 

601 "ZINTERCARD", 

602 "ZLEXCOUNT", 

603 "ZMSCORE", 

604 "ZRANGE", 

605 "ZRANGEBYLEX", 

606 "ZRANGEBYSCORE", 

607 "ZRANK", 

608 "ZREVRANGE", 

609 "ZREVRANGEBYLEX", 

610 "ZREVRANGEBYSCORE", 

611 "ZREVRANK", 

612 "ZSCORE", 

613 "ZUNION", 

614 ] 

615 

616 def __init__( 

617 self, 

618 max_size: int = DEFAULT_MAX_SIZE, 

619 cache_class: Any = DEFAULT_CACHE_CLASS, 

620 eviction_policy: EvictionPolicy = DEFAULT_EVICTION_POLICY, 

621 tracking_mode: TrackingMode = TrackingMode.PLAIN, 

622 cache_predicate: CachePredicate | None = None, 

623 ): 

624 # A bare string here - ``tracking_mode="optin"`` - would compare equal to no 

625 # ``TrackingMode`` member and so silently behave as plain mode, and the failure mode 

626 # of a mis-configured cache is a wrongly-cached reply. Refused instead, which is the 

627 # one thing this configuration validates. 

628 if not isinstance(tracking_mode, TrackingMode): 

629 raise TypeError( 

630 "tracking_mode must be a redis.cache.TrackingMode member, got " 

631 f"{tracking_mode!r}" 

632 ) 

633 

634 if cache_predicate is not None and tracking_mode is TrackingMode.PLAIN: 

635 warnings.warn( 

636 "cache_predicate is only consulted in optin and optout modes and is " 

637 "ignored with tracking_mode=plain.", 

638 UserWarning, 

639 stacklevel=2, 

640 ) 

641 

642 if cache_predicate is None and tracking_mode is TrackingMode.OPTIN: 

643 warnings.warn( 

644 "tracking_mode=optin with no cache_predicate stores nothing; every read is " 

645 "sent alone and left untracked. Configure cache_predicate to select what to " 

646 "cache.", 

647 UserWarning, 

648 stacklevel=2, 

649 ) 

650 

651 self._cache_class = cache_class 

652 self._max_size = max_size 

653 self._eviction_policy = eviction_policy 

654 self._tracking_mode = tracking_mode 

655 self._cache_predicate = cache_predicate 

656 # Defaulted here rather than taken as a constructor argument: eligibility is 

657 # configured at client level, through the ``metadata_resolver`` of the client or the 

658 # pool, which injects it below. Defaulting it means a config built standalone - in a 

659 # test, or by a user who configures nothing else - is fully functional, and decides 

660 # eligibility from the command metadata this library ships. 

661 self._metadata_resolver: MetadataResolver = StaticMetadataResolver() 

662 

663 def set_metadata_resolver(self, metadata_resolver: MetadataResolver) -> None: 

664 """ 

665 Set the metadata resolver that decides which commands may be cached. 

666 

667 Called by the connection pool with the client-level resolver, so that one object 

668 serves both cluster routing and cache eligibility. Deliberately not part of 

669 :class:`CacheConfigurationInterface`: that ABC is public and implemented by third 

670 parties, so a custom configuration keeps whatever eligibility logic it has. 

671 

672 Which object the pool calls this on depends on how the cache was supplied, and the 

673 difference is observable to a caller who reuses one configuration: 

674 

675 - Given ``cache_config=``, the pool copies the configuration first, so the caller's 

676 object keeps the resolver it had and two clients sharing it stay independent. A 

677 later call to this method on the caller's object does not reach a pool already 

678 built from it. 

679 - Given ``cache=`` or ``cache_factory=``, the caller supplied a whole cache that 

680 reads its configuration on every lookup and cannot be handed a different one, so 

681 the pool calls this on the configuration inside it. One configuration reused that 

682 way therefore ends up with whichever resolver was injected last. 

683 

684 Args: 

685 metadata_resolver: The resolver to decide eligibility through. 

686 """ 

687 self._metadata_resolver = metadata_resolver 

688 

689 def get_cache_class(self): 

690 return self._cache_class 

691 

692 def get_max_size(self) -> int: 

693 return self._max_size 

694 

695 def get_eviction_policy(self) -> EvictionPolicy: 

696 return self._eviction_policy 

697 

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

699 return count > self._max_size 

700 

701 def get_tracking_mode(self) -> TrackingMode: 

702 return self._tracking_mode 

703 

704 def get_cache_predicate(self) -> CachePredicate | None: 

705 return self._cache_predicate 

706 

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

708 # Fails closed on everything the resolver cannot decide: an unknown command, a name 

709 # the record tables cannot be keyed by, and a record built from incomplete metadata 

710 # all resolve to False. The verdict is memoized per command name, so this is a dict 

711 # hit on the command execution path. 

712 return self._metadata_resolver.is_cacheable(command) 

713 

714 def should_cache(self, command: str, keys: tuple) -> bool: 

715 """ 

716 Intent: does the application want this eligible reply stored? 

717 

718 The second of the three decisions a cached read passes, and the only one the 

719 application configures directly. 

720 Eligibility - whether the reply is safe to cache at all - is answered before this 

721 by :meth:`is_allowed_to_cache` from the command metadata, and is never widened here. 

722 

723 Asked only after eligibility said yes and the invocation supplied keys, so the 

724 predicate never sees a command it could not affect and never sees an empty key tuple. 

725 One function call per eligible read that reaches the cache layer. 

726 

727 Args: 

728 command: The command name as the command method spells it. 

729 keys: The Redis keys of this invocation. 

730 

731 Returns: 

732 bool: True when the reply may be stored. 

733 """ 

734 if self._tracking_mode is TrackingMode.PLAIN: 

735 return True 

736 

737 if self._cache_predicate is None: 

738 return self._tracking_mode is TrackingMode.OPTOUT 

739 

740 return bool(self._cache_predicate(command, keys)) 

741 

742 def is_trackable_read(self, command: str) -> bool: 

743 """ 

744 Whether the server would remember this command's keys - the readonly flag alone. 

745 

746 Never affects what may be stored. It decides only whether a ``CLIENT CACHING NO`` in 

747 front of a read is worth sending under ``optout`` tracking, so it fails closed in the 

748 cheap direction: skipping the ``NO`` wastes invalidation-table entries, where storing 

749 an untracked reply is stale forever. 

750 

751 Args: 

752 command: The command name as the command method spells it. 

753 

754 Returns: 

755 bool: True only when the command carries the ``readonly`` command flag. 

756 """ 

757 return self._metadata_resolver.is_trackable_read(command) 

758 

759 

760class CacheFactoryInterface(ABC): 

761 @abstractmethod 

762 def get_cache(self) -> CacheInterface: 

763 pass 

764 

765 

766class CacheFactory(CacheFactoryInterface): 

767 def __init__(self, cache_config: CacheConfig | None = None): 

768 self._config = cache_config 

769 

770 if self._config is None: 

771 self._config = CacheConfig() 

772 

773 def get_cache(self) -> CacheInterface: 

774 cache_class = self._config.get_cache_class() 

775 return CacheProxy(cache_class(cache_config=self._config))