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.
Files changed (53) hide show
  1. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/LICENSE +1 -0
  2. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/PKG-INFO +13 -11
  3. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/README.md +4 -2
  4. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/pyproject.toml +16 -13
  5. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/pyproject.toml.orig +17 -13
  6. xtr_cache-4.0.2/src/xtr_cache/.agents/skills/xtr-cache/SKILL.md +277 -0
  7. xtr_cache-4.0.2/src/xtr_cache/.agents/skills/xtr-cache/references/adapters.md +80 -0
  8. xtr_cache-4.0.2/src/xtr_cache/.agents/skills/xtr-cache/references/stampede-and-marshallers.md +64 -0
  9. xtr_cache-4.0.2/src/xtr_cache/_internal_key.py +22 -0
  10. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/abstract_adapter.py +4 -20
  11. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/adapter_factory.py +15 -0
  12. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/array_adapter.py +31 -2
  13. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/chain_adapter.py +4 -1
  14. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/filesystem_adapter.py +32 -5
  15. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/redis_adapter.py +13 -6
  16. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tag_aware_adapter.py +19 -8
  17. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/cache_bundle.py +33 -12
  18. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/cache_config.py +28 -0
  19. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/cache_item.py +10 -2
  20. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/cache_pool_clearer.py +59 -6
  21. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_invalidate_tags_command.py +4 -2
  22. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_prune_command.py +2 -0
  23. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/lock_registry.py +10 -4
  24. xtr_cache-4.0.2/src/xtr_cache/recipe/__init__.py +15 -0
  25. xtr_cache-4.0.2/src/xtr_cache/recipe/manifest.toml +21 -0
  26. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/__init__.py +0 -0
  27. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/_digest.py +0 -0
  28. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/__init__.py +0 -0
  29. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/adapter_interface.py +0 -0
  30. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/contracts_mixin.py +0 -0
  31. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/deferred_items_mixin.py +0 -0
  32. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/null_adapter.py +0 -0
  33. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tag_aware_adapter_interface.py +0 -0
  34. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tagged_value.py +0 -0
  35. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/__init__.py +0 -0
  36. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/pool_config.py +0 -0
  37. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/__init__.py +0 -0
  38. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_clear_command.py +0 -0
  39. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_delete_command.py +0 -0
  40. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_list_command.py +0 -0
  41. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/pool_command.py +0 -0
  42. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/pools.py +0 -0
  43. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/__init__.py +0 -0
  44. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/logic_error.py +0 -0
  45. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/marshalling_error.py +0 -0
  46. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/__init__.py +0 -0
  47. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/default_marshaller.py +0 -0
  48. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/deflate_marshaller.py +0 -0
  49. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/marshaller_interface.py +0 -0
  50. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/sodium_marshaller.py +0 -0
  51. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/pruneable_interface.py +0 -0
  52. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/py.typed +0 -0
  53. {xtr_cache-2.0.0 → xtr_cache-4.0.2}/src/xtr_cache/value_wrapper.py +0 -0
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 xterr
4
+ Copyright (c) 2016-present Fabien Potencier
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xtr-cache
3
- Version: 2.0.0
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>=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'
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 set a namespace when the database holds anything else: without one, clearing empties
194
- the database. A client built from a DSN gives up on a server silent for 5 seconds — a miss, or a
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 set a namespace when the database holds anything else: without one, clearing empties
158
- the database. A client built from a DSN gives up on a server silent for 5 seconds — a miss, or a
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 = "2.0.0"
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>=2.0,<3",
29
- "xtr-clock>=2.0,<3",
30
- "xtr-lock>=2.0,<3",
31
- "xtr-logging-contracts>=2.0,<3",
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>=2.0,<3",
43
- "xtr-service-contracts>=2.0,<3",
42
+ "xtr-dependency-injection>=4.0,<5",
43
+ "xtr-service-contracts>=4.0,<5",
44
44
  ]
45
- console = ["xtr-console>=2.0,<3"]
46
- logging = ["xtr-logging>=2.0,<3"]
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>=2.0,<3",
62
- "xtr-dependency-injection>=2.0,<3",
63
- "xtr-logging>=2.0,<3",
64
- "xtr-service-contracts>=2.0,<3",
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 = "2.0.0"
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>=2.0,<3",
26
- "xtr-clock>=2.0,<3",
27
- "xtr-lock>=2.0,<3",
28
- "xtr-logging-contracts>=2.0,<3",
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>=2.0,<3",
40
- "xtr-service-contracts>=2.0,<3",
39
+ "xtr-dependency-injection>=4.0,<5",
40
+ "xtr-service-contracts>=4.0,<5",
41
41
  ]
42
42
  console = [
43
- "xtr-console>=2.0,<3",
43
+ "xtr-console>=4.0,<5",
44
44
  ]
45
45
  logging = [
46
- "xtr-logging>=2.0,<3",
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>=2.0,<3",
65
- "xtr-dependency-injection>=2.0,<3",
66
- "xtr-logging>=2.0,<3",
67
- "xtr-service-contracts>=2.0,<3",
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__ = ()