xtr-cache 1.4.0__tar.gz → 2.0.0__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-1.4.0 → xtr_cache-2.0.0}/PKG-INFO +20 -14
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/README.md +9 -3
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/pyproject.toml +15 -15
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/pyproject.toml.orig +14 -14
- xtr_cache-2.0.0/src/xtr_cache/_digest.py +18 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/abstract_adapter.py +3 -4
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/adapter_factory.py +9 -5
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/array_adapter.py +13 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/contracts_mixin.py +4 -2
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/filesystem_adapter.py +5 -5
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/redis_adapter.py +3 -3
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/tag_aware_adapter.py +30 -11
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/bundle/cache_bundle.py +5 -7
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/marshaller/default_marshaller.py +1 -1
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/marshaller/deflate_marshaller.py +28 -4
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/LICENSE +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/__init__.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/__init__.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/adapter_interface.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/chain_adapter.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/deferred_items_mixin.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/null_adapter.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/tag_aware_adapter_interface.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/adapter/tagged_value.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/bundle/__init__.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/bundle/cache_config.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/bundle/pool_config.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/cache_item.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/cache_pool_clearer.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/__init__.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/cache_pool_clear_command.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/cache_pool_delete_command.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/cache_pool_invalidate_tags_command.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/cache_pool_list_command.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/cache_pool_prune_command.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/pool_command.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/pools.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/exception/__init__.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/exception/logic_error.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/exception/marshalling_error.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/lock_registry.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/marshaller/__init__.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/marshaller/marshaller_interface.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/marshaller/sodium_marshaller.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/pruneable_interface.py +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/py.typed +0 -0
- {xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/value_wrapper.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xtr-cache
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
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
|
-
Author:
|
|
7
|
-
Author-email:
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
10
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -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>=2.0,<3
|
|
20
|
+
Requires-Dist: xtr-clock>=2.0,<3
|
|
21
|
+
Requires-Dist: xtr-lock>=2.0,<3
|
|
22
|
+
Requires-Dist: xtr-logging-contracts>=2.0,<3
|
|
23
|
+
Requires-Dist: xtr-console>=2.0,<3 ; extra == 'console'
|
|
24
|
+
Requires-Dist: xtr-dependency-injection>=2.0,<3 ; extra == 'di'
|
|
25
|
+
Requires-Dist: xtr-service-contracts>=2.0,<3 ; extra == 'di'
|
|
26
|
+
Requires-Dist: xtr-logging>=2.0,<3 ; 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
|
|
@@ -174,7 +174,8 @@ first write. An expired file is removed when read; `await pool.prune()` removes
|
|
|
174
174
|
Given no directory, a pool uses `xtr-cache` in the system's temporary directory, which any user
|
|
175
175
|
may create first. Values are unpickled when read, so that directory is made readable by its owner
|
|
176
176
|
alone, and one belonging to another user is refused: reads miss, writes fail, and the pool logs
|
|
177
|
-
why. A directory you give is used as it is.
|
|
177
|
+
why. A directory you give is used as it is. Where the system has no user ids to compare, as
|
|
178
|
+
on Windows, neither check applies: the directory is created, but its owner is not checked.
|
|
178
179
|
|
|
179
180
|
### `RedisAdapter`
|
|
180
181
|
|
|
@@ -184,7 +185,7 @@ from xtr_cache import RedisAdapter
|
|
|
184
185
|
|
|
185
186
|
pool = RedisAdapter(Redis.from_url("redis://cache:6379/0"), "app") # your client, you close it
|
|
186
187
|
pool = RedisAdapter.from_url("redis://cache:6379/0", "app") # its own client ...
|
|
187
|
-
await pool.
|
|
188
|
+
await pool.close() # ... which it closes
|
|
188
189
|
```
|
|
189
190
|
|
|
190
191
|
Each value is a string key, `namespace:key`, expired by the server itself. Reads fetch many keys
|
|
@@ -226,6 +227,9 @@ deletes its version. Nothing is listed or scanned, so invalidating costs the sam
|
|
|
226
227
|
items carry the tag. Versions read are trusted for `known_tag_versions_ttl` seconds (0.15 by
|
|
227
228
|
default), which is how soon an invalidation elsewhere reaches this process. Items saved as
|
|
228
229
|
deferred take their versions when committed, so an invalidation meanwhile does not apply to them.
|
|
230
|
+
A tag's first version is given one save at a time in a process; two processes giving one at the
|
|
231
|
+
same moment may each keep their own, and the items of the one that lost miss once and are
|
|
232
|
+
computed again.
|
|
229
233
|
|
|
230
234
|
Tagging an item of any other pool raises `LogicError`.
|
|
231
235
|
|
|
@@ -234,7 +238,9 @@ Tagging an item of any other pool raises `LogicError`.
|
|
|
234
238
|
`get()` protects the backend three ways:
|
|
235
239
|
|
|
236
240
|
- **One computation per key in a process.** Concurrent misses on one key share the first caller's
|
|
237
|
-
computation
|
|
241
|
+
computation — and its result: they all get the very object computed, so copy it before changing
|
|
242
|
+
it; what is cached is not affected. If it fails or is cancelled, the others compute for
|
|
243
|
+
themselves, side by side.
|
|
238
244
|
- **One computation per key across processes**, with a `LockRegistry`. Keys are spread over
|
|
239
245
|
`slots` locks (20 by default); the caller that takes a key's slot computes and saves, the others
|
|
240
246
|
wait for it and read what was saved — or compute themselves after `wait` seconds (30), so a stuck
|
|
@@ -138,7 +138,8 @@ first write. An expired file is removed when read; `await pool.prune()` removes
|
|
|
138
138
|
Given no directory, a pool uses `xtr-cache` in the system's temporary directory, which any user
|
|
139
139
|
may create first. Values are unpickled when read, so that directory is made readable by its owner
|
|
140
140
|
alone, and one belonging to another user is refused: reads miss, writes fail, and the pool logs
|
|
141
|
-
why. A directory you give is used as it is.
|
|
141
|
+
why. A directory you give is used as it is. Where the system has no user ids to compare, as
|
|
142
|
+
on Windows, neither check applies: the directory is created, but its owner is not checked.
|
|
142
143
|
|
|
143
144
|
### `RedisAdapter`
|
|
144
145
|
|
|
@@ -148,7 +149,7 @@ from xtr_cache import RedisAdapter
|
|
|
148
149
|
|
|
149
150
|
pool = RedisAdapter(Redis.from_url("redis://cache:6379/0"), "app") # your client, you close it
|
|
150
151
|
pool = RedisAdapter.from_url("redis://cache:6379/0", "app") # its own client ...
|
|
151
|
-
await pool.
|
|
152
|
+
await pool.close() # ... which it closes
|
|
152
153
|
```
|
|
153
154
|
|
|
154
155
|
Each value is a string key, `namespace:key`, expired by the server itself. Reads fetch many keys
|
|
@@ -190,6 +191,9 @@ deletes its version. Nothing is listed or scanned, so invalidating costs the sam
|
|
|
190
191
|
items carry the tag. Versions read are trusted for `known_tag_versions_ttl` seconds (0.15 by
|
|
191
192
|
default), which is how soon an invalidation elsewhere reaches this process. Items saved as
|
|
192
193
|
deferred take their versions when committed, so an invalidation meanwhile does not apply to them.
|
|
194
|
+
A tag's first version is given one save at a time in a process; two processes giving one at the
|
|
195
|
+
same moment may each keep their own, and the items of the one that lost miss once and are
|
|
196
|
+
computed again.
|
|
193
197
|
|
|
194
198
|
Tagging an item of any other pool raises `LogicError`.
|
|
195
199
|
|
|
@@ -198,7 +202,9 @@ Tagging an item of any other pool raises `LogicError`.
|
|
|
198
202
|
`get()` protects the backend three ways:
|
|
199
203
|
|
|
200
204
|
- **One computation per key in a process.** Concurrent misses on one key share the first caller's
|
|
201
|
-
computation
|
|
205
|
+
computation — and its result: they all get the very object computed, so copy it before changing
|
|
206
|
+
it; what is cached is not affected. If it fails or is cancelled, the others compute for
|
|
207
|
+
themselves, side by side.
|
|
202
208
|
- **One computation per key across processes**, with a `LockRegistry`. Keys are spread over
|
|
203
209
|
`slots` locks (20 by default); the caller that takes a key's slot computes and saves, the others
|
|
204
210
|
wait for it and read what was saved — or compute themselves after `wait` seconds (30), so a stuck
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-cache"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
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,25 +25,25 @@ 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>=2.0,<3",
|
|
29
|
+
"xtr-clock>=2.0,<3",
|
|
30
|
+
"xtr-lock>=2.0,<3",
|
|
31
|
+
"xtr-logging-contracts>=2.0,<3",
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[[project.authors]]
|
|
35
|
-
name = "
|
|
36
|
-
email = "
|
|
35
|
+
name = "Xterr"
|
|
36
|
+
email = "me@xterr.dev"
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
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>=2.0,<3",
|
|
43
|
+
"xtr-service-contracts>=2.0,<3",
|
|
44
44
|
]
|
|
45
|
-
console = ["xtr-console>=
|
|
46
|
-
logging = ["xtr-logging>=
|
|
45
|
+
console = ["xtr-console>=2.0,<3"]
|
|
46
|
+
logging = ["xtr-logging>=2.0,<3"]
|
|
47
47
|
|
|
48
48
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
49
49
|
cache = "xtr_cache.bundle:CacheBundle"
|
|
@@ -58,10 +58,10 @@ dev = [
|
|
|
58
58
|
"ty>=0.0.83",
|
|
59
59
|
"pynacl>=1.5",
|
|
60
60
|
"redis>=5.0",
|
|
61
|
-
"xtr-console>=
|
|
62
|
-
"xtr-dependency-injection>=
|
|
63
|
-
"xtr-logging>=
|
|
64
|
-
"xtr-service-contracts>=
|
|
61
|
+
"xtr-console>=2.0,<3",
|
|
62
|
+
"xtr-dependency-injection>=2.0,<3",
|
|
63
|
+
"xtr-logging>=2.0,<3",
|
|
64
|
+
"xtr-service-contracts>=2.0,<3",
|
|
65
65
|
]
|
|
66
66
|
|
|
67
67
|
[build-system]
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "xtr-cache"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
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"
|
|
7
7
|
license = "MIT"
|
|
8
8
|
license-files = ["LICENSE"]
|
|
9
9
|
authors = [
|
|
10
|
-
{ name = "
|
|
10
|
+
{ name = "Xterr", email = "me@xterr.dev" }
|
|
11
11
|
]
|
|
12
12
|
keywords = ["cache", "redis", "stampede", "tags", "asyncio"]
|
|
13
13
|
classifiers = [
|
|
@@ -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>=2.0,<3",
|
|
26
|
+
"xtr-clock>=2.0,<3",
|
|
27
|
+
"xtr-lock>=2.0,<3",
|
|
28
|
+
"xtr-logging-contracts>=2.0,<3",
|
|
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>=2.0,<3",
|
|
40
|
+
"xtr-service-contracts>=2.0,<3",
|
|
41
41
|
]
|
|
42
42
|
console = [
|
|
43
|
-
"xtr-console>=
|
|
43
|
+
"xtr-console>=2.0,<3",
|
|
44
44
|
]
|
|
45
45
|
logging = [
|
|
46
|
-
"xtr-logging>=
|
|
46
|
+
"xtr-logging>=2.0,<3",
|
|
47
47
|
]
|
|
48
48
|
|
|
49
49
|
# Advertises the bundle so debug:bundles can name it when installed but not listed.
|
|
@@ -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>=2.0,<3",
|
|
65
|
+
"xtr-dependency-injection>=2.0,<3",
|
|
66
|
+
"xtr-logging>=2.0,<3",
|
|
67
|
+
"xtr-service-contracts>=2.0,<3",
|
|
68
68
|
]
|
|
69
69
|
|
|
70
70
|
[build-system]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""A short, key-safe digest of a text, for what the pools store under names derived from it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
|
|
8
|
+
__all__ = ["urlsafe_digest"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def urlsafe_digest(text: str, *, size: int = 32) -> str:
|
|
12
|
+
"""Return the first ``size`` bytes of ``text``'s SHA-256, in URL-safe base64 without padding.
|
|
13
|
+
|
|
14
|
+
Stored identifiers and file names are made from it: its output must never
|
|
15
|
+
change, or what was stored could no longer be found.
|
|
16
|
+
"""
|
|
17
|
+
digest = hashlib.sha256(text.encode()).digest()[:size]
|
|
18
|
+
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import base64
|
|
6
5
|
import contextlib
|
|
7
|
-
import hashlib
|
|
8
6
|
import math
|
|
9
7
|
import re
|
|
10
8
|
from abc import ABC, abstractmethod
|
|
@@ -13,6 +11,7 @@ from typing import TYPE_CHECKING, ClassVar, Final, Self
|
|
|
13
11
|
from typing_extensions import override
|
|
14
12
|
from xtr_cache_contracts import InvalidArgumentError, NamespacedPoolInterface
|
|
15
13
|
|
|
14
|
+
from xtr_cache._digest import urlsafe_digest
|
|
16
15
|
from xtr_cache.cache_item import CacheItem
|
|
17
16
|
from xtr_cache.exception.marshalling_error import MarshallingError
|
|
18
17
|
|
|
@@ -267,6 +266,7 @@ class AbstractAdapter(
|
|
|
267
266
|
|
|
268
267
|
@override
|
|
269
268
|
def with_sub_namespace(self, namespace: str, /) -> Self:
|
|
269
|
+
_ = self._flights() # made now, so the view shares it rather than make its own
|
|
270
270
|
clone = self._unqueued_copy()
|
|
271
271
|
clone._namespace = ( # noqa: SLF001 — a copy of this very class.
|
|
272
272
|
self._namespace + CacheItem.validate_key(namespace) + self.NS_SEPARATOR
|
|
@@ -323,8 +323,7 @@ class AbstractAdapter(
|
|
|
323
323
|
if self.max_id_length is None or len(id_) <= self.max_id_length:
|
|
324
324
|
return id_
|
|
325
325
|
|
|
326
|
-
|
|
327
|
-
return self._namespace + digest.rstrip("=") + self.NS_SEPARATOR
|
|
326
|
+
return self._namespace + urlsafe_digest(key, size=16) + self.NS_SEPARATOR
|
|
328
327
|
|
|
329
328
|
def _unmarshall_found(
|
|
330
329
|
self,
|
|
@@ -4,7 +4,12 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING, Final, final
|
|
6
6
|
|
|
7
|
-
from xtr_lock.store import
|
|
7
|
+
from xtr_lock.store import (
|
|
8
|
+
describe_connection_scheme,
|
|
9
|
+
is_redis_client,
|
|
10
|
+
is_redis_dsn,
|
|
11
|
+
redis_installed,
|
|
12
|
+
)
|
|
8
13
|
|
|
9
14
|
from xtr_cache.exception import InvalidArgumentError
|
|
10
15
|
|
|
@@ -128,7 +133,6 @@ class AdapterFactory:
|
|
|
128
133
|
raise InvalidArgumentError(RedisAdapter.MISSING_CLIENT)
|
|
129
134
|
return
|
|
130
135
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
raise InvalidArgumentError(f'Unsupported cache connection: "{described}".')
|
|
136
|
+
raise InvalidArgumentError(
|
|
137
|
+
f'Unsupported cache connection: "{describe_connection_scheme(connection)}".'
|
|
138
|
+
)
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import dataclasses
|
|
5
6
|
from typing import TYPE_CHECKING, cast, final
|
|
6
7
|
|
|
7
8
|
from typing_extensions import override
|
|
8
9
|
|
|
9
10
|
from xtr_cache.exception import InvalidArgumentError, MarshallingError
|
|
10
11
|
from xtr_cache.marshaller.default_marshaller import DefaultMarshaller
|
|
12
|
+
from xtr_cache.value_wrapper import ValueWrapper
|
|
11
13
|
|
|
12
14
|
from .abstract_adapter import AbstractAdapter
|
|
13
15
|
|
|
@@ -144,6 +146,8 @@ class ArrayAdapter(AbstractAdapter):
|
|
|
144
146
|
if self._max_lifetime and (not lifetime or lifetime > self._max_lifetime):
|
|
145
147
|
lifetime = self._max_lifetime
|
|
146
148
|
expiry = self._clock.now().timestamp() + lifetime if lifetime else None
|
|
149
|
+
if expiry is not None:
|
|
150
|
+
values = {id_: _capped(value, expiry) for id_, value in values.items()}
|
|
147
151
|
|
|
148
152
|
failed: list[str] = []
|
|
149
153
|
stored: Mapping[str, object] = values
|
|
@@ -163,3 +167,12 @@ class ArrayAdapter(AbstractAdapter):
|
|
|
163
167
|
@override
|
|
164
168
|
def __repr__(self) -> str:
|
|
165
169
|
return f"{type(self).__name__}({self._default_lifetime!r})"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _capped(value: object, expiry: float) -> object:
|
|
173
|
+
"""Return ``value``, its recorded expiry no later than ``expiry``, when the entry is."""
|
|
174
|
+
if not isinstance(value, ValueWrapper) or value.metadata.get("expiry", expiry) <= expiry:
|
|
175
|
+
return value
|
|
176
|
+
metadata = value.metadata.copy()
|
|
177
|
+
metadata["expiry"] = expiry
|
|
178
|
+
return dataclasses.replace(value, metadata=metadata)
|
|
@@ -44,8 +44,10 @@ class ContractsMixin(CacheMixin, LoggerAware, ABC):
|
|
|
44
44
|
|
|
45
45
|
- It is saved with how long it took, which — with its expiry — is what
|
|
46
46
|
makes early recomputation possible on a later read.
|
|
47
|
-
- Concurrent misses on one key in this process share one computation
|
|
48
|
-
|
|
47
|
+
- Concurrent misses on one key in this process share one computation, and
|
|
48
|
+
its result: each of them gets the very object computed, so change it
|
|
49
|
+
only after copying it. What is cached stays apart. If the computation
|
|
50
|
+
fails or is cancelled, each of the others computes for itself.
|
|
49
51
|
- With a :class:`~xtr_cache.lock_registry.LockRegistry`, misses in every
|
|
50
52
|
process sharing its locks share one computation too.
|
|
51
53
|
- A callback reading its own key — directly or through what it calls —
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import base64
|
|
7
|
-
import hashlib
|
|
8
6
|
import os
|
|
9
7
|
import secrets
|
|
10
8
|
import stat
|
|
@@ -16,6 +14,7 @@ from urllib.parse import quote, unquote
|
|
|
16
14
|
|
|
17
15
|
from typing_extensions import override
|
|
18
16
|
|
|
17
|
+
from xtr_cache._digest import urlsafe_digest
|
|
19
18
|
from xtr_cache.exception import InvalidArgumentError
|
|
20
19
|
from xtr_cache.marshaller.default_marshaller import DefaultMarshaller
|
|
21
20
|
from xtr_cache.pruneable_interface import PruneableInterface
|
|
@@ -149,8 +148,7 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
|
|
|
149
148
|
return f"{name}({self._namespace!r}, {self._default_lifetime!r}, {str(self._directory)!r})"
|
|
150
149
|
|
|
151
150
|
def _path(self, id_: str) -> Path:
|
|
152
|
-
digest =
|
|
153
|
-
digest = digest.rstrip("=")
|
|
151
|
+
digest = urlsafe_digest(id_)
|
|
154
152
|
return self._directory / digest[0] / digest[1] / digest[2:]
|
|
155
153
|
|
|
156
154
|
def _read_all(self, ids: Sequence[str], now: float) -> dict[str, bytes]:
|
|
@@ -225,7 +223,9 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
|
|
|
225
223
|
Only the default directory is checked: one the application chose is
|
|
226
224
|
its own. Creating it makes it private; an existing one of this user's
|
|
227
225
|
is made private too. Once found trustworthy, it stays so: in a
|
|
228
|
-
temporary directory nobody else may rename or remove it.
|
|
226
|
+
temporary directory nobody else may rename or remove it. Where the
|
|
227
|
+
system has no user ids, as on Windows, the owner compared is the
|
|
228
|
+
directory's own, so the check passes: the directory is taken as it is.
|
|
229
229
|
"""
|
|
230
230
|
root = self._private_root
|
|
231
231
|
if root is None or self._private_root_checked:
|
|
@@ -134,7 +134,7 @@ class RedisAdapter(AbstractAdapter):
|
|
|
134
134
|
) -> RedisAdapter:
|
|
135
135
|
"""Connect to the server ``dsn`` names, on first use, and own the connection.
|
|
136
136
|
|
|
137
|
-
Close the adapter with :meth:`
|
|
137
|
+
Close the adapter with :meth:`close` when done.
|
|
138
138
|
|
|
139
139
|
Raises:
|
|
140
140
|
InvalidArgumentError: When the scheme is not a Redis one, or the
|
|
@@ -168,10 +168,10 @@ class RedisAdapter(AbstractAdapter):
|
|
|
168
168
|
|
|
169
169
|
@property
|
|
170
170
|
def owns_connection(self) -> bool:
|
|
171
|
-
"""Whether :meth:`
|
|
171
|
+
"""Whether :meth:`close` closes the client, because :meth:`from_url` opened it."""
|
|
172
172
|
return self._owns_connection
|
|
173
173
|
|
|
174
|
-
async def
|
|
174
|
+
async def close(self) -> None:
|
|
175
175
|
"""Close the connection, when :meth:`from_url` opened it; otherwise do nothing."""
|
|
176
176
|
if self._owns_connection:
|
|
177
177
|
await self._redis.aclose()
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import secrets
|
|
6
7
|
from datetime import UTC, datetime
|
|
7
8
|
from typing import TYPE_CHECKING, Final, Self, final
|
|
@@ -63,6 +64,7 @@ class TagAwareAdapter(
|
|
|
63
64
|
_known_ttl: float
|
|
64
65
|
_known: dict[str, tuple[str | None, float]]
|
|
65
66
|
_deferred: dict[str, CacheItem]
|
|
67
|
+
_minting: asyncio.Lock
|
|
66
68
|
|
|
67
69
|
def __init__(
|
|
68
70
|
self,
|
|
@@ -89,6 +91,7 @@ class TagAwareAdapter(
|
|
|
89
91
|
self._known_ttl = known_tag_versions_ttl
|
|
90
92
|
self._known = {}
|
|
91
93
|
self._deferred = {}
|
|
94
|
+
self._minting = asyncio.Lock()
|
|
92
95
|
if clock is not None:
|
|
93
96
|
self._clock = clock
|
|
94
97
|
|
|
@@ -175,6 +178,7 @@ class TagAwareAdapter(
|
|
|
175
178
|
|
|
176
179
|
@override
|
|
177
180
|
def with_sub_namespace(self, namespace: str, /) -> Self:
|
|
181
|
+
_ = self._flights() # made now, so the view shares it rather than make its own
|
|
178
182
|
clone = self._unqueued_copy()
|
|
179
183
|
if isinstance(self._items, NamespacedPoolInterface):
|
|
180
184
|
clone._items = self._items.with_sub_namespace(namespace) # noqa: SLF001 — a copy of this very class.
|
|
@@ -242,19 +246,34 @@ class TagAwareAdapter(
|
|
|
242
246
|
return versions
|
|
243
247
|
|
|
244
248
|
async def _versions_for_saving(self, tags: Iterable[str]) -> dict[str, str]:
|
|
245
|
-
"""Return each tag's version, giving a new one to every tag that has none.
|
|
249
|
+
"""Return each tag's version, giving a new one to every tag that has none.
|
|
250
|
+
|
|
251
|
+
New versions are given one save at a time: saves giving one tag a
|
|
252
|
+
version at once would each stamp their items with their own, and the
|
|
253
|
+
items of all but the last would never be hits.
|
|
254
|
+
"""
|
|
246
255
|
current = await self._current_versions(tags)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
+
if all(found is not None for found in current.values()):
|
|
257
|
+
return {tag: found for tag, found in current.items() if found is not None}
|
|
258
|
+
|
|
259
|
+
async with self._minting:
|
|
260
|
+
# A save holding the lock before this one may have given them one meanwhile.
|
|
261
|
+
current.update(
|
|
262
|
+
await self._current_versions(
|
|
263
|
+
[tag for tag, found in current.items() if found is None]
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
versions = {tag: found or secrets.token_hex(6) for tag, found in current.items()}
|
|
267
|
+
created = [tag for tag, found in current.items() if found is None]
|
|
268
|
+
|
|
256
269
|
for tag in created:
|
|
257
|
-
|
|
270
|
+
version = CacheItem(TAGS_PREFIX + tag, versions[tag], clock=self._clock)
|
|
271
|
+
_ = await self._tags.save_deferred(version.expires_after(_TAG_VERSION_LIFETIME))
|
|
272
|
+
# A version the tags pool did not keep must not be trusted here while others miss it.
|
|
273
|
+
if created and await self._tags.commit():
|
|
274
|
+
now = self._clock.now().timestamp()
|
|
275
|
+
for tag in created:
|
|
276
|
+
self._remember(tag, versions[tag], now)
|
|
258
277
|
|
|
259
278
|
return versions
|
|
260
279
|
|
|
@@ -19,8 +19,6 @@ container closes; between messages, the kernel's resetter commits it.
|
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
|
-
import base64
|
|
23
|
-
import hashlib
|
|
24
22
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
25
23
|
from typing import Final, final
|
|
26
24
|
|
|
@@ -48,6 +46,7 @@ from xtr_lock.store import RedisStore, StoreFactory
|
|
|
48
46
|
from xtr_logging_contracts import LoggerAwareInterface, LoggerInterface
|
|
49
47
|
from xtr_service_contracts import ContainerInterface
|
|
50
48
|
|
|
49
|
+
from xtr_cache._digest import urlsafe_digest
|
|
51
50
|
from xtr_cache.adapter.adapter_factory import AdapterFactory
|
|
52
51
|
from xtr_cache.adapter.adapter_interface import AdapterInterface
|
|
53
52
|
from xtr_cache.adapter.chain_adapter import ChainAdapter
|
|
@@ -96,7 +95,7 @@ class CacheBundle(Bundle[CacheConfig]):
|
|
|
96
95
|
if not bundle_active(builder, "logging"):
|
|
97
96
|
return
|
|
98
97
|
# Logging is an optional peer, importable only once it is active.
|
|
99
|
-
from xtr_logging.bundle import LoggingConfig # noqa: PLC0415
|
|
98
|
+
from xtr_logging.bundle import LoggingConfig # noqa: PLC0415 — optional peer
|
|
100
99
|
|
|
101
100
|
def add_cache_channel(config: LoggingConfig) -> LoggingConfig:
|
|
102
101
|
return config.with_channels(CACHE_CHANNEL)
|
|
@@ -193,7 +192,7 @@ async def _lock_registry(
|
|
|
193
192
|
yield registry
|
|
194
193
|
finally:
|
|
195
194
|
if isinstance(store, RedisStore):
|
|
196
|
-
await store.
|
|
195
|
+
await store.close()
|
|
197
196
|
|
|
198
197
|
|
|
199
198
|
def _cache_pool_clearer(config: CacheConfig, container: ContainerInterface) -> CachePoolClearer:
|
|
@@ -254,7 +253,7 @@ def _pool_factory(
|
|
|
254
253
|
finally:
|
|
255
254
|
for each in built:
|
|
256
255
|
if isinstance(each, RedisAdapter):
|
|
257
|
-
await each.
|
|
256
|
+
await each.close()
|
|
258
257
|
|
|
259
258
|
return pool
|
|
260
259
|
|
|
@@ -285,5 +284,4 @@ async def _build_adapters(
|
|
|
285
284
|
|
|
286
285
|
def _namespace(name: str, seed: str) -> str:
|
|
287
286
|
"""Derive a pool's namespace from its name and the seed: short, and a valid key."""
|
|
288
|
-
|
|
289
|
-
return base64.urlsafe_b64encode(digest).decode("ascii")[:_NAMESPACE_LENGTH]
|
|
287
|
+
return urlsafe_digest(f"{name}.{seed}")[:_NAMESPACE_LENGTH]
|
|
@@ -51,7 +51,7 @@ class DefaultMarshaller(MarshallerInterface):
|
|
|
51
51
|
try:
|
|
52
52
|
# Only this application writes what is read here; see the class docstring.
|
|
53
53
|
# pickle.loads is typed as returning Any; what it returns is any object.
|
|
54
|
-
return cast("object", pickle.loads(value)) # noqa: S301
|
|
54
|
+
return cast("object", pickle.loads(value)) # noqa: S301 — only this application writes it
|
|
55
55
|
# Unpickling can raise whatever the stored object's reconstruction raises.
|
|
56
56
|
except Exception as error:
|
|
57
57
|
raise MarshallingError(f"the stored value cannot be unpickled: {error}") from error
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import zlib
|
|
6
|
-
from typing import TYPE_CHECKING, final
|
|
6
|
+
from typing import TYPE_CHECKING, Final, final
|
|
7
7
|
|
|
8
8
|
from typing_extensions import override
|
|
9
9
|
|
|
10
|
+
from xtr_cache.exception import MarshallingError
|
|
11
|
+
|
|
10
12
|
from .marshaller_interface import MarshallerInterface
|
|
11
13
|
|
|
12
14
|
if TYPE_CHECKING:
|
|
@@ -14,6 +16,10 @@ if TYPE_CHECKING:
|
|
|
14
16
|
|
|
15
17
|
__all__ = ["DeflateMarshaller"]
|
|
16
18
|
|
|
19
|
+
_HEADER_LENGTH: Final = 2
|
|
20
|
+
_DEFLATE: Final = 8
|
|
21
|
+
"""The compression method a zlib header names in its low four bits."""
|
|
22
|
+
|
|
17
23
|
|
|
18
24
|
@final
|
|
19
25
|
class DeflateMarshaller(MarshallerInterface):
|
|
@@ -38,14 +44,32 @@ class DeflateMarshaller(MarshallerInterface):
|
|
|
38
44
|
|
|
39
45
|
@override
|
|
40
46
|
def unmarshall(self, value: bytes, /) -> object:
|
|
47
|
+
"""Inflate ``value`` and decode it; bytes never compressed are decoded as they are.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
MarshallingError: If ``value`` is compressed but corrupt.
|
|
51
|
+
"""
|
|
52
|
+
if not _compressed(value):
|
|
53
|
+
# Written before compression was switched on: read it as it is.
|
|
54
|
+
return self._marshaller.unmarshall(value)
|
|
41
55
|
try:
|
|
42
56
|
inflated = zlib.decompress(value)
|
|
43
|
-
except zlib.error:
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
except zlib.error as error:
|
|
58
|
+
raise MarshallingError(
|
|
59
|
+
f"the stored value is compressed but corrupt: {error}"
|
|
60
|
+
) from error
|
|
46
61
|
|
|
47
62
|
return self._marshaller.unmarshall(inflated)
|
|
48
63
|
|
|
49
64
|
@override
|
|
50
65
|
def __repr__(self) -> str:
|
|
51
66
|
return f"{type(self).__name__}({self._marshaller!r})"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _compressed(value: bytes) -> bool:
|
|
70
|
+
"""Tell whether ``value`` starts with a zlib header: deflate, and a check that adds up."""
|
|
71
|
+
return (
|
|
72
|
+
len(value) >= _HEADER_LENGTH
|
|
73
|
+
and value[0] & 0x0F == _DEFLATE
|
|
74
|
+
and ((value[0] << 8) | value[1]) % 31 == 0
|
|
75
|
+
)
|
|
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
|
{xtr_cache-1.4.0 → xtr_cache-2.0.0}/src/xtr_cache/command/cache_pool_invalidate_tags_command.py
RENAMED
|
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
|