xtr-cache 2.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-2.0.0 → xtr_cache-4.0.2}/LICENSE +1 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/PKG-INFO +13 -11
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/README.md +4 -2
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/pyproject.toml +16 -13
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/pyproject.toml.orig +17 -13
- xtr_cache-4.0.2/src/xtr_cache/.agents/skills/xtr-cache/SKILL.md +277 -0
- xtr_cache-4.0.2/src/xtr_cache/.agents/skills/xtr-cache/references/adapters.md +80 -0
- xtr_cache-4.0.2/src/xtr_cache/.agents/skills/xtr-cache/references/stampede-and-marshallers.md +64 -0
- xtr_cache-4.0.2/src/xtr_cache/_internal_key.py +22 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/abstract_adapter.py +4 -20
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/adapter_factory.py +15 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/array_adapter.py +31 -2
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/chain_adapter.py +4 -1
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/filesystem_adapter.py +32 -5
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/redis_adapter.py +13 -6
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tag_aware_adapter.py +19 -8
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/cache_bundle.py +33 -12
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/cache_config.py +28 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/cache_item.py +10 -2
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/cache_pool_clearer.py +59 -6
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_invalidate_tags_command.py +4 -2
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_prune_command.py +2 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/lock_registry.py +10 -4
- xtr_cache-4.0.2/src/xtr_cache/recipe/__init__.py +15 -0
- xtr_cache-4.0.2/src/xtr_cache/recipe/manifest.toml +21 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/__init__.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/_digest.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/__init__.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/adapter_interface.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/contracts_mixin.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/deferred_items_mixin.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/null_adapter.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tag_aware_adapter_interface.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tagged_value.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/__init__.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/pool_config.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/__init__.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_clear_command.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_delete_command.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_list_command.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/pool_command.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/pools.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/__init__.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/logic_error.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/marshalling_error.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/__init__.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/default_marshaller.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/deflate_marshaller.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/marshaller_interface.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/sodium_marshaller.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/pruneable_interface.py +0 -0
- {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/py.typed +0 -0
- {xtr_cache-2.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
|
|
|
@@ -300,6 +300,8 @@ Everything adding this package to an application on
|
|
|
300
300
|
|
|
301
301
|
- **Install** — `uv add "xtr-cache[di]"`; add `redis`, `sodium`, `console` or `logging` for what
|
|
302
302
|
you use.
|
|
303
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* and *Ignore* steps below: it
|
|
304
|
+
lists `CacheBundle` and ignores `/var/`. There is no config file or environment to write.
|
|
303
305
|
- **Activate** — `CacheBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
304
306
|
from `xtr_cache.bundle`.
|
|
305
307
|
- **Brings along** — the logging and console bundles, when those packages are installed.
|
|
@@ -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
|
|
|
@@ -264,6 +264,8 @@ Everything adding this package to an application on
|
|
|
264
264
|
|
|
265
265
|
- **Install** — `uv add "xtr-cache[di]"`; add `redis`, `sodium`, `console` or `logging` for what
|
|
266
266
|
you use.
|
|
267
|
+
- **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* and *Ignore* steps below: it
|
|
268
|
+
lists `CacheBundle` and ignores `/var/`. There is no config file or environment to write.
|
|
267
269
|
- **Activate** — `CacheBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
|
|
268
270
|
from `xtr_cache.bundle`.
|
|
269
271
|
- **Brings along** — the logging and console bundles, when those packages are installed.
|
|
@@ -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,15 +39,18 @@ 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"
|
|
50
50
|
|
|
51
|
+
[project.entry-points.xtr_recipes]
|
|
52
|
+
cache = "xtr_cache.recipe"
|
|
53
|
+
|
|
51
54
|
[dependency-groups]
|
|
52
55
|
dev = [
|
|
53
56
|
"basedpyright>=1.21",
|
|
@@ -58,10 +61,10 @@ dev = [
|
|
|
58
61
|
"ty>=0.0.83",
|
|
59
62
|
"pynacl>=1.5",
|
|
60
63
|
"redis>=5.0",
|
|
61
|
-
"xtr-console>=
|
|
62
|
-
"xtr-dependency-injection>=
|
|
63
|
-
"xtr-logging>=
|
|
64
|
-
"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",
|
|
65
68
|
]
|
|
66
69
|
|
|
67
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.
|
|
@@ -51,6 +51,10 @@ logging = [
|
|
|
51
51
|
[project.entry-points."xtr_dependency_injection.bundles"]
|
|
52
52
|
cache = "xtr_cache.bundle:CacheBundle"
|
|
53
53
|
|
|
54
|
+
# Advertises the recipe — what *Use in an application* takes — for recipes:sync.
|
|
55
|
+
[project.entry-points."xtr_recipes"]
|
|
56
|
+
cache = "xtr_cache.recipe"
|
|
57
|
+
|
|
54
58
|
[dependency-groups]
|
|
55
59
|
dev = [
|
|
56
60
|
"basedpyright>=1.21",
|
|
@@ -61,10 +65,10 @@ dev = [
|
|
|
61
65
|
"ty>=0.0.83",
|
|
62
66
|
"pynacl>=1.5",
|
|
63
67
|
"redis>=5.0",
|
|
64
|
-
"xtr-console>=
|
|
65
|
-
"xtr-dependency-injection>=
|
|
66
|
-
"xtr-logging>=
|
|
67
|
-
"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",
|
|
68
72
|
]
|
|
69
73
|
|
|
70
74
|
[build-system]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-cache
|
|
3
|
+
description: How to cache values with xtr-cache and type against xtr-cache-contracts — fetch-or-compute pools in memory, in files or in Redis, chained or tag-aware, with stampede protection. Use when code should remember an expensive result, needs a TTL or an expiry, invalidation by tag, a cache cleared or pruned, a Redis or filesystem cache pool, encrypted cached values, or a test that must freeze a cache lifetime; also when adding CacheBundle and named pools to an application on xtr-dependency-injection, or reading the cache:pool:* commands.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-cache
|
|
7
|
+
|
|
8
|
+
A cache pool takes the key and the function that computes the value, and decides when to run it:
|
|
9
|
+
`await cache.get(key, callback)`. That one call is what makes a miss computed once for every
|
|
10
|
+
caller waiting on it, and a hot value refreshed before it expires. Every call that reaches a
|
|
11
|
+
backend is awaited, and a backend failing never raises: reads miss, writes return `False`, and
|
|
12
|
+
the pool logs why.
|
|
13
|
+
|
|
14
|
+
## Quick reference
|
|
15
|
+
|
|
16
|
+
- Type application code against the contracts: `from xtr_cache_contracts import CacheInterface`.
|
|
17
|
+
The implementations re-export the same objects, so either import path works, but a class that
|
|
18
|
+
only caches should depend on `xtr-cache-contracts`.
|
|
19
|
+
- Fetch-or-compute: `value = await cache.get("invoice.7", load_invoice)`. The callback is an
|
|
20
|
+
`async def` taking the item and returning the value.
|
|
21
|
+
- Set the lifetime inside the callback, on the item: `item.expires_after(600)` or
|
|
22
|
+
`item.expires_at(moment)`. Tags too: `item.tag(["customer.7"])`.
|
|
23
|
+
- Drop one key: `await cache.delete("invoice.7")`. Drop by tag:
|
|
24
|
+
`await cache.invalidate_tags(["customer.7"])` on a `TagAwareAdapter`.
|
|
25
|
+
- Need a hit told apart from a miss, several keys at once, or batched writes? Drop to the item
|
|
26
|
+
pool: `get_item`, `get_items`, `save`, `save_deferred`, `commit`, `delete_item`, `clear`.
|
|
27
|
+
- Keys and tags are non-empty strings with none of `{}()/\@:` (`RESERVED_CHARACTERS`); letters,
|
|
28
|
+
digits, `_` and `.` under 64 characters work everywhere. One key holds one type.
|
|
29
|
+
- In an application: activate `CacheBundle`, name pools in `CacheConfig`, inject
|
|
30
|
+
`CacheInterface` for the `app` pool and `Annotated[CacheInterface, Target("name")]` for another.
|
|
31
|
+
|
|
32
|
+
## Cache a value
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from xtr_cache_contracts import CacheInterface, ItemInterface
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Invoices:
|
|
39
|
+
def __init__(self, cache: CacheInterface) -> None:
|
|
40
|
+
self._cache = cache
|
|
41
|
+
|
|
42
|
+
async def get(self, invoice_id: int) -> Invoice:
|
|
43
|
+
async def load(item: ItemInterface) -> Invoice:
|
|
44
|
+
item.expires_after(600) # seconds, or a timedelta
|
|
45
|
+
return await self._fetch(invoice_id)
|
|
46
|
+
|
|
47
|
+
return await self._cache.get(f"invoice.{invoice_id}", load)
|
|
48
|
+
|
|
49
|
+
async def forget(self, invoice_id: int) -> None:
|
|
50
|
+
_ = await self._cache.delete(f"invoice.{invoice_id}")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If the callback raises, nothing is stored and the error reaches the caller unchanged. An item
|
|
54
|
+
whose lifetime is zero or less is removed instead of saved. `item.expires_after(None)` falls back
|
|
55
|
+
to the pool's `default_lifetime`.
|
|
56
|
+
|
|
57
|
+
Pass a `Metadata` dict to learn about the read — the pool fills in `expiry` (Unix timestamp),
|
|
58
|
+
`ctime` (milliseconds the value took to compute), `tags`, and `save_failed` when a computed value
|
|
59
|
+
could not be stored:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from xtr_cache_contracts import Metadata
|
|
63
|
+
|
|
64
|
+
metadata: Metadata = {}
|
|
65
|
+
report = await cache.get("report.daily", build_report, metadata=metadata)
|
|
66
|
+
if metadata.get("save_failed"):
|
|
67
|
+
...
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Work with items
|
|
71
|
+
|
|
72
|
+
Use the item level only when fetch-or-compute cannot answer the question.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
item = await pool.get_item("rate.42")
|
|
76
|
+
if not item.is_hit():
|
|
77
|
+
_ = await pool.save(item.set(0).expires_after(60))
|
|
78
|
+
|
|
79
|
+
items = await pool.get_items(["a", "b", "c"]) # an item per key, hit or miss, in order
|
|
80
|
+
_ = await pool.save_deferred(item) # queued ...
|
|
81
|
+
_ = await pool.commit() # ... written in one batch
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`None` is a value like any other — `is_hit()` is the only way to tell it from a miss. Changing an
|
|
85
|
+
item reaches the backend only once it is saved.
|
|
86
|
+
|
|
87
|
+
## Pick an adapter
|
|
88
|
+
|
|
89
|
+
| Adapter | Keeps values | Build it |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `ArrayAdapter` | this process's memory | `ArrayAdapter(60, max_items=1000)` |
|
|
92
|
+
| `FilesystemAdapter` | one file each under a directory | `FilesystemAdapter("app", 60, "/var/cache/app")` |
|
|
93
|
+
| `RedisAdapter` | a Redis or Valkey server | `RedisAdapter.from_url("redis://cache:6379/0", "app")` |
|
|
94
|
+
| `NullAdapter` | nowhere; every read misses | `NullAdapter()` |
|
|
95
|
+
| `ChainAdapter` | several pools, fastest first | `ChainAdapter([ArrayAdapter(), redis])` |
|
|
96
|
+
| `TagAwareAdapter` | any pool, plus tag versions | `TagAwareAdapter(redis)` |
|
|
97
|
+
|
|
98
|
+
Shared arguments: `namespace` (first positional, except on `ArrayAdapter` and `ChainAdapter`,
|
|
99
|
+
which have no use for one) and `default_lifetime` in seconds, `0` keeping values until deleted.
|
|
100
|
+
`pool.with_sub_namespace("tenant42")` returns a view whose keys live under `tenant42` and can be
|
|
101
|
+
cleared on their own. `AdapterFactory.create_adapter(dsn, namespace, default_lifetime)` builds any
|
|
102
|
+
of them from what a configuration names.
|
|
103
|
+
|
|
104
|
+
See [references/adapters.md](references/adapters.md) for each adapter's own options, the DSNs the
|
|
105
|
+
factory reads, and the rules on Redis namespaces and filesystem directories.
|
|
106
|
+
|
|
107
|
+
## Invalidate by tag
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from xtr_cache import TagAwareAdapter
|
|
111
|
+
|
|
112
|
+
cache = TagAwareAdapter(RedisAdapter.from_url("redis://cache:6379/0", "app"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def load_order(item: ItemInterface) -> Order:
|
|
116
|
+
item.tag([f"customer.{customer_id}", "orders"])
|
|
117
|
+
return await orders.fetch(order_id)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_ = await cache.get(f"order.{order_id}", load_order)
|
|
121
|
+
_ = await cache.invalidate_tags([f"customer.{customer_id}"])
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Invalidating costs the same however many items carry the tag: each tag has a version, and an item
|
|
125
|
+
is a hit only while every version it was saved with is unchanged. Versions read are trusted for
|
|
126
|
+
`known_tag_versions_ttl` seconds (0.15 by default), which is how soon an invalidation elsewhere
|
|
127
|
+
reaches this process. Calling `item.tag(...)` on a pool that is not tag-aware raises `LogicError`.
|
|
128
|
+
|
|
129
|
+
## Stampede protection and marshallers
|
|
130
|
+
|
|
131
|
+
`get()` already computes a missed key once per process, however many callers wait on it. Two knobs
|
|
132
|
+
on top: `cache.set_lock_registry(LockRegistry(...))` extends that to every process sharing the
|
|
133
|
+
lock store, and `beta` on each `get()` tunes refreshing a value shortly before it expires (`0`
|
|
134
|
+
off, `math.inf` now, `None` the pool's own `1.0`).
|
|
135
|
+
|
|
136
|
+
Adapters that store bytes encode values with a `MarshallerInterface`, pickle by default. On a
|
|
137
|
+
backend anything else can write to, use `SodiumMarshaller` instead: it encrypts, authenticates, and
|
|
138
|
+
reads bytes it did not write as a miss.
|
|
139
|
+
|
|
140
|
+
See [references/stampede-and-marshallers.md](references/stampede-and-marshallers.md) for the lock
|
|
141
|
+
registry's slots and waits, how early recomputation decides, and the marshaller table with key
|
|
142
|
+
rotation.
|
|
143
|
+
|
|
144
|
+
## Testing
|
|
145
|
+
|
|
146
|
+
- Give the code under test an `ArrayAdapter()` — a real pool, in memory, no cleanup — or a
|
|
147
|
+
`NullAdapter()` to prove it works with every read a miss.
|
|
148
|
+
- Lifetimes and early recomputation read the clock in force, so freeze them with `xtr-clock`:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from xtr_cache import ArrayAdapter
|
|
152
|
+
from xtr_clock.testing import mock_time
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def test_an_invoice_is_cached_for_ten_minutes() -> None:
|
|
156
|
+
cache = ArrayAdapter()
|
|
157
|
+
invoices = Invoices(cache)
|
|
158
|
+
|
|
159
|
+
with mock_time("2026-01-01 00:00:00") as clock:
|
|
160
|
+
first = await invoices.get(7)
|
|
161
|
+
assert await invoices.get(7) == first # a hit; a serialized pool hands back a copy
|
|
162
|
+
|
|
163
|
+
clock.sleep(601)
|
|
164
|
+
assert await invoices.get(7) is not first
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- Pass `beta=0` in a test that must not see a value recomputed early.
|
|
168
|
+
- With a kernel, override the pool service — a `(type, name)` key for a named pool:
|
|
169
|
+
`await boot_for_test(kernel, overrides={CacheInterface: ArrayAdapter(), (CacheInterface, "sessions"): ArrayAdapter()})`.
|
|
170
|
+
|
|
171
|
+
## Use in an application
|
|
172
|
+
|
|
173
|
+
`uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
|
|
174
|
+
`CacheBundle` and ignores `/var/`. That is the steps below a recipe can do; the others it prints
|
|
175
|
+
for you to make.
|
|
176
|
+
|
|
177
|
+
1. **Install** — `uv add "xtr-cache[di]"`; add `redis`, `sodium` or `console` for what you use.
|
|
178
|
+
2. **Activate** — `CacheBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported from
|
|
179
|
+
`xtr_cache.bundle`.
|
|
180
|
+
3. **Brings along** — the logging and console bundles, when those packages are installed. With
|
|
181
|
+
logging, a `cache` channel is added and every pool logs there.
|
|
182
|
+
4. **Configure** — optional; with no configuration there is one `app` pool on files:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
# <app>/config/cache.py
|
|
186
|
+
from xtr_dependency_injection import configure, env
|
|
187
|
+
|
|
188
|
+
from xtr_cache.bundle import CacheConfig, PoolConfig
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@configure
|
|
192
|
+
def cache() -> CacheConfig:
|
|
193
|
+
return CacheConfig(
|
|
194
|
+
app=env("CACHE_DSN"),
|
|
195
|
+
pools={
|
|
196
|
+
"sessions": "redis://cache:6379/1",
|
|
197
|
+
"catalogue": PoolConfig(adapter=["array", "redis://cache:6379"], tags=True),
|
|
198
|
+
"reports": PoolConfig(default_lifetime=3600), # the app pool's adapter
|
|
199
|
+
},
|
|
200
|
+
stampede_lock="redis://cache:6379/2",
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
| `CacheConfig` field | Meaning |
|
|
205
|
+
| --- | --- |
|
|
206
|
+
| `app` | The `app` pool's adapter, or several to chain. `"filesystem"` by default |
|
|
207
|
+
| `pools` | Every other pool: an adapter, several, or a `PoolConfig(adapter, default_lifetime, tags, namespace)`. No adapter means the `app` pool's. `tags=True` keeps versions in the pool, `tags="other"` in the pool named `other` |
|
|
208
|
+
| `directory` | Where `"filesystem"` writes: `"%kernel.share_dir%/cache"` by default |
|
|
209
|
+
| `prefix_seed` | What each pool's namespace is derived from, with its name: the project directory by default, so two applications on one backend never meet |
|
|
210
|
+
| `stampede_lock` | Any lock DSN; `"flock://%kernel.share_dir%/cache/locks"` by default, `None` for per-process protection only |
|
|
211
|
+
|
|
212
|
+
5. **Environment** — nothing required; a DSN given as `env(...)` must be set at boot, which
|
|
213
|
+
checks every pool and refuses a DSN no adapter serves.
|
|
214
|
+
6. **Ignore** — `var/`, where the filesystem pools write.
|
|
215
|
+
7. **Use** — inject the interface; the `app` pool has no qualifier, every other pool is named:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
from typing import Annotated
|
|
219
|
+
|
|
220
|
+
from xtr_dependency_injection import Target, as_service
|
|
221
|
+
from xtr_cache_contracts import CacheInterface, TagAwareCacheInterface
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@as_service
|
|
225
|
+
class Catalogue:
|
|
226
|
+
def __init__(
|
|
227
|
+
self,
|
|
228
|
+
cache: CacheInterface, # the app pool
|
|
229
|
+
products: Annotated[TagAwareCacheInterface, Target("catalogue")],
|
|
230
|
+
) -> None: ...
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Every pool is registered under `AdapterInterface`, `CacheInterface`,
|
|
234
|
+
`CacheItemPoolInterface` and `NamespacedPoolInterface`, plus `TagAwareCacheInterface` and
|
|
235
|
+
`TagAwareAdapterInterface` when `tags` is set. Replace the pickle marshaller by registering
|
|
236
|
+
your own `MarshallerInterface` service.
|
|
237
|
+
8. **Check** — `debug:bundles` shows `cache` as `listed` and `active`.
|
|
238
|
+
`cache:pool:list`, `cache:pool:clear`, `cache:pool:delete`, `cache:pool:invalidate-tags` and
|
|
239
|
+
`cache:pool:prune` are there once the console bundle is active.
|
|
240
|
+
9. **Remove** — drop the `BUNDLES` entry, delete `<app>/config/cache.py`, then
|
|
241
|
+
`uv remove xtr-cache`.
|
|
242
|
+
|
|
243
|
+
Without a container, hand the commands their pools once and import `xtr_cache.command` before the
|
|
244
|
+
application runs:
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
from xtr_cache import CachePoolClearer
|
|
248
|
+
from xtr_cache.command import use_pools
|
|
249
|
+
|
|
250
|
+
use_pools(CachePoolClearer({"app": cache, "sessions": sessions}))
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Errors
|
|
254
|
+
|
|
255
|
+
Every error derives from `CacheError` (the contracts' base). A backend failing is never one of
|
|
256
|
+
them: it is logged, and the call misses or returns `False`.
|
|
257
|
+
|
|
258
|
+
| Error | Raised when |
|
|
259
|
+
| --- | --- |
|
|
260
|
+
| `InvalidArgumentError` | A key, tag, namespace, `beta`, DSN or option is invalid. Also a `ValueError`; what is wrong is in `reason` |
|
|
261
|
+
| `LogicError` | An item of a pool without tags is tagged |
|
|
262
|
+
| `MarshallingError` | A marshaller cannot decode stored bytes — pools catch it and read a miss |
|
|
263
|
+
|
|
264
|
+
## Do not
|
|
265
|
+
|
|
266
|
+
- Do not check then read then write by hand. Hand `get()` the callback; three calls have a race
|
|
267
|
+
between each.
|
|
268
|
+
- Do not treat a `None` return as a miss. `None` is a cached value; use `item.is_hit()`.
|
|
269
|
+
- Do not store two types under one key: a hit is returned as the callback's type, unchecked.
|
|
270
|
+
- Do not wrap a cache call in `try/except` to survive a dead backend. It already misses and logs.
|
|
271
|
+
- Do not mutate what `get()` returned and expect the cache to keep the original — concurrent
|
|
272
|
+
callers get the same object. Copy first.
|
|
273
|
+
- Do not put a reserved character in a key; `{}()/\@:` raise `InvalidArgumentError`.
|
|
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
|
+
- Do not pickle from a backend others can write to. Use `SodiumMarshaller`.
|
|
277
|
+
- Do not sleep in a test to let a value expire. Freeze the clock with `mock_time`.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Adapters
|
|
2
|
+
|
|
3
|
+
| Adapter | Keeps values | Build it |
|
|
4
|
+
| --- | --- | --- |
|
|
5
|
+
| `ArrayAdapter` | this process's memory | `ArrayAdapter(60, max_items=1000)` |
|
|
6
|
+
| `FilesystemAdapter` | one file each under a directory | `FilesystemAdapter("app", 60, "/var/cache/app")` |
|
|
7
|
+
| `RedisAdapter` | a Redis or Valkey server | `RedisAdapter.from_url("redis://cache:6379/0", "app")` |
|
|
8
|
+
| `NullAdapter` | nowhere; every read misses | `NullAdapter()` |
|
|
9
|
+
| `ChainAdapter` | several pools, fastest first | `ChainAdapter([ArrayAdapter(), redis], 30)` |
|
|
10
|
+
| `TagAwareAdapter` | any pool, plus tag versions | `TagAwareAdapter(redis)` |
|
|
11
|
+
|
|
12
|
+
Shared arguments: `namespace` first positional (`ArrayAdapter` and `ChainAdapter` have no use for
|
|
13
|
+
one, so their first argument is `default_lifetime`), then `default_lifetime` in seconds, with `0`
|
|
14
|
+
keeping values until they are deleted, and keyword-only `marshaller` and `clock`.
|
|
15
|
+
|
|
16
|
+
`pool.with_sub_namespace("tenant42")` returns a view of the same pool whose keys live under
|
|
17
|
+
`tenant42`, so they clear together and the parent's other keys are left alone.
|
|
18
|
+
|
|
19
|
+
## `ArrayAdapter`
|
|
20
|
+
|
|
21
|
+
A dictionary. Values are serialized on the way in by default, so a caller gets a copy it may
|
|
22
|
+
change freely; `store_serialized=False` keeps the objects themselves. `max_items` drops the least
|
|
23
|
+
recently used beyond a count, `max_lifetime` caps every value's lifetime.
|
|
24
|
+
|
|
25
|
+
## `FilesystemAdapter`
|
|
26
|
+
|
|
27
|
+
One file per value under `directory/namespace`. A write lands in a temporary file and replaces the
|
|
28
|
+
value's file in one step, so a reader never sees half a value, and the file work runs on a worker
|
|
29
|
+
thread. Nothing is created until the first write. An expired file is removed when read;
|
|
30
|
+
`await pool.prune()` removes the rest.
|
|
31
|
+
|
|
32
|
+
Given no directory, the pool uses `xtr-cache` in the system's temporary directory, creates it
|
|
33
|
+
readable by its owner alone, and refuses one belonging to another user — reads miss, writes fail,
|
|
34
|
+
and the pool logs why. A directory you name is used as it is.
|
|
35
|
+
|
|
36
|
+
## `RedisAdapter`
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from redis.asyncio import Redis
|
|
40
|
+
from xtr_cache import RedisAdapter
|
|
41
|
+
|
|
42
|
+
pool = RedisAdapter(Redis.from_url("redis://cache:6379/0"), "app") # your client, you close it
|
|
43
|
+
pool = RedisAdapter.from_url("redis://cache:6379/0", "app") # its own client ...
|
|
44
|
+
await pool.close() # ... which it closes
|
|
45
|
+
```
|
|
46
|
+
|
|
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 a
|
|
49
|
+
namespace is required: without one, `clear()` would empty the whole database. A client built from a
|
|
50
|
+
DSN gives up
|
|
51
|
+
on a server silent for 5 seconds unless `?socket_timeout=` and `?socket_connect_timeout=` say
|
|
52
|
+
otherwise. One server; not a cluster, not Sentinel. Needs the `redis` extra and a client returning
|
|
53
|
+
bytes: one made with `decode_responses=True` raises `InvalidArgumentError`.
|
|
54
|
+
|
|
55
|
+
## `ChainAdapter`
|
|
56
|
+
|
|
57
|
+
Reads ask each pool in turn, and a value found in a slower one is copied into the faster ones with
|
|
58
|
+
the life it has left, so a copy never outlives the original. Writes, deletes and clears reach
|
|
59
|
+
every pool.
|
|
60
|
+
|
|
61
|
+
## `TagAwareAdapter`
|
|
62
|
+
|
|
63
|
+
`TagAwareAdapter(items_pool, tags_pool=None, known_tag_versions_ttl=0.15)`. Tag versions live in
|
|
64
|
+
`tags_pool`, or in `items_pool` when none is given — a faster or wider-shared pool there makes
|
|
65
|
+
invalidation cheaper or wider.
|
|
66
|
+
|
|
67
|
+
## `AdapterFactory`
|
|
68
|
+
|
|
69
|
+
`AdapterFactory.create_adapter(connection, namespace, default_lifetime, *, marshaller=None,
|
|
70
|
+
directory=None, clock=None)` builds any of them from what a configuration names:
|
|
71
|
+
|
|
72
|
+
| Given | Adapter |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| `"array"`, `"null"` | `ArrayAdapter`, `NullAdapter` |
|
|
75
|
+
| `"filesystem"`, `"filesystem:///var/cache/app"` | `FilesystemAdapter`, in the directory given |
|
|
76
|
+
| `"redis://…"`, `"rediss://…"`, `"unix://…"`, `"valkey://…"`, `"valkeys://…"` | `RedisAdapter` owning its connection |
|
|
77
|
+
| an asyncio Redis client | `RedisAdapter` on that client |
|
|
78
|
+
|
|
79
|
+
`AdapterFactory.validate(dsn)` refuses a DSN no adapter serves without building one or connecting;
|
|
80
|
+
its message names the scheme or the type, never credentials.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Stampede protection and marshallers
|
|
2
|
+
|
|
3
|
+
## Stampede protection
|
|
4
|
+
|
|
5
|
+
`get()` keeps one hot key from being computed many times over, three ways.
|
|
6
|
+
|
|
7
|
+
**One computation per key in a process**, always on. Concurrent misses on one key wait for the
|
|
8
|
+
first caller and all receive the very object it computed, so copy it before changing it; what was
|
|
9
|
+
cached is unaffected. If that computation fails or is cancelled, the others compute for
|
|
10
|
+
themselves, side by side.
|
|
11
|
+
|
|
12
|
+
**One computation per key across processes**, once the pool has a `LockRegistry`:
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from xtr_cache import ArrayAdapter, LockRegistry
|
|
16
|
+
from xtr_lock import LockFactory, RedisStore
|
|
17
|
+
|
|
18
|
+
cache = ArrayAdapter()
|
|
19
|
+
cache.set_lock_registry(LockRegistry(LockFactory(RedisStore.from_url("redis://cache"))))
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`LockRegistry(locks, *, slots=20, wait=30, prefix="xtr-cache.")`. Keys are spread over `slots`
|
|
23
|
+
locks; the caller that takes a key's slot computes and saves, the others wait `wait` seconds for
|
|
24
|
+
it and then read what was saved, or compute for themselves and stop using that slot. The lock
|
|
25
|
+
store decides who shares the locks — file locks cover one machine, Redis covers several. Slots are
|
|
26
|
+
chosen from the pool's namespace and the key, so two pools never wait on each other's keys.
|
|
27
|
+
`set_lock_registry(None)` turns it off again.
|
|
28
|
+
|
|
29
|
+
**Early recomputation**, tuned by `beta` on each `get()`. A value is stored with how long it took
|
|
30
|
+
to compute and when it expires, and a hit may be recomputed before expiry, with a chance that
|
|
31
|
+
grows as expiry nears and faster for values that are slow to compute — so one caller refreshes it
|
|
32
|
+
while the others keep reading the old one. `0` disables it, `math.inf` recomputes now, `None`
|
|
33
|
+
leaves the choice to the pool (`1.0`).
|
|
34
|
+
|
|
35
|
+
A callback that reads its own key, directly or through what it calls, computes without saving
|
|
36
|
+
rather than waiting on itself. Lifetimes and recomputation read the clock in force, so
|
|
37
|
+
`xtr_clock.testing.mock_time()` freezes both.
|
|
38
|
+
|
|
39
|
+
## Marshallers
|
|
40
|
+
|
|
41
|
+
Adapters that store bytes — files, Redis, and memory while serializing — encode values with a
|
|
42
|
+
`MarshallerInterface`. Pickle is the default because a cache reads values back without being told
|
|
43
|
+
their type, so a dataclass comes back a dataclass.
|
|
44
|
+
|
|
45
|
+
| Marshaller | Does |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `DefaultMarshaller()` | `pickle`; anything that pickles round-trips |
|
|
48
|
+
| `DeflateMarshaller(inner)` | compresses what `inner` encodes; reads uncompressed bytes too |
|
|
49
|
+
| `SodiumMarshaller(keys, inner=None)` | encrypts and authenticates what `inner` encodes |
|
|
50
|
+
|
|
51
|
+
Unpickling runs code named by the bytes, which is only safe while nothing else can write to the
|
|
52
|
+
backend. On a server shared with other applications, encrypt:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from xtr_cache import RedisAdapter, SodiumMarshaller
|
|
56
|
+
|
|
57
|
+
key = SodiumMarshaller.generate_key() # once; keep it in a secret, base64-encoded
|
|
58
|
+
pool = RedisAdapter.from_url("redis://cache", "app", marshaller=SodiumMarshaller([key]))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Bytes anyone else wrote are refused before pickle sees them, and read as a miss. Keys rotate: the
|
|
62
|
+
first encrypts, every one decrypts, so put a new key first and keep the old one until what it
|
|
63
|
+
encrypted has expired. `SodiumMarshaller.is_supported()` tells whether the `sodium` extra is
|
|
64
|
+
installed.
|
|
@@ -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__ = ()
|