xtr-cache 3.0.0__tar.gz → 4.0.2__tar.gz
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-3.0.0 → xtr_cache-4.0.2}/LICENSE +1 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/PKG-INFO +11 -11
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/README.md +2 -2
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/pyproject.toml +13 -13
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/pyproject.toml.orig +13 -13
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/.agents/skills/xtr-cache/SKILL.md +3 -3
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/.agents/skills/xtr-cache/references/adapters.md +3 -2
- xtr_cache-4.0.2/src/xtr_cache/_internal_key.py +22 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/abstract_adapter.py +4 -20
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/adapter_factory.py +15 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/array_adapter.py +31 -2
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/chain_adapter.py +4 -1
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/filesystem_adapter.py +32 -5
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/redis_adapter.py +13 -6
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tag_aware_adapter.py +19 -8
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/cache_bundle.py +21 -2
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/cache_config.py +28 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/cache_item.py +10 -2
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/cache_pool_clearer.py +59 -6
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_invalidate_tags_command.py +4 -2
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_prune_command.py +2 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/lock_registry.py +10 -4
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/.agents/skills/xtr-cache/references/stampede-and-marshallers.md +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/__init__.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/_digest.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/__init__.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/adapter_interface.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/contracts_mixin.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/deferred_items_mixin.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/null_adapter.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tag_aware_adapter_interface.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tagged_value.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/__init__.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/pool_config.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/__init__.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_clear_command.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_delete_command.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_list_command.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/pool_command.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/pools.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/__init__.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/logic_error.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/marshalling_error.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/__init__.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/default_marshaller.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/deflate_marshaller.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/marshaller_interface.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/sodium_marshaller.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/pruneable_interface.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/py.typed +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/recipe/__init__.py +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/recipe/manifest.toml +0 -0
- {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/value_wrapper.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xtr-cache
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.2
|
|
4
4
|
Summary: Cache pools in memory, in files, in Redis, chained or tag-aware, with stampede protection.
|
|
5
5
|
Keywords: cache,redis,stampede,tags,asyncio
|
|
6
6
|
Author: Xterr
|
|
@@ -16,14 +16,14 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Classifier: Typing :: Typed
|
|
18
18
|
Requires-Dist: typing-extensions>=4.4
|
|
19
|
-
Requires-Dist: xtr-cache-contracts>=
|
|
20
|
-
Requires-Dist: xtr-clock>=
|
|
21
|
-
Requires-Dist: xtr-lock>=
|
|
22
|
-
Requires-Dist: xtr-logging-contracts>=
|
|
23
|
-
Requires-Dist: xtr-console>=
|
|
24
|
-
Requires-Dist: xtr-dependency-injection>=
|
|
25
|
-
Requires-Dist: xtr-service-contracts>=
|
|
26
|
-
Requires-Dist: xtr-logging>=
|
|
19
|
+
Requires-Dist: xtr-cache-contracts>=4.0,<5
|
|
20
|
+
Requires-Dist: xtr-clock>=4.0,<5
|
|
21
|
+
Requires-Dist: xtr-lock>=4.0,<5
|
|
22
|
+
Requires-Dist: xtr-logging-contracts>=4.0,<5
|
|
23
|
+
Requires-Dist: xtr-console>=4.0,<5 ; extra == 'console'
|
|
24
|
+
Requires-Dist: xtr-dependency-injection>=4.0,<5 ; extra == 'di'
|
|
25
|
+
Requires-Dist: xtr-service-contracts>=4.0,<5 ; extra == 'di'
|
|
26
|
+
Requires-Dist: xtr-logging>=4.0,<5 ; extra == 'logging'
|
|
27
27
|
Requires-Dist: redis>=5.0 ; extra == 'redis'
|
|
28
28
|
Requires-Dist: pynacl>=1.5 ; extra == 'sodium'
|
|
29
29
|
Requires-Python: >=3.11
|
|
@@ -190,8 +190,8 @@ await pool.close() # ... which it closes
|
|
|
190
190
|
|
|
191
191
|
Each value is a string key, `namespace:key`, expired by the server itself. Reads fetch many keys
|
|
192
192
|
in one round trip, writes are pipelined, and clearing scans the namespace and unlinks its keys in
|
|
193
|
-
batches — so
|
|
194
|
-
|
|
193
|
+
batches — so a namespace is required: without one, clearing would empty the database. A client
|
|
194
|
+
built from a DSN gives up on a server silent for 5 seconds — a miss, or a
|
|
195
195
|
failed write — unless `?socket_timeout=` and `?socket_connect_timeout=` in the DSN say otherwise.
|
|
196
196
|
One server; not a cluster, not Sentinel.
|
|
197
197
|
|
|
@@ -154,8 +154,8 @@ await pool.close() # ... which it closes
|
|
|
154
154
|
|
|
155
155
|
Each value is a string key, `namespace:key`, expired by the server itself. Reads fetch many keys
|
|
156
156
|
in one round trip, writes are pipelined, and clearing scans the namespace and unlinks its keys in
|
|
157
|
-
batches — so
|
|
158
|
-
|
|
157
|
+
batches — so a namespace is required: without one, clearing would empty the database. A client
|
|
158
|
+
built from a DSN gives up on a server silent for 5 seconds — a miss, or a
|
|
159
159
|
failed write — unless `?socket_timeout=` and `?socket_connect_timeout=` in the DSN say otherwise.
|
|
160
160
|
One server; not a cluster, not Sentinel.
|
|
161
161
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-cache"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.0.2"
|
|
4
4
|
description = "Cache pools in memory, in files, in Redis, chained or tag-aware, with stampede protection."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -25,10 +25,10 @@ classifiers = [
|
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
27
|
"typing-extensions>=4.4",
|
|
28
|
-
"xtr-cache-contracts>=
|
|
29
|
-
"xtr-clock>=
|
|
30
|
-
"xtr-lock>=
|
|
31
|
-
"xtr-logging-contracts>=
|
|
28
|
+
"xtr-cache-contracts>=4.0,<5",
|
|
29
|
+
"xtr-clock>=4.0,<5",
|
|
30
|
+
"xtr-lock>=4.0,<5",
|
|
31
|
+
"xtr-logging-contracts>=4.0,<5",
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[[project.authors]]
|
|
@@ -39,11 +39,11 @@ email = "me@xterr.dev"
|
|
|
39
39
|
redis = ["redis>=5.0"]
|
|
40
40
|
sodium = ["pynacl>=1.5"]
|
|
41
41
|
di = [
|
|
42
|
-
"xtr-dependency-injection>=
|
|
43
|
-
"xtr-service-contracts>=
|
|
42
|
+
"xtr-dependency-injection>=4.0,<5",
|
|
43
|
+
"xtr-service-contracts>=4.0,<5",
|
|
44
44
|
]
|
|
45
|
-
console = ["xtr-console>=
|
|
46
|
-
logging = ["xtr-logging>=
|
|
45
|
+
console = ["xtr-console>=4.0,<5"]
|
|
46
|
+
logging = ["xtr-logging>=4.0,<5"]
|
|
47
47
|
|
|
48
48
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
49
49
|
cache = "xtr_cache.bundle:CacheBundle"
|
|
@@ -61,10 +61,10 @@ dev = [
|
|
|
61
61
|
"ty>=0.0.83",
|
|
62
62
|
"pynacl>=1.5",
|
|
63
63
|
"redis>=5.0",
|
|
64
|
-
"xtr-console>=
|
|
65
|
-
"xtr-dependency-injection>=
|
|
66
|
-
"xtr-logging>=
|
|
67
|
-
"xtr-service-contracts>=
|
|
64
|
+
"xtr-console>=4.0,<5",
|
|
65
|
+
"xtr-dependency-injection>=4.0,<5",
|
|
66
|
+
"xtr-logging>=4.0,<5",
|
|
67
|
+
"xtr-service-contracts>=4.0,<5",
|
|
68
68
|
]
|
|
69
69
|
|
|
70
70
|
[build-system]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-cache"
|
|
3
|
-
version = "
|
|
3
|
+
version = "4.0.2"
|
|
4
4
|
description = "Cache pools in memory, in files, in Redis, chained or tag-aware, with stampede protection."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -22,10 +22,10 @@ classifiers = [
|
|
|
22
22
|
]
|
|
23
23
|
dependencies = [
|
|
24
24
|
"typing-extensions>=4.4",
|
|
25
|
-
"xtr-cache-contracts>=
|
|
26
|
-
"xtr-clock>=
|
|
27
|
-
"xtr-lock>=
|
|
28
|
-
"xtr-logging-contracts>=
|
|
25
|
+
"xtr-cache-contracts>=4.0,<5",
|
|
26
|
+
"xtr-clock>=4.0,<5",
|
|
27
|
+
"xtr-lock>=4.0,<5",
|
|
28
|
+
"xtr-logging-contracts>=4.0,<5",
|
|
29
29
|
]
|
|
30
30
|
|
|
31
31
|
[project.optional-dependencies]
|
|
@@ -36,14 +36,14 @@ sodium = [
|
|
|
36
36
|
"pynacl>=1.5",
|
|
37
37
|
]
|
|
38
38
|
di = [
|
|
39
|
-
"xtr-dependency-injection>=
|
|
40
|
-
"xtr-service-contracts>=
|
|
39
|
+
"xtr-dependency-injection>=4.0,<5",
|
|
40
|
+
"xtr-service-contracts>=4.0,<5",
|
|
41
41
|
]
|
|
42
42
|
console = [
|
|
43
|
-
"xtr-console>=
|
|
43
|
+
"xtr-console>=4.0,<5",
|
|
44
44
|
]
|
|
45
45
|
logging = [
|
|
46
|
-
"xtr-logging>=
|
|
46
|
+
"xtr-logging>=4.0,<5",
|
|
47
47
|
]
|
|
48
48
|
|
|
49
49
|
# Advertises the bundle so debug:bundles can name it when installed but not listed.
|
|
@@ -65,10 +65,10 @@ dev = [
|
|
|
65
65
|
"ty>=0.0.83",
|
|
66
66
|
"pynacl>=1.5",
|
|
67
67
|
"redis>=5.0",
|
|
68
|
-
"xtr-console>=
|
|
69
|
-
"xtr-dependency-injection>=
|
|
70
|
-
"xtr-logging>=
|
|
71
|
-
"xtr-service-contracts>=
|
|
68
|
+
"xtr-console>=4.0,<5",
|
|
69
|
+
"xtr-dependency-injection>=4.0,<5",
|
|
70
|
+
"xtr-logging>=4.0,<5",
|
|
71
|
+
"xtr-service-contracts>=4.0,<5",
|
|
72
72
|
]
|
|
73
73
|
|
|
74
74
|
[build-system]
|
|
@@ -158,7 +158,7 @@ rotation.
|
|
|
158
158
|
|
|
159
159
|
with mock_time("2026-01-01 00:00:00") as clock:
|
|
160
160
|
first = await invoices.get(7)
|
|
161
|
-
assert await invoices.get(7)
|
|
161
|
+
assert await invoices.get(7) == first # a hit; a serialized pool hands back a copy
|
|
162
162
|
|
|
163
163
|
clock.sleep(601)
|
|
164
164
|
assert await invoices.get(7) is not first
|
|
@@ -271,7 +271,7 @@ them: it is logged, and the call misses or returns `False`.
|
|
|
271
271
|
- Do not mutate what `get()` returned and expect the cache to keep the original — concurrent
|
|
272
272
|
callers get the same object. Copy first.
|
|
273
273
|
- Do not put a reserved character in a key; `{}()/\@:` raise `InvalidArgumentError`.
|
|
274
|
-
- Do not
|
|
275
|
-
|
|
274
|
+
- Do not leave a `RedisAdapter` built by `from_url` unclosed. A namespace is required: without
|
|
275
|
+
one, `clear()` would empty the whole database.
|
|
276
276
|
- Do not pickle from a backend others can write to. Use `SodiumMarshaller`.
|
|
277
277
|
- Do not sleep in a test to let a value expire. Freeze the clock with `mock_time`.
|
{xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/.agents/skills/xtr-cache/references/adapters.md
RENAMED
|
@@ -45,8 +45,9 @@ await pool.close() # ... which it closes
|
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
Each value is one string key, `namespace:key`, expired by the server. Reads fetch many keys in one
|
|
48
|
-
round trip, writes are pipelined, and `clear()` scans the namespace and unlinks in batches — so
|
|
49
|
-
|
|
48
|
+
round trip, writes are pipelined, and `clear()` scans the namespace and unlinks in batches — so a
|
|
49
|
+
namespace is required: without one, `clear()` would empty the whole database. A client built from a
|
|
50
|
+
DSN gives up
|
|
50
51
|
on a server silent for 5 seconds unless `?socket_timeout=` and `?socket_connect_timeout=` say
|
|
51
52
|
otherwise. One server; not a cluster, not Sentinel. Needs the `redis` extra and a client returning
|
|
52
53
|
bytes: one made with `decode_responses=True` raises `InvalidArgumentError`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""A key the library forms for itself, exempt from the user-key character rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
__all__ = ["InternalKey"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@final
|
|
11
|
+
class InternalKey(str):
|
|
12
|
+
"""A key a pool builds internally, trusted past :meth:`CacheItem.validate_key`.
|
|
13
|
+
|
|
14
|
+
A tag-aware pool keeps each tag's version under a key starting with a
|
|
15
|
+
control character, so no user key — none of which may hold one — can ever
|
|
16
|
+
name it. :meth:`~xtr_cache.cache_item.CacheItem.validate_key` refuses those
|
|
17
|
+
characters for every key it is handed, which would refuse the version keys
|
|
18
|
+
too; marking them as this type lets the pool reach its own keys without
|
|
19
|
+
reopening that door to a caller.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, ClassVar, Final, Self
|
|
|
11
11
|
from typing_extensions import override
|
|
12
12
|
from xtr_cache_contracts import InvalidArgumentError, NamespacedPoolInterface
|
|
13
13
|
|
|
14
|
-
from xtr_cache._digest import urlsafe_digest
|
|
15
14
|
from xtr_cache.cache_item import CacheItem
|
|
16
15
|
from xtr_cache.exception.marshalling_error import MarshallingError
|
|
17
16
|
|
|
@@ -45,17 +44,12 @@ class AbstractAdapter(
|
|
|
45
44
|
cannot be reached makes reads miss and writes return ``False``; the code
|
|
46
45
|
caching keeps running.
|
|
47
46
|
|
|
48
|
-
An identifier is the pool's namespace followed by the key.
|
|
49
|
-
a limit on identifier length sets :attr:`max_id_length`, and keys that
|
|
50
|
-
would exceed it are hashed.
|
|
47
|
+
An identifier is the pool's namespace followed by the key.
|
|
51
48
|
"""
|
|
52
49
|
|
|
53
50
|
NS_SEPARATOR: ClassVar[str] = ":"
|
|
54
51
|
"""What separates a namespace from what follows it in an identifier."""
|
|
55
52
|
|
|
56
|
-
max_id_length: ClassVar[int | None] = None
|
|
57
|
-
"""The longest identifier the backend takes, or ``None`` for no limit."""
|
|
58
|
-
|
|
59
53
|
_namespace: str
|
|
60
54
|
_default_lifetime: float
|
|
61
55
|
_clock: ClockInterface
|
|
@@ -80,8 +74,7 @@ class AbstractAdapter(
|
|
|
80
74
|
|
|
81
75
|
Raises:
|
|
82
76
|
InvalidArgumentError: When ``namespace`` is not a valid key once
|
|
83
|
-
its separators are removed, has an empty sub-namespace
|
|
84
|
-
leaves no room for keys within :attr:`max_id_length`.
|
|
77
|
+
its separators are removed, or has an empty sub-namespace.
|
|
85
78
|
"""
|
|
86
79
|
if namespace:
|
|
87
80
|
if self.NS_SEPARATOR * 2 in namespace:
|
|
@@ -89,11 +82,6 @@ class AbstractAdapter(
|
|
|
89
82
|
f'Cache namespace "{namespace}" contains an empty sub-namespace.',
|
|
90
83
|
)
|
|
91
84
|
_ = CacheItem.validate_key(namespace.replace(self.NS_SEPARATOR, ""))
|
|
92
|
-
if self.max_id_length is not None and len(namespace) > self.max_id_length - 24:
|
|
93
|
-
raise InvalidArgumentError(
|
|
94
|
-
f"A cache namespace must be {self.max_id_length - 24} characters at most, "
|
|
95
|
-
f"got {len(namespace)}.",
|
|
96
|
-
)
|
|
97
85
|
namespace += self.NS_SEPARATOR
|
|
98
86
|
|
|
99
87
|
self._namespace = namespace
|
|
@@ -314,16 +302,12 @@ class AbstractAdapter(
|
|
|
314
302
|
return CacheItem.from_stored(key, stored, clock=self._clock)
|
|
315
303
|
|
|
316
304
|
def _get_id(self, key: str) -> str:
|
|
317
|
-
"""Return the backend identifier of ``key
|
|
305
|
+
"""Return the backend identifier of ``key``: the namespace, then the key.
|
|
318
306
|
|
|
319
307
|
Raises:
|
|
320
308
|
InvalidArgumentError: When ``key`` is not a valid key.
|
|
321
309
|
"""
|
|
322
|
-
|
|
323
|
-
if self.max_id_length is None or len(id_) <= self.max_id_length:
|
|
324
|
-
return id_
|
|
325
|
-
|
|
326
|
-
return self._namespace + urlsafe_digest(key, size=16) + self.NS_SEPARATOR
|
|
310
|
+
return self._namespace + CacheItem.validate_key(key)
|
|
327
311
|
|
|
328
312
|
def _unmarshall_found(
|
|
329
313
|
self,
|
|
@@ -32,6 +32,7 @@ __all__ = ["AdapterFactory"]
|
|
|
32
32
|
_FILESYSTEM: Final = "filesystem"
|
|
33
33
|
_FILESYSTEM_PREFIX: Final = "filesystem://"
|
|
34
34
|
_KEYWORDS: Final = ("array", "null", _FILESYSTEM)
|
|
35
|
+
_PRUNEABLE_KEYWORDS: Final = ("array", _FILESYSTEM)
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
@final
|
|
@@ -136,3 +137,17 @@ class AdapterFactory:
|
|
|
136
137
|
raise InvalidArgumentError(
|
|
137
138
|
f'Unsupported cache connection: "{describe_connection_scheme(connection)}".'
|
|
138
139
|
)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def serves_pruneable(connection: object) -> bool:
|
|
143
|
+
"""Tell whether ``connection`` names an adapter whose backend keeps expired values.
|
|
144
|
+
|
|
145
|
+
A memory or filesystem pool holds a value's slot until something drops
|
|
146
|
+
it, so pruning frees them; a Redis server and the null pool expire or
|
|
147
|
+
keep nothing of their own, so pruning one does nothing. Read from the
|
|
148
|
+
configuration, so a command can skip a pool it cannot prune without
|
|
149
|
+
building it — without reaching a server.
|
|
150
|
+
"""
|
|
151
|
+
return isinstance(connection, str) and (
|
|
152
|
+
connection in _PRUNEABLE_KEYWORDS or connection.startswith(_FILESYSTEM_PREFIX)
|
|
153
|
+
)
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import dataclasses
|
|
6
|
-
from typing import TYPE_CHECKING, cast, final
|
|
6
|
+
from typing import TYPE_CHECKING, Final, cast, final
|
|
7
7
|
|
|
8
8
|
from typing_extensions import override
|
|
9
9
|
|
|
10
10
|
from xtr_cache.exception import InvalidArgumentError, MarshallingError
|
|
11
11
|
from xtr_cache.marshaller.default_marshaller import DefaultMarshaller
|
|
12
|
+
from xtr_cache.pruneable_interface import PruneableInterface
|
|
12
13
|
from xtr_cache.value_wrapper import ValueWrapper
|
|
13
14
|
|
|
14
15
|
from .abstract_adapter import AbstractAdapter
|
|
@@ -22,9 +23,12 @@ if TYPE_CHECKING:
|
|
|
22
23
|
|
|
23
24
|
__all__ = ["ArrayAdapter"]
|
|
24
25
|
|
|
26
|
+
_PRUNE_EVERY: Final = 1000
|
|
27
|
+
"""How many values are saved between the sweeps that drop what has expired."""
|
|
28
|
+
|
|
25
29
|
|
|
26
30
|
@final
|
|
27
|
-
class ArrayAdapter(AbstractAdapter):
|
|
31
|
+
class ArrayAdapter(AbstractAdapter, PruneableInterface):
|
|
28
32
|
"""Keeps values in a dictionary, for tests and for caching within one process.
|
|
29
33
|
|
|
30
34
|
Values are stored serialized by default, so what a caller gets back is a
|
|
@@ -34,6 +38,11 @@ class ArrayAdapter(AbstractAdapter):
|
|
|
34
38
|
|
|
35
39
|
Every method completes without awaiting, so tasks on one event loop never
|
|
36
40
|
see it half-updated. Two instances share nothing.
|
|
41
|
+
|
|
42
|
+
Nothing expires an entry on its own: a read drops the one it finds expired,
|
|
43
|
+
and every ``1000`` saves sweep the rest, so a churn of short-lived keys
|
|
44
|
+
does not grow the dictionary without bound. :meth:`prune` sweeps them on
|
|
45
|
+
demand.
|
|
37
46
|
"""
|
|
38
47
|
|
|
39
48
|
_values: dict[str, tuple[object, float | None]]
|
|
@@ -41,6 +50,7 @@ class ArrayAdapter(AbstractAdapter):
|
|
|
41
50
|
_max_lifetime: float
|
|
42
51
|
_max_items: int
|
|
43
52
|
_marshaller: MarshallerInterface
|
|
53
|
+
_saves: int
|
|
44
54
|
|
|
45
55
|
def __init__( # noqa: PLR0913 — every option past the lifetime is keyword-only.
|
|
46
56
|
self,
|
|
@@ -81,6 +91,7 @@ class ArrayAdapter(AbstractAdapter):
|
|
|
81
91
|
self._max_lifetime = max_lifetime
|
|
82
92
|
self._max_items = max_items
|
|
83
93
|
self._marshaller = marshaller if marshaller is not None else DefaultMarshaller()
|
|
94
|
+
self._saves = 0
|
|
84
95
|
|
|
85
96
|
def values(self) -> dict[str, object]:
|
|
86
97
|
"""Return every live value by identifier, as stored; for tests."""
|
|
@@ -91,6 +102,12 @@ class ArrayAdapter(AbstractAdapter):
|
|
|
91
102
|
if expiry is None or expiry > now
|
|
92
103
|
}
|
|
93
104
|
|
|
105
|
+
@override
|
|
106
|
+
async def prune(self) -> bool:
|
|
107
|
+
"""Drop every value whose expiry has passed."""
|
|
108
|
+
self._prune(self._clock.now().timestamp())
|
|
109
|
+
return True
|
|
110
|
+
|
|
94
111
|
@override
|
|
95
112
|
async def _do_fetch(self, ids: Sequence[str]) -> Mapping[str, object]:
|
|
96
113
|
now = self._clock.now().timestamp()
|
|
@@ -162,8 +179,20 @@ class ArrayAdapter(AbstractAdapter):
|
|
|
162
179
|
while len(self._values) > self._max_items:
|
|
163
180
|
del self._values[next(iter(self._values))]
|
|
164
181
|
|
|
182
|
+
self._saves += len(stored)
|
|
183
|
+
if self._saves >= _PRUNE_EVERY:
|
|
184
|
+
self._saves = 0
|
|
185
|
+
self._prune(self._clock.now().timestamp())
|
|
186
|
+
|
|
165
187
|
return failed or True
|
|
166
188
|
|
|
189
|
+
def _prune(self, now: float) -> None:
|
|
190
|
+
"""Drop every value whose expiry is at or before ``now``."""
|
|
191
|
+
for id_ in [
|
|
192
|
+
id_ for id_, (_, expiry) in self._values.items() if expiry is not None and expiry <= now
|
|
193
|
+
]:
|
|
194
|
+
del self._values[id_]
|
|
195
|
+
|
|
167
196
|
@override
|
|
168
197
|
def __repr__(self) -> str:
|
|
169
198
|
return f"{type(self).__name__}({self._default_lifetime!r})"
|
|
@@ -90,6 +90,7 @@ class ChainAdapter(ContractsMixin, AdapterInterface, NamespacedPoolInterface, Pr
|
|
|
90
90
|
found: dict[str, CacheItem] = {}
|
|
91
91
|
missing = wanted
|
|
92
92
|
missed: list[AdapterInterface] = []
|
|
93
|
+
written: list[AdapterInterface] = []
|
|
93
94
|
for adapter in self._adapters:
|
|
94
95
|
if not missing:
|
|
95
96
|
break
|
|
@@ -99,10 +100,12 @@ class ChainAdapter(ContractsMixin, AdapterInterface, NamespacedPoolInterface, Pr
|
|
|
99
100
|
found[key] = item
|
|
100
101
|
for faster in missed:
|
|
101
102
|
_ = await faster.save_deferred(self._copy(item))
|
|
103
|
+
if faster not in written:
|
|
104
|
+
written.append(faster)
|
|
102
105
|
missing = [key for key in missing if key not in found]
|
|
103
106
|
missed.append(adapter)
|
|
104
107
|
|
|
105
|
-
for faster in
|
|
108
|
+
for faster in written:
|
|
106
109
|
_ = await faster.commit()
|
|
107
110
|
|
|
108
111
|
return {key: found.get(key) or CacheItem(key, clock=self._clock) for key in wanted}
|
|
@@ -47,6 +47,14 @@ _HEADER_BYTES: Final = 4096
|
|
|
47
47
|
_PRIVATE_MODE: Final = 0o700
|
|
48
48
|
"""The default directory's mode: in the shared temporary directory, only its owner may enter."""
|
|
49
49
|
|
|
50
|
+
_PRIVATE_FILE_MODE: Final = 0o600
|
|
51
|
+
_PRIVATE_DIR_MODE: Final = 0o700
|
|
52
|
+
"""A pool's own files and directories, reachable by their owner alone, the default."""
|
|
53
|
+
|
|
54
|
+
_SHARED_FILE_MODE: Final = 0o666
|
|
55
|
+
_SHARED_DIR_MODE: Final = 0o777
|
|
56
|
+
"""The modes ``shared`` opts into, so processes run as other users can read the files."""
|
|
57
|
+
|
|
50
58
|
|
|
51
59
|
@final
|
|
52
60
|
class FilesystemAdapter(AbstractAdapter, PruneableInterface):
|
|
@@ -60,6 +68,15 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
|
|
|
60
68
|
Nothing is created until the first value is written. An expired file is
|
|
61
69
|
removed when it is read; :meth:`prune` removes the ones nobody reads.
|
|
62
70
|
|
|
71
|
+
Files are the owner's alone (``0o600``) in directories the owner alone may
|
|
72
|
+
enter (``0o700``), unless ``shared`` opens them to everyone (``0o666`` and
|
|
73
|
+
``0o777``, as the umask allows) so processes run as other users can read
|
|
74
|
+
them. A shared directory is dangerous with the pickle marshaller: anyone
|
|
75
|
+
who can write a file into it chooses what this process unpickles, which is
|
|
76
|
+
code execution. Share a directory only with a marshaller that
|
|
77
|
+
authenticates what it reads, such as
|
|
78
|
+
:class:`~xtr_cache.marshaller.SodiumMarshaller`.
|
|
79
|
+
|
|
63
80
|
Without a directory of its own, a pool keeps its files in the system's
|
|
64
81
|
temporary directory, where every user may create one first. Values are
|
|
65
82
|
unpickled when read, so there the directory is made for this user alone,
|
|
@@ -71,13 +88,16 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
|
|
|
71
88
|
_marshaller: MarshallerInterface
|
|
72
89
|
_private_root: Path | None
|
|
73
90
|
_private_root_checked: bool
|
|
91
|
+
_file_mode: int
|
|
92
|
+
_dir_mode: int
|
|
74
93
|
|
|
75
|
-
def __init__(
|
|
94
|
+
def __init__( # noqa: PLR0913 — every option past the directory is keyword-only.
|
|
76
95
|
self,
|
|
77
96
|
namespace: str = "",
|
|
78
97
|
default_lifetime: float = 0.0,
|
|
79
98
|
directory: str | os.PathLike[str] | None = None,
|
|
80
99
|
*,
|
|
100
|
+
shared: bool = False,
|
|
81
101
|
marshaller: MarshallerInterface | None = None,
|
|
82
102
|
clock: ClockInterface | None = None,
|
|
83
103
|
) -> None:
|
|
@@ -90,6 +110,11 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
|
|
|
90
110
|
expiry; ``0`` for no limit.
|
|
91
111
|
directory: Where the files go. ``None`` uses ``xtr-cache`` under
|
|
92
112
|
the system's temporary directory.
|
|
113
|
+
shared: Create files and directories readable by every user
|
|
114
|
+
(``0o666``/``0o777``, as the umask allows) rather than the
|
|
115
|
+
owner alone (``0o600``/``0o700``). Dangerous with pickle: a
|
|
116
|
+
shared directory lets anyone choose what is unpickled, which
|
|
117
|
+
is code execution. Pair it with an authenticating marshaller.
|
|
93
118
|
marshaller: What turns values into bytes. Pickle when omitted.
|
|
94
119
|
clock: What lifetimes are counted from. ``None`` reads the clock
|
|
95
120
|
in force.
|
|
@@ -108,6 +133,8 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
|
|
|
108
133
|
self._private_root = base if directory is None else None
|
|
109
134
|
self._private_root_checked = False
|
|
110
135
|
self._marshaller = marshaller if marshaller is not None else DefaultMarshaller()
|
|
136
|
+
self._file_mode = _SHARED_FILE_MODE if shared else _PRIVATE_FILE_MODE
|
|
137
|
+
self._dir_mode = _SHARED_DIR_MODE if shared else _PRIVATE_DIR_MODE
|
|
111
138
|
|
|
112
139
|
@property
|
|
113
140
|
def directory(self) -> Path:
|
|
@@ -203,10 +230,10 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
|
|
|
203
230
|
header = f"{expiry:.6f}\n{quote(id_, safe='')}\n".encode("ascii")
|
|
204
231
|
temporary = path.parent / f"{_TEMPORARY_PREFIX}{secrets.token_hex(8)}"
|
|
205
232
|
try:
|
|
206
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
-
#
|
|
208
|
-
# can share the directory.
|
|
209
|
-
with os.fdopen(os.open(temporary, _WRITE_FLAGS,
|
|
233
|
+
path.parent.mkdir(self._dir_mode, parents=True, exist_ok=True)
|
|
234
|
+
# The owner's alone by default; readable by whoever the umask lets read it
|
|
235
|
+
# when shared, so processes run as other users can share the directory.
|
|
236
|
+
with os.fdopen(os.open(temporary, _WRITE_FLAGS, self._file_mode), "wb") as file:
|
|
210
237
|
_ = file.write(header)
|
|
211
238
|
_ = file.write(data)
|
|
212
239
|
_ = temporary.replace(path)
|
|
@@ -86,7 +86,7 @@ class RedisAdapter(AbstractAdapter):
|
|
|
86
86
|
def __init__(
|
|
87
87
|
self,
|
|
88
88
|
redis: Redis,
|
|
89
|
-
namespace: str
|
|
89
|
+
namespace: str,
|
|
90
90
|
default_lifetime: float = 0.0,
|
|
91
91
|
*,
|
|
92
92
|
marshaller: MarshallerInterface | None = None,
|
|
@@ -96,8 +96,9 @@ class RedisAdapter(AbstractAdapter):
|
|
|
96
96
|
|
|
97
97
|
Args:
|
|
98
98
|
redis: An asyncio client.
|
|
99
|
-
namespace: Put in front of every key
|
|
100
|
-
|
|
99
|
+
namespace: Put in front of every key, and required: without one
|
|
100
|
+
:meth:`clear` unlinks every key in the database, not only this
|
|
101
|
+
pool's.
|
|
101
102
|
default_lifetime: Seconds a value lives when its item sets no
|
|
102
103
|
expiry; ``0`` for no limit.
|
|
103
104
|
marshaller: What turns values into bytes. Pickle when omitted.
|
|
@@ -105,8 +106,9 @@ class RedisAdapter(AbstractAdapter):
|
|
|
105
106
|
clock in force.
|
|
106
107
|
|
|
107
108
|
Raises:
|
|
108
|
-
InvalidArgumentError: When
|
|
109
|
-
stored values are bytes, and would
|
|
109
|
+
InvalidArgumentError: When ``namespace`` is empty, or the client
|
|
110
|
+
decodes replies into text — stored values are bytes, and would
|
|
111
|
+
never read back.
|
|
110
112
|
"""
|
|
111
113
|
# The client is typed for its blocking twin too; see _Client.
|
|
112
114
|
client = cast("_Client", cast("object", redis))
|
|
@@ -115,6 +117,11 @@ class RedisAdapter(AbstractAdapter):
|
|
|
115
117
|
"A Redis cache pool needs a client returning bytes; "
|
|
116
118
|
"this one was created with decode_responses=True.",
|
|
117
119
|
)
|
|
120
|
+
if not namespace:
|
|
121
|
+
raise InvalidArgumentError(
|
|
122
|
+
"A Redis cache pool needs a namespace: clearing it unlinks every "
|
|
123
|
+
"key under the namespace, and empties the whole database without one.",
|
|
124
|
+
)
|
|
118
125
|
|
|
119
126
|
super().__init__(namespace, default_lifetime, clock=clock)
|
|
120
127
|
self._redis = redis
|
|
@@ -126,7 +133,7 @@ class RedisAdapter(AbstractAdapter):
|
|
|
126
133
|
def from_url(
|
|
127
134
|
cls,
|
|
128
135
|
dsn: str,
|
|
129
|
-
namespace: str
|
|
136
|
+
namespace: str,
|
|
130
137
|
default_lifetime: float = 0.0,
|
|
131
138
|
*,
|
|
132
139
|
marshaller: MarshallerInterface | None = None,
|
|
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Final, Self, final
|
|
|
10
10
|
from typing_extensions import override
|
|
11
11
|
from xtr_cache_contracts import Metadata, NamespacedPoolInterface
|
|
12
12
|
|
|
13
|
+
from xtr_cache._internal_key import InternalKey
|
|
13
14
|
from xtr_cache.cache_item import CacheItem
|
|
14
15
|
from xtr_cache.pruneable_interface import PruneableInterface
|
|
15
16
|
from xtr_cache.value_wrapper import ValueWrapper
|
|
@@ -29,11 +30,14 @@ if TYPE_CHECKING:
|
|
|
29
30
|
__all__ = ["TagAwareAdapter"]
|
|
30
31
|
|
|
31
32
|
TAGS_PREFIX: Final = "\x01tags\x01"
|
|
33
|
+
"""What the key holding a tag's version starts with, so it never meets a user key."""
|
|
32
34
|
|
|
33
|
-
#: How long a tag's version is kept. Given explicitly, so a tags pool's default
|
|
34
|
-
#: lifetime never drops a version before the values that were saved with it.
|
|
35
35
|
_TAG_VERSION_LIFETIME: Final = 100 * 365 * 86400
|
|
36
|
-
"""
|
|
36
|
+
"""How long a tag's version is kept.
|
|
37
|
+
|
|
38
|
+
Given explicitly, so a tags pool's default lifetime never drops a version
|
|
39
|
+
before the values that were saved with it.
|
|
40
|
+
"""
|
|
37
41
|
|
|
38
42
|
|
|
39
43
|
@final
|
|
@@ -99,7 +103,7 @@ class TagAwareAdapter(
|
|
|
99
103
|
async def invalidate_tags(self, tags: Iterable[str], /) -> bool:
|
|
100
104
|
ids: list[str] = []
|
|
101
105
|
for tag in tags:
|
|
102
|
-
ids.append(TAGS_PREFIX + CacheItem.validate_key(tag))
|
|
106
|
+
ids.append(InternalKey(TAGS_PREFIX + CacheItem.validate_key(tag)))
|
|
103
107
|
_ = self._known.pop(tag, None)
|
|
104
108
|
|
|
105
109
|
return not ids or await self._tags.delete_items(ids)
|
|
@@ -200,7 +204,12 @@ class TagAwareAdapter(
|
|
|
200
204
|
stored = inner.get()
|
|
201
205
|
if not isinstance(stored, TaggedValue):
|
|
202
206
|
return CacheItem(
|
|
203
|
-
key,
|
|
207
|
+
key,
|
|
208
|
+
stored,
|
|
209
|
+
hit=True,
|
|
210
|
+
metadata=inner.metadata.copy(),
|
|
211
|
+
taggable=True,
|
|
212
|
+
clock=self._clock,
|
|
204
213
|
)
|
|
205
214
|
|
|
206
215
|
if any(versions.get(tag) != version for tag, version in stored.versions.items()):
|
|
@@ -236,9 +245,10 @@ class TagAwareAdapter(
|
|
|
236
245
|
unknown.append(tag)
|
|
237
246
|
|
|
238
247
|
if unknown:
|
|
239
|
-
|
|
248
|
+
keys = {tag: InternalKey(TAGS_PREFIX + tag) for tag in unknown}
|
|
249
|
+
items = await self._tags.get_items(keys.values())
|
|
240
250
|
for tag in unknown:
|
|
241
|
-
item = items[
|
|
251
|
+
item = items[keys[tag]]
|
|
242
252
|
version = item.get() if item.is_hit() else None
|
|
243
253
|
versions[tag] = version if isinstance(version, str) else None
|
|
244
254
|
self._remember(tag, versions[tag], now)
|
|
@@ -267,7 +277,8 @@ class TagAwareAdapter(
|
|
|
267
277
|
created = [tag for tag, found in current.items() if found is None]
|
|
268
278
|
|
|
269
279
|
for tag in created:
|
|
270
|
-
|
|
280
|
+
key = InternalKey(TAGS_PREFIX + tag)
|
|
281
|
+
version = CacheItem(key, versions[tag], clock=self._clock)
|
|
271
282
|
_ = await self._tags.save_deferred(version.expires_after(_TAG_VERSION_LIFETIME))
|
|
272
283
|
# A version the tags pool did not keep must not be trusted here while others miss it.
|
|
273
284
|
if created and await self._tags.commit():
|
|
@@ -54,7 +54,7 @@ from xtr_cache.adapter.contracts_mixin import ContractsMixin
|
|
|
54
54
|
from xtr_cache.adapter.redis_adapter import RedisAdapter
|
|
55
55
|
from xtr_cache.adapter.tag_aware_adapter import TagAwareAdapter
|
|
56
56
|
from xtr_cache.adapter.tag_aware_adapter_interface import TagAwareAdapterInterface
|
|
57
|
-
from xtr_cache.cache_pool_clearer import CachePoolClearer
|
|
57
|
+
from xtr_cache.cache_pool_clearer import CachePoolClearer, PoolCapabilities
|
|
58
58
|
from xtr_cache.exception import InvalidArgumentError
|
|
59
59
|
from xtr_cache.lock_registry import LockRegistry
|
|
60
60
|
from xtr_cache.marshaller.default_marshaller import DefaultMarshaller
|
|
@@ -197,7 +197,26 @@ async def _lock_registry(
|
|
|
197
197
|
|
|
198
198
|
def _cache_pool_clearer(config: CacheConfig, container: ContainerInterface) -> CachePoolClearer:
|
|
199
199
|
"""Name every pool for the commands, each built only when a command first needs it."""
|
|
200
|
-
|
|
200
|
+
configs = config.pool_configs()
|
|
201
|
+
return CachePoolClearer(
|
|
202
|
+
{name: _provider(container, name) for name in configs},
|
|
203
|
+
capabilities={name: _capabilities(pool) for name, pool in configs.items()},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _capabilities(pool: PoolConfig) -> PoolCapabilities:
|
|
208
|
+
"""Read what a pool can do from its configuration, so a command need not build it.
|
|
209
|
+
|
|
210
|
+
A tag-aware pool and a chain are always prunable (each wraps the pool
|
|
211
|
+
underneath), so is a single pool whose adapter keeps expired values.
|
|
212
|
+
"""
|
|
213
|
+
entries = pool.adapters()
|
|
214
|
+
prunable = (
|
|
215
|
+
bool(pool.tags)
|
|
216
|
+
or len(entries) > 1
|
|
217
|
+
or any(AdapterFactory.serves_pruneable(entry) for entry in entries)
|
|
218
|
+
)
|
|
219
|
+
return PoolCapabilities(tag_aware=bool(pool.tags), prunable=prunable)
|
|
201
220
|
|
|
202
221
|
|
|
203
222
|
def _provider(
|
|
@@ -25,6 +25,30 @@ def _no_pools() -> dict[str, PoolEntry]:
|
|
|
25
25
|
return {}
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def _refuse_tags_cycles(edges: Mapping[str, str]) -> None:
|
|
29
|
+
"""Refuse a cycle in the pools' tags references, of any length, naming its pools.
|
|
30
|
+
|
|
31
|
+
Each pool keeping its tags in another points at one pool only, so the
|
|
32
|
+
references form a functional graph: a walk from any pool revisits a pool
|
|
33
|
+
exactly when it is caught in a cycle. A cycle would make building one of
|
|
34
|
+
its pools need itself, so it is refused before anything is built.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
InvalidArgumentError: When the tags references form a cycle.
|
|
38
|
+
"""
|
|
39
|
+
for start in edges:
|
|
40
|
+
seen: list[str] = []
|
|
41
|
+
node = start
|
|
42
|
+
while node in edges:
|
|
43
|
+
if node in seen:
|
|
44
|
+
cycle = [*seen[seen.index(node) :], node]
|
|
45
|
+
raise InvalidArgumentError(
|
|
46
|
+
f"The cache pools form a tags cycle: {' -> '.join(cycle)}.",
|
|
47
|
+
)
|
|
48
|
+
seen.append(node)
|
|
49
|
+
node = edges[node]
|
|
50
|
+
|
|
51
|
+
|
|
28
52
|
@dataclass(frozen=True, slots=True)
|
|
29
53
|
class CacheConfig:
|
|
30
54
|
"""Which cache pools exist, and what each keeps its values in.
|
|
@@ -82,6 +106,7 @@ class CacheConfig:
|
|
|
82
106
|
InvalidArgumentError: When the configuration cannot be read.
|
|
83
107
|
"""
|
|
84
108
|
_ = PoolConfig(adapter=self.app)
|
|
109
|
+
edges: dict[str, str] = {}
|
|
85
110
|
for name, entry in self.pools.items():
|
|
86
111
|
if not isinstance(name, str) or not name: # pyright: ignore[reportUnnecessaryIsInstance] -- configs are written by hand; the annotation is not enforced
|
|
87
112
|
raise InvalidArgumentError(f"A cache pool needs a non-empty name, got {name!r}.")
|
|
@@ -96,6 +121,9 @@ class CacheConfig:
|
|
|
96
121
|
f'The "{name}" cache pool keeps its tags in "{entry.tags}", '
|
|
97
122
|
f"which is not another pool.",
|
|
98
123
|
)
|
|
124
|
+
elif isinstance(entry.tags, str):
|
|
125
|
+
edges[name] = entry.tags
|
|
126
|
+
_refuse_tags_cycles(edges)
|
|
99
127
|
|
|
100
128
|
def pool_configs(self) -> dict[str, PoolConfig]:
|
|
101
129
|
"""Return every pool, ``app`` first, each with its adapters resolved to a tuple."""
|
|
@@ -9,6 +9,7 @@ from typing_extensions import override
|
|
|
9
9
|
from xtr_cache_contracts import RESERVED_CHARACTERS, InvalidArgumentError, ItemInterface, Metadata
|
|
10
10
|
from xtr_clock import Clock
|
|
11
11
|
|
|
12
|
+
from ._internal_key import InternalKey
|
|
12
13
|
from .exception.logic_error import LogicError
|
|
13
14
|
from .value_wrapper import ValueWrapper
|
|
14
15
|
|
|
@@ -115,8 +116,9 @@ class CacheItem(ItemInterface):
|
|
|
115
116
|
"""Return ``key`` when it is a valid key or tag.
|
|
116
117
|
|
|
117
118
|
Raises:
|
|
118
|
-
InvalidArgumentError: When ``key`` is not a string, is empty,
|
|
119
|
-
holds one of
|
|
119
|
+
InvalidArgumentError: When ``key`` is not a string, is empty, holds
|
|
120
|
+
a non-printable character, or holds one of
|
|
121
|
+
:data:`~xtr_cache_contracts.RESERVED_CHARACTERS`.
|
|
120
122
|
"""
|
|
121
123
|
if not isinstance(key, str):
|
|
122
124
|
raise InvalidArgumentError(
|
|
@@ -124,6 +126,12 @@ class CacheItem(ItemInterface):
|
|
|
124
126
|
)
|
|
125
127
|
if not key:
|
|
126
128
|
raise InvalidArgumentError("A cache key must not be empty.")
|
|
129
|
+
if isinstance(key, InternalKey):
|
|
130
|
+
return key
|
|
131
|
+
if not key.isprintable():
|
|
132
|
+
raise InvalidArgumentError(
|
|
133
|
+
f"A cache key must not contain non-printable characters, got {key!r}.",
|
|
134
|
+
)
|
|
127
135
|
if any(character in RESERVED_CHARACTERS for character in key):
|
|
128
136
|
raise InvalidArgumentError(
|
|
129
137
|
f'Cache key "{key}" contains one of the reserved characters '
|
|
@@ -3,20 +3,47 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
6
7
|
from typing import TYPE_CHECKING, TypeAlias, final
|
|
7
8
|
|
|
8
9
|
from typing_extensions import override
|
|
9
|
-
from xtr_cache_contracts import
|
|
10
|
+
from xtr_cache_contracts import (
|
|
11
|
+
CacheItemPoolInterface,
|
|
12
|
+
InvalidArgumentError,
|
|
13
|
+
TagAwareCacheInterface,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from xtr_cache.pruneable_interface import PruneableInterface
|
|
10
17
|
|
|
11
18
|
if TYPE_CHECKING:
|
|
12
19
|
from collections.abc import Awaitable, Mapping
|
|
13
20
|
|
|
14
|
-
__all__ = ["CachePoolClearer", "PoolProvider"]
|
|
21
|
+
__all__ = ["CachePoolClearer", "PoolCapabilities", "PoolProvider"]
|
|
15
22
|
|
|
16
23
|
PoolProvider: TypeAlias = CacheItemPoolInterface | Callable[[], "Awaitable[CacheItemPoolInterface]"]
|
|
17
24
|
"""A pool, or what builds it when first asked for."""
|
|
18
25
|
|
|
19
26
|
|
|
27
|
+
@final
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class PoolCapabilities:
|
|
30
|
+
"""What a pool can do, read from its configuration before it is built.
|
|
31
|
+
|
|
32
|
+
A command that acts on only some pools — pruning the ones that keep
|
|
33
|
+
expired values, invalidating tags in the tag-aware ones — asks these of
|
|
34
|
+
every pool and builds only the ones that answer yes, so naming a Redis
|
|
35
|
+
pool it would skip never reaches the server.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
tag_aware: Whether items can be tagged and invalidated by tag.
|
|
39
|
+
prunable: Whether the backend keeps expired values, so pruning frees
|
|
40
|
+
them.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
tag_aware: bool
|
|
44
|
+
prunable: bool
|
|
45
|
+
|
|
46
|
+
|
|
20
47
|
@final
|
|
21
48
|
class CachePoolClearer:
|
|
22
49
|
"""Knows every named pool, and clears them by name.
|
|
@@ -26,15 +53,27 @@ class CachePoolClearer:
|
|
|
26
53
|
nothing until a command uses it.
|
|
27
54
|
"""
|
|
28
55
|
|
|
29
|
-
__slots__ = ("_built", "_pools")
|
|
56
|
+
__slots__ = ("_built", "_capabilities", "_pools")
|
|
30
57
|
|
|
31
58
|
_pools: dict[str, PoolProvider]
|
|
32
59
|
_built: dict[str, CacheItemPoolInterface]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
60
|
+
_capabilities: dict[str, PoolCapabilities]
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
pools: Mapping[str, PoolProvider] | None = None,
|
|
65
|
+
*,
|
|
66
|
+
capabilities: Mapping[str, PoolCapabilities] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Know ``pools`` by name, with ``capabilities`` a command can read unbuilt.
|
|
69
|
+
|
|
70
|
+
A pool given built answers its own capabilities by its type; one given
|
|
71
|
+
as a function to build later needs an entry in ``capabilities``, so a
|
|
72
|
+
command can tell what it does without building it.
|
|
73
|
+
"""
|
|
36
74
|
self._pools = dict(pools or {})
|
|
37
75
|
self._built = {}
|
|
76
|
+
self._capabilities = dict(capabilities or {})
|
|
38
77
|
|
|
39
78
|
def has_pool(self, name: str) -> bool:
|
|
40
79
|
"""Tell whether a pool is known by ``name``."""
|
|
@@ -44,6 +83,20 @@ class CachePoolClearer:
|
|
|
44
83
|
"""Return every pool's name, sorted."""
|
|
45
84
|
return tuple(sorted(self._pools))
|
|
46
85
|
|
|
86
|
+
def is_tag_aware(self, name: str) -> bool:
|
|
87
|
+
"""Tell whether the pool ``name`` can be invalidated by tag, without building it."""
|
|
88
|
+
capabilities = self._capabilities.get(name)
|
|
89
|
+
if capabilities is not None:
|
|
90
|
+
return capabilities.tag_aware
|
|
91
|
+
return isinstance(self._pools.get(name), TagAwareCacheInterface)
|
|
92
|
+
|
|
93
|
+
def is_prunable(self, name: str) -> bool:
|
|
94
|
+
"""Tell whether the pool known by ``name`` keeps expired values, without building it."""
|
|
95
|
+
capabilities = self._capabilities.get(name)
|
|
96
|
+
if capabilities is not None:
|
|
97
|
+
return capabilities.prunable
|
|
98
|
+
return isinstance(self._pools.get(name), PruneableInterface)
|
|
99
|
+
|
|
47
100
|
async def get_pool(self, name: str) -> CacheItemPoolInterface:
|
|
48
101
|
"""Return the pool known by ``name``, building it the first time.
|
|
49
102
|
|
{xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_invalidate_tags_command.py
RENAMED
|
@@ -83,12 +83,14 @@ async def _invalidate(
|
|
|
83
83
|
invalidated = 0
|
|
84
84
|
errors = False
|
|
85
85
|
for name in names:
|
|
86
|
-
|
|
87
|
-
if not isinstance(cache, TagAwareCacheInterface):
|
|
86
|
+
if not clearer.is_tag_aware(name):
|
|
88
87
|
if named:
|
|
89
88
|
io.error(f'Cache pool "{escape(name)}" is not tag-aware.')
|
|
90
89
|
errors = True
|
|
91
90
|
continue
|
|
91
|
+
cache = await clearer.get_pool(name)
|
|
92
|
+
if not isinstance(cache, TagAwareCacheInterface):
|
|
93
|
+
continue
|
|
92
94
|
if not await cache.invalidate_tags(tags):
|
|
93
95
|
io.error(f'Cache tags could not be invalidated in pool "{escape(name)}".')
|
|
94
96
|
errors = True
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import time
|
|
7
6
|
import zlib
|
|
8
7
|
from typing import TYPE_CHECKING, Final, TypeVar, cast, final
|
|
9
8
|
|
|
10
9
|
from typing_extensions import override
|
|
10
|
+
from xtr_clock import MonotonicClock
|
|
11
11
|
from xtr_logging_contracts import LoggerAware
|
|
12
12
|
|
|
13
13
|
from .exception import InvalidArgumentError
|
|
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
|
|
|
16
16
|
from collections.abc import Awaitable, Callable
|
|
17
17
|
|
|
18
18
|
from xtr_cache_contracts import ItemInterface
|
|
19
|
+
from xtr_clock import ClockInterface
|
|
19
20
|
from xtr_lock import LockFactory
|
|
20
21
|
|
|
21
22
|
__all__ = ["LockRegistry"]
|
|
@@ -48,13 +49,14 @@ class LockRegistry(LoggerAware):
|
|
|
48
49
|
so every process agrees on which lock guards it.
|
|
49
50
|
"""
|
|
50
51
|
|
|
51
|
-
__slots__ = ("_evicted", "_locks", "_prefix", "_slots", "_wait")
|
|
52
|
+
__slots__ = ("_clock", "_evicted", "_locks", "_prefix", "_slots", "_wait")
|
|
52
53
|
|
|
53
54
|
_locks: LockFactory
|
|
54
55
|
_slots: int
|
|
55
56
|
_evicted: dict[int, float]
|
|
56
57
|
_wait: float
|
|
57
58
|
_prefix: str
|
|
59
|
+
_clock: ClockInterface
|
|
58
60
|
|
|
59
61
|
def __init__(
|
|
60
62
|
self,
|
|
@@ -63,6 +65,7 @@ class LockRegistry(LoggerAware):
|
|
|
63
65
|
slots: int = DEFAULT_SLOTS,
|
|
64
66
|
wait: float = DEFAULT_WAIT,
|
|
65
67
|
prefix: str = "xtr-cache.",
|
|
68
|
+
clock: ClockInterface | None = None,
|
|
66
69
|
) -> None:
|
|
67
70
|
"""Take slots' locks from ``locks``.
|
|
68
71
|
|
|
@@ -71,6 +74,8 @@ class LockRegistry(LoggerAware):
|
|
|
71
74
|
slots: How many values may be computed at once.
|
|
72
75
|
wait: How long to wait for another caller's computation, in seconds.
|
|
73
76
|
prefix: Put in front of each slot's number to name its lock.
|
|
77
|
+
clock: What the eviction window is timed against; a monotonic clock
|
|
78
|
+
when omitted, so a wall-clock jump never widens or shortens it.
|
|
74
79
|
|
|
75
80
|
Raises:
|
|
76
81
|
InvalidArgumentError: When ``slots`` or ``wait`` is not positive.
|
|
@@ -85,6 +90,7 @@ class LockRegistry(LoggerAware):
|
|
|
85
90
|
self._evicted = {}
|
|
86
91
|
self._wait = wait
|
|
87
92
|
self._prefix = prefix
|
|
93
|
+
self._clock = clock if clock is not None else MonotonicClock()
|
|
88
94
|
|
|
89
95
|
async def compute(
|
|
90
96
|
self,
|
|
@@ -147,14 +153,14 @@ class LockRegistry(LoggerAware):
|
|
|
147
153
|
until = self._evicted.get(slot)
|
|
148
154
|
if until is None:
|
|
149
155
|
return slot
|
|
150
|
-
if
|
|
156
|
+
if self._clock.now().timestamp() < until:
|
|
151
157
|
return None
|
|
152
158
|
del self._evicted[slot]
|
|
153
159
|
return slot
|
|
154
160
|
|
|
155
161
|
def _evict(self, slot: int) -> None:
|
|
156
162
|
"""Set ``slot`` aside for a while, its holder stuck, so later callers do not wait on it."""
|
|
157
|
-
self._evicted[slot] =
|
|
163
|
+
self._evicted[slot] = self._clock.now().timestamp() + self._wait
|
|
158
164
|
|
|
159
165
|
@property
|
|
160
166
|
def slots(self) -> int:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|