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.
Files changed (46) hide show
  1. xtr_cache-1.2.0/LICENSE +21 -0
  2. xtr_cache-1.2.0/PKG-INFO +412 -0
  3. xtr_cache-1.2.0/README.md +378 -0
  4. xtr_cache-1.2.0/pyproject.toml +182 -0
  5. xtr_cache-1.2.0/pyproject.toml.orig +187 -0
  6. xtr_cache-1.2.0/src/xtr_cache/__init__.py +111 -0
  7. xtr_cache-1.2.0/src/xtr_cache/adapter/__init__.py +31 -0
  8. xtr_cache-1.2.0/src/xtr_cache/adapter/abstract_adapter.py +356 -0
  9. xtr_cache-1.2.0/src/xtr_cache/adapter/adapter_factory.py +134 -0
  10. xtr_cache-1.2.0/src/xtr_cache/adapter/adapter_interface.py +42 -0
  11. xtr_cache-1.2.0/src/xtr_cache/adapter/array_adapter.py +165 -0
  12. xtr_cache-1.2.0/src/xtr_cache/adapter/chain_adapter.py +185 -0
  13. xtr_cache-1.2.0/src/xtr_cache/adapter/contracts_mixin.py +190 -0
  14. xtr_cache-1.2.0/src/xtr_cache/adapter/deferred_items_mixin.py +78 -0
  15. xtr_cache-1.2.0/src/xtr_cache/adapter/filesystem_adapter.py +256 -0
  16. xtr_cache-1.2.0/src/xtr_cache/adapter/null_adapter.py +84 -0
  17. xtr_cache-1.2.0/src/xtr_cache/adapter/redis_adapter.py +234 -0
  18. xtr_cache-1.2.0/src/xtr_cache/adapter/tag_aware_adapter.py +261 -0
  19. xtr_cache-1.2.0/src/xtr_cache/adapter/tag_aware_adapter_interface.py +16 -0
  20. xtr_cache-1.2.0/src/xtr_cache/adapter/tagged_value.py +28 -0
  21. xtr_cache-1.2.0/src/xtr_cache/bundle/__init__.py +17 -0
  22. xtr_cache-1.2.0/src/xtr_cache/bundle/cache_bundle.py +289 -0
  23. xtr_cache-1.2.0/src/xtr_cache/bundle/cache_config.py +115 -0
  24. xtr_cache-1.2.0/src/xtr_cache/bundle/pool_config.py +69 -0
  25. xtr_cache-1.2.0/src/xtr_cache/cache_item.py +262 -0
  26. xtr_cache-1.2.0/src/xtr_cache/cache_pool_clearer.py +79 -0
  27. xtr_cache-1.2.0/src/xtr_cache/command/__init__.py +33 -0
  28. xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_clear_command.py +65 -0
  29. xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_delete_command.py +50 -0
  30. xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_invalidate_tags_command.py +99 -0
  31. xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_list_command.py +32 -0
  32. xtr_cache-1.2.0/src/xtr_cache/command/cache_pool_prune_command.py +47 -0
  33. xtr_cache-1.2.0/src/xtr_cache/command/pool_command.py +37 -0
  34. xtr_cache-1.2.0/src/xtr_cache/command/pools.py +35 -0
  35. xtr_cache-1.2.0/src/xtr_cache/exception/__init__.py +20 -0
  36. xtr_cache-1.2.0/src/xtr_cache/exception/logic_error.py +25 -0
  37. xtr_cache-1.2.0/src/xtr_cache/exception/marshalling_error.py +27 -0
  38. xtr_cache-1.2.0/src/xtr_cache/lock_registry.py +165 -0
  39. xtr_cache-1.2.0/src/xtr_cache/marshaller/__init__.py +15 -0
  40. xtr_cache-1.2.0/src/xtr_cache/marshaller/default_marshaller.py +61 -0
  41. xtr_cache-1.2.0/src/xtr_cache/marshaller/deflate_marshaller.py +51 -0
  42. xtr_cache-1.2.0/src/xtr_cache/marshaller/marshaller_interface.py +39 -0
  43. xtr_cache-1.2.0/src/xtr_cache/marshaller/sodium_marshaller.py +143 -0
  44. xtr_cache-1.2.0/src/xtr_cache/pruneable_interface.py +26 -0
  45. xtr_cache-1.2.0/src/xtr_cache/py.typed +0 -0
  46. xtr_cache-1.2.0/src/xtr_cache/value_wrapper.py +30 -0
@@ -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.
@@ -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).