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