xtr-cache 3.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-3.0.0 → xtr_cache-4.0.2}/LICENSE +1 -0
  2. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/PKG-INFO +11 -11
  3. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/README.md +2 -2
  4. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/pyproject.toml +13 -13
  5. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/pyproject.toml.orig +13 -13
  6. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/.agents/skills/xtr-cache/SKILL.md +3 -3
  7. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/.agents/skills/xtr-cache/references/adapters.md +3 -2
  8. xtr_cache-4.0.2/src/xtr_cache/_internal_key.py +22 -0
  9. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/abstract_adapter.py +4 -20
  10. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/adapter_factory.py +15 -0
  11. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/array_adapter.py +31 -2
  12. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/chain_adapter.py +4 -1
  13. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/filesystem_adapter.py +32 -5
  14. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/redis_adapter.py +13 -6
  15. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tag_aware_adapter.py +19 -8
  16. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/cache_bundle.py +21 -2
  17. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/cache_config.py +28 -0
  18. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/cache_item.py +10 -2
  19. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/cache_pool_clearer.py +59 -6
  20. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_invalidate_tags_command.py +4 -2
  21. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_prune_command.py +2 -0
  22. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/lock_registry.py +10 -4
  23. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/.agents/skills/xtr-cache/references/stampede-and-marshallers.md +0 -0
  24. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/__init__.py +0 -0
  25. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/_digest.py +0 -0
  26. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/__init__.py +0 -0
  27. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/adapter_interface.py +0 -0
  28. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/contracts_mixin.py +0 -0
  29. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/deferred_items_mixin.py +0 -0
  30. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/null_adapter.py +0 -0
  31. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tag_aware_adapter_interface.py +0 -0
  32. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/adapter/tagged_value.py +0 -0
  33. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/__init__.py +0 -0
  34. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/bundle/pool_config.py +0 -0
  35. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/__init__.py +0 -0
  36. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_clear_command.py +0 -0
  37. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_delete_command.py +0 -0
  38. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/cache_pool_list_command.py +0 -0
  39. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/pool_command.py +0 -0
  40. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/command/pools.py +0 -0
  41. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/__init__.py +0 -0
  42. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/logic_error.py +0 -0
  43. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/exception/marshalling_error.py +0 -0
  44. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/__init__.py +0 -0
  45. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/default_marshaller.py +0 -0
  46. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/deflate_marshaller.py +0 -0
  47. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/marshaller_interface.py +0 -0
  48. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/marshaller/sodium_marshaller.py +0 -0
  49. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/pruneable_interface.py +0 -0
  50. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/py.typed +0 -0
  51. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/recipe/__init__.py +0 -0
  52. {xtr_cache-3.0.0 → xtr_cache-4.0.2}/src/xtr_cache/recipe/manifest.toml +0 -0
  53. {xtr_cache-3.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: 3.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>=3.0,<4
20
- Requires-Dist: xtr-clock>=3.0,<4
21
- Requires-Dist: xtr-lock>=3.0,<4
22
- Requires-Dist: xtr-logging-contracts>=3.0,<4
23
- Requires-Dist: xtr-console>=3.0,<4 ; extra == 'console'
24
- Requires-Dist: xtr-dependency-injection>=3.0,<4 ; extra == 'di'
25
- Requires-Dist: xtr-service-contracts>=3.0,<4 ; extra == 'di'
26
- Requires-Dist: xtr-logging>=3.0,<4 ; 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
 
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-cache"
3
- version = "3.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>=3.0,<4",
29
- "xtr-clock>=3.0,<4",
30
- "xtr-lock>=3.0,<4",
31
- "xtr-logging-contracts>=3.0,<4",
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,11 +39,11 @@ email = "me@xterr.dev"
39
39
  redis = ["redis>=5.0"]
40
40
  sodium = ["pynacl>=1.5"]
41
41
  di = [
42
- "xtr-dependency-injection>=3.0,<4",
43
- "xtr-service-contracts>=3.0,<4",
42
+ "xtr-dependency-injection>=4.0,<5",
43
+ "xtr-service-contracts>=4.0,<5",
44
44
  ]
45
- console = ["xtr-console>=3.0,<4"]
46
- logging = ["xtr-logging>=3.0,<4"]
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"
@@ -61,10 +61,10 @@ dev = [
61
61
  "ty>=0.0.83",
62
62
  "pynacl>=1.5",
63
63
  "redis>=5.0",
64
- "xtr-console>=3.0,<4",
65
- "xtr-dependency-injection>=3.0,<4",
66
- "xtr-logging>=3.0,<4",
67
- "xtr-service-contracts>=3.0,<4",
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",
68
68
  ]
69
69
 
70
70
  [build-system]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xtr-cache"
3
- version = "3.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>=3.0,<4",
26
- "xtr-clock>=3.0,<4",
27
- "xtr-lock>=3.0,<4",
28
- "xtr-logging-contracts>=3.0,<4",
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>=3.0,<4",
40
- "xtr-service-contracts>=3.0,<4",
39
+ "xtr-dependency-injection>=4.0,<5",
40
+ "xtr-service-contracts>=4.0,<5",
41
41
  ]
42
42
  console = [
43
- "xtr-console>=3.0,<4",
43
+ "xtr-console>=4.0,<5",
44
44
  ]
45
45
  logging = [
46
- "xtr-logging>=3.0,<4",
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.
@@ -65,10 +65,10 @@ dev = [
65
65
  "ty>=0.0.83",
66
66
  "pynacl>=1.5",
67
67
  "redis>=5.0",
68
- "xtr-console>=3.0,<4",
69
- "xtr-dependency-injection>=3.0,<4",
70
- "xtr-logging>=3.0,<4",
71
- "xtr-service-contracts>=3.0,<4",
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",
72
72
  ]
73
73
 
74
74
  [build-system]
@@ -158,7 +158,7 @@ rotation.
158
158
 
159
159
  with mock_time("2026-01-01 00:00:00") as clock:
160
160
  first = await invoices.get(7)
161
- assert await invoices.get(7) is first # a hit; nothing fetched
161
+ assert await invoices.get(7) == first # a hit; a serialized pool hands back a copy
162
162
 
163
163
  clock.sleep(601)
164
164
  assert await invoices.get(7) is not first
@@ -271,7 +271,7 @@ them: it is logged, and the call misses or returns `False`.
271
271
  - Do not mutate what `get()` returned and expect the cache to keep the original — concurrent
272
272
  callers get the same object. Copy first.
273
273
  - Do not put a reserved character in a key; `{}()/\@:` raise `InvalidArgumentError`.
274
- - Do not run a `RedisAdapter` without a namespace on a database holding anything else — `clear()`
275
- empties it — and do not leave one built by `from_url` unclosed.
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
276
  - Do not pickle from a backend others can write to. Use `SodiumMarshaller`.
277
277
  - Do not sleep in a test to let a value expire. Freeze the clock with `mock_time`.
@@ -45,8 +45,9 @@ await pool.close() # ... which it closes
45
45
  ```
46
46
 
47
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
49
- always set a namespace when the database holds anything else. A client built from a DSN gives up
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
50
51
  on a server silent for 5 seconds unless `?socket_timeout=` and `?socket_connect_timeout=` say
51
52
  otherwise. One server; not a cluster, not Sentinel. Needs the `redis` extra and a client returning
52
53
  bytes: one made with `decode_responses=True` raises `InvalidArgumentError`.
@@ -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__ = ()
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, ClassVar, Final, Self
11
11
  from typing_extensions import override
12
12
  from xtr_cache_contracts import InvalidArgumentError, NamespacedPoolInterface
13
13
 
14
- from xtr_cache._digest import urlsafe_digest
15
14
  from xtr_cache.cache_item import CacheItem
16
15
  from xtr_cache.exception.marshalling_error import MarshallingError
17
16
 
@@ -45,17 +44,12 @@ class AbstractAdapter(
45
44
  cannot be reached makes reads miss and writes return ``False``; the code
46
45
  caching keeps running.
47
46
 
48
- An identifier is the pool's namespace followed by the key. A backend with
49
- a limit on identifier length sets :attr:`max_id_length`, and keys that
50
- would exceed it are hashed.
47
+ An identifier is the pool's namespace followed by the key.
51
48
  """
52
49
 
53
50
  NS_SEPARATOR: ClassVar[str] = ":"
54
51
  """What separates a namespace from what follows it in an identifier."""
55
52
 
56
- max_id_length: ClassVar[int | None] = None
57
- """The longest identifier the backend takes, or ``None`` for no limit."""
58
-
59
53
  _namespace: str
60
54
  _default_lifetime: float
61
55
  _clock: ClockInterface
@@ -80,8 +74,7 @@ class AbstractAdapter(
80
74
 
81
75
  Raises:
82
76
  InvalidArgumentError: When ``namespace`` is not a valid key once
83
- its separators are removed, has an empty sub-namespace, or
84
- leaves no room for keys within :attr:`max_id_length`.
77
+ its separators are removed, or has an empty sub-namespace.
85
78
  """
86
79
  if namespace:
87
80
  if self.NS_SEPARATOR * 2 in namespace:
@@ -89,11 +82,6 @@ class AbstractAdapter(
89
82
  f'Cache namespace "{namespace}" contains an empty sub-namespace.',
90
83
  )
91
84
  _ = CacheItem.validate_key(namespace.replace(self.NS_SEPARATOR, ""))
92
- if self.max_id_length is not None and len(namespace) > self.max_id_length - 24:
93
- raise InvalidArgumentError(
94
- f"A cache namespace must be {self.max_id_length - 24} characters at most, "
95
- f"got {len(namespace)}.",
96
- )
97
85
  namespace += self.NS_SEPARATOR
98
86
 
99
87
  self._namespace = namespace
@@ -314,16 +302,12 @@ class AbstractAdapter(
314
302
  return CacheItem.from_stored(key, stored, clock=self._clock)
315
303
 
316
304
  def _get_id(self, key: str) -> str:
317
- """Return the backend identifier of ``key``, hashing it when it would be too long.
305
+ """Return the backend identifier of ``key``: the namespace, then the key.
318
306
 
319
307
  Raises:
320
308
  InvalidArgumentError: When ``key`` is not a valid key.
321
309
  """
322
- id_ = self._namespace + CacheItem.validate_key(key)
323
- if self.max_id_length is None or len(id_) <= self.max_id_length:
324
- return id_
325
-
326
- return self._namespace + urlsafe_digest(key, size=16) + self.NS_SEPARATOR
310
+ return self._namespace + CacheItem.validate_key(key)
327
311
 
328
312
  def _unmarshall_found(
329
313
  self,
@@ -32,6 +32,7 @@ __all__ = ["AdapterFactory"]
32
32
  _FILESYSTEM: Final = "filesystem"
33
33
  _FILESYSTEM_PREFIX: Final = "filesystem://"
34
34
  _KEYWORDS: Final = ("array", "null", _FILESYSTEM)
35
+ _PRUNEABLE_KEYWORDS: Final = ("array", _FILESYSTEM)
35
36
 
36
37
 
37
38
  @final
@@ -136,3 +137,17 @@ class AdapterFactory:
136
137
  raise InvalidArgumentError(
137
138
  f'Unsupported cache connection: "{describe_connection_scheme(connection)}".'
138
139
  )
140
+
141
+ @staticmethod
142
+ def serves_pruneable(connection: object) -> bool:
143
+ """Tell whether ``connection`` names an adapter whose backend keeps expired values.
144
+
145
+ A memory or filesystem pool holds a value's slot until something drops
146
+ it, so pruning frees them; a Redis server and the null pool expire or
147
+ keep nothing of their own, so pruning one does nothing. Read from the
148
+ configuration, so a command can skip a pool it cannot prune without
149
+ building it — without reaching a server.
150
+ """
151
+ return isinstance(connection, str) and (
152
+ connection in _PRUNEABLE_KEYWORDS or connection.startswith(_FILESYSTEM_PREFIX)
153
+ )
@@ -3,12 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import dataclasses
6
- from typing import TYPE_CHECKING, cast, final
6
+ from typing import TYPE_CHECKING, Final, cast, final
7
7
 
8
8
  from typing_extensions import override
9
9
 
10
10
  from xtr_cache.exception import InvalidArgumentError, MarshallingError
11
11
  from xtr_cache.marshaller.default_marshaller import DefaultMarshaller
12
+ from xtr_cache.pruneable_interface import PruneableInterface
12
13
  from xtr_cache.value_wrapper import ValueWrapper
13
14
 
14
15
  from .abstract_adapter import AbstractAdapter
@@ -22,9 +23,12 @@ if TYPE_CHECKING:
22
23
 
23
24
  __all__ = ["ArrayAdapter"]
24
25
 
26
+ _PRUNE_EVERY: Final = 1000
27
+ """How many values are saved between the sweeps that drop what has expired."""
28
+
25
29
 
26
30
  @final
27
- class ArrayAdapter(AbstractAdapter):
31
+ class ArrayAdapter(AbstractAdapter, PruneableInterface):
28
32
  """Keeps values in a dictionary, for tests and for caching within one process.
29
33
 
30
34
  Values are stored serialized by default, so what a caller gets back is a
@@ -34,6 +38,11 @@ class ArrayAdapter(AbstractAdapter):
34
38
 
35
39
  Every method completes without awaiting, so tasks on one event loop never
36
40
  see it half-updated. Two instances share nothing.
41
+
42
+ Nothing expires an entry on its own: a read drops the one it finds expired,
43
+ and every ``1000`` saves sweep the rest, so a churn of short-lived keys
44
+ does not grow the dictionary without bound. :meth:`prune` sweeps them on
45
+ demand.
37
46
  """
38
47
 
39
48
  _values: dict[str, tuple[object, float | None]]
@@ -41,6 +50,7 @@ class ArrayAdapter(AbstractAdapter):
41
50
  _max_lifetime: float
42
51
  _max_items: int
43
52
  _marshaller: MarshallerInterface
53
+ _saves: int
44
54
 
45
55
  def __init__( # noqa: PLR0913 — every option past the lifetime is keyword-only.
46
56
  self,
@@ -81,6 +91,7 @@ class ArrayAdapter(AbstractAdapter):
81
91
  self._max_lifetime = max_lifetime
82
92
  self._max_items = max_items
83
93
  self._marshaller = marshaller if marshaller is not None else DefaultMarshaller()
94
+ self._saves = 0
84
95
 
85
96
  def values(self) -> dict[str, object]:
86
97
  """Return every live value by identifier, as stored; for tests."""
@@ -91,6 +102,12 @@ class ArrayAdapter(AbstractAdapter):
91
102
  if expiry is None or expiry > now
92
103
  }
93
104
 
105
+ @override
106
+ async def prune(self) -> bool:
107
+ """Drop every value whose expiry has passed."""
108
+ self._prune(self._clock.now().timestamp())
109
+ return True
110
+
94
111
  @override
95
112
  async def _do_fetch(self, ids: Sequence[str]) -> Mapping[str, object]:
96
113
  now = self._clock.now().timestamp()
@@ -162,8 +179,20 @@ class ArrayAdapter(AbstractAdapter):
162
179
  while len(self._values) > self._max_items:
163
180
  del self._values[next(iter(self._values))]
164
181
 
182
+ self._saves += len(stored)
183
+ if self._saves >= _PRUNE_EVERY:
184
+ self._saves = 0
185
+ self._prune(self._clock.now().timestamp())
186
+
165
187
  return failed or True
166
188
 
189
+ def _prune(self, now: float) -> None:
190
+ """Drop every value whose expiry is at or before ``now``."""
191
+ for id_ in [
192
+ id_ for id_, (_, expiry) in self._values.items() if expiry is not None and expiry <= now
193
+ ]:
194
+ del self._values[id_]
195
+
167
196
  @override
168
197
  def __repr__(self) -> str:
169
198
  return f"{type(self).__name__}({self._default_lifetime!r})"
@@ -90,6 +90,7 @@ class ChainAdapter(ContractsMixin, AdapterInterface, NamespacedPoolInterface, Pr
90
90
  found: dict[str, CacheItem] = {}
91
91
  missing = wanted
92
92
  missed: list[AdapterInterface] = []
93
+ written: list[AdapterInterface] = []
93
94
  for adapter in self._adapters:
94
95
  if not missing:
95
96
  break
@@ -99,10 +100,12 @@ class ChainAdapter(ContractsMixin, AdapterInterface, NamespacedPoolInterface, Pr
99
100
  found[key] = item
100
101
  for faster in missed:
101
102
  _ = await faster.save_deferred(self._copy(item))
103
+ if faster not in written:
104
+ written.append(faster)
102
105
  missing = [key for key in missing if key not in found]
103
106
  missed.append(adapter)
104
107
 
105
- for faster in missed:
108
+ for faster in written:
106
109
  _ = await faster.commit()
107
110
 
108
111
  return {key: found.get(key) or CacheItem(key, clock=self._clock) for key in wanted}
@@ -47,6 +47,14 @@ _HEADER_BYTES: Final = 4096
47
47
  _PRIVATE_MODE: Final = 0o700
48
48
  """The default directory's mode: in the shared temporary directory, only its owner may enter."""
49
49
 
50
+ _PRIVATE_FILE_MODE: Final = 0o600
51
+ _PRIVATE_DIR_MODE: Final = 0o700
52
+ """A pool's own files and directories, reachable by their owner alone, the default."""
53
+
54
+ _SHARED_FILE_MODE: Final = 0o666
55
+ _SHARED_DIR_MODE: Final = 0o777
56
+ """The modes ``shared`` opts into, so processes run as other users can read the files."""
57
+
50
58
 
51
59
  @final
52
60
  class FilesystemAdapter(AbstractAdapter, PruneableInterface):
@@ -60,6 +68,15 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
60
68
  Nothing is created until the first value is written. An expired file is
61
69
  removed when it is read; :meth:`prune` removes the ones nobody reads.
62
70
 
71
+ Files are the owner's alone (``0o600``) in directories the owner alone may
72
+ enter (``0o700``), unless ``shared`` opens them to everyone (``0o666`` and
73
+ ``0o777``, as the umask allows) so processes run as other users can read
74
+ them. A shared directory is dangerous with the pickle marshaller: anyone
75
+ who can write a file into it chooses what this process unpickles, which is
76
+ code execution. Share a directory only with a marshaller that
77
+ authenticates what it reads, such as
78
+ :class:`~xtr_cache.marshaller.SodiumMarshaller`.
79
+
63
80
  Without a directory of its own, a pool keeps its files in the system's
64
81
  temporary directory, where every user may create one first. Values are
65
82
  unpickled when read, so there the directory is made for this user alone,
@@ -71,13 +88,16 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
71
88
  _marshaller: MarshallerInterface
72
89
  _private_root: Path | None
73
90
  _private_root_checked: bool
91
+ _file_mode: int
92
+ _dir_mode: int
74
93
 
75
- def __init__(
94
+ def __init__( # noqa: PLR0913 — every option past the directory is keyword-only.
76
95
  self,
77
96
  namespace: str = "",
78
97
  default_lifetime: float = 0.0,
79
98
  directory: str | os.PathLike[str] | None = None,
80
99
  *,
100
+ shared: bool = False,
81
101
  marshaller: MarshallerInterface | None = None,
82
102
  clock: ClockInterface | None = None,
83
103
  ) -> None:
@@ -90,6 +110,11 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
90
110
  expiry; ``0`` for no limit.
91
111
  directory: Where the files go. ``None`` uses ``xtr-cache`` under
92
112
  the system's temporary directory.
113
+ shared: Create files and directories readable by every user
114
+ (``0o666``/``0o777``, as the umask allows) rather than the
115
+ owner alone (``0o600``/``0o700``). Dangerous with pickle: a
116
+ shared directory lets anyone choose what is unpickled, which
117
+ is code execution. Pair it with an authenticating marshaller.
93
118
  marshaller: What turns values into bytes. Pickle when omitted.
94
119
  clock: What lifetimes are counted from. ``None`` reads the clock
95
120
  in force.
@@ -108,6 +133,8 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
108
133
  self._private_root = base if directory is None else None
109
134
  self._private_root_checked = False
110
135
  self._marshaller = marshaller if marshaller is not None else DefaultMarshaller()
136
+ self._file_mode = _SHARED_FILE_MODE if shared else _PRIVATE_FILE_MODE
137
+ self._dir_mode = _SHARED_DIR_MODE if shared else _PRIVATE_DIR_MODE
111
138
 
112
139
  @property
113
140
  def directory(self) -> Path:
@@ -203,10 +230,10 @@ class FilesystemAdapter(AbstractAdapter, PruneableInterface):
203
230
  header = f"{expiry:.6f}\n{quote(id_, safe='')}\n".encode("ascii")
204
231
  temporary = path.parent / f"{_TEMPORARY_PREFIX}{secrets.token_hex(8)}"
205
232
  try:
206
- path.parent.mkdir(parents=True, exist_ok=True)
207
- # Readable by whoever the umask lets read it, so processes run as other users
208
- # can share the directory.
209
- with os.fdopen(os.open(temporary, _WRITE_FLAGS, 0o666), "wb") as file:
233
+ path.parent.mkdir(self._dir_mode, parents=True, exist_ok=True)
234
+ # The owner's alone by default; readable by whoever the umask lets read it
235
+ # when shared, so processes run as other users can share the directory.
236
+ with os.fdopen(os.open(temporary, _WRITE_FLAGS, self._file_mode), "wb") as file:
210
237
  _ = file.write(header)
211
238
  _ = file.write(data)
212
239
  _ = temporary.replace(path)
@@ -86,7 +86,7 @@ class RedisAdapter(AbstractAdapter):
86
86
  def __init__(
87
87
  self,
88
88
  redis: Redis,
89
- namespace: str = "",
89
+ namespace: str,
90
90
  default_lifetime: float = 0.0,
91
91
  *,
92
92
  marshaller: MarshallerInterface | None = None,
@@ -96,8 +96,9 @@ class RedisAdapter(AbstractAdapter):
96
96
 
97
97
  Args:
98
98
  redis: An asyncio client.
99
- namespace: Put in front of every key. Set one when the database
100
- holds anything else.
99
+ namespace: Put in front of every key, and required: without one
100
+ :meth:`clear` unlinks every key in the database, not only this
101
+ pool's.
101
102
  default_lifetime: Seconds a value lives when its item sets no
102
103
  expiry; ``0`` for no limit.
103
104
  marshaller: What turns values into bytes. Pickle when omitted.
@@ -105,8 +106,9 @@ class RedisAdapter(AbstractAdapter):
105
106
  clock in force.
106
107
 
107
108
  Raises:
108
- InvalidArgumentError: When the client decodes replies into text:
109
- stored values are bytes, and would never read back.
109
+ InvalidArgumentError: When ``namespace`` is empty, or the client
110
+ decodes replies into text — stored values are bytes, and would
111
+ never read back.
110
112
  """
111
113
  # The client is typed for its blocking twin too; see _Client.
112
114
  client = cast("_Client", cast("object", redis))
@@ -115,6 +117,11 @@ class RedisAdapter(AbstractAdapter):
115
117
  "A Redis cache pool needs a client returning bytes; "
116
118
  "this one was created with decode_responses=True.",
117
119
  )
120
+ if not namespace:
121
+ raise InvalidArgumentError(
122
+ "A Redis cache pool needs a namespace: clearing it unlinks every "
123
+ "key under the namespace, and empties the whole database without one.",
124
+ )
118
125
 
119
126
  super().__init__(namespace, default_lifetime, clock=clock)
120
127
  self._redis = redis
@@ -126,7 +133,7 @@ class RedisAdapter(AbstractAdapter):
126
133
  def from_url(
127
134
  cls,
128
135
  dsn: str,
129
- namespace: str = "",
136
+ namespace: str,
130
137
  default_lifetime: float = 0.0,
131
138
  *,
132
139
  marshaller: MarshallerInterface | None = None,
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Final, Self, final
10
10
  from typing_extensions import override
11
11
  from xtr_cache_contracts import Metadata, NamespacedPoolInterface
12
12
 
13
+ from xtr_cache._internal_key import InternalKey
13
14
  from xtr_cache.cache_item import CacheItem
14
15
  from xtr_cache.pruneable_interface import PruneableInterface
15
16
  from xtr_cache.value_wrapper import ValueWrapper
@@ -29,11 +30,14 @@ if TYPE_CHECKING:
29
30
  __all__ = ["TagAwareAdapter"]
30
31
 
31
32
  TAGS_PREFIX: Final = "\x01tags\x01"
33
+ """What the key holding a tag's version starts with, so it never meets a user key."""
32
34
 
33
- #: How long a tag's version is kept. Given explicitly, so a tags pool's default
34
- #: lifetime never drops a version before the values that were saved with it.
35
35
  _TAG_VERSION_LIFETIME: Final = 100 * 365 * 86400
36
- """What the key holding a tag's version starts with, so it never meets a user key."""
36
+ """How long a tag's version is kept.
37
+
38
+ Given explicitly, so a tags pool's default lifetime never drops a version
39
+ before the values that were saved with it.
40
+ """
37
41
 
38
42
 
39
43
  @final
@@ -99,7 +103,7 @@ class TagAwareAdapter(
99
103
  async def invalidate_tags(self, tags: Iterable[str], /) -> bool:
100
104
  ids: list[str] = []
101
105
  for tag in tags:
102
- ids.append(TAGS_PREFIX + CacheItem.validate_key(tag))
106
+ ids.append(InternalKey(TAGS_PREFIX + CacheItem.validate_key(tag)))
103
107
  _ = self._known.pop(tag, None)
104
108
 
105
109
  return not ids or await self._tags.delete_items(ids)
@@ -200,7 +204,12 @@ class TagAwareAdapter(
200
204
  stored = inner.get()
201
205
  if not isinstance(stored, TaggedValue):
202
206
  return CacheItem(
203
- key, stored, hit=True, metadata=inner.metadata, taggable=True, clock=self._clock
207
+ key,
208
+ stored,
209
+ hit=True,
210
+ metadata=inner.metadata.copy(),
211
+ taggable=True,
212
+ clock=self._clock,
204
213
  )
205
214
 
206
215
  if any(versions.get(tag) != version for tag, version in stored.versions.items()):
@@ -236,9 +245,10 @@ class TagAwareAdapter(
236
245
  unknown.append(tag)
237
246
 
238
247
  if unknown:
239
- items = await self._tags.get_items([TAGS_PREFIX + tag for tag in unknown])
248
+ keys = {tag: InternalKey(TAGS_PREFIX + tag) for tag in unknown}
249
+ items = await self._tags.get_items(keys.values())
240
250
  for tag in unknown:
241
- item = items[TAGS_PREFIX + tag]
251
+ item = items[keys[tag]]
242
252
  version = item.get() if item.is_hit() else None
243
253
  versions[tag] = version if isinstance(version, str) else None
244
254
  self._remember(tag, versions[tag], now)
@@ -267,7 +277,8 @@ class TagAwareAdapter(
267
277
  created = [tag for tag, found in current.items() if found is None]
268
278
 
269
279
  for tag in created:
270
- version = CacheItem(TAGS_PREFIX + tag, versions[tag], clock=self._clock)
280
+ key = InternalKey(TAGS_PREFIX + tag)
281
+ version = CacheItem(key, versions[tag], clock=self._clock)
271
282
  _ = await self._tags.save_deferred(version.expires_after(_TAG_VERSION_LIFETIME))
272
283
  # A version the tags pool did not keep must not be trusted here while others miss it.
273
284
  if created and await self._tags.commit():
@@ -54,7 +54,7 @@ from xtr_cache.adapter.contracts_mixin import ContractsMixin
54
54
  from xtr_cache.adapter.redis_adapter import RedisAdapter
55
55
  from xtr_cache.adapter.tag_aware_adapter import TagAwareAdapter
56
56
  from xtr_cache.adapter.tag_aware_adapter_interface import TagAwareAdapterInterface
57
- from xtr_cache.cache_pool_clearer import CachePoolClearer
57
+ from xtr_cache.cache_pool_clearer import CachePoolClearer, PoolCapabilities
58
58
  from xtr_cache.exception import InvalidArgumentError
59
59
  from xtr_cache.lock_registry import LockRegistry
60
60
  from xtr_cache.marshaller.default_marshaller import DefaultMarshaller
@@ -197,7 +197,26 @@ async def _lock_registry(
197
197
 
198
198
  def _cache_pool_clearer(config: CacheConfig, container: ContainerInterface) -> CachePoolClearer:
199
199
  """Name every pool for the commands, each built only when a command first needs it."""
200
- return CachePoolClearer({name: _provider(container, name) for name in config.pool_configs()})
200
+ configs = config.pool_configs()
201
+ return CachePoolClearer(
202
+ {name: _provider(container, name) for name in configs},
203
+ capabilities={name: _capabilities(pool) for name, pool in configs.items()},
204
+ )
205
+
206
+
207
+ def _capabilities(pool: PoolConfig) -> PoolCapabilities:
208
+ """Read what a pool can do from its configuration, so a command need not build it.
209
+
210
+ A tag-aware pool and a chain are always prunable (each wraps the pool
211
+ underneath), so is a single pool whose adapter keeps expired values.
212
+ """
213
+ entries = pool.adapters()
214
+ prunable = (
215
+ bool(pool.tags)
216
+ or len(entries) > 1
217
+ or any(AdapterFactory.serves_pruneable(entry) for entry in entries)
218
+ )
219
+ return PoolCapabilities(tag_aware=bool(pool.tags), prunable=prunable)
201
220
 
202
221
 
203
222
  def _provider(
@@ -25,6 +25,30 @@ def _no_pools() -> dict[str, PoolEntry]:
25
25
  return {}
26
26
 
27
27
 
28
+ def _refuse_tags_cycles(edges: Mapping[str, str]) -> None:
29
+ """Refuse a cycle in the pools' tags references, of any length, naming its pools.
30
+
31
+ Each pool keeping its tags in another points at one pool only, so the
32
+ references form a functional graph: a walk from any pool revisits a pool
33
+ exactly when it is caught in a cycle. A cycle would make building one of
34
+ its pools need itself, so it is refused before anything is built.
35
+
36
+ Raises:
37
+ InvalidArgumentError: When the tags references form a cycle.
38
+ """
39
+ for start in edges:
40
+ seen: list[str] = []
41
+ node = start
42
+ while node in edges:
43
+ if node in seen:
44
+ cycle = [*seen[seen.index(node) :], node]
45
+ raise InvalidArgumentError(
46
+ f"The cache pools form a tags cycle: {' -> '.join(cycle)}.",
47
+ )
48
+ seen.append(node)
49
+ node = edges[node]
50
+
51
+
28
52
  @dataclass(frozen=True, slots=True)
29
53
  class CacheConfig:
30
54
  """Which cache pools exist, and what each keeps its values in.
@@ -82,6 +106,7 @@ class CacheConfig:
82
106
  InvalidArgumentError: When the configuration cannot be read.
83
107
  """
84
108
  _ = PoolConfig(adapter=self.app)
109
+ edges: dict[str, str] = {}
85
110
  for name, entry in self.pools.items():
86
111
  if not isinstance(name, str) or not name: # pyright: ignore[reportUnnecessaryIsInstance] -- configs are written by hand; the annotation is not enforced
87
112
  raise InvalidArgumentError(f"A cache pool needs a non-empty name, got {name!r}.")
@@ -96,6 +121,9 @@ class CacheConfig:
96
121
  f'The "{name}" cache pool keeps its tags in "{entry.tags}", '
97
122
  f"which is not another pool.",
98
123
  )
124
+ elif isinstance(entry.tags, str):
125
+ edges[name] = entry.tags
126
+ _refuse_tags_cycles(edges)
99
127
 
100
128
  def pool_configs(self) -> dict[str, PoolConfig]:
101
129
  """Return every pool, ``app`` first, each with its adapters resolved to a tuple."""
@@ -9,6 +9,7 @@ from typing_extensions import override
9
9
  from xtr_cache_contracts import RESERVED_CHARACTERS, InvalidArgumentError, ItemInterface, Metadata
10
10
  from xtr_clock import Clock
11
11
 
12
+ from ._internal_key import InternalKey
12
13
  from .exception.logic_error import LogicError
13
14
  from .value_wrapper import ValueWrapper
14
15
 
@@ -115,8 +116,9 @@ class CacheItem(ItemInterface):
115
116
  """Return ``key`` when it is a valid key or tag.
116
117
 
117
118
  Raises:
118
- InvalidArgumentError: When ``key`` is not a string, is empty, or
119
- holds one of :data:`~xtr_cache_contracts.RESERVED_CHARACTERS`.
119
+ InvalidArgumentError: When ``key`` is not a string, is empty, holds
120
+ a non-printable character, or holds one of
121
+ :data:`~xtr_cache_contracts.RESERVED_CHARACTERS`.
120
122
  """
121
123
  if not isinstance(key, str):
122
124
  raise InvalidArgumentError(
@@ -124,6 +126,12 @@ class CacheItem(ItemInterface):
124
126
  )
125
127
  if not key:
126
128
  raise InvalidArgumentError("A cache key must not be empty.")
129
+ if isinstance(key, InternalKey):
130
+ return key
131
+ if not key.isprintable():
132
+ raise InvalidArgumentError(
133
+ f"A cache key must not contain non-printable characters, got {key!r}.",
134
+ )
127
135
  if any(character in RESERVED_CHARACTERS for character in key):
128
136
  raise InvalidArgumentError(
129
137
  f'Cache key "{key}" contains one of the reserved characters '
@@ -3,20 +3,47 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
+ from dataclasses import dataclass
6
7
  from typing import TYPE_CHECKING, TypeAlias, final
7
8
 
8
9
  from typing_extensions import override
9
- from xtr_cache_contracts import CacheItemPoolInterface, InvalidArgumentError
10
+ from xtr_cache_contracts import (
11
+ CacheItemPoolInterface,
12
+ InvalidArgumentError,
13
+ TagAwareCacheInterface,
14
+ )
15
+
16
+ from xtr_cache.pruneable_interface import PruneableInterface
10
17
 
11
18
  if TYPE_CHECKING:
12
19
  from collections.abc import Awaitable, Mapping
13
20
 
14
- __all__ = ["CachePoolClearer", "PoolProvider"]
21
+ __all__ = ["CachePoolClearer", "PoolCapabilities", "PoolProvider"]
15
22
 
16
23
  PoolProvider: TypeAlias = CacheItemPoolInterface | Callable[[], "Awaitable[CacheItemPoolInterface]"]
17
24
  """A pool, or what builds it when first asked for."""
18
25
 
19
26
 
27
+ @final
28
+ @dataclass(frozen=True, slots=True)
29
+ class PoolCapabilities:
30
+ """What a pool can do, read from its configuration before it is built.
31
+
32
+ A command that acts on only some pools — pruning the ones that keep
33
+ expired values, invalidating tags in the tag-aware ones — asks these of
34
+ every pool and builds only the ones that answer yes, so naming a Redis
35
+ pool it would skip never reaches the server.
36
+
37
+ Attributes:
38
+ tag_aware: Whether items can be tagged and invalidated by tag.
39
+ prunable: Whether the backend keeps expired values, so pruning frees
40
+ them.
41
+ """
42
+
43
+ tag_aware: bool
44
+ prunable: bool
45
+
46
+
20
47
  @final
21
48
  class CachePoolClearer:
22
49
  """Knows every named pool, and clears them by name.
@@ -26,15 +53,27 @@ class CachePoolClearer:
26
53
  nothing until a command uses it.
27
54
  """
28
55
 
29
- __slots__ = ("_built", "_pools")
56
+ __slots__ = ("_built", "_capabilities", "_pools")
30
57
 
31
58
  _pools: dict[str, PoolProvider]
32
59
  _built: dict[str, CacheItemPoolInterface]
33
-
34
- def __init__(self, pools: Mapping[str, PoolProvider] | None = None) -> None:
35
- """Know ``pools`` by name."""
60
+ _capabilities: dict[str, PoolCapabilities]
61
+
62
+ def __init__(
63
+ self,
64
+ pools: Mapping[str, PoolProvider] | None = None,
65
+ *,
66
+ capabilities: Mapping[str, PoolCapabilities] | None = None,
67
+ ) -> None:
68
+ """Know ``pools`` by name, with ``capabilities`` a command can read unbuilt.
69
+
70
+ A pool given built answers its own capabilities by its type; one given
71
+ as a function to build later needs an entry in ``capabilities``, so a
72
+ command can tell what it does without building it.
73
+ """
36
74
  self._pools = dict(pools or {})
37
75
  self._built = {}
76
+ self._capabilities = dict(capabilities or {})
38
77
 
39
78
  def has_pool(self, name: str) -> bool:
40
79
  """Tell whether a pool is known by ``name``."""
@@ -44,6 +83,20 @@ class CachePoolClearer:
44
83
  """Return every pool's name, sorted."""
45
84
  return tuple(sorted(self._pools))
46
85
 
86
+ def is_tag_aware(self, name: str) -> bool:
87
+ """Tell whether the pool ``name`` can be invalidated by tag, without building it."""
88
+ capabilities = self._capabilities.get(name)
89
+ if capabilities is not None:
90
+ return capabilities.tag_aware
91
+ return isinstance(self._pools.get(name), TagAwareCacheInterface)
92
+
93
+ def is_prunable(self, name: str) -> bool:
94
+ """Tell whether the pool known by ``name`` keeps expired values, without building it."""
95
+ capabilities = self._capabilities.get(name)
96
+ if capabilities is not None:
97
+ return capabilities.prunable
98
+ return isinstance(self._pools.get(name), PruneableInterface)
99
+
47
100
  async def get_pool(self, name: str) -> CacheItemPoolInterface:
48
101
  """Return the pool known by ``name``, building it the first time.
49
102
 
@@ -83,12 +83,14 @@ async def _invalidate(
83
83
  invalidated = 0
84
84
  errors = False
85
85
  for name in names:
86
- cache = await clearer.get_pool(name)
87
- if not isinstance(cache, TagAwareCacheInterface):
86
+ if not clearer.is_tag_aware(name):
88
87
  if named:
89
88
  io.error(f'Cache pool "{escape(name)}" is not tag-aware.')
90
89
  errors = True
91
90
  continue
91
+ cache = await clearer.get_pool(name)
92
+ if not isinstance(cache, TagAwareCacheInterface):
93
+ continue
92
94
  if not await cache.invalidate_tags(tags):
93
95
  io.error(f'Cache tags could not be invalidated in pool "{escape(name)}".')
94
96
  errors = True
@@ -32,6 +32,8 @@ class CachePoolPruneCommand(PoolCommand):
32
32
 
33
33
  failed: list[str] = []
34
34
  for name in clearer.pool_names():
35
+ if not clearer.is_prunable(name):
36
+ continue
35
37
  cache = await clearer.get_pool(name)
36
38
  if not isinstance(cache, PruneableInterface):
37
39
  continue
@@ -3,11 +3,11 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import time
7
6
  import zlib
8
7
  from typing import TYPE_CHECKING, Final, TypeVar, cast, final
9
8
 
10
9
  from typing_extensions import override
10
+ from xtr_clock import MonotonicClock
11
11
  from xtr_logging_contracts import LoggerAware
12
12
 
13
13
  from .exception import InvalidArgumentError
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
16
16
  from collections.abc import Awaitable, Callable
17
17
 
18
18
  from xtr_cache_contracts import ItemInterface
19
+ from xtr_clock import ClockInterface
19
20
  from xtr_lock import LockFactory
20
21
 
21
22
  __all__ = ["LockRegistry"]
@@ -48,13 +49,14 @@ class LockRegistry(LoggerAware):
48
49
  so every process agrees on which lock guards it.
49
50
  """
50
51
 
51
- __slots__ = ("_evicted", "_locks", "_prefix", "_slots", "_wait")
52
+ __slots__ = ("_clock", "_evicted", "_locks", "_prefix", "_slots", "_wait")
52
53
 
53
54
  _locks: LockFactory
54
55
  _slots: int
55
56
  _evicted: dict[int, float]
56
57
  _wait: float
57
58
  _prefix: str
59
+ _clock: ClockInterface
58
60
 
59
61
  def __init__(
60
62
  self,
@@ -63,6 +65,7 @@ class LockRegistry(LoggerAware):
63
65
  slots: int = DEFAULT_SLOTS,
64
66
  wait: float = DEFAULT_WAIT,
65
67
  prefix: str = "xtr-cache.",
68
+ clock: ClockInterface | None = None,
66
69
  ) -> None:
67
70
  """Take slots' locks from ``locks``.
68
71
 
@@ -71,6 +74,8 @@ class LockRegistry(LoggerAware):
71
74
  slots: How many values may be computed at once.
72
75
  wait: How long to wait for another caller's computation, in seconds.
73
76
  prefix: Put in front of each slot's number to name its lock.
77
+ clock: What the eviction window is timed against; a monotonic clock
78
+ when omitted, so a wall-clock jump never widens or shortens it.
74
79
 
75
80
  Raises:
76
81
  InvalidArgumentError: When ``slots`` or ``wait`` is not positive.
@@ -85,6 +90,7 @@ class LockRegistry(LoggerAware):
85
90
  self._evicted = {}
86
91
  self._wait = wait
87
92
  self._prefix = prefix
93
+ self._clock = clock if clock is not None else MonotonicClock()
88
94
 
89
95
  async def compute(
90
96
  self,
@@ -147,14 +153,14 @@ class LockRegistry(LoggerAware):
147
153
  until = self._evicted.get(slot)
148
154
  if until is None:
149
155
  return slot
150
- if time.monotonic() < until:
156
+ if self._clock.now().timestamp() < until:
151
157
  return None
152
158
  del self._evicted[slot]
153
159
  return slot
154
160
 
155
161
  def _evict(self, slot: int) -> None:
156
162
  """Set ``slot`` aside for a while, its holder stuck, so later callers do not wait on it."""
157
- self._evicted[slot] = time.monotonic() + self._wait
163
+ self._evicted[slot] = self._clock.now().timestamp() + self._wait
158
164
 
159
165
  @property
160
166
  def slots(self) -> int: