xtr-cache 1.4.0__tar.gz → 2.0.0__tar.gz

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