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.
- xtr_cache/__init__.py +111 -0
- xtr_cache/adapter/__init__.py +31 -0
- xtr_cache/adapter/abstract_adapter.py +356 -0
- xtr_cache/adapter/adapter_factory.py +134 -0
- xtr_cache/adapter/adapter_interface.py +42 -0
- xtr_cache/adapter/array_adapter.py +165 -0
- xtr_cache/adapter/chain_adapter.py +185 -0
- xtr_cache/adapter/contracts_mixin.py +190 -0
- xtr_cache/adapter/deferred_items_mixin.py +78 -0
- xtr_cache/adapter/filesystem_adapter.py +256 -0
- xtr_cache/adapter/null_adapter.py +84 -0
- xtr_cache/adapter/redis_adapter.py +234 -0
- xtr_cache/adapter/tag_aware_adapter.py +261 -0
- xtr_cache/adapter/tag_aware_adapter_interface.py +16 -0
- xtr_cache/adapter/tagged_value.py +28 -0
- xtr_cache/bundle/__init__.py +17 -0
- xtr_cache/bundle/cache_bundle.py +289 -0
- xtr_cache/bundle/cache_config.py +115 -0
- xtr_cache/bundle/pool_config.py +69 -0
- xtr_cache/cache_item.py +262 -0
- xtr_cache/cache_pool_clearer.py +79 -0
- xtr_cache/command/__init__.py +33 -0
- xtr_cache/command/cache_pool_clear_command.py +65 -0
- xtr_cache/command/cache_pool_delete_command.py +50 -0
- xtr_cache/command/cache_pool_invalidate_tags_command.py +99 -0
- xtr_cache/command/cache_pool_list_command.py +32 -0
- xtr_cache/command/cache_pool_prune_command.py +47 -0
- xtr_cache/command/pool_command.py +37 -0
- xtr_cache/command/pools.py +35 -0
- xtr_cache/exception/__init__.py +20 -0
- xtr_cache/exception/logic_error.py +25 -0
- xtr_cache/exception/marshalling_error.py +27 -0
- xtr_cache/lock_registry.py +165 -0
- xtr_cache/marshaller/__init__.py +15 -0
- xtr_cache/marshaller/default_marshaller.py +61 -0
- xtr_cache/marshaller/deflate_marshaller.py +51 -0
- xtr_cache/marshaller/marshaller_interface.py +39 -0
- xtr_cache/marshaller/sodium_marshaller.py +143 -0
- xtr_cache/pruneable_interface.py +26 -0
- xtr_cache/py.typed +0 -0
- xtr_cache/value_wrapper.py +30 -0
- xtr_cache-1.2.0.dist-info/METADATA +412 -0
- xtr_cache-1.2.0.dist-info/RECORD +45 -0
- xtr_cache-1.2.0.dist-info/WHEEL +4 -0
- xtr_cache-1.2.0.dist-info/licenses/LICENSE +21 -0
xtr_cache/__init__.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Cache pools in memory, in files, in Redis, chained or tag-aware.
|
|
2
|
+
|
|
3
|
+
Every pool implements the ``xtr-cache-contracts`` interfaces, which this
|
|
4
|
+
package re-exports rather than redefines — ``xtr_cache.CacheInterface is
|
|
5
|
+
xtr_cache_contracts.CacheInterface`` — so code written against the contract
|
|
6
|
+
receives these pools unchanged.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
cache = FilesystemAdapter("app")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def load_profile(item: ItemInterface) -> Profile:
|
|
13
|
+
item.expires_after(3600)
|
|
14
|
+
return await profiles.fetch(user_id)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
profile = await cache.get(f"profile.{user_id}", load_profile)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Every call that reaches a backend is awaited. A backend failing never raises:
|
|
21
|
+
reads miss, writes return ``False``, and the pool logs why.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
27
|
+
|
|
28
|
+
from xtr_cache_contracts import (
|
|
29
|
+
RESERVED_CHARACTERS,
|
|
30
|
+
CacheInterface,
|
|
31
|
+
CacheItemPoolInterface,
|
|
32
|
+
CacheMixin,
|
|
33
|
+
Callback,
|
|
34
|
+
ItemInterface,
|
|
35
|
+
Metadata,
|
|
36
|
+
NamespacedPoolInterface,
|
|
37
|
+
TagAwareCacheInterface,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from .adapter import (
|
|
41
|
+
AbstractAdapter,
|
|
42
|
+
AdapterFactory,
|
|
43
|
+
AdapterInterface,
|
|
44
|
+
ArrayAdapter,
|
|
45
|
+
ChainAdapter,
|
|
46
|
+
ContractsMixin,
|
|
47
|
+
FilesystemAdapter,
|
|
48
|
+
NullAdapter,
|
|
49
|
+
RedisAdapter,
|
|
50
|
+
TagAwareAdapter,
|
|
51
|
+
TagAwareAdapterInterface,
|
|
52
|
+
TaggedValue,
|
|
53
|
+
)
|
|
54
|
+
from .cache_item import CacheItem
|
|
55
|
+
from .cache_pool_clearer import CachePoolClearer, PoolProvider
|
|
56
|
+
from .exception import CacheError, InvalidArgumentError, LogicError, MarshallingError
|
|
57
|
+
from .lock_registry import LockRegistry
|
|
58
|
+
from .marshaller import (
|
|
59
|
+
DefaultMarshaller,
|
|
60
|
+
DeflateMarshaller,
|
|
61
|
+
MarshallerInterface,
|
|
62
|
+
SodiumMarshaller,
|
|
63
|
+
)
|
|
64
|
+
from .pruneable_interface import PruneableInterface
|
|
65
|
+
from .value_wrapper import ValueWrapper
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
__version__ = version("xtr-cache")
|
|
69
|
+
except PackageNotFoundError: # pragma: no cover
|
|
70
|
+
# Running from a source tree or a vendored copy, with no installed
|
|
71
|
+
# metadata to read. Having no version is better than refusing to import.
|
|
72
|
+
__version__ = "0+unknown"
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"RESERVED_CHARACTERS",
|
|
76
|
+
"AbstractAdapter",
|
|
77
|
+
"AdapterFactory",
|
|
78
|
+
"AdapterInterface",
|
|
79
|
+
"ArrayAdapter",
|
|
80
|
+
"CacheError",
|
|
81
|
+
"CacheInterface",
|
|
82
|
+
"CacheItem",
|
|
83
|
+
"CacheItemPoolInterface",
|
|
84
|
+
"CacheMixin",
|
|
85
|
+
"CachePoolClearer",
|
|
86
|
+
"Callback",
|
|
87
|
+
"ChainAdapter",
|
|
88
|
+
"ContractsMixin",
|
|
89
|
+
"DefaultMarshaller",
|
|
90
|
+
"DeflateMarshaller",
|
|
91
|
+
"FilesystemAdapter",
|
|
92
|
+
"InvalidArgumentError",
|
|
93
|
+
"ItemInterface",
|
|
94
|
+
"LockRegistry",
|
|
95
|
+
"LogicError",
|
|
96
|
+
"MarshallerInterface",
|
|
97
|
+
"MarshallingError",
|
|
98
|
+
"Metadata",
|
|
99
|
+
"NamespacedPoolInterface",
|
|
100
|
+
"NullAdapter",
|
|
101
|
+
"PoolProvider",
|
|
102
|
+
"PruneableInterface",
|
|
103
|
+
"RedisAdapter",
|
|
104
|
+
"SodiumMarshaller",
|
|
105
|
+
"TagAwareAdapter",
|
|
106
|
+
"TagAwareAdapterInterface",
|
|
107
|
+
"TagAwareCacheInterface",
|
|
108
|
+
"TaggedValue",
|
|
109
|
+
"ValueWrapper",
|
|
110
|
+
"__version__",
|
|
111
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The pools: in memory, in files, in Redis, nowhere, chained, or tag-aware."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .abstract_adapter import AbstractAdapter
|
|
6
|
+
from .adapter_factory import AdapterFactory
|
|
7
|
+
from .adapter_interface import AdapterInterface
|
|
8
|
+
from .array_adapter import ArrayAdapter
|
|
9
|
+
from .chain_adapter import ChainAdapter
|
|
10
|
+
from .contracts_mixin import ContractsMixin
|
|
11
|
+
from .filesystem_adapter import FilesystemAdapter
|
|
12
|
+
from .null_adapter import NullAdapter
|
|
13
|
+
from .redis_adapter import RedisAdapter
|
|
14
|
+
from .tag_aware_adapter import TagAwareAdapter
|
|
15
|
+
from .tag_aware_adapter_interface import TagAwareAdapterInterface
|
|
16
|
+
from .tagged_value import TaggedValue
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AbstractAdapter",
|
|
20
|
+
"AdapterFactory",
|
|
21
|
+
"AdapterInterface",
|
|
22
|
+
"ArrayAdapter",
|
|
23
|
+
"ChainAdapter",
|
|
24
|
+
"ContractsMixin",
|
|
25
|
+
"FilesystemAdapter",
|
|
26
|
+
"NullAdapter",
|
|
27
|
+
"RedisAdapter",
|
|
28
|
+
"TagAwareAdapter",
|
|
29
|
+
"TagAwareAdapterInterface",
|
|
30
|
+
"TaggedValue",
|
|
31
|
+
]
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""The shared machinery of a pool over a key-value backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import hashlib
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import TYPE_CHECKING, ClassVar, Final, Self
|
|
12
|
+
|
|
13
|
+
from typing_extensions import override
|
|
14
|
+
from xtr_cache_contracts import InvalidArgumentError, NamespacedPoolInterface
|
|
15
|
+
|
|
16
|
+
from xtr_cache.cache_item import CacheItem
|
|
17
|
+
from xtr_cache.exception.marshalling_error import MarshallingError
|
|
18
|
+
|
|
19
|
+
from .adapter_interface import AdapterInterface
|
|
20
|
+
from .contracts_mixin import ContractsMixin
|
|
21
|
+
from .deferred_items_mixin import DeferredItemsMixin
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
25
|
+
|
|
26
|
+
from xtr_clock import ClockInterface
|
|
27
|
+
|
|
28
|
+
from xtr_cache.marshaller.marshaller_interface import MarshallerInterface
|
|
29
|
+
|
|
30
|
+
__all__ = ["AbstractAdapter"]
|
|
31
|
+
|
|
32
|
+
_PREFIX_PATTERN: Final = re.compile(r"[-+.:_A-Za-z0-9]*")
|
|
33
|
+
|
|
34
|
+
_MISSING: Final = object()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AbstractAdapter(
|
|
38
|
+
DeferredItemsMixin, ContractsMixin, AdapterInterface, NamespacedPoolInterface, ABC
|
|
39
|
+
):
|
|
40
|
+
"""A pool whose backend stores values by identifier, with a lifetime each.
|
|
41
|
+
|
|
42
|
+
A subclass implements five operations on identifiers — fetch, has, clear,
|
|
43
|
+
delete, save — and gets every pool method on top: keys validated and
|
|
44
|
+
namespaced, deferred saves committed in batches by lifetime, sub-namespaces,
|
|
45
|
+
fetch-or-compute, and failures logged instead of raised. A backend that
|
|
46
|
+
cannot be reached makes reads miss and writes return ``False``; the code
|
|
47
|
+
caching keeps running.
|
|
48
|
+
|
|
49
|
+
An identifier is the pool's namespace followed by the key. A backend with
|
|
50
|
+
a limit on identifier length sets :attr:`max_id_length`, and keys that
|
|
51
|
+
would exceed it are hashed.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
NS_SEPARATOR: ClassVar[str] = ":"
|
|
55
|
+
"""What separates a namespace from what follows it in an identifier."""
|
|
56
|
+
|
|
57
|
+
max_id_length: ClassVar[int | None] = None
|
|
58
|
+
"""The longest identifier the backend takes, or ``None`` for no limit."""
|
|
59
|
+
|
|
60
|
+
_namespace: str
|
|
61
|
+
_default_lifetime: float
|
|
62
|
+
_clock: ClockInterface
|
|
63
|
+
_deferred: dict[str, CacheItem]
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
namespace: str = "",
|
|
68
|
+
default_lifetime: float = 0.0,
|
|
69
|
+
*,
|
|
70
|
+
clock: ClockInterface | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Keep values under ``namespace``, living ``default_lifetime`` unless an item says not.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
namespace: Put in front of every key; ``:`` separates
|
|
76
|
+
sub-namespaces. Empty for none.
|
|
77
|
+
default_lifetime: Seconds a value lives when its item sets no
|
|
78
|
+
expiry; ``0`` keeps it until it is deleted.
|
|
79
|
+
clock: What lifetimes are counted from. ``None`` reads the clock
|
|
80
|
+
in force.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
InvalidArgumentError: When ``namespace`` is not a valid key once
|
|
84
|
+
its separators are removed, has an empty sub-namespace, or
|
|
85
|
+
leaves no room for keys within :attr:`max_id_length`.
|
|
86
|
+
"""
|
|
87
|
+
if namespace:
|
|
88
|
+
if self.NS_SEPARATOR * 2 in namespace:
|
|
89
|
+
raise InvalidArgumentError(
|
|
90
|
+
f'Cache namespace "{namespace}" contains an empty sub-namespace.',
|
|
91
|
+
)
|
|
92
|
+
_ = CacheItem.validate_key(namespace.replace(self.NS_SEPARATOR, ""))
|
|
93
|
+
if self.max_id_length is not None and len(namespace) > self.max_id_length - 24:
|
|
94
|
+
raise InvalidArgumentError(
|
|
95
|
+
f"A cache namespace must be {self.max_id_length - 24} characters at most, "
|
|
96
|
+
f"got {len(namespace)}.",
|
|
97
|
+
)
|
|
98
|
+
namespace += self.NS_SEPARATOR
|
|
99
|
+
|
|
100
|
+
self._namespace = namespace
|
|
101
|
+
self._default_lifetime = default_lifetime
|
|
102
|
+
self._deferred = {}
|
|
103
|
+
if clock is not None:
|
|
104
|
+
self._clock = clock
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def namespace(self) -> str:
|
|
108
|
+
"""What every identifier starts with, separator included; empty for none."""
|
|
109
|
+
return self._namespace
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def default_lifetime(self) -> float:
|
|
113
|
+
"""Seconds a value lives when its item sets no expiry; ``0`` for no limit."""
|
|
114
|
+
return self._default_lifetime
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
async def _do_fetch(self, ids: Sequence[str]) -> Mapping[str, object]:
|
|
118
|
+
"""Return the stored value of every identifier the backend holds, leaving out the rest."""
|
|
119
|
+
|
|
120
|
+
@abstractmethod
|
|
121
|
+
async def _do_have(self, id_: str) -> bool:
|
|
122
|
+
"""Tell whether the backend holds a live value under ``id_``."""
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
async def _do_clear(self, namespace: str) -> bool:
|
|
126
|
+
"""Remove every value whose identifier starts with ``namespace``; all of them when empty."""
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
async def _do_delete(self, ids: Sequence[str]) -> bool:
|
|
130
|
+
"""Remove the values under ``ids``; ``False`` when that failed."""
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
async def _do_save(self, values: Mapping[str, object], lifetime: float) -> bool | Sequence[str]:
|
|
134
|
+
"""Store ``values`` by identifier for ``lifetime`` seconds, ``0`` meaning no limit.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
``True`` when every value was stored, ``False`` when none could
|
|
138
|
+
be and why is unknown, or the identifiers that failed.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
@override
|
|
142
|
+
async def get_item(self, key: str, /) -> CacheItem:
|
|
143
|
+
id_ = self._get_id(key)
|
|
144
|
+
await self._commit_if_queued([key])
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
found = await self._do_fetch([id_])
|
|
148
|
+
except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
|
|
149
|
+
self._log('Failed to fetch key "{key}": {reason}', error, key=key)
|
|
150
|
+
return self._create_item(key)
|
|
151
|
+
|
|
152
|
+
stored = found.get(id_, _MISSING)
|
|
153
|
+
if stored is _MISSING:
|
|
154
|
+
return self._create_item(key)
|
|
155
|
+
|
|
156
|
+
return self._create_item(key, stored)
|
|
157
|
+
|
|
158
|
+
@override
|
|
159
|
+
async def get_items(self, keys: Iterable[str], /) -> Mapping[str, CacheItem]:
|
|
160
|
+
wanted = list(dict.fromkeys(keys))
|
|
161
|
+
ids = {key: self._get_id(key) for key in wanted}
|
|
162
|
+
await self._commit_if_queued(wanted)
|
|
163
|
+
|
|
164
|
+
found: Mapping[str, object] = {}
|
|
165
|
+
try:
|
|
166
|
+
found = await self._do_fetch(list(ids.values()))
|
|
167
|
+
except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
|
|
168
|
+
self._log("Failed to fetch items: {reason}", error, keys=wanted)
|
|
169
|
+
|
|
170
|
+
items: dict[str, CacheItem] = {}
|
|
171
|
+
for key, id_ in ids.items():
|
|
172
|
+
stored = found.get(id_, _MISSING)
|
|
173
|
+
items[key] = (
|
|
174
|
+
self._create_item(key) if stored is _MISSING else self._create_item(key, stored)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return items
|
|
178
|
+
|
|
179
|
+
@override
|
|
180
|
+
async def has_item(self, key: str, /) -> bool:
|
|
181
|
+
id_ = self._get_id(key)
|
|
182
|
+
await self._commit_if_queued([key])
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
return await self._do_have(id_)
|
|
186
|
+
except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
|
|
187
|
+
self._log('Failed to check if key "{key}" is cached: {reason}', error, key=key)
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
@override
|
|
191
|
+
async def clear(self, prefix: str = "") -> bool:
|
|
192
|
+
self._drop_deferred(prefix)
|
|
193
|
+
if not _PREFIX_PATTERN.fullmatch(prefix):
|
|
194
|
+
self._log("Failed to clear the cache: the prefix contains invalid characters.")
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
return await self._do_clear(self._namespace + prefix)
|
|
199
|
+
except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
|
|
200
|
+
self._log("Failed to clear the cache: {reason}", error)
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
@override
|
|
204
|
+
async def delete_item(self, key: str, /) -> bool:
|
|
205
|
+
return await self.delete_items([key])
|
|
206
|
+
|
|
207
|
+
@override
|
|
208
|
+
async def delete_items(self, keys: Iterable[str], /) -> bool:
|
|
209
|
+
ids = {key: self._get_id(key) for key in keys}
|
|
210
|
+
self._forget_deferred(ids)
|
|
211
|
+
|
|
212
|
+
if not ids:
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
# A batch failing is retried one key at a time below, where each failure is logged.
|
|
216
|
+
with contextlib.suppress(Exception):
|
|
217
|
+
if await self._do_delete(list(ids.values())):
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
ok = True
|
|
221
|
+
for key, id_ in ids.items():
|
|
222
|
+
try:
|
|
223
|
+
if await self._do_delete([id_]):
|
|
224
|
+
continue
|
|
225
|
+
self._log('Failed to delete key "{key}".', key=key)
|
|
226
|
+
except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
|
|
227
|
+
self._log('Failed to delete key "{key}": {reason}', error, key=key)
|
|
228
|
+
ok = False
|
|
229
|
+
|
|
230
|
+
return ok
|
|
231
|
+
|
|
232
|
+
@override
|
|
233
|
+
async def commit(self) -> bool:
|
|
234
|
+
deferred, self._deferred = self._deferred, {}
|
|
235
|
+
if not deferred:
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
now = self._clock.now().timestamp()
|
|
239
|
+
default_expiry = now + self._default_lifetime if self._default_lifetime > 0 else None
|
|
240
|
+
by_lifetime: dict[float, dict[str, object]] = {}
|
|
241
|
+
keys_by_id: dict[str, str] = {}
|
|
242
|
+
expired: list[str] = []
|
|
243
|
+
for key, item in deferred.items():
|
|
244
|
+
id_ = self._get_id(key)
|
|
245
|
+
keys_by_id[id_] = key
|
|
246
|
+
if item.expiry is None:
|
|
247
|
+
lifetime = max(self._default_lifetime, 0.0)
|
|
248
|
+
else:
|
|
249
|
+
lifetime = math.ceil((item.expiry - now) * 1000) / 1000
|
|
250
|
+
if lifetime <= 0:
|
|
251
|
+
expired.append(id_)
|
|
252
|
+
continue
|
|
253
|
+
by_lifetime.setdefault(lifetime, {})[id_] = item.pack(default_expiry)
|
|
254
|
+
|
|
255
|
+
ok = True
|
|
256
|
+
if expired:
|
|
257
|
+
try:
|
|
258
|
+
_ = await self._do_delete(expired)
|
|
259
|
+
except Exception as error: # noqa: BLE001 — a cache failure must never fail the caller.
|
|
260
|
+
ok = False
|
|
261
|
+
self._log("Failed to delete expired items: {reason}", error)
|
|
262
|
+
|
|
263
|
+
for lifetime, values in by_lifetime.items():
|
|
264
|
+
ok = await self._save_batch(values, lifetime, keys_by_id) and ok
|
|
265
|
+
|
|
266
|
+
return ok
|
|
267
|
+
|
|
268
|
+
@override
|
|
269
|
+
def with_sub_namespace(self, namespace: str, /) -> Self:
|
|
270
|
+
clone = self._unqueued_copy()
|
|
271
|
+
clone._namespace = ( # noqa: SLF001 — a copy of this very class.
|
|
272
|
+
self._namespace + CacheItem.validate_key(namespace) + self.NS_SEPARATOR
|
|
273
|
+
)
|
|
274
|
+
return clone
|
|
275
|
+
|
|
276
|
+
@override
|
|
277
|
+
def _scope(self) -> str:
|
|
278
|
+
return self._namespace
|
|
279
|
+
|
|
280
|
+
async def _save_batch(
|
|
281
|
+
self,
|
|
282
|
+
values: Mapping[str, object],
|
|
283
|
+
lifetime: float,
|
|
284
|
+
keys_by_id: Mapping[str, str],
|
|
285
|
+
) -> bool:
|
|
286
|
+
"""Save one batch, retrying value by value when the backend did not say what failed."""
|
|
287
|
+
error: Exception | None = None
|
|
288
|
+
try:
|
|
289
|
+
result = await self._do_save(values, lifetime)
|
|
290
|
+
except Exception as raised: # noqa: BLE001 — logged below, per value.
|
|
291
|
+
result, error = False, raised
|
|
292
|
+
|
|
293
|
+
if result is True:
|
|
294
|
+
return True
|
|
295
|
+
if result is not False:
|
|
296
|
+
for id_ in result:
|
|
297
|
+
self._log_save_failure(keys_by_id[id_], values[id_])
|
|
298
|
+
return len(result) == 0
|
|
299
|
+
if len(values) == 1:
|
|
300
|
+
for id_, value in values.items():
|
|
301
|
+
self._log_save_failure(keys_by_id[id_], value, error)
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
ok = True
|
|
305
|
+
for id_, value in values.items():
|
|
306
|
+
ok = await self._save_batch({id_: value}, lifetime, keys_by_id) and ok
|
|
307
|
+
|
|
308
|
+
return ok
|
|
309
|
+
|
|
310
|
+
def _create_item(self, key: str, stored: object = _MISSING) -> CacheItem:
|
|
311
|
+
if stored is _MISSING:
|
|
312
|
+
return CacheItem(key, clock=self._clock)
|
|
313
|
+
|
|
314
|
+
return CacheItem.from_stored(key, stored, clock=self._clock)
|
|
315
|
+
|
|
316
|
+
def _get_id(self, key: str) -> str:
|
|
317
|
+
"""Return the backend identifier of ``key``, hashing it when it would be too long.
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
InvalidArgumentError: When ``key`` is not a valid key.
|
|
321
|
+
"""
|
|
322
|
+
id_ = self._namespace + CacheItem.validate_key(key)
|
|
323
|
+
if self.max_id_length is None or len(id_) <= self.max_id_length:
|
|
324
|
+
return id_
|
|
325
|
+
|
|
326
|
+
digest = base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()[:16]).decode()
|
|
327
|
+
return self._namespace + digest.rstrip("=") + self.NS_SEPARATOR
|
|
328
|
+
|
|
329
|
+
def _unmarshall_found(
|
|
330
|
+
self,
|
|
331
|
+
marshaller: MarshallerInterface,
|
|
332
|
+
raw: Mapping[str, bytes],
|
|
333
|
+
) -> dict[str, object]:
|
|
334
|
+
"""Decode what a byte backend returned, leaving out — and logging — what does not decode."""
|
|
335
|
+
values: dict[str, object] = {}
|
|
336
|
+
for id_, data in raw.items():
|
|
337
|
+
try:
|
|
338
|
+
values[id_] = marshaller.unmarshall(data)
|
|
339
|
+
except MarshallingError as error:
|
|
340
|
+
self._log('Failed to read key "{key}": {reason}', error, key=id_)
|
|
341
|
+
|
|
342
|
+
return values
|
|
343
|
+
|
|
344
|
+
def _log_save_failure(self, key: str, value: object, error: Exception | None = None) -> None:
|
|
345
|
+
kind = type(value).__qualname__
|
|
346
|
+
if error is None:
|
|
347
|
+
self._log(f'Failed to save key "{{key}}" of type {kind}.', key=key)
|
|
348
|
+
else:
|
|
349
|
+
self._log(f'Failed to save key "{{key}}" of type {kind}: {{reason}}', error, key=key)
|
|
350
|
+
|
|
351
|
+
def _log(self, message: str, error: Exception | None = None, **context: object) -> None:
|
|
352
|
+
context["cache_adapter"] = type(self).__name__
|
|
353
|
+
if error is not None:
|
|
354
|
+
context["exception"] = error
|
|
355
|
+
context["reason"] = str(error)
|
|
356
|
+
self.logger.warning(message, context)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Builds an adapter from a DSN, a keyword, or a connection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Final, final
|
|
6
|
+
|
|
7
|
+
from xtr_lock.store import is_redis_client, is_redis_dsn, redis_installed
|
|
8
|
+
|
|
9
|
+
from xtr_cache.exception import InvalidArgumentError
|
|
10
|
+
|
|
11
|
+
from .array_adapter import ArrayAdapter
|
|
12
|
+
from .filesystem_adapter import FilesystemAdapter
|
|
13
|
+
from .null_adapter import NullAdapter
|
|
14
|
+
from .redis_adapter import RedisAdapter
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import os
|
|
18
|
+
|
|
19
|
+
from xtr_clock import ClockInterface
|
|
20
|
+
|
|
21
|
+
from xtr_cache.marshaller.marshaller_interface import MarshallerInterface
|
|
22
|
+
|
|
23
|
+
from .adapter_interface import AdapterInterface
|
|
24
|
+
|
|
25
|
+
__all__ = ["AdapterFactory"]
|
|
26
|
+
|
|
27
|
+
_FILESYSTEM: Final = "filesystem"
|
|
28
|
+
_FILESYSTEM_PREFIX: Final = "filesystem://"
|
|
29
|
+
_KEYWORDS: Final = ("array", "null", _FILESYSTEM)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@final
|
|
33
|
+
class AdapterFactory:
|
|
34
|
+
"""Turns what a configuration names into an adapter.
|
|
35
|
+
|
|
36
|
+
| Given | Adapter |
|
|
37
|
+
|---|---|
|
|
38
|
+
| ``"array"`` | :class:`ArrayAdapter` |
|
|
39
|
+
| ``"null"`` | :class:`NullAdapter` |
|
|
40
|
+
| ``"filesystem"`` | :class:`FilesystemAdapter` in ``directory``, or the temporary one |
|
|
41
|
+
| ``"filesystem:///var/cache/app"`` | :class:`FilesystemAdapter` in that directory |
|
|
42
|
+
| ``"redis://…"``, ``"rediss://…"``, ``"unix://…"`` | :class:`RedisAdapter`, its own client |
|
|
43
|
+
| ``"valkey://…"``, ``"valkeys://…"`` | the same, read as ``redis`` / ``rediss`` |
|
|
44
|
+
| an asyncio Redis client | :class:`RedisAdapter` on that client |
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__slots__ = ()
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def create_adapter( # noqa: PLR0913 — every option past the connection is keyword-only.
|
|
51
|
+
connection: object,
|
|
52
|
+
namespace: str = "",
|
|
53
|
+
default_lifetime: float = 0.0,
|
|
54
|
+
*,
|
|
55
|
+
marshaller: MarshallerInterface | None = None,
|
|
56
|
+
directory: str | os.PathLike[str] | None = None,
|
|
57
|
+
clock: ClockInterface | None = None,
|
|
58
|
+
) -> AdapterInterface:
|
|
59
|
+
"""Build the adapter ``connection`` names.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
connection: A DSN, a keyword, or an asyncio Redis client.
|
|
63
|
+
namespace: The pool's namespace; an in-memory pool has no use for one.
|
|
64
|
+
default_lifetime: Seconds a value lives when its item sets no expiry.
|
|
65
|
+
marshaller: What turns values into bytes, for a backend storing bytes.
|
|
66
|
+
directory: Where ``"filesystem"`` keeps its files.
|
|
67
|
+
clock: What lifetimes are counted from.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
InvalidArgumentError: When no adapter serves ``connection``. The
|
|
71
|
+
message names its scheme or type only, never credentials.
|
|
72
|
+
"""
|
|
73
|
+
if is_redis_client(connection):
|
|
74
|
+
return RedisAdapter(
|
|
75
|
+
connection,
|
|
76
|
+
namespace,
|
|
77
|
+
default_lifetime,
|
|
78
|
+
marshaller=marshaller,
|
|
79
|
+
clock=clock,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if not isinstance(connection, str):
|
|
83
|
+
raise InvalidArgumentError(
|
|
84
|
+
f'Unsupported cache connection: "{type(connection).__qualname__}".',
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
AdapterFactory.validate(connection)
|
|
88
|
+
|
|
89
|
+
if connection == "array":
|
|
90
|
+
return ArrayAdapter(default_lifetime, marshaller=marshaller, clock=clock)
|
|
91
|
+
if connection == "null":
|
|
92
|
+
return NullAdapter()
|
|
93
|
+
if connection == _FILESYSTEM or connection.startswith(_FILESYSTEM_PREFIX):
|
|
94
|
+
path = (
|
|
95
|
+
connection.removeprefix(_FILESYSTEM_PREFIX) if connection != _FILESYSTEM else None
|
|
96
|
+
)
|
|
97
|
+
return FilesystemAdapter(
|
|
98
|
+
namespace,
|
|
99
|
+
default_lifetime,
|
|
100
|
+
path or directory,
|
|
101
|
+
marshaller=marshaller,
|
|
102
|
+
clock=clock,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# The only thing validate() lets through that is not handled above.
|
|
106
|
+
return RedisAdapter.from_url(
|
|
107
|
+
connection,
|
|
108
|
+
namespace,
|
|
109
|
+
default_lifetime,
|
|
110
|
+
marshaller=marshaller,
|
|
111
|
+
clock=clock,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def validate(connection: str) -> None:
|
|
116
|
+
"""Refuse a DSN no adapter serves, without building one or connecting to anything.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
InvalidArgumentError: When no adapter serves ``connection``, or it
|
|
120
|
+
needs an extra that is not installed. The message names its
|
|
121
|
+
scheme only, never credentials.
|
|
122
|
+
"""
|
|
123
|
+
if connection in _KEYWORDS or connection.startswith(_FILESYSTEM_PREFIX):
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if is_redis_dsn(connection):
|
|
127
|
+
if not redis_installed():
|
|
128
|
+
raise InvalidArgumentError(RedisAdapter.MISSING_CLIENT)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
scheme, separator, _ = connection.partition(":")
|
|
132
|
+
described = f"{scheme}:" if separator else connection
|
|
133
|
+
|
|
134
|
+
raise InvalidArgumentError(f'Unsupported cache connection: "{described}".')
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""What every pool of this library is: an item pool, a cache, and resettable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
from xtr_cache_contracts import CacheInterface, CacheItemPoolInterface
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterable, Mapping
|
|
12
|
+
|
|
13
|
+
from xtr_cache.cache_item import CacheItem
|
|
14
|
+
|
|
15
|
+
__all__ = ["AdapterInterface"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class AdapterInterface(CacheItemPoolInterface, CacheInterface, Protocol):
|
|
20
|
+
"""A pool handing out :class:`~xtr_cache.cache_item.CacheItem`, with fetch-or-compute on top.
|
|
21
|
+
|
|
22
|
+
Every adapter of this library is one, which is what lets them wrap and
|
|
23
|
+
chain each other: an item one hands out, another stores.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
@override
|
|
27
|
+
async def get_item(self, key: str, /) -> CacheItem:
|
|
28
|
+
"""Return the item for ``key``, a hit or a miss."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
async def get_items(self, keys: Iterable[str], /) -> Mapping[str, CacheItem]:
|
|
33
|
+
"""Return an item for each of ``keys``, in the order asked."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
async def reset(self) -> None:
|
|
37
|
+
"""End a unit of work: store what was saved as deferred, and forget per-unit state.
|
|
38
|
+
|
|
39
|
+
What a long-running process calls between messages or requests, so
|
|
40
|
+
deferred items do not wait for a commit that nothing will call.
|
|
41
|
+
"""
|
|
42
|
+
...
|