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
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
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
10from redis.commands.metadata import MetadataResolver, StaticMetadataResolver
11from redis.observability.attributes import CSCReason
14class CacheEntryStatus(Enum):
15 VALID = "VALID"
16 IN_PROGRESS = "IN_PROGRESS"
19class EvictionPolicyType(Enum):
20 time_based = "time_based"
21 frequency_based = "frequency_based"
24class TrackingMode(Enum):
25 """
26 How a cache-managed connection enables server-side tracking.
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.
32 ``PLAIN`` is the default, and was the only mode before 8.2, which added ``OPTIN`` and
33 ``OPTOUT``.
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 """
39 PLAIN = "plain"
40 """``CLIENT TRACKING ON`` - every trackable read is tracked."""
42 OPTIN = "optin"
43 """``CLIENT TRACKING ON OPTIN`` - tracked only after ``CLIENT CACHING YES``."""
45 OPTOUT = "optout"
46 """``CLIENT TRACKING ON OPTOUT`` - tracked unless ``CLIENT CACHING NO``."""
49CachePredicate = Callable[[str, tuple], bool]
50"""Decides whether the application wants an eligible reply cached.
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"""
59@dataclass(frozen=True)
60class CacheKey:
61 """
62 Represents a unique key for a cache entry.
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 """
73 command: str
74 redis_keys: tuple
75 redis_args: tuple = () # Additional arguments for the Redis command; affects cache key uniqueness.
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
91 def __hash__(self):
92 return hash(
93 (self.cache_key, self.cache_value, self.status, self.connection_ref)
94 )
96 def __eq__(self, other):
97 return hash(self) == hash(other)
100class EvictionPolicyInterface(ABC):
101 @property
102 @abstractmethod
103 def cache(self):
104 pass
106 @cache.setter
107 @abstractmethod
108 def cache(self, value):
109 pass
111 @property
112 @abstractmethod
113 def type(self) -> EvictionPolicyType:
114 pass
116 @abstractmethod
117 def evict_next(self) -> CacheKey:
118 pass
120 @abstractmethod
121 def evict_many(self, count: int) -> list[CacheKey]:
122 pass
124 @abstractmethod
125 def touch(self, cache_key: CacheKey) -> None:
126 pass
129class CacheConfigurationInterface(ABC):
130 @abstractmethod
131 def get_cache_class(self):
132 pass
134 @abstractmethod
135 def get_max_size(self) -> int:
136 pass
138 @abstractmethod
139 def get_eviction_policy(self):
140 pass
142 @abstractmethod
143 def is_exceeds_max_size(self, count: int) -> bool:
144 pass
146 @abstractmethod
147 def is_allowed_to_cache(self, command: str) -> bool:
148 pass
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.
156 def get_tracking_mode(self) -> TrackingMode:
157 return TrackingMode.PLAIN
159 def should_cache(self, command: str, keys: tuple) -> bool:
160 return True
162 def is_trackable_read(self, command: str) -> bool:
163 return False
166class CacheInterface(ABC):
167 @property
168 @abstractmethod
169 def collection(self) -> OrderedDict:
170 pass
172 @property
173 @abstractmethod
174 def config(self) -> CacheConfigurationInterface:
175 pass
177 @property
178 @abstractmethod
179 def eviction_policy(self) -> EvictionPolicyInterface:
180 pass
182 @property
183 @abstractmethod
184 def size(self) -> int:
185 pass
187 @abstractmethod
188 def get(self, key: CacheKey) -> CacheEntry | None:
189 pass
191 @abstractmethod
192 def set(self, entry: CacheEntry) -> bool:
193 pass
195 @abstractmethod
196 def delete_by_cache_keys(self, cache_keys: list[CacheKey]) -> list[bool]:
197 pass
199 @abstractmethod
200 def delete_by_redis_keys(self, redis_keys: list[bytes]) -> list[bool]:
201 pass
203 @abstractmethod
204 def flush(self) -> int:
205 pass
207 @abstractmethod
208 def is_cachable(self, key: CacheKey) -> bool:
209 pass
212class _IndexedCacheEntries(OrderedDict):
213 """
214 The cache's entry map, carrying a reverse index from Redis key to the entries holding it.
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.
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.
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.
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 """
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)
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)
262 def __delitem__(self, key: CacheKey) -> None:
263 with self._lock:
264 super().__delitem__(key)
265 self._unindex(key)
267 def pop(self, key: CacheKey, *args):
268 with self._lock:
269 value = super().pop(key, *args)
270 self._unindex(key)
271 return value
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
279 def clear(self) -> None:
280 with self._lock:
281 super().clear()
282 self._by_redis_key.clear()
284 def holders_of(self, redis_key) -> frozenset:
285 """
286 The cache keys of every entry whose invocation named ``redis_key``.
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,))
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]
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
325 @property
326 def collection(self) -> OrderedDict:
327 return self._cache
329 @property
330 def config(self) -> CacheConfigurationInterface:
331 return self._cache_config
333 @property
334 def eviction_policy(self) -> EvictionPolicyInterface:
335 return self._eviction_policy
337 @property
338 def size(self) -> int:
339 return len(self._cache)
341 def set(self, entry: CacheEntry) -> bool:
342 if not self.is_cachable(entry.cache_key):
343 return False
345 self._cache[entry.cache_key] = entry
346 self._eviction_policy.touch(entry.cache_key)
348 return True
350 def get(self, key: CacheKey) -> CacheEntry | None:
351 entry = self._cache.get(key, None)
353 if entry is None:
354 return None
356 self._eviction_policy.touch(key)
357 return entry
359 def delete_by_cache_keys(self, cache_keys: list[CacheKey]) -> list[bool]:
360 response = []
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)
369 return response
371 def delete_by_redis_keys(self, redis_keys: list[bytes] | list[str]) -> list[bool]:
372 response = []
373 keys_to_delete = []
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
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)
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)
402 for key in keys_to_delete:
403 self._cache.pop(key)
405 return response
407 def flush(self) -> int:
408 elem_count = len(self._cache)
409 self._cache.clear()
410 return elem_count
412 def is_cachable(self, key: CacheKey) -> bool:
413 return self._cache_config.is_allowed_to_cache(key.command)
416class CacheProxy(CacheInterface):
417 """
418 Proxy object that wraps cache implementations to enable additional logic on top
419 """
421 def __init__(self, cache: CacheInterface):
422 self._cache = cache
424 @property
425 def collection(self) -> OrderedDict:
426 return self._cache.collection
428 @property
429 def config(self) -> CacheConfigurationInterface:
430 return self._cache.config
432 @property
433 def eviction_policy(self) -> EvictionPolicyInterface:
434 return self._cache.eviction_policy
436 @property
437 def size(self) -> int:
438 return self._cache.size
440 def get(self, key: CacheKey) -> CacheEntry | None:
441 return self._cache.get(key)
443 def set(self, entry: CacheEntry) -> bool:
444 is_set = self._cache.set(entry)
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
450 record_csc_eviction(
451 count=1,
452 reason=CSCReason.FULL,
453 )
454 self.eviction_policy.evict_next()
456 return is_set
458 def delete_by_cache_keys(self, cache_keys: list[CacheKey]) -> list[bool]:
459 return self._cache.delete_by_cache_keys(cache_keys)
461 def delete_by_redis_keys(self, redis_keys: list[bytes]) -> list[bool]:
462 return self._cache.delete_by_redis_keys(redis_keys)
464 def flush(self) -> int:
465 return self._cache.flush()
467 def is_cachable(self, key: CacheKey) -> bool:
468 return self._cache.is_cachable(key)
471class LRUPolicy(EvictionPolicyInterface):
472 def __init__(self):
473 self.cache = None
475 @property
476 def cache(self):
477 return self._cache
479 @cache.setter
480 def cache(self, cache: CacheInterface):
481 self._cache = cache
483 @property
484 def type(self) -> EvictionPolicyType:
485 return EvictionPolicyType.time_based
487 def evict_next(self) -> CacheKey:
488 self._assert_cache()
489 popped_entry = self._cache.collection.popitem(last=False)
490 return popped_entry[0]
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")
497 popped_keys = []
499 for _ in range(count):
500 popped_entry = self._cache.collection.popitem(last=False)
501 popped_keys.append(popped_entry[0])
503 return popped_keys
505 def touch(self, cache_key: CacheKey) -> None:
506 self._assert_cache()
508 if self._cache.collection.get(cache_key) is None:
509 raise ValueError("Given entry does not belong to the cache")
511 self._cache.collection.move_to_end(cache_key)
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.")
518class EvictionPolicy(Enum):
519 LRU = LRUPolicy
522class CacheConfig(CacheConfigurationInterface):
523 DEFAULT_CACHE_CLASS = DefaultCache
524 DEFAULT_EVICTION_POLICY = EvictionPolicy.LRU
525 DEFAULT_MAX_SIZE = 10000
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 ]
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 )
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 )
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 )
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()
663 def set_metadata_resolver(self, metadata_resolver: MetadataResolver) -> None:
664 """
665 Set the metadata resolver that decides which commands may be cached.
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.
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:
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.
684 Args:
685 metadata_resolver: The resolver to decide eligibility through.
686 """
687 self._metadata_resolver = metadata_resolver
689 def get_cache_class(self):
690 return self._cache_class
692 def get_max_size(self) -> int:
693 return self._max_size
695 def get_eviction_policy(self) -> EvictionPolicy:
696 return self._eviction_policy
698 def is_exceeds_max_size(self, count: int) -> bool:
699 return count > self._max_size
701 def get_tracking_mode(self) -> TrackingMode:
702 return self._tracking_mode
704 def get_cache_predicate(self) -> CachePredicate | None:
705 return self._cache_predicate
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)
714 def should_cache(self, command: str, keys: tuple) -> bool:
715 """
716 Intent: does the application want this eligible reply stored?
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.
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.
727 Args:
728 command: The command name as the command method spells it.
729 keys: The Redis keys of this invocation.
731 Returns:
732 bool: True when the reply may be stored.
733 """
734 if self._tracking_mode is TrackingMode.PLAIN:
735 return True
737 if self._cache_predicate is None:
738 return self._tracking_mode is TrackingMode.OPTOUT
740 return bool(self._cache_predicate(command, keys))
742 def is_trackable_read(self, command: str) -> bool:
743 """
744 Whether the server would remember this command's keys - the readonly flag alone.
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.
751 Args:
752 command: The command name as the command method spells it.
754 Returns:
755 bool: True only when the command carries the ``readonly`` command flag.
756 """
757 return self._metadata_resolver.is_trackable_read(command)
760class CacheFactoryInterface(ABC):
761 @abstractmethod
762 def get_cache(self) -> CacheInterface:
763 pass
766class CacheFactory(CacheFactoryInterface):
767 def __init__(self, cache_config: CacheConfig | None = None):
768 self._config = cache_config
770 if self._config is None:
771 self._config = CacheConfig()
773 def get_cache(self) -> CacheInterface:
774 cache_class = self._config.get_cache_class()
775 return CacheProxy(cache_class(cache_config=self._config))