xtr-cache 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. xtr_cache/__init__.py +111 -0
  2. xtr_cache/adapter/__init__.py +31 -0
  3. xtr_cache/adapter/abstract_adapter.py +356 -0
  4. xtr_cache/adapter/adapter_factory.py +134 -0
  5. xtr_cache/adapter/adapter_interface.py +42 -0
  6. xtr_cache/adapter/array_adapter.py +165 -0
  7. xtr_cache/adapter/chain_adapter.py +185 -0
  8. xtr_cache/adapter/contracts_mixin.py +190 -0
  9. xtr_cache/adapter/deferred_items_mixin.py +78 -0
  10. xtr_cache/adapter/filesystem_adapter.py +256 -0
  11. xtr_cache/adapter/null_adapter.py +84 -0
  12. xtr_cache/adapter/redis_adapter.py +234 -0
  13. xtr_cache/adapter/tag_aware_adapter.py +261 -0
  14. xtr_cache/adapter/tag_aware_adapter_interface.py +16 -0
  15. xtr_cache/adapter/tagged_value.py +28 -0
  16. xtr_cache/bundle/__init__.py +17 -0
  17. xtr_cache/bundle/cache_bundle.py +289 -0
  18. xtr_cache/bundle/cache_config.py +115 -0
  19. xtr_cache/bundle/pool_config.py +69 -0
  20. xtr_cache/cache_item.py +262 -0
  21. xtr_cache/cache_pool_clearer.py +79 -0
  22. xtr_cache/command/__init__.py +33 -0
  23. xtr_cache/command/cache_pool_clear_command.py +65 -0
  24. xtr_cache/command/cache_pool_delete_command.py +50 -0
  25. xtr_cache/command/cache_pool_invalidate_tags_command.py +99 -0
  26. xtr_cache/command/cache_pool_list_command.py +32 -0
  27. xtr_cache/command/cache_pool_prune_command.py +47 -0
  28. xtr_cache/command/pool_command.py +37 -0
  29. xtr_cache/command/pools.py +35 -0
  30. xtr_cache/exception/__init__.py +20 -0
  31. xtr_cache/exception/logic_error.py +25 -0
  32. xtr_cache/exception/marshalling_error.py +27 -0
  33. xtr_cache/lock_registry.py +165 -0
  34. xtr_cache/marshaller/__init__.py +15 -0
  35. xtr_cache/marshaller/default_marshaller.py +61 -0
  36. xtr_cache/marshaller/deflate_marshaller.py +51 -0
  37. xtr_cache/marshaller/marshaller_interface.py +39 -0
  38. xtr_cache/marshaller/sodium_marshaller.py +143 -0
  39. xtr_cache/pruneable_interface.py +26 -0
  40. xtr_cache/py.typed +0 -0
  41. xtr_cache/value_wrapper.py +30 -0
  42. xtr_cache-1.2.0.dist-info/METADATA +412 -0
  43. xtr_cache-1.2.0.dist-info/RECORD +45 -0
  44. xtr_cache-1.2.0.dist-info/WHEEL +4 -0
  45. xtr_cache-1.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,165 @@
1
+ """A pool in this process's memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast, final
6
+
7
+ from typing_extensions import override
8
+
9
+ from xtr_cache.exception import InvalidArgumentError, MarshallingError
10
+ from xtr_cache.marshaller.default_marshaller import DefaultMarshaller
11
+
12
+ from .abstract_adapter import AbstractAdapter
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Mapping, Sequence
16
+
17
+ from xtr_clock import ClockInterface
18
+
19
+ from xtr_cache.marshaller.marshaller_interface import MarshallerInterface
20
+
21
+ __all__ = ["ArrayAdapter"]
22
+
23
+
24
+ @final
25
+ class ArrayAdapter(AbstractAdapter):
26
+ """Keeps values in a dictionary, for tests and for caching within one process.
27
+
28
+ Values are stored serialized by default, so what a caller gets back is a
29
+ copy: changing it does not change what is cached, exactly as with any
30
+ other backend. Turn that off to store the objects themselves, faster but
31
+ shared.
32
+
33
+ Every method completes without awaiting, so tasks on one event loop never
34
+ see it half-updated. Two instances share nothing.
35
+ """
36
+
37
+ _values: dict[str, tuple[object, float | None]]
38
+ _store_serialized: bool
39
+ _max_lifetime: float
40
+ _max_items: int
41
+ _marshaller: MarshallerInterface
42
+
43
+ def __init__( # noqa: PLR0913 — every option past the lifetime is keyword-only.
44
+ self,
45
+ default_lifetime: float = 0.0,
46
+ *,
47
+ store_serialized: bool = True,
48
+ max_lifetime: float = 0.0,
49
+ max_items: int = 0,
50
+ marshaller: MarshallerInterface | None = None,
51
+ clock: ClockInterface | None = None,
52
+ ) -> None:
53
+ """Keep values in memory.
54
+
55
+ Args:
56
+ default_lifetime: Seconds a value lives when its item sets no
57
+ expiry; ``0`` for no limit.
58
+ store_serialized: Store a serialized copy rather than the object.
59
+ max_lifetime: Seconds no value outlives, whatever its item says;
60
+ ``0`` for no cap.
61
+ max_items: How many values to keep, dropping the least recently
62
+ used beyond; ``0`` for no limit.
63
+ marshaller: What serializes values when ``store_serialized``.
64
+ Pickle when omitted.
65
+ clock: What lifetimes are counted from. ``None`` reads the clock
66
+ in force.
67
+
68
+ Raises:
69
+ InvalidArgumentError: When ``max_lifetime`` or ``max_items`` is negative.
70
+ """
71
+ if max_lifetime < 0:
72
+ raise InvalidArgumentError(f"max_lifetime must not be negative, got {max_lifetime}.")
73
+ if max_items < 0:
74
+ raise InvalidArgumentError(f"max_items must not be negative, got {max_items}.")
75
+
76
+ super().__init__("", default_lifetime, clock=clock)
77
+ self._values = {}
78
+ self._store_serialized = store_serialized
79
+ self._max_lifetime = max_lifetime
80
+ self._max_items = max_items
81
+ self._marshaller = marshaller if marshaller is not None else DefaultMarshaller()
82
+
83
+ def values(self) -> dict[str, object]:
84
+ """Return every live value by identifier, as stored; for tests."""
85
+ now = self._clock.now().timestamp()
86
+ return {
87
+ id_: stored
88
+ for id_, (stored, expiry) in self._values.items()
89
+ if expiry is None or expiry > now
90
+ }
91
+
92
+ @override
93
+ async def _do_fetch(self, ids: Sequence[str]) -> Mapping[str, object]:
94
+ now = self._clock.now().timestamp()
95
+ found: dict[str, object] = {}
96
+ for id_ in ids:
97
+ entry = self._values.get(id_)
98
+ if entry is None:
99
+ continue
100
+ stored, expiry = entry
101
+ if expiry is not None and expiry <= now:
102
+ del self._values[id_]
103
+ continue
104
+ if self._max_items:
105
+ # Most recently used last, so eviction takes from the front.
106
+ self._values[id_] = self._values.pop(id_)
107
+ if not self._store_serialized:
108
+ found[id_] = stored
109
+ continue
110
+ try:
111
+ # Stored serialized, so what is stored is the marshaller's bytes.
112
+ found[id_] = self._marshaller.unmarshall(cast("bytes", stored))
113
+ except MarshallingError as error:
114
+ del self._values[id_]
115
+ self._log('Failed to read key "{key}": {reason}', error, key=id_)
116
+
117
+ return found
118
+
119
+ @override
120
+ async def _do_have(self, id_: str) -> bool:
121
+ entry = self._values.get(id_)
122
+ if entry is None:
123
+ return False
124
+ expiry = entry[1]
125
+ return expiry is None or expiry > self._clock.now().timestamp()
126
+
127
+ @override
128
+ async def _do_clear(self, namespace: str) -> bool:
129
+ if not namespace:
130
+ self._values.clear()
131
+ else:
132
+ for id_ in [id_ for id_ in self._values if id_.startswith(namespace)]:
133
+ del self._values[id_]
134
+ return True
135
+
136
+ @override
137
+ async def _do_delete(self, ids: Sequence[str]) -> bool:
138
+ for id_ in ids:
139
+ _ = self._values.pop(id_, None)
140
+ return True
141
+
142
+ @override
143
+ async def _do_save(self, values: Mapping[str, object], lifetime: float) -> bool | Sequence[str]:
144
+ if self._max_lifetime and (not lifetime or lifetime > self._max_lifetime):
145
+ lifetime = self._max_lifetime
146
+ expiry = self._clock.now().timestamp() + lifetime if lifetime else None
147
+
148
+ failed: list[str] = []
149
+ stored: Mapping[str, object] = values
150
+ if self._store_serialized:
151
+ stored, failed = self._marshaller.marshall(values)
152
+
153
+ for id_, value in stored.items():
154
+ _ = self._values.pop(id_, None)
155
+ self._values[id_] = (value, expiry)
156
+
157
+ if self._max_items:
158
+ while len(self._values) > self._max_items:
159
+ del self._values[next(iter(self._values))]
160
+
161
+ return failed or True
162
+
163
+ @override
164
+ def __repr__(self) -> str:
165
+ return f"{type(self).__name__}({self._default_lifetime!r})"
@@ -0,0 +1,185 @@
1
+ """Several pools in front of one another, the fastest first."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING, Self, final
8
+
9
+ from typing_extensions import override
10
+ from xtr_cache_contracts import InvalidArgumentError, NamespacedPoolInterface
11
+
12
+ from xtr_cache.cache_item import CacheItem
13
+ from xtr_cache.pruneable_interface import PruneableInterface
14
+
15
+ from .adapter_interface import AdapterInterface
16
+ from .contracts_mixin import ContractsMixin
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterable, Mapping, Sequence
20
+
21
+ from xtr_cache_contracts import ItemInterface
22
+ from xtr_clock import ClockInterface
23
+
24
+ __all__ = ["ChainAdapter"]
25
+
26
+
27
+ @final
28
+ class ChainAdapter(ContractsMixin, AdapterInterface, NamespacedPoolInterface, PruneableInterface):
29
+ """Reads from the first pool that has a value, and fills the faster ones with it.
30
+
31
+ Typically memory in front of Redis: a hit in memory costs nothing, a miss
32
+ there asks Redis, and a value found in Redis is copied into memory for
33
+ the next read — with what is left of its lifetime, when it was stored
34
+ with one, or ``default_lifetime`` otherwise. Writes, deletes and clears
35
+ go to every pool.
36
+ """
37
+
38
+ _adapters: tuple[AdapterInterface, ...]
39
+ _default_lifetime: float
40
+
41
+ def __init__(
42
+ self,
43
+ adapters: Sequence[AdapterInterface],
44
+ default_lifetime: float = 0.0,
45
+ *,
46
+ clock: ClockInterface | None = None,
47
+ ) -> None:
48
+ """Chain ``adapters``, the fastest first.
49
+
50
+ Args:
51
+ adapters: The pools, in the order they are read.
52
+ default_lifetime: Seconds a value copied into a faster pool lives,
53
+ when it was stored without an expiry; ``0`` leaves it to that
54
+ pool's own default.
55
+ clock: What lifetimes are counted from. ``None`` reads the clock
56
+ in force.
57
+
58
+ Raises:
59
+ InvalidArgumentError: When ``adapters`` is empty.
60
+ """
61
+ if not adapters:
62
+ raise InvalidArgumentError("A chain needs at least one cache pool.")
63
+
64
+ self._adapters = tuple(adapters)
65
+ self._default_lifetime = default_lifetime
66
+ if clock is not None:
67
+ self._clock = clock
68
+
69
+ @property
70
+ def adapters(self) -> tuple[AdapterInterface, ...]:
71
+ """The pools, in the order they are read."""
72
+ return self._adapters
73
+
74
+ @override
75
+ async def get_item(self, key: str, /) -> CacheItem:
76
+ missed: list[AdapterInterface] = []
77
+ for adapter in self._adapters:
78
+ item = await adapter.get_item(key)
79
+ if item.is_hit():
80
+ for faster in missed:
81
+ _ = await faster.save(self._copy(item))
82
+ return item
83
+ missed.append(adapter)
84
+
85
+ return CacheItem(key, clock=self._clock)
86
+
87
+ @override
88
+ async def get_items(self, keys: Iterable[str], /) -> Mapping[str, CacheItem]:
89
+ wanted = list(dict.fromkeys(keys))
90
+ found: dict[str, CacheItem] = {}
91
+ missing = wanted
92
+ missed: list[AdapterInterface] = []
93
+ for adapter in self._adapters:
94
+ if not missing:
95
+ break
96
+ items = await adapter.get_items(missing)
97
+ for key, item in items.items():
98
+ if item.is_hit():
99
+ found[key] = item
100
+ for faster in missed:
101
+ _ = await faster.save_deferred(self._copy(item))
102
+ missing = [key for key in missing if key not in found]
103
+ missed.append(adapter)
104
+
105
+ for faster in missed:
106
+ _ = await faster.commit()
107
+
108
+ return {key: found.get(key) or CacheItem(key, clock=self._clock) for key in wanted}
109
+
110
+ @override
111
+ async def has_item(self, key: str, /) -> bool:
112
+ for adapter in self._adapters:
113
+ if await adapter.has_item(key):
114
+ return True
115
+ return False
116
+
117
+ @override
118
+ async def clear(self, prefix: str = "") -> bool:
119
+ return all([await adapter.clear(prefix) for adapter in self._adapters])
120
+
121
+ @override
122
+ async def delete_item(self, key: str, /) -> bool:
123
+ return all([await adapter.delete_item(key) for adapter in self._adapters])
124
+
125
+ @override
126
+ async def delete_items(self, keys: Iterable[str], /) -> bool:
127
+ wanted = list(keys)
128
+ return all([await adapter.delete_items(wanted) for adapter in self._adapters])
129
+
130
+ @override
131
+ async def save(self, item: ItemInterface, /) -> bool:
132
+ return all([await adapter.save(item) for adapter in self._adapters])
133
+
134
+ @override
135
+ async def save_deferred(self, item: ItemInterface, /) -> bool:
136
+ return all([await adapter.save_deferred(item) for adapter in self._adapters])
137
+
138
+ @override
139
+ async def commit(self) -> bool:
140
+ return all([await adapter.commit() for adapter in self._adapters])
141
+
142
+ @override
143
+ async def prune(self) -> bool:
144
+ return all(
145
+ [
146
+ await adapter.prune()
147
+ for adapter in self._adapters
148
+ if isinstance(adapter, PruneableInterface)
149
+ ],
150
+ )
151
+
152
+ @override
153
+ async def reset(self) -> None:
154
+ for adapter in self._adapters:
155
+ await adapter.reset()
156
+
157
+ @override
158
+ def with_sub_namespace(self, namespace: str, /) -> Self:
159
+ clone = copy.copy(self)
160
+ clone._adapters = tuple( # noqa: SLF001 — a copy of this very class.
161
+ adapter.with_sub_namespace(namespace)
162
+ if isinstance(adapter, NamespacedPoolInterface)
163
+ else adapter
164
+ for adapter in self._adapters
165
+ )
166
+ return clone
167
+
168
+ @override
169
+ def _scope(self) -> str:
170
+ first = self._adapters[0]
171
+ return first._scope() if isinstance(first, ContractsMixin) else "" # noqa: SLF001 — a pool of this library.
172
+
173
+ @override
174
+ def __repr__(self) -> str:
175
+ return f"{type(self).__name__}({list(self._adapters)!r}, {self._default_lifetime!r})"
176
+
177
+ def _copy(self, item: CacheItem) -> CacheItem:
178
+ """Return an item taking ``item``'s value into a faster pool, with the life it has left."""
179
+ carried = CacheItem(item.key, item.get(), clock=self._clock).carry_metadata(item.metadata)
180
+ expiry = item.metadata.get("expiry")
181
+ if expiry is not None:
182
+ return carried.expires_at(datetime.fromtimestamp(expiry, UTC))
183
+ if self._default_lifetime:
184
+ return carried.expires_after(self._default_lifetime)
185
+ return carried
@@ -0,0 +1,190 @@
1
+ """Fetch-or-compute on a pool of :class:`CacheItem`: one computation per key, timed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ from abc import ABC, abstractmethod
8
+ from contextvars import ContextVar
9
+ from typing import TYPE_CHECKING, Final, TypeVar, cast
10
+
11
+ from typing_extensions import override
12
+ from xtr_cache_contracts import CacheMixin
13
+ from xtr_clock import Clock
14
+ from xtr_logging_contracts import LoggerAware
15
+
16
+ if TYPE_CHECKING:
17
+ from xtr_cache_contracts import Callback, ItemInterface, Metadata
18
+ from xtr_clock import ClockInterface
19
+
20
+ from xtr_cache.cache_item import CacheItem
21
+ from xtr_cache.lock_registry import LockRegistry
22
+
23
+ __all__ = ["ContractsMixin"]
24
+
25
+ _T = TypeVar("_T")
26
+
27
+ _CLOCK_IN_FORCE: Final = Clock()
28
+
29
+ _COMPUTING: ContextVar[frozenset[tuple[int, str]]] = ContextVar(
30
+ "xtr_cache_computing",
31
+ default=frozenset(),
32
+ )
33
+ """The computations the running task is inside of: its flight registry, and the scoped key."""
34
+
35
+ _FAILED: Final = object()
36
+ """What a shared computation resolves to when its owner failed or was cancelled."""
37
+
38
+
39
+ class ContractsMixin(CacheMixin, LoggerAware, ABC):
40
+ """Fetch-or-compute on a pool of :class:`~xtr_cache.cache_item.CacheItem`.
41
+
42
+ Built on :class:`~xtr_cache_contracts.CacheMixin`'s template, adjusting how
43
+ a missing value is computed:
44
+
45
+ - It is saved with how long it took, which — with its expiry — is what
46
+ makes early recomputation possible on a later read.
47
+ - Concurrent misses on one key in this process share one computation. If
48
+ it fails or is cancelled, each of the others computes for itself.
49
+ - With a :class:`~xtr_cache.lock_registry.LockRegistry`, misses in every
50
+ process sharing its locks share one computation too.
51
+ - A callback reading its own key — directly or through what it calls —
52
+ computes without saving, instead of waiting on itself.
53
+
54
+ Computations are keyed by :meth:`_scope` and the key, so pools on one
55
+ backend under different namespaces never wait on each other.
56
+ """
57
+
58
+ _lock_registry: LockRegistry | None = None
59
+ _clock: ClockInterface = _CLOCK_IN_FORCE
60
+ _in_flight: dict[str, asyncio.Future[object]]
61
+
62
+ @abstractmethod
63
+ @override
64
+ async def get_item(self, key: str, /) -> CacheItem:
65
+ """Return the item for ``key``, a hit or a miss."""
66
+
67
+ def set_lock_registry(self, registry: LockRegistry | None) -> None:
68
+ """Compute missing values under ``registry``'s locks; ``None`` for this process alone."""
69
+ self._lock_registry = registry
70
+
71
+ @abstractmethod
72
+ def _scope(self) -> str:
73
+ """Return what sets this pool's keys apart from another's on the same backend."""
74
+
75
+ @override
76
+ def _now(self) -> float:
77
+ return self._clock.now().timestamp()
78
+
79
+ @override
80
+ def _on_elected(self, item: ItemInterface, remaining: float) -> None:
81
+ self.logger.info(
82
+ 'Item "{key}" elected for early recomputation {delta}s before its expiration',
83
+ {"key": item.key, "delta": f"{remaining:.1f}"},
84
+ )
85
+
86
+ @override
87
+ async def _compute(
88
+ self,
89
+ item: ItemInterface,
90
+ callback: Callback[_T],
91
+ beta: float,
92
+ metadata: Metadata | None,
93
+ ) -> _T:
94
+ """Compute the value once for every concurrent caller, under the lock registry if any."""
95
+ # The template hands back what get_item returned, which here is always a CacheItem.
96
+ item = cast("CacheItem", item)
97
+ flights = self._flights()
98
+ scoped = self._scope() + item.key
99
+ if (id(flights), scoped) in _COMPUTING.get():
100
+ value = await callback(item)
101
+ _ = item.set(value)
102
+ return value
103
+
104
+ shared = flights.get(scoped)
105
+ if shared is not None:
106
+ result = await asyncio.shield(shared)
107
+ if result is not _FAILED:
108
+ # Computed by the same kind of callback for the same key: the caller's promise.
109
+ return cast("_T", result)
110
+ # Queueing behind another attempt would make every caller wait for every failure.
111
+ return await self._compute_and_save(item, callback, metadata, scoped)
112
+
113
+ future: asyncio.Future[object] = asyncio.get_running_loop().create_future()
114
+ flights[scoped] = future
115
+ try:
116
+ value = await self._compute_locked(item, callback, beta, metadata, scoped)
117
+ except BaseException:
118
+ future.set_result(_FAILED)
119
+ raise
120
+ else:
121
+ future.set_result(value)
122
+ return value
123
+ finally:
124
+ if flights.get(scoped) is future:
125
+ del flights[scoped]
126
+
127
+ async def _compute_locked(
128
+ self,
129
+ item: CacheItem,
130
+ callback: Callback[_T],
131
+ beta: float,
132
+ metadata: Metadata | None,
133
+ scoped: str,
134
+ ) -> _T:
135
+ """Compute under the lock registry's slot for ``scoped``, when there is a registry."""
136
+ if self._lock_registry is None:
137
+ return await self._compute_and_save(item, callback, metadata, scoped)
138
+
139
+ async def compute() -> _T:
140
+ return await self._compute_and_save(item, callback, metadata, scoped)
141
+
142
+ async def read() -> ItemInterface:
143
+ return await self.get_item(item.key)
144
+
145
+ return await self._lock_registry.compute(scoped, compute, read, force=math.isinf(beta))
146
+
147
+ async def _compute_and_save(
148
+ self,
149
+ item: CacheItem,
150
+ callback: Callback[_T],
151
+ metadata: Metadata | None,
152
+ scoped: str,
153
+ ) -> _T:
154
+ """Run ``callback``, record how long it took, save the value, and report it."""
155
+ token = _COMPUTING.set(_COMPUTING.get() | {(id(self._flights()), scoped)})
156
+ started = self._now()
157
+ try:
158
+ value = await callback(item)
159
+ finally:
160
+ _COMPUTING.reset(token)
161
+
162
+ ctime = math.ceil(1000 * (self._now() - started))
163
+ _ = item.set(value).record_computation(ctime)
164
+ if metadata is not None:
165
+ _report(metadata, item, ctime)
166
+ if not await self.save(item) and metadata is not None:
167
+ metadata["save_failed"] = True
168
+
169
+ return value
170
+
171
+ def _flights(self) -> dict[str, asyncio.Future[object]]:
172
+ """Return the computations in progress, by scoped key; views of this pool share them."""
173
+ try:
174
+ return self._in_flight
175
+ except AttributeError:
176
+ self._in_flight = {}
177
+ return self._in_flight
178
+
179
+
180
+ def _report(metadata: Metadata, item: CacheItem, ctime: int) -> None:
181
+ """Tell the caller what the computed value is stored with, dropping what no longer holds."""
182
+ metadata["ctime"] = ctime
183
+ if item.expiry is not None:
184
+ metadata["expiry"] = item.expiry
185
+ else:
186
+ _ = metadata.pop("expiry", None)
187
+ if item.pending_tags:
188
+ metadata["tags"] = item.pending_tags
189
+ else:
190
+ _ = metadata.pop("tags", None)
@@ -0,0 +1,78 @@
1
+ """The queue of items saved as deferred, for a pool that commits them in one batch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from abc import ABC, abstractmethod
7
+ from typing import TYPE_CHECKING, Self
8
+
9
+ from typing_extensions import override
10
+ from xtr_cache_contracts import CacheItemPoolInterface
11
+
12
+ from xtr_cache.cache_item import CacheItem
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Iterable
16
+
17
+ from xtr_cache_contracts import ItemInterface
18
+
19
+ __all__ = ["DeferredItemsMixin"]
20
+
21
+
22
+ class DeferredItemsMixin(CacheItemPoolInterface, ABC):
23
+ """Queues saved items until :meth:`commit`, which the pool implements.
24
+
25
+ :meth:`save` is a deferred save committed at once, so a pool writes in one
26
+ place only. What is queued is the pool's to drop when the keys are
27
+ deleted or cleared, and to commit before a queued key is read.
28
+ """
29
+
30
+ _deferred: dict[str, CacheItem]
31
+
32
+ @abstractmethod
33
+ @override
34
+ async def commit(self) -> bool:
35
+ """Store every queued item."""
36
+
37
+ @override
38
+ async def save(self, item: ItemInterface, /) -> bool:
39
+ if not isinstance(item, CacheItem):
40
+ return False
41
+
42
+ self._deferred[item.key] = item
43
+ return await self.commit()
44
+
45
+ @override
46
+ async def save_deferred(self, item: ItemInterface, /) -> bool:
47
+ if not isinstance(item, CacheItem):
48
+ return False
49
+
50
+ self._deferred[item.key] = item
51
+ return True
52
+
53
+ async def reset(self) -> None:
54
+ """Commit what was queued, so it does not wait for a commit nothing will call."""
55
+ if self._deferred:
56
+ _ = await self.commit()
57
+
58
+ async def _commit_if_queued(self, keys: Iterable[str]) -> None:
59
+ """Commit first when any of ``keys`` is queued, so a read never misses what was saved."""
60
+ if any(key in self._deferred for key in keys):
61
+ _ = await self.commit()
62
+
63
+ def _forget_deferred(self, keys: Iterable[str]) -> None:
64
+ """Drop the queued items of ``keys``, which are being deleted."""
65
+ for key in keys:
66
+ _ = self._deferred.pop(key, None)
67
+
68
+ def _drop_deferred(self, prefix: str) -> None:
69
+ """Drop the queued items whose key starts with ``prefix``, which are being cleared."""
70
+ self._deferred = {
71
+ key: item for key, item in self._deferred.items() if not key.startswith(prefix)
72
+ }
73
+
74
+ def _unqueued_copy(self) -> Self:
75
+ """Return a shallow copy of this pool with nothing queued, for a sub-namespace view."""
76
+ clone = copy.copy(self)
77
+ clone._deferred = {} # noqa: SLF001 — a copy of this very class.
78
+ return clone