xtr-cache-contracts 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_contracts/__init__.py +67 -0
- xtr_cache_contracts/cache_interface.py +85 -0
- xtr_cache_contracts/cache_item_pool_interface.py +133 -0
- xtr_cache_contracts/cache_mixin.py +169 -0
- xtr_cache_contracts/callback.py +33 -0
- xtr_cache_contracts/exception/__init__.py +17 -0
- xtr_cache_contracts/exception/cache_error.py +20 -0
- xtr_cache_contracts/exception/invalid_argument_error.py +28 -0
- xtr_cache_contracts/item_interface.py +93 -0
- xtr_cache_contracts/metadata.py +37 -0
- xtr_cache_contracts/namespaced_pool_interface.py +29 -0
- xtr_cache_contracts/py.typed +0 -0
- xtr_cache_contracts/tag_aware_cache_interface.py +47 -0
- xtr_cache_contracts-1.2.0.dist-info/METADATA +239 -0
- xtr_cache_contracts-1.2.0.dist-info/RECORD +17 -0
- xtr_cache_contracts-1.2.0.dist-info/WHEEL +4 -0
- xtr_cache_contracts-1.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The caching contract: what code that caches depends on, and nothing more.
|
|
2
|
+
|
|
3
|
+
Code that caches should not decide where values go. It takes a
|
|
4
|
+
:class:`CacheInterface` and hands it a key and the function that computes the
|
|
5
|
+
value; the application decides whether memory, files or a server sit behind
|
|
6
|
+
it — so depending on this package costs a library nothing but the contract.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
async def load_profile(item: ItemInterface) -> Profile:
|
|
10
|
+
item.expires_after(3600)
|
|
11
|
+
return await profiles.fetch(user_id)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
profile = await cache.get(f"profile.{user_id}", load_profile)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Three levels, most code needing only the first:
|
|
18
|
+
|
|
19
|
+
- :class:`CacheInterface` — fetch-or-compute, and delete.
|
|
20
|
+
:class:`TagAwareCacheInterface` adds invalidation by tag.
|
|
21
|
+
- :class:`CacheItemPoolInterface` — the items underneath: hits told apart
|
|
22
|
+
from misses, reads of several keys at once, batched writes. What a backend
|
|
23
|
+
implements; :class:`CacheMixin` builds :class:`CacheInterface` on it.
|
|
24
|
+
- :class:`NamespacedPoolInterface` — a view of a pool confined to a
|
|
25
|
+
sub-namespace.
|
|
26
|
+
|
|
27
|
+
Every call that reaches a backend is awaited. Backends and everything that
|
|
28
|
+
acts on stored values — adapters, serialisation, stampede locks, the bundle —
|
|
29
|
+
are ``xtr-cache``, which implements this contract and re-exports it, so the
|
|
30
|
+
two are never two different objects.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
36
|
+
|
|
37
|
+
from .cache_interface import CacheInterface
|
|
38
|
+
from .cache_item_pool_interface import CacheItemPoolInterface
|
|
39
|
+
from .cache_mixin import CacheMixin
|
|
40
|
+
from .callback import Callback
|
|
41
|
+
from .exception import CacheError, InvalidArgumentError
|
|
42
|
+
from .item_interface import RESERVED_CHARACTERS, ItemInterface
|
|
43
|
+
from .metadata import Metadata
|
|
44
|
+
from .namespaced_pool_interface import NamespacedPoolInterface
|
|
45
|
+
from .tag_aware_cache_interface import TagAwareCacheInterface
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
__version__ = version("xtr-cache-contracts")
|
|
49
|
+
except PackageNotFoundError: # pragma: no cover
|
|
50
|
+
# Running from a source tree or a vendored copy, with no installed
|
|
51
|
+
# metadata to read. Having no version is better than refusing to import.
|
|
52
|
+
__version__ = "0+unknown"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"RESERVED_CHARACTERS",
|
|
56
|
+
"CacheError",
|
|
57
|
+
"CacheInterface",
|
|
58
|
+
"CacheItemPoolInterface",
|
|
59
|
+
"CacheMixin",
|
|
60
|
+
"Callback",
|
|
61
|
+
"InvalidArgumentError",
|
|
62
|
+
"ItemInterface",
|
|
63
|
+
"Metadata",
|
|
64
|
+
"NamespacedPoolInterface",
|
|
65
|
+
"TagAwareCacheInterface",
|
|
66
|
+
"__version__",
|
|
67
|
+
]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Read a value from a cache, computing and storing it when it is missing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, TypeVar, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .callback import Callback
|
|
9
|
+
from .metadata import Metadata
|
|
10
|
+
|
|
11
|
+
__all__ = ["CacheInterface"]
|
|
12
|
+
|
|
13
|
+
_T = TypeVar("_T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@runtime_checkable
|
|
17
|
+
class CacheInterface(Protocol):
|
|
18
|
+
"""Covers most caching needs in two calls: fetch-or-compute, and forget.
|
|
19
|
+
|
|
20
|
+
The type code that caches should depend on. Instead of checking for a
|
|
21
|
+
value, computing it on a miss and saving it — three steps, with a race
|
|
22
|
+
between each — a caller hands :meth:`get` the function that computes the
|
|
23
|
+
value, and the cache decides when to call it:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
profile = await cache.get(f"profile.{user_id}", load_profile)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Deciding in one place is what lets an implementation protect a backend
|
|
30
|
+
from a stampede — many callers missing the same key at once, all
|
|
31
|
+
computing it — by computing it once and sharing the result, or by
|
|
32
|
+
refreshing a value shortly before it expires.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
async def get(
|
|
36
|
+
self,
|
|
37
|
+
key: str,
|
|
38
|
+
callback: Callback[_T],
|
|
39
|
+
/,
|
|
40
|
+
*,
|
|
41
|
+
beta: float | None = None,
|
|
42
|
+
metadata: Metadata | None = None,
|
|
43
|
+
) -> _T:
|
|
44
|
+
"""Return the value cached under ``key``, computing and saving it on a miss.
|
|
45
|
+
|
|
46
|
+
On a miss ``callback`` is awaited with the item for ``key``; what it
|
|
47
|
+
returns is saved and returned. A value that cannot be saved is still
|
|
48
|
+
returned — the cache is an optimisation, not a dependency — and
|
|
49
|
+
reported through ``metadata``.
|
|
50
|
+
|
|
51
|
+
On a hit the stored value is returned as the type ``callback`` would
|
|
52
|
+
have returned: keep one key for one type.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
key: The key the value is stored under.
|
|
56
|
+
callback: Computes the value on a miss.
|
|
57
|
+
beta: How eagerly to recompute a value before it expires. The
|
|
58
|
+
chance grows as expiry nears, and faster the larger ``beta``
|
|
59
|
+
is. ``0`` disables early recomputation; ``math.inf`` forces
|
|
60
|
+
recomputation now. ``None`` lets the implementation choose,
|
|
61
|
+
``1.0`` being the usual choice.
|
|
62
|
+
metadata: A mapping to fill with the metadata of the value — see
|
|
63
|
+
:class:`~xtr_cache_contracts.metadata.Metadata` — plus
|
|
64
|
+
``save_failed`` when a computed value could not be saved.
|
|
65
|
+
Keys already in it are overwritten, not cleared.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The cached value, or the one ``callback`` computed.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
InvalidArgumentError: When ``key`` is not a valid key, or ``beta``
|
|
72
|
+
is negative.
|
|
73
|
+
"""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
async def delete(self, key: str, /) -> bool:
|
|
77
|
+
"""Remove the value under ``key``; a key holding nothing is fine.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
``False`` when the backend failed; ``True`` otherwise.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
InvalidArgumentError: When ``key`` is not a valid key.
|
|
84
|
+
"""
|
|
85
|
+
...
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""A store of cache items: look them up, save them, delete them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
|
|
10
|
+
from .item_interface import ItemInterface
|
|
11
|
+
|
|
12
|
+
__all__ = ["CacheItemPoolInterface"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class CacheItemPoolInterface(Protocol):
|
|
17
|
+
"""The item-level view of a cache, for code that needs more than fetch-or-compute.
|
|
18
|
+
|
|
19
|
+
Most code should depend on
|
|
20
|
+
:class:`~xtr_cache_contracts.cache_interface.CacheInterface`. This is the
|
|
21
|
+
level below it: a hit told apart from a miss, several keys read in one
|
|
22
|
+
round trip, writes batched until :meth:`commit`. It is also the level a
|
|
23
|
+
backend implements — :class:`~xtr_cache_contracts.cache_mixin.CacheMixin`
|
|
24
|
+
builds the fetch-or-compute contract on top of it.
|
|
25
|
+
|
|
26
|
+
The rules:
|
|
27
|
+
|
|
28
|
+
- A key is a non-empty string without any of
|
|
29
|
+
:data:`~xtr_cache_contracts.item_interface.RESERVED_CHARACTERS`. Letters,
|
|
30
|
+
digits, ``_`` and ``.`` up to 64 characters work everywhere; a pool may
|
|
31
|
+
accept more. A key it refuses raises
|
|
32
|
+
:class:`~xtr_cache_contracts.exception.InvalidArgumentError`.
|
|
33
|
+
- A backend failure never raises: the call returns ``False``, or reads as
|
|
34
|
+
a miss, and the implementation logs it. Code that caches keeps working
|
|
35
|
+
when the cache does not.
|
|
36
|
+
- A pool saves items of the kind it hands out, and refuses any other by
|
|
37
|
+
returning ``False``. Pools of one implementation may take each other's
|
|
38
|
+
items, which is how one pool fills another.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
async def get_item(self, key: str, /) -> ItemInterface:
|
|
42
|
+
"""Return the item for ``key``, a hit or a miss.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
InvalidArgumentError: When ``key`` is not a valid key.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
async def get_items(self, keys: Iterable[str], /) -> Mapping[str, ItemInterface]:
|
|
50
|
+
"""Return an item for each of ``keys``, hits and misses alike, in the order asked.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
InvalidArgumentError: When a key is not a valid key.
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
async def has_item(self, key: str, /) -> bool:
|
|
58
|
+
"""Tell whether the pool holds a value for ``key``.
|
|
59
|
+
|
|
60
|
+
Do not follow it with :meth:`get_item`: the value may expire in
|
|
61
|
+
between. Read the item and ask it
|
|
62
|
+
:meth:`~xtr_cache_contracts.item_interface.ItemInterface.is_hit`
|
|
63
|
+
instead.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
InvalidArgumentError: When ``key`` is not a valid key.
|
|
67
|
+
"""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
async def clear(self, prefix: str = "") -> bool:
|
|
71
|
+
"""Remove every item whose key starts with ``prefix``; every item, by default.
|
|
72
|
+
|
|
73
|
+
Items saved as deferred and not yet committed are dropped too.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
``False`` when the backend failed; ``True`` otherwise.
|
|
77
|
+
"""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
async def delete_item(self, key: str, /) -> bool:
|
|
81
|
+
"""Remove the item under ``key``; a key holding nothing is fine.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
``False`` when the backend failed; ``True`` otherwise.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
InvalidArgumentError: When ``key`` is not a valid key.
|
|
88
|
+
"""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
async def delete_items(self, keys: Iterable[str], /) -> bool:
|
|
92
|
+
"""Remove the items under ``keys``.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
``False`` when the backend failed for any of them; ``True``
|
|
96
|
+
otherwise.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
InvalidArgumentError: When a key is not a valid key.
|
|
100
|
+
"""
|
|
101
|
+
...
|
|
102
|
+
|
|
103
|
+
async def save(self, item: ItemInterface, /) -> bool:
|
|
104
|
+
"""Store ``item`` now.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
``False`` when the backend failed, or when ``item`` is of a kind
|
|
108
|
+
the pool does not store; ``True`` otherwise.
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
async def save_deferred(self, item: ItemInterface, /) -> bool:
|
|
113
|
+
"""Queue ``item`` to be stored by the next :meth:`commit`.
|
|
114
|
+
|
|
115
|
+
Reading a queued item's key from this pool commits the queue first, so
|
|
116
|
+
the pool never answers with a value older than one it was given.
|
|
117
|
+
Nothing commits the queue on its own when the pool is discarded: call
|
|
118
|
+
:meth:`commit`.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
``False`` when ``item`` is of a kind the pool does not store;
|
|
122
|
+
``True`` otherwise.
|
|
123
|
+
"""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
async def commit(self) -> bool:
|
|
127
|
+
"""Store every item queued by :meth:`save_deferred`.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
``False`` when the backend failed for any of them; ``True``
|
|
131
|
+
otherwise.
|
|
132
|
+
"""
|
|
133
|
+
...
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Fetch-or-compute, for a class that already knows how to read, save and delete items."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from random import random
|
|
8
|
+
from typing import TYPE_CHECKING, Final, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
from xtr_clock import now
|
|
12
|
+
|
|
13
|
+
from .cache_interface import CacheInterface
|
|
14
|
+
from .exception.invalid_argument_error import InvalidArgumentError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .callback import Callback
|
|
18
|
+
from .item_interface import ItemInterface
|
|
19
|
+
from .metadata import Metadata
|
|
20
|
+
|
|
21
|
+
__all__ = ["CacheMixin"]
|
|
22
|
+
|
|
23
|
+
_T = TypeVar("_T")
|
|
24
|
+
|
|
25
|
+
_DEFAULT_BETA: Final = 1.0
|
|
26
|
+
"""The ``beta`` used when none is given; what the early-expiry model is tuned for."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CacheMixin(CacheInterface, ABC):
|
|
30
|
+
"""Implements :class:`~xtr_cache_contracts.cache_interface.CacheInterface` on a pool.
|
|
31
|
+
|
|
32
|
+
A class that implements
|
|
33
|
+
:class:`~xtr_cache_contracts.cache_item_pool_interface.CacheItemPoolInterface`
|
|
34
|
+
derives from this too and gets :meth:`get` and :meth:`delete` built on its
|
|
35
|
+
own :meth:`get_item`, :meth:`save` and :meth:`delete_item`. List it after
|
|
36
|
+
the pool's interfaces, so the pool's own methods are the ones found.
|
|
37
|
+
|
|
38
|
+
Early recomputation: a hit whose metadata says when it expires and how
|
|
39
|
+
long it took to compute may be recomputed before it expires, with a
|
|
40
|
+
chance that grows as expiry nears. Under load, one caller refreshes the
|
|
41
|
+
value while the others still read the old one, rather than all of them
|
|
42
|
+
missing at the same moment. A value stored without that metadata is only
|
|
43
|
+
recomputed once it has expired. The time is read from the clock in force
|
|
44
|
+
(:func:`xtr_clock.now`), so freezing it in a test freezes this too.
|
|
45
|
+
|
|
46
|
+
:meth:`get` is a template an implementation adjusts through three steps
|
|
47
|
+
rather than rewrites: :meth:`_compute` — how a missing value is computed
|
|
48
|
+
and saved, where concurrent misses can be made to share one computation
|
|
49
|
+
or wait on a lock; :meth:`_now` — which clock is read; and
|
|
50
|
+
:meth:`_on_elected` — what to do when a hit is elected for early
|
|
51
|
+
recomputation, such as logging it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def get_item(self, key: str, /) -> ItemInterface:
|
|
56
|
+
"""Return the item for ``key``, a hit or a miss."""
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def save(self, item: ItemInterface, /) -> bool:
|
|
60
|
+
"""Store ``item`` now; ``False`` when that failed."""
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def delete_item(self, key: str, /) -> bool:
|
|
64
|
+
"""Remove the item under ``key``; ``False`` when that failed."""
|
|
65
|
+
|
|
66
|
+
@override
|
|
67
|
+
async def get(
|
|
68
|
+
self,
|
|
69
|
+
key: str,
|
|
70
|
+
callback: Callback[_T],
|
|
71
|
+
/,
|
|
72
|
+
*,
|
|
73
|
+
beta: float | None = None,
|
|
74
|
+
metadata: Metadata | None = None,
|
|
75
|
+
) -> _T:
|
|
76
|
+
"""Return the value under ``key``, computing and saving it on a miss.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
InvalidArgumentError: When ``key`` is not a valid key, or ``beta``
|
|
80
|
+
is negative or not a number.
|
|
81
|
+
"""
|
|
82
|
+
beta = _DEFAULT_BETA if beta is None else beta
|
|
83
|
+
# Written this way round so that NaN, which compares false to everything, is refused too.
|
|
84
|
+
if not beta >= 0:
|
|
85
|
+
raise InvalidArgumentError(f"beta must be zero or more, got {beta!r}")
|
|
86
|
+
|
|
87
|
+
item = await self.get_item(key)
|
|
88
|
+
found = item.metadata
|
|
89
|
+
if metadata is not None:
|
|
90
|
+
metadata.update(found)
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
item.is_hit()
|
|
94
|
+
and not math.isinf(beta)
|
|
95
|
+
and not self._elect_early_recomputation(item, found, beta)
|
|
96
|
+
):
|
|
97
|
+
# The key's value was stored by the same callback type, which is the caller's promise.
|
|
98
|
+
return cast("_T", item.get())
|
|
99
|
+
|
|
100
|
+
return await self._compute(item, callback, beta, metadata)
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
async def delete(self, key: str, /) -> bool:
|
|
104
|
+
"""Remove the value under ``key``; ``False`` when the backend failed."""
|
|
105
|
+
return await self.delete_item(key)
|
|
106
|
+
|
|
107
|
+
async def _compute(
|
|
108
|
+
self,
|
|
109
|
+
item: ItemInterface,
|
|
110
|
+
callback: Callback[_T],
|
|
111
|
+
beta: float,
|
|
112
|
+
metadata: Metadata | None,
|
|
113
|
+
) -> _T:
|
|
114
|
+
"""Compute the value for ``item`` with ``callback``, save it, and return it.
|
|
115
|
+
|
|
116
|
+
A value that cannot be saved is still returned, and reported through
|
|
117
|
+
``metadata``. ``beta`` is infinite when the caller forces the value to
|
|
118
|
+
be computed again.
|
|
119
|
+
"""
|
|
120
|
+
del beta
|
|
121
|
+
value = await callback(item)
|
|
122
|
+
_ = item.set(value)
|
|
123
|
+
if not await self.save(item) and metadata is not None:
|
|
124
|
+
metadata["save_failed"] = True
|
|
125
|
+
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
def _now(self) -> float:
|
|
129
|
+
"""Return the current time as a Unix timestamp, from the clock in force."""
|
|
130
|
+
return now().timestamp()
|
|
131
|
+
|
|
132
|
+
def _on_elected(self, item: ItemInterface, remaining: float) -> None:
|
|
133
|
+
"""Hear that ``item`` was elected for early recomputation, ``remaining`` seconds early."""
|
|
134
|
+
del item, remaining
|
|
135
|
+
|
|
136
|
+
def _elect_early_recomputation(
|
|
137
|
+
self,
|
|
138
|
+
item: ItemInterface,
|
|
139
|
+
metadata: Metadata,
|
|
140
|
+
beta: float,
|
|
141
|
+
) -> bool:
|
|
142
|
+
"""Decide whether a hit should be recomputed now, ahead of its expiry.
|
|
143
|
+
|
|
144
|
+
The value is recomputed when its expiry falls within a random span
|
|
145
|
+
ahead of now — the time it took to compute, scaled by ``beta`` and by
|
|
146
|
+
an exponentially distributed factor. Values that are slow to compute
|
|
147
|
+
are refreshed earlier, and callers spread over time do not all pick
|
|
148
|
+
the same moment. An elected item has its expiry reset, so the pool's
|
|
149
|
+
default lifetime applies to the recomputed value unless the callback
|
|
150
|
+
sets one.
|
|
151
|
+
"""
|
|
152
|
+
expiry = metadata.get("expiry")
|
|
153
|
+
ctime = metadata.get("ctime")
|
|
154
|
+
if not expiry or not ctime:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
current = self._now()
|
|
158
|
+
if expiry > current - ctime / 1000 * beta * math.log(_draw()):
|
|
159
|
+
return False
|
|
160
|
+
|
|
161
|
+
_ = item.expires_at(None)
|
|
162
|
+
self._on_elected(item, expiry - current)
|
|
163
|
+
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _draw() -> float:
|
|
168
|
+
"""Return a random number in ``(0, 1]``, so its logarithm is always defined."""
|
|
169
|
+
return 1.0 - random() # noqa: S311 — spreads recomputation over time; nothing here is a secret.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""What computes a value a cache does not have yet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import TypeAlias, TypeVar
|
|
7
|
+
|
|
8
|
+
from .item_interface import ItemInterface
|
|
9
|
+
|
|
10
|
+
__all__ = ["Callback"]
|
|
11
|
+
|
|
12
|
+
_T = TypeVar("_T")
|
|
13
|
+
|
|
14
|
+
Callback: TypeAlias = Callable[[ItemInterface], Awaitable[_T]]
|
|
15
|
+
"""Computes the value for an item, on a miss.
|
|
16
|
+
|
|
17
|
+
Receives the item the value is for, so it can set how long the value lives
|
|
18
|
+
(:meth:`~xtr_cache_contracts.item_interface.ItemInterface.expires_after`) or
|
|
19
|
+
tag it (:meth:`~xtr_cache_contracts.item_interface.ItemInterface.tag`), and
|
|
20
|
+
returns the value. Asynchronous, because computing a value worth caching is
|
|
21
|
+
usually I/O.
|
|
22
|
+
|
|
23
|
+
Any ``async def`` taking the item fits, and so does an object with an
|
|
24
|
+
``async def __call__`` taking it. Raising stores nothing: the error reaches
|
|
25
|
+
the caller of :meth:`~xtr_cache_contracts.cache_interface.CacheInterface.get`
|
|
26
|
+
as it was raised.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
async def load_profile(item: ItemInterface) -> Profile:
|
|
30
|
+
item.expires_after(3600)
|
|
31
|
+
return await profiles.fetch(user_id)
|
|
32
|
+
```
|
|
33
|
+
"""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""The errors the caching contract names.
|
|
2
|
+
|
|
3
|
+
:class:`CacheError` is the root every caching error derives from, including
|
|
4
|
+
every one an implementation adds, so catching it catches anything caching can
|
|
5
|
+
go wrong with, whichever package raised it. :class:`InvalidArgumentError` is
|
|
6
|
+
the one the interfaces here document raising.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .cache_error import CacheError
|
|
12
|
+
from .invalid_argument_error import InvalidArgumentError
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CacheError",
|
|
16
|
+
"InvalidArgumentError",
|
|
17
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The root every caching error derives from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["CacheError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CacheError(Exception):
|
|
9
|
+
"""Base class for every error raised through the caching contract.
|
|
10
|
+
|
|
11
|
+
Catch this to handle anything caching can go wrong with, whichever
|
|
12
|
+
package raised it; catch a subclass to handle one cause. Every subclass
|
|
13
|
+
carries the data a caller needs as typed attributes and composes its own
|
|
14
|
+
message from them.
|
|
15
|
+
|
|
16
|
+
A backend failing is not one of those causes: a cache is an optimisation,
|
|
17
|
+
so a pool reports a backend it cannot reach by returning ``False`` rather
|
|
18
|
+
than by raising. What does raise is a mistake in the calling code — a key
|
|
19
|
+
no pool accepts, a tag on an item that cannot carry one.
|
|
20
|
+
"""
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""A cache was given a key, a tag or an option it cannot work with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .cache_error import CacheError
|
|
6
|
+
|
|
7
|
+
__all__ = ["InvalidArgumentError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidArgumentError(CacheError, ValueError):
|
|
11
|
+
"""A cache was given a key, a tag or an option it cannot work with.
|
|
12
|
+
|
|
13
|
+
An empty key, one holding a reserved character, a negative ``beta``.
|
|
14
|
+
Raised where the argument is given, before anything reaches a backend.
|
|
15
|
+
|
|
16
|
+
Also a :class:`ValueError`, so code that already guards its input with
|
|
17
|
+
``except ValueError`` keeps working without learning a new exception.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
reason: What is wrong with the argument.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
reason: str
|
|
24
|
+
|
|
25
|
+
def __init__(self, reason: str) -> None:
|
|
26
|
+
"""Record what is wrong with the argument."""
|
|
27
|
+
self.reason = reason
|
|
28
|
+
super().__init__(reason)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""One cached entry: its key, its value if there is one, and how long it lives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Final, Protocol, Self, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
|
|
11
|
+
from .metadata import Metadata
|
|
12
|
+
|
|
13
|
+
__all__ = ["RESERVED_CHARACTERS", "ItemInterface"]
|
|
14
|
+
|
|
15
|
+
RESERVED_CHARACTERS: Final = "{}()/\\@:"
|
|
16
|
+
"""Characters no key and no tag may contain.
|
|
17
|
+
|
|
18
|
+
Pools use them for their own structure — namespace separators above all — so
|
|
19
|
+
a key holding one could reach into another key's space.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class ItemInterface(Protocol):
|
|
25
|
+
"""A cache entry as a pool hands it out: looked up, maybe found, ready to save.
|
|
26
|
+
|
|
27
|
+
A pool returns an item for every key it is asked about, found or not;
|
|
28
|
+
:meth:`is_hit` tells which. That is what lets ``None`` be a value like any
|
|
29
|
+
other rather than a stand-in for "not cached".
|
|
30
|
+
|
|
31
|
+
An item is a plain object in memory. Changing it — :meth:`set`,
|
|
32
|
+
:meth:`expires_after`, :meth:`tag` — reaches the backend only once the item
|
|
33
|
+
is handed back to the pool it came from, through
|
|
34
|
+
:meth:`CacheItemPoolInterface.save
|
|
35
|
+
<xtr_cache_contracts.cache_item_pool_interface.CacheItemPoolInterface.save>`
|
|
36
|
+
or, inside :meth:`CacheInterface.get
|
|
37
|
+
<xtr_cache_contracts.cache_interface.CacheInterface.get>`, by the pool
|
|
38
|
+
itself. The mutators return the item, so calls chain.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def key(self) -> str:
|
|
43
|
+
"""The key the item is stored under."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def get(self) -> object:
|
|
47
|
+
"""Return the value; ``None`` when the item is not a hit.
|
|
48
|
+
|
|
49
|
+
Typed ``object`` because a pool holds values of any type: narrow it
|
|
50
|
+
where the type is known. :meth:`CacheInterface.get
|
|
51
|
+
<xtr_cache_contracts.cache_interface.CacheInterface.get>` does that
|
|
52
|
+
for you, through the type its callback returns.
|
|
53
|
+
"""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
def is_hit(self) -> bool:
|
|
57
|
+
"""Tell whether the pool had a value for the key when the item was looked up."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def set(self, value: object, /) -> Self:
|
|
61
|
+
"""Set the value to save; the item is not saved until the pool is told to."""
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
def expires_at(self, expiration: datetime | None, /) -> Self:
|
|
65
|
+
"""Expire the item at ``expiration``; ``None`` falls back to the pool's default."""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
def expires_after(self, ttl: float | timedelta | None, /) -> Self:
|
|
69
|
+
"""Expire the item ``ttl`` from now, in seconds or as a duration.
|
|
70
|
+
|
|
71
|
+
A lifetime of zero or less expires the item at once: saving it removes
|
|
72
|
+
whatever the pool held under its key. ``None`` falls back to the
|
|
73
|
+
pool's default.
|
|
74
|
+
"""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
def tag(self, tags: str | Iterable[str], /) -> Self:
|
|
78
|
+
"""Add one tag, or several, to invalidate the item by later.
|
|
79
|
+
|
|
80
|
+
Tags follow the same rules as keys.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
InvalidArgumentError: When a tag is empty or holds a reserved
|
|
84
|
+
character.
|
|
85
|
+
CacheError: When the item comes from a pool that cannot store
|
|
86
|
+
tags.
|
|
87
|
+
"""
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def metadata(self) -> Metadata:
|
|
92
|
+
"""What the pool stored alongside the value; empty when it stored nothing."""
|
|
93
|
+
...
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""What a pool stores alongside a value, and reports back about it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, TypedDict
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
__all__ = ["Metadata"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Metadata(TypedDict, total=False):
|
|
14
|
+
"""Facts about a cached value, every one of them optional.
|
|
15
|
+
|
|
16
|
+
A pool that records none returns an empty mapping. The keys a pool fills
|
|
17
|
+
in are what makes early recomputation possible: knowing when a value
|
|
18
|
+
expires and how long it took to compute is enough to refresh it shortly
|
|
19
|
+
before it runs out, instead of making every caller wait once it has.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
expiry: When the value expires, as a Unix timestamp in seconds. Wall
|
|
23
|
+
clock rather than monotonic, because the value is shared with
|
|
24
|
+
other processes and other machines.
|
|
25
|
+
ctime: How long the value took to compute, in milliseconds.
|
|
26
|
+
tags: The tags the value was saved with.
|
|
27
|
+
save_failed: Set by :meth:`CacheInterface.get
|
|
28
|
+
<xtr_cache_contracts.cache_interface.CacheInterface.get>` when the
|
|
29
|
+
value it computed could not be saved. Never stored, so never in
|
|
30
|
+
:attr:`ItemInterface.metadata
|
|
31
|
+
<xtr_cache_contracts.item_interface.ItemInterface.metadata>`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
expiry: float
|
|
35
|
+
ctime: int
|
|
36
|
+
tags: Sequence[str]
|
|
37
|
+
save_failed: bool
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""A pool that can hand out a view of itself confined to a sub-namespace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, Self, runtime_checkable
|
|
6
|
+
|
|
7
|
+
__all__ = ["NamespacedPoolInterface"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class NamespacedPoolInterface(Protocol):
|
|
12
|
+
"""Confines keys to a sub-namespace, so a group of them can be dropped at once.
|
|
13
|
+
|
|
14
|
+
Keys written through the returned pool are prefixed with the backend's
|
|
15
|
+
own namespace separator, so clearing that namespace invalidates them
|
|
16
|
+
together without listing them. Tags ignore sub-namespaces: invalidating a
|
|
17
|
+
tag reaches a value whichever sub-namespace it was saved in.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def with_sub_namespace(self, namespace: str, /) -> Self:
|
|
21
|
+
"""Return a pool whose keys live under ``namespace``, inside this pool's namespace.
|
|
22
|
+
|
|
23
|
+
This pool is left as it was; the returned one is new.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
InvalidArgumentError: When ``namespace`` is empty or holds a
|
|
27
|
+
reserved character.
|
|
28
|
+
"""
|
|
29
|
+
...
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""A cache whose values can be invalidated in groups, by tag."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from .cache_interface import CacheInterface
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
|
|
12
|
+
__all__ = ["TagAwareCacheInterface"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class TagAwareCacheInterface(CacheInterface, Protocol):
|
|
17
|
+
"""A cache that can drop every value carrying a tag, without knowing their keys.
|
|
18
|
+
|
|
19
|
+
A value is tagged when it is computed, through the item its callback
|
|
20
|
+
receives:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
async def load_invoice(item: ItemInterface) -> Invoice:
|
|
24
|
+
item.tag(f"customer.{customer_id}")
|
|
25
|
+
return await invoices.fetch(invoice_id)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Invalidating ``customer.42`` later drops every invoice cached for that
|
|
29
|
+
customer, whatever it was cached under.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
async def invalidate_tags(self, tags: Iterable[str], /) -> bool:
|
|
33
|
+
"""Invalidate every value tagged with any of ``tags``.
|
|
34
|
+
|
|
35
|
+
An implementation built on an item pool does not invalidate items
|
|
36
|
+
saved as deferred and not yet committed; they are committed as usual.
|
|
37
|
+
That lets a caller replace old tagged values with new ones without a
|
|
38
|
+
window where neither is cached.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
``False`` when the backend failed; ``True`` otherwise.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
InvalidArgumentError: When a tag is empty or holds a reserved
|
|
45
|
+
character.
|
|
46
|
+
"""
|
|
47
|
+
...
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-cache-contracts
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: The caching contract: compute-once reads, item pools, tags and namespaces, with no backend attached.
|
|
5
|
+
Keywords: cache,contracts,interface,protocol,asyncio,stampede
|
|
6
|
+
Author: Razvan Ceana
|
|
7
|
+
Author-email: Razvan Ceana <razvan@ceana.ro>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: typing-extensions>=4.4
|
|
19
|
+
Requires-Dist: xtr-clock>=1.0,<2
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
<div align="center">
|
|
24
|
+
|
|
25
|
+
# xtr-cache-contracts
|
|
26
|
+
|
|
27
|
+
**The caching contract, and nothing else — so a library that caches installs nothing else.**
|
|
28
|
+
|
|
29
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
30
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
31
|
+
<img alt="core dependencies: 2" src="https://img.shields.io/badge/core%20deps-2-3FB950">
|
|
32
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
33
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
34
|
+
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Why?
|
|
40
|
+
|
|
41
|
+
A library that caches should not decide where values go. It takes a `CacheInterface`, hands it a
|
|
42
|
+
key and the function that computes the value, and leaves memory, files or a server to the
|
|
43
|
+
application that wires it.
|
|
44
|
+
|
|
45
|
+
That needs the *contract*, not a backend. This package is that dependency, reduced to what the
|
|
46
|
+
seam is made of:
|
|
47
|
+
|
|
48
|
+
- 🔁 **`CacheInterface`** — fetch-or-compute in one call, and delete.
|
|
49
|
+
- 🏷️ **`TagAwareCacheInterface`** — drop every value carrying a tag, whatever its key.
|
|
50
|
+
- 🗃️ **`CacheItemPoolInterface`** — the items underneath: hits told apart from misses, several
|
|
51
|
+
keys per round trip, batched writes.
|
|
52
|
+
- 📁 **`NamespacedPoolInterface`** — a view of a pool confined to a sub-namespace.
|
|
53
|
+
- 🧩 **`CacheMixin`** — `CacheInterface` for free on any pool, with early recomputation.
|
|
54
|
+
- 🪶 **Two small dependencies** — `typing-extensions`, for `@override` on 3.11, and
|
|
55
|
+
`xtr-clock`, which has none of its own.
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from xtr_cache_contracts import CacheInterface, ItemInterface
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Profiles:
|
|
62
|
+
def __init__(self, cache: CacheInterface) -> None:
|
|
63
|
+
self._cache = cache
|
|
64
|
+
|
|
65
|
+
async def get(self, user_id: int) -> Profile:
|
|
66
|
+
async def load(item: ItemInterface) -> Profile:
|
|
67
|
+
item.expires_after(3600)
|
|
68
|
+
return await fetch_profile(user_id)
|
|
69
|
+
|
|
70
|
+
return await self._cache.get(f"profile.{user_id}", load)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
uv add xtr-cache-contracts
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Requires Python 3.11+.
|
|
80
|
+
|
|
81
|
+
## Who installs what
|
|
82
|
+
|
|
83
|
+
| | Depends on |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| **A library that caches** | `xtr-cache-contracts` at runtime. |
|
|
86
|
+
| **An application** | `xtr-cache`, which implements this contract with adapters and wires pools from configuration. |
|
|
87
|
+
|
|
88
|
+
`xtr-cache` **re-exports** every symbol here rather than redefining it, so
|
|
89
|
+
`xtr_cache.CacheInterface is xtr_cache_contracts.CacheInterface`. That identity is what lets a
|
|
90
|
+
container register a pool under the interface and a library, which never imported `xtr-cache`,
|
|
91
|
+
receive it.
|
|
92
|
+
|
|
93
|
+
## Fetch-or-compute
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
class CacheInterface(Protocol):
|
|
97
|
+
async def get(
|
|
98
|
+
self,
|
|
99
|
+
key: str,
|
|
100
|
+
callback: Callback[T],
|
|
101
|
+
/,
|
|
102
|
+
*,
|
|
103
|
+
beta: float | None = None,
|
|
104
|
+
metadata: Metadata | None = None,
|
|
105
|
+
) -> T: ...
|
|
106
|
+
|
|
107
|
+
async def delete(self, key: str, /) -> bool: ...
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
On a miss, `callback` is awaited with the item for `key`; what it returns is saved and returned.
|
|
111
|
+
On a hit, the stored value comes back as the type `callback` returns — keep one key for one type.
|
|
112
|
+
|
|
113
|
+
Checking for a value, computing it and saving it is three steps with a race between each. Handing
|
|
114
|
+
the cache the computation instead lets it decide when to run it, which is what makes stampede
|
|
115
|
+
protection possible: computing a missing key once for every caller waiting on it, or refreshing
|
|
116
|
+
a value just before it expires.
|
|
117
|
+
|
|
118
|
+
- **The callback** is any `async def` taking the item, or an object with an `async def __call__`.
|
|
119
|
+
It sets the value's lifetime and tags through the item. If it raises, nothing is stored and
|
|
120
|
+
the error reaches the caller unchanged.
|
|
121
|
+
- **`beta`** controls early recomputation. A hit whose metadata says when it expires and how long
|
|
122
|
+
it took to compute may be recomputed before it expires, with a chance that grows as expiry
|
|
123
|
+
nears and as `beta` grows. `0` disables it, `math.inf` forces recomputation now, and `None`
|
|
124
|
+
leaves the choice to the implementation (`1.0` in `CacheMixin`).
|
|
125
|
+
- **`metadata`** is a dict the cache fills: `expiry` (Unix timestamp), `ctime` (milliseconds the
|
|
126
|
+
value took to compute), `tags`, and `save_failed` when a computed value could not be stored.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from xtr_cache_contracts import Metadata
|
|
130
|
+
|
|
131
|
+
metadata: Metadata = {}
|
|
132
|
+
report = await cache.get("report.daily", build_report, metadata=metadata)
|
|
133
|
+
if metadata.get("save_failed"):
|
|
134
|
+
...
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Items and pools
|
|
138
|
+
|
|
139
|
+
`CacheItemPoolInterface` is the level below: for code that needs a hit told apart from a miss,
|
|
140
|
+
several keys in one round trip, or writes batched until `commit()` — and the level a backend
|
|
141
|
+
implements.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
item = await pool.get_item("rate.42")
|
|
145
|
+
if not item.is_hit():
|
|
146
|
+
await pool.save(item.set(0).expires_after(60))
|
|
147
|
+
|
|
148
|
+
items = await pool.get_items(["a", "b", "c"]) # every key, hit or miss, in the order asked
|
|
149
|
+
await pool.save_deferred(item) # queued ...
|
|
150
|
+
await pool.commit() # ... stored
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
An item is returned for every key, found or not, so `None` is a value like any other. Changing
|
|
154
|
+
an item reaches the backend only once it is saved. A pool refuses, with `False`, an item of a kind
|
|
155
|
+
it does not store.
|
|
156
|
+
|
|
157
|
+
The rules:
|
|
158
|
+
|
|
159
|
+
- **Keys** are non-empty strings without any of `{}()/\@:` (`RESERVED_CHARACTERS`). Letters,
|
|
160
|
+
digits, `_` and `.` up to 64 characters work everywhere; a pool may accept more. Tags follow
|
|
161
|
+
the same rules.
|
|
162
|
+
- **A backend failure never raises.** The call returns `False`, or reads as a miss, and the
|
|
163
|
+
implementation logs it: code that caches keeps working when the cache does not. What raises
|
|
164
|
+
is a mistake in the calling code — an invalid key, a tag on an item whose pool cannot store
|
|
165
|
+
tags.
|
|
166
|
+
- **Lifetimes** are set on the item: `expires_after(seconds or timedelta)` or
|
|
167
|
+
`expires_at(datetime)`. `None` falls back to the pool's default; a lifetime of zero or less
|
|
168
|
+
removes the key when the item is saved.
|
|
169
|
+
- **Deferred items** are committed by `commit()`, or before their key is read from the same pool.
|
|
170
|
+
Nothing commits them when the pool is discarded.
|
|
171
|
+
|
|
172
|
+
## Tags and namespaces
|
|
173
|
+
|
|
174
|
+
A tag-aware cache drops values by tag rather than by key. Tag a value when computing it:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
async def load_invoice(item: ItemInterface) -> Invoice:
|
|
178
|
+
item.tag([f"customer.{customer_id}", "invoices"])
|
|
179
|
+
return await fetch_invoice(invoice_id)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
await cache.get(f"invoice.{invoice_id}", load_invoice)
|
|
183
|
+
await cache.invalidate_tags([f"customer.{customer_id}"]) # every invoice of that customer
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
A namespaced pool hands out a view of itself whose keys live under a sub-namespace, so a group of
|
|
187
|
+
keys can be cleared together: `pool.with_sub_namespace("tenant42")`. The original pool is left as
|
|
188
|
+
it was. Tags ignore sub-namespaces.
|
|
189
|
+
|
|
190
|
+
## Implementing a pool
|
|
191
|
+
|
|
192
|
+
Implement `CacheItemPoolInterface` and derive from `CacheMixin` to get `get()` and `delete()`
|
|
193
|
+
built on your `get_item()`, `save()` and `delete_item()`:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from xtr_cache_contracts import CacheItemPoolInterface, CacheMixin
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class MyPool(CacheItemPoolInterface, CacheMixin): ...
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`CacheMixin` computes a miss, saves it, reports a failed save in `metadata`, and recomputes a hit
|
|
203
|
+
early when its metadata allows. It reads the time from the clock in force (`xtr_clock.now()`),
|
|
204
|
+
so `mock_time()` or a `MockClock` installed by a test freezes it too. It does not make concurrent misses on one key compute once — that
|
|
205
|
+
needs a lock or a shared in-flight computation, and is the implementation's to add by overriding
|
|
206
|
+
`get()`.
|
|
207
|
+
|
|
208
|
+
## What is not here
|
|
209
|
+
|
|
210
|
+
Everything that stores or acts on values: the item class, adapters for memory, files and Redis,
|
|
211
|
+
serialisation, stampede locking, chaining, and the bundle. All of that is
|
|
212
|
+
[xtr-cache](https://github.com/xterr/python-xtr-cache).
|
|
213
|
+
|
|
214
|
+
There is no simple key-value interface (`get(key, default)` / `set(key, value, ttl)`): it cannot
|
|
215
|
+
tell a cached `None` from a miss, and checking then reading is a race. Fetch-or-compute covers the
|
|
216
|
+
common case, and the item pool covers the rest.
|
|
217
|
+
|
|
218
|
+
## Errors
|
|
219
|
+
|
|
220
|
+
| Error | Raised when |
|
|
221
|
+
| --- | --- |
|
|
222
|
+
| `CacheError` | Never directly — the base every caching error derives from, `xtr-cache`'s included, such as the one `tag()` raises on an item whose pool cannot store tags |
|
|
223
|
+
| `InvalidArgumentError` | A key, a tag, a namespace or `beta` is invalid (also a `ValueError`); what is wrong is in `reason` |
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
228
|
+
`packages/xtr-cache-contracts`; run the commands below from there. The
|
|
229
|
+
`python-xtr-cache-contracts` repository is a read-only copy, so send issues and pull requests to
|
|
230
|
+
the monorepo.
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
uv sync
|
|
234
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
xtr_cache_contracts/__init__.py,sha256=ACsLYZsgturVqjAPrZ5QQxYecWk5zlMvll4jRWaTxvU,2455
|
|
2
|
+
xtr_cache_contracts/cache_interface.py,sha256=mlRJfCwO-sPktkxN5jmsB8-Ey_UnqZnl2CXwMQh5qts,3090
|
|
3
|
+
xtr_cache_contracts/cache_item_pool_interface.py,sha256=IeEGNCSbAC8np80YcD-DstAfvulMcmPwYzDCmbFnrjU,4544
|
|
4
|
+
xtr_cache_contracts/cache_mixin.py,sha256=bc9sK7RmXPvqx8WE-SF_8yDWEnX1pQkx0p7jMNYNeEI,6278
|
|
5
|
+
xtr_cache_contracts/callback.py,sha256=5gmOBm43JeEWYTLtSmoYEtfUdaxP5W5rg34xxgVTs_4,1081
|
|
6
|
+
xtr_cache_contracts/exception/__init__.py,sha256=T8t2AuVMuyX5yx0dhuurxRNVUxr_b_QhcyYaYtBkLVM,515
|
|
7
|
+
xtr_cache_contracts/exception/cache_error.py,sha256=fbO2QTMCB_jNhx_NvZWzQv0_Zq9SkXQFbR1Skv6_chE,767
|
|
8
|
+
xtr_cache_contracts/exception/invalid_argument_error.py,sha256=NX4WdrTCL0pLvsgn-pKytP_vFreYvSJz2YJwkjwECkg,858
|
|
9
|
+
xtr_cache_contracts/item_interface.py,sha256=2Wp_HDUUlPCcMOEDY0Fsb1PsGLjRJp28baCF5VTk6-U,3308
|
|
10
|
+
xtr_cache_contracts/metadata.py,sha256=PP0Pi2wuu2101feeKsWH6302Ghu7JkHPyBVrDfT8YGk,1387
|
|
11
|
+
xtr_cache_contracts/namespaced_pool_interface.py,sha256=-6GpGEX9OZ7_vF7vdvk8wdlQXz00lW8ilF93nuu_HNU,1019
|
|
12
|
+
xtr_cache_contracts/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
xtr_cache_contracts/tag_aware_cache_interface.py,sha256=y8UWZQ2umFCbJeWxPCye7B6WlPFShWVJTuGgY4mZvRw,1490
|
|
14
|
+
xtr_cache_contracts-1.2.0.dist-info/licenses/LICENSE,sha256=Jc61y5SgAtm5uDf7mEdR5fxnx6z0RsmS7V4P1dE0TF0,1062
|
|
15
|
+
xtr_cache_contracts-1.2.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
|
|
16
|
+
xtr_cache_contracts-1.2.0.dist-info/METADATA,sha256=Wh-a3RZGzpwKSPi7zqfNebW0ZuPXbY_uECI1uw4fBoM,9561
|
|
17
|
+
xtr_cache_contracts-1.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|