xtr-cache 1.2.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.2.0/LICENSE +21 -0
- xtr_cache-1.2.0/PKG-INFO +412 -0
- xtr_cache-1.2.0/README.md +378 -0
- xtr_cache-1.2.0/pyproject.toml +182 -0
- xtr_cache-1.2.0/pyproject.toml.orig +187 -0
- xtr_cache-1.2.0/src/xtr_cache/__init__.py +111 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/__init__.py +31 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/abstract_adapter.py +356 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/adapter_factory.py +134 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/adapter_interface.py +42 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/array_adapter.py +165 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/chain_adapter.py +185 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/contracts_mixin.py +190 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/deferred_items_mixin.py +78 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/filesystem_adapter.py +256 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/null_adapter.py +84 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/redis_adapter.py +234 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/tag_aware_adapter.py +261 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/tag_aware_adapter_interface.py +16 -0
- xtr_cache-1.2.0/src/xtr_cache/adapter/tagged_value.py +28 -0
- xtr_cache-1.2.0/src/xtr_cache/bundle/__init__.py +17 -0
- xtr_cache-1.2.0/src/xtr_cache/bundle/cache_bundle.py +289 -0
- xtr_cache-1.2.0/src/xtr_cache/bundle/cache_config.py +115 -0
- xtr_cache-1.2.0/src/xtr_cache/bundle/pool_config.py +69 -0
- xtr_cache-1.2.0/src/xtr_cache/cache_item.py +262 -0
- xtr_cache-1.2.0/src/xtr_cache/cache_pool_clearer.py +79 -0
- xtr_cache-1.2.0/src/xtr_cache/command/__init__.py +33 -0
- xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_clear_command.py +65 -0
- xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_delete_command.py +50 -0
- xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_invalidate_tags_command.py +99 -0
- xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_list_command.py +32 -0
- xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_prune_command.py +47 -0
- xtr_cache-1.2.0/src/xtr_cache/command/pool_command.py +37 -0
- xtr_cache-1.2.0/src/xtr_cache/command/pools.py +35 -0
- xtr_cache-1.2.0/src/xtr_cache/exception/__init__.py +20 -0
- xtr_cache-1.2.0/src/xtr_cache/exception/logic_error.py +25 -0
- xtr_cache-1.2.0/src/xtr_cache/exception/marshalling_error.py +27 -0
- xtr_cache-1.2.0/src/xtr_cache/lock_registry.py +165 -0
- xtr_cache-1.2.0/src/xtr_cache/marshaller/__init__.py +15 -0
- xtr_cache-1.2.0/src/xtr_cache/marshaller/default_marshaller.py +61 -0
- xtr_cache-1.2.0/src/xtr_cache/marshaller/deflate_marshaller.py +51 -0
- xtr_cache-1.2.0/src/xtr_cache/marshaller/marshaller_interface.py +39 -0
- xtr_cache-1.2.0/src/xtr_cache/marshaller/sodium_marshaller.py +143 -0
- xtr_cache-1.2.0/src/xtr_cache/pruneable_interface.py +26 -0
- xtr_cache-1.2.0/src/xtr_cache/py.typed +0 -0
- xtr_cache-1.2.0/src/xtr_cache/value_wrapper.py +30 -0
xtr_cache-1.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
xtr_cache-1.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-cache
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: Cache pools in memory, in files, in Redis, chained or tag-aware, with stampede protection.
|
|
5
|
+
Keywords: cache,redis,stampede,tags,asyncio
|
|
6
|
+
Author: Razvan Ceana
|
|
7
|
+
Author-email: Razvan Ceana <razvan@ceana.ro>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: typing-extensions>=4.4
|
|
19
|
+
Requires-Dist: xtr-cache-contracts>=1.0,<2
|
|
20
|
+
Requires-Dist: xtr-clock>=1.0,<2
|
|
21
|
+
Requires-Dist: xtr-lock>=1.0,<2
|
|
22
|
+
Requires-Dist: xtr-logging-contracts>=1.0,<2
|
|
23
|
+
Requires-Dist: xtr-console>=1.0,<2 ; extra == 'console'
|
|
24
|
+
Requires-Dist: xtr-dependency-injection>=1.0,<2 ; extra == 'di'
|
|
25
|
+
Requires-Dist: xtr-service-contracts>=1.0,<2 ; extra == 'di'
|
|
26
|
+
Requires-Dist: redis>=5.0 ; extra == 'redis'
|
|
27
|
+
Requires-Dist: pynacl>=1.5 ; extra == 'sodium'
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Provides-Extra: console
|
|
30
|
+
Provides-Extra: di
|
|
31
|
+
Provides-Extra: redis
|
|
32
|
+
Provides-Extra: sodium
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
<div align="center">
|
|
36
|
+
|
|
37
|
+
# xtr-cache
|
|
38
|
+
|
|
39
|
+
**Cache pools in memory, in files, in Redis, chained or tag-aware — computed once, even under load.**
|
|
40
|
+
|
|
41
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
42
|
+
<img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
|
|
43
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
44
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
45
|
+
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Why?
|
|
51
|
+
|
|
52
|
+
A value that is expensive to compute and read often belongs in a cache. Doing that by hand —
|
|
53
|
+
check, compute on a miss, save — is three steps with a race between each: under load, every
|
|
54
|
+
request that misses computes the same value at the same moment, and the backend it protects takes
|
|
55
|
+
the whole stampede at once.
|
|
56
|
+
|
|
57
|
+
This package implements the [xtr-cache-contracts](../xtr-cache-contracts) interfaces and makes the
|
|
58
|
+
backend a constructor argument:
|
|
59
|
+
|
|
60
|
+
- 🔁 **Fetch-or-compute** — `await cache.get(key, compute)`, one call.
|
|
61
|
+
- 🐘 **Stampede protection** — concurrent misses share one computation, across processes too, and
|
|
62
|
+
hot values are refreshed shortly before they expire.
|
|
63
|
+
- 🗄️ **Six adapters** — memory, files, Redis, nowhere, a chain of them, or tag-aware on top of any.
|
|
64
|
+
- 🏷️ **Tags** — invalidate every value carrying a tag at once, whatever its key.
|
|
65
|
+
- 🔐 **Encryption** — values encrypted and authenticated with libsodium, for a shared backend.
|
|
66
|
+
- 🧩 **A bundle** — named pools configured in Python, and `cache:pool:*` console commands.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from xtr_cache import FilesystemAdapter, ItemInterface
|
|
70
|
+
|
|
71
|
+
cache = FilesystemAdapter("app")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def load_profile(item: ItemInterface) -> Profile:
|
|
75
|
+
item.expires_after(3600)
|
|
76
|
+
return await profiles.fetch(user_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
profile = await cache.get(f"profile.{user_id}", load_profile)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Install
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
uv add xtr-cache
|
|
86
|
+
uv add "xtr-cache[redis]" # RedisAdapter, on redis-py's asyncio client
|
|
87
|
+
uv add "xtr-cache[sodium]" # SodiumMarshaller, on PyNaCl
|
|
88
|
+
uv add "xtr-cache[di]" # the bundle for xtr-dependency-injection
|
|
89
|
+
uv add "xtr-cache[console]" # the cache:pool:* commands for xtr-console
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Requires Python 3.11+. Depends on `xtr-cache-contracts`, `xtr-clock`, `xtr-lock` and
|
|
93
|
+
`xtr-logging-contracts`.
|
|
94
|
+
|
|
95
|
+
Every contract symbol is re-exported, not redefined: `xtr_cache.CacheInterface is
|
|
96
|
+
xtr_cache_contracts.CacheInterface`, so a library typed against the contract receives these pools
|
|
97
|
+
unchanged.
|
|
98
|
+
|
|
99
|
+
## Quick start
|
|
100
|
+
|
|
101
|
+
Two levels, most code needing the first.
|
|
102
|
+
|
|
103
|
+
**Fetch-or-compute.** Hand the cache the key and what computes the value; it decides when to call
|
|
104
|
+
it. The callback sets the value's lifetime — and tags, on a tag-aware pool — through the item it
|
|
105
|
+
receives:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
async def load_invoice(item: ItemInterface) -> Invoice:
|
|
109
|
+
item.expires_after(timedelta(minutes=10))
|
|
110
|
+
return await invoices.fetch(invoice_id)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
invoice = await cache.get(f"invoice.{invoice_id}", load_invoice)
|
|
114
|
+
await cache.delete(f"invoice.{invoice_id}")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Items.** When a hit must be told apart from a miss, several keys read at once, or writes
|
|
118
|
+
batched:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
item = await cache.get_item("rate.42")
|
|
122
|
+
if not item.is_hit():
|
|
123
|
+
await cache.save(item.set(0).expires_after(60))
|
|
124
|
+
|
|
125
|
+
items = await cache.get_items(["a", "b", "c"]) # every key, hit or miss, in order
|
|
126
|
+
await cache.save_deferred(item) # queued ...
|
|
127
|
+
await cache.commit() # ... stored in one batch
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Keys are non-empty strings without any of `{}()/\@:`. `None` is a value like any other. A pool
|
|
131
|
+
never raises because its backend failed: reads miss, writes return `False`, and the pool logs why
|
|
132
|
+
through its logger (`pool.set_logger(...)`).
|
|
133
|
+
|
|
134
|
+
## Adapters
|
|
135
|
+
|
|
136
|
+
| Adapter | Keeps values | Shared with | Prunes |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `ArrayAdapter` | in this process's memory | nobody | — |
|
|
139
|
+
| `FilesystemAdapter` | one file each, under a directory | processes on this machine | ✓ |
|
|
140
|
+
| `RedisAdapter` | on a Redis or Valkey server | anything reaching the server | — |
|
|
141
|
+
| `NullAdapter` | nowhere: every read misses | — | — |
|
|
142
|
+
| `ChainAdapter` | in several pools, fastest first | whatever its pools share | ✓ |
|
|
143
|
+
| `TagAwareAdapter` | in any pool, with tag versions | whatever its pools share | ✓ |
|
|
144
|
+
|
|
145
|
+
Every adapter takes a `default_lifetime` in seconds, applied when an item sets no expiry (`0`
|
|
146
|
+
keeps values until deleted), and a `namespace` its keys live under. `pool.with_sub_namespace("t")`
|
|
147
|
+
returns a view whose keys live under `t` inside it — cleared together, left alone by the parent's
|
|
148
|
+
other keys.
|
|
149
|
+
|
|
150
|
+
`AdapterFactory.create_adapter(...)` builds any of them from what a configuration names:
|
|
151
|
+
|
|
152
|
+
| Given | Adapter |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `"array"`, `"null"` | `ArrayAdapter`, `NullAdapter` |
|
|
155
|
+
| `"filesystem"`, `"filesystem:///var/cache/app"` | `FilesystemAdapter`, in the directory given |
|
|
156
|
+
| `"redis://…"`, `"rediss://…"`, `"unix://…"`, `"valkey://…"`, `"valkeys://…"` | `RedisAdapter` owning its connection |
|
|
157
|
+
| an asyncio Redis client | `RedisAdapter` on that client |
|
|
158
|
+
|
|
159
|
+
### `ArrayAdapter`
|
|
160
|
+
|
|
161
|
+
A dictionary. Values are stored serialized by default, so what a caller gets back is a copy it can
|
|
162
|
+
change freely; `store_serialized=False` stores the objects themselves. `max_items` drops the least
|
|
163
|
+
recently used beyond a count, `max_lifetime` caps every value's lifetime.
|
|
164
|
+
|
|
165
|
+
### `FilesystemAdapter`
|
|
166
|
+
|
|
167
|
+
One file per value under `directory/namespace`, holding its expiry, its key and its bytes. A write
|
|
168
|
+
goes to a temporary file first and replaces the value's file in one step, so readers never see
|
|
169
|
+
half a value. File work runs on a worker thread, off the event loop. Nothing is created until the
|
|
170
|
+
first write. An expired file is removed when read; `await pool.prune()` removes the rest.
|
|
171
|
+
|
|
172
|
+
### `RedisAdapter`
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from redis.asyncio import Redis
|
|
176
|
+
from xtr_cache import RedisAdapter
|
|
177
|
+
|
|
178
|
+
pool = RedisAdapter(Redis.from_url("redis://cache:6379/0"), "app") # your client, you close it
|
|
179
|
+
pool = RedisAdapter.from_url("redis://cache:6379/0", "app") # its own client ...
|
|
180
|
+
await pool.aclose() # ... which it closes
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Each value is a string key, `namespace:key`, expired by the server itself. Reads fetch many keys
|
|
184
|
+
in one round trip, writes are pipelined, and clearing scans the namespace and unlinks its keys in
|
|
185
|
+
batches — so set a namespace when the database holds anything else: without one, clearing empties
|
|
186
|
+
the database. One server; not a cluster, not Sentinel.
|
|
187
|
+
|
|
188
|
+
### `ChainAdapter`
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
cache = ChainAdapter([ArrayAdapter(), RedisAdapter.from_url("redis://cache", "app")])
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Reads ask each pool in turn. A value found in a slower one is copied into the faster ones, with
|
|
195
|
+
the life it has left — every expiring value is stored with its expiry, so a copy never outlives
|
|
196
|
+
the original — or `default_lifetime` when it was stored to live forever. Writes, deletes
|
|
197
|
+
and clears reach every pool.
|
|
198
|
+
|
|
199
|
+
### `TagAwareAdapter`
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
cache = TagAwareAdapter(RedisAdapter.from_url("redis://cache", "app"))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def load_order(item: ItemInterface) -> Order:
|
|
206
|
+
item.tag([f"customer.{customer_id}", "orders"])
|
|
207
|
+
return await orders.fetch(order_id)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
await cache.get(f"order.{order_id}", load_order)
|
|
211
|
+
await cache.invalidate_tags([f"customer.{customer_id}"]) # every order of that customer
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Each tag has a version, kept in the tags pool — the items pool unless another is given. An item is
|
|
215
|
+
saved with its tags' versions and is a hit only while every one is unchanged; invalidating a tag
|
|
216
|
+
deletes its version. Nothing is listed or scanned, so invalidating costs the same however many
|
|
217
|
+
items carry the tag. Versions read are trusted for `known_tag_versions_ttl` seconds (0.15 by
|
|
218
|
+
default), which is how soon an invalidation elsewhere reaches this process. Items saved as
|
|
219
|
+
deferred take their versions when committed, so an invalidation meanwhile does not apply to them.
|
|
220
|
+
|
|
221
|
+
Tagging an item of any other pool raises `LogicError`.
|
|
222
|
+
|
|
223
|
+
## Stampede protection
|
|
224
|
+
|
|
225
|
+
`get()` protects the backend three ways:
|
|
226
|
+
|
|
227
|
+
- **One computation per key in a process.** Concurrent misses on one key share the first caller's
|
|
228
|
+
computation. If it fails or is cancelled, the others compute for themselves, side by side.
|
|
229
|
+
- **One computation per key across processes**, with a `LockRegistry`. Keys are spread over
|
|
230
|
+
`slots` locks (20 by default); the caller that takes a key's slot computes and saves, the others
|
|
231
|
+
wait for it and read what was saved — or compute themselves after `wait` seconds (30), so a stuck
|
|
232
|
+
holder never stalls every reader, and stop using that slot from then on. Slots are chosen by the
|
|
233
|
+
pool's namespace and the key, so two pools never wait on each other's keys. The lock store decides who shares the locks: file locks for
|
|
234
|
+
every process on a machine, Redis for every machine.
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from xtr_lock import LockFactory, RedisStore
|
|
238
|
+
|
|
239
|
+
registry = LockRegistry(LockFactory(RedisStore.from_url("redis://cache")))
|
|
240
|
+
cache.set_lock_registry(registry)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
- **Early recomputation.** A computed value is stored with how long it took and when it expires —
|
|
244
|
+
its own lifetime, or the pool's default. On a hit, the value may be recomputed before it
|
|
245
|
+
expires, with a chance that grows as expiry nears and faster for values that are slow to compute. Under load one caller refreshes it while the others
|
|
246
|
+
keep reading the old one, instead of all missing at once. `beta` tunes it: `0` disables it,
|
|
247
|
+
`math.inf` forces a recomputation now.
|
|
248
|
+
|
|
249
|
+
A callback reading its own key — directly or through what it calls — computes without saving
|
|
250
|
+
rather than waiting on itself. Time comes from the clock in force (`xtr_clock`), so
|
|
251
|
+
`mock_time()` freezes lifetimes and recomputation alike.
|
|
252
|
+
|
|
253
|
+
## Marshallers
|
|
254
|
+
|
|
255
|
+
Adapters that store bytes — files, Redis, and memory when serializing — encode values with a
|
|
256
|
+
marshaller, pickle by default: a cache reads values back without being told their type, and pickle
|
|
257
|
+
is the format that carries it. A dataclass comes back a dataclass.
|
|
258
|
+
|
|
259
|
+
| Marshaller | Does |
|
|
260
|
+
|---|---|
|
|
261
|
+
| `DefaultMarshaller` | `pickle`; any object that pickles round-trips |
|
|
262
|
+
| `DeflateMarshaller(inner)` | compresses what `inner` encodes; reads uncompressed bytes too |
|
|
263
|
+
| `SodiumMarshaller(keys, inner=None)` | encrypts and authenticates what `inner` encodes |
|
|
264
|
+
|
|
265
|
+
Unpickling runs code named by the bytes, which is safe only while nothing else can write to the
|
|
266
|
+
backend. On a Redis server shared with other applications, use `SodiumMarshaller`: values are
|
|
267
|
+
encrypted with libsodium's authenticated secret-key encryption, so they can be neither read nor
|
|
268
|
+
forged without a key, and bytes anyone else wrote are refused — read as a miss — before pickle
|
|
269
|
+
ever sees them.
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
from xtr_cache import RedisAdapter, SodiumMarshaller
|
|
273
|
+
|
|
274
|
+
key = SodiumMarshaller.generate_key() # once; keep it in a secret, base64-encoded
|
|
275
|
+
pool = RedisAdapter.from_url("redis://cache", "app", marshaller=SodiumMarshaller([key]))
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Keys rotate: the first encrypts, every one decrypts. Put the new key first and keep the old one
|
|
279
|
+
until what it encrypted has expired.
|
|
280
|
+
|
|
281
|
+
## Kernel / bundle
|
|
282
|
+
|
|
283
|
+
With [xtr-dependency-injection](../xtr-dependency-injection), list the bundle and name the pools:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
# app/bundles.py
|
|
287
|
+
from xtr_cache.bundle import CacheBundle
|
|
288
|
+
|
|
289
|
+
BUNDLES = {CacheBundle: {"all": True}}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
# app/config/cache.py
|
|
294
|
+
from xtr_dependency_injection import configure, env
|
|
295
|
+
from xtr_cache.bundle import CacheConfig, PoolConfig
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@configure
|
|
299
|
+
def cache() -> CacheConfig:
|
|
300
|
+
return CacheConfig(
|
|
301
|
+
app=env("CACHE_DSN"),
|
|
302
|
+
pools={
|
|
303
|
+
"sessions": "redis://cache:6379/1",
|
|
304
|
+
"catalogue": PoolConfig(adapter=["array", "redis://cache:6379"], tags=True),
|
|
305
|
+
"reports": PoolConfig(default_lifetime=3600), # the app pool's adapter
|
|
306
|
+
},
|
|
307
|
+
stampede_lock="redis://cache:6379/2",
|
|
308
|
+
)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Every pool is registered under `AdapterInterface`, `CacheInterface`, `CacheItemPoolInterface`
|
|
312
|
+
and `NamespacedPoolInterface` — plus `TagAwareCacheInterface` and `TagAwareAdapterInterface` for
|
|
313
|
+
a pool with `tags=True` — qualified by its name. The `app` pool is also provided without a
|
|
314
|
+
qualifier.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
from typing import Annotated
|
|
318
|
+
|
|
319
|
+
from xtr_dependency_injection import Target, as_service
|
|
320
|
+
from xtr_cache_contracts import CacheInterface, TagAwareCacheInterface
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@as_service
|
|
324
|
+
class Catalogue:
|
|
325
|
+
def __init__(
|
|
326
|
+
self,
|
|
327
|
+
cache: CacheInterface, # the app pool
|
|
328
|
+
products: Annotated[TagAwareCacheInterface, Target("catalogue")],
|
|
329
|
+
) -> None: ...
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
| `CacheConfig` field | Meaning |
|
|
333
|
+
|---|---|
|
|
334
|
+
| `app` | The `app` pool's adapter, or several to chain. `"filesystem"` by default |
|
|
335
|
+
| `pools` | Every other pool: an adapter, several, or a `PoolConfig(adapter, default_lifetime, tags, namespace)`. A pool without adapter uses `app`'s. `tags=True` keeps tag versions in the pool itself; `tags="other"` keeps them in the pool named `other` |
|
|
336
|
+
| `directory` | Where `"filesystem"` keeps files: `"%kernel.share_dir%/cache"` by default, a directory of the system's temporary one set aside for the project |
|
|
337
|
+
| `prefix_seed` | What each pool's namespace is derived from, with its name: `"%kernel.project_dir%"` by default, so two applications on one backend never meet; give two the same seed to share values |
|
|
338
|
+
| `stampede_lock` | Where stampede locks go — any lock DSN: `"flock://%kernel.share_dir%/cache/locks"` by default, `"redis://…"` to span machines — or `None` for per-process protection only |
|
|
339
|
+
|
|
340
|
+
An adapter entry is a DSN or keyword `AdapterFactory` reads — `env(...)` included — or a
|
|
341
|
+
`Reference(Redis, "cache")` (from `xtr_dependency_injection`) to a client the container already
|
|
342
|
+
provides, which the application keeps closing.
|
|
343
|
+
|
|
344
|
+
- **Zero config**: one `app` pool on files. Nothing is opened until a pool is first asked for.
|
|
345
|
+
- **Checked at boot**: booting reads the configuration, `env()` values included, and refuses a DSN
|
|
346
|
+
no adapter serves, a Redis DSN without the `redis` extra, a `Reference` the container
|
|
347
|
+
does not provide, or a stampede lock no store serves — naming the pool, never a DSN's
|
|
348
|
+
credentials.
|
|
349
|
+
- **Marshaller**: pools encode with the `MarshallerInterface` service, pickle by default. Register
|
|
350
|
+
your own to replace it:
|
|
351
|
+
|
|
352
|
+
```python
|
|
353
|
+
@as_service
|
|
354
|
+
def marshaller(key: Annotated[str, Autowire(env="CACHE_DECRYPTION_KEY")]) -> MarshallerInterface:
|
|
355
|
+
return SodiumMarshaller([key])
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
- **Lifecycle**: between messages the kernel's resetter commits what each pool deferred; when the
|
|
359
|
+
container closes, pools commit and close the connections they opened.
|
|
360
|
+
- **Logging**: when the logging bundle is active, a `cache` channel is added and every pool logs
|
|
361
|
+
there — backend failures at warning, stampede waits at info.
|
|
362
|
+
- **Console**: when the console bundle is active, the commands below are registered.
|
|
363
|
+
|
|
364
|
+
## Console commands
|
|
365
|
+
|
|
366
|
+
| Command | Does |
|
|
367
|
+
|---|---|
|
|
368
|
+
| `cache:pool:list` | Lists the pools by name |
|
|
369
|
+
| `cache:pool:clear POOL… [--all] [--exclude POOL]` | Clears the named pools, or all of them |
|
|
370
|
+
| `cache:pool:delete POOL KEY` | Deletes one item |
|
|
371
|
+
| `cache:pool:invalidate-tags TAG… [-p POOL]` | Invalidates tags in the named pools, or every tag-aware one |
|
|
372
|
+
| `cache:pool:prune` | Removes expired values from every pool that keeps them |
|
|
373
|
+
|
|
374
|
+
Without a container, hand them pools once, and import `xtr_cache.command` before the application
|
|
375
|
+
runs:
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
from xtr_cache import CachePoolClearer
|
|
379
|
+
from xtr_cache.command import use_pools
|
|
380
|
+
|
|
381
|
+
use_pools(CachePoolClearer({"app": cache, "sessions": sessions}))
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Errors
|
|
385
|
+
|
|
386
|
+
Every error derives from the contract's `CacheError` and carries what went wrong as `reason`. A
|
|
387
|
+
backend failing is never one of them: it is logged, and the call misses or returns `False`.
|
|
388
|
+
|
|
389
|
+
| Error | Raised when |
|
|
390
|
+
|---|---|
|
|
391
|
+
| `InvalidArgumentError` | A key, tag, namespace, `beta`, DSN or option is invalid (also a `ValueError`) |
|
|
392
|
+
| `LogicError` | An item of a pool without tags is tagged |
|
|
393
|
+
| `MarshallingError` | A marshaller cannot decode stored bytes — caught by pools, which read a miss |
|
|
394
|
+
|
|
395
|
+
## Development
|
|
396
|
+
|
|
397
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
398
|
+
`packages/xtr-cache`; run the commands below from there. The `python-xtr-cache` repository is a
|
|
399
|
+
read-only copy, so send issues and pull requests to the monorepo.
|
|
400
|
+
|
|
401
|
+
```sh
|
|
402
|
+
uv sync
|
|
403
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The suite reaches no server: the Redis adapter runs against a fake client kept in memory, and
|
|
407
|
+
every adapter that stores values — all but `NullAdapter`, which stores none — passes the same
|
|
408
|
+
conformance tests.
|
|
409
|
+
|
|
410
|
+
## License
|
|
411
|
+
|
|
412
|
+
MIT — see [LICENSE](LICENSE).
|