cachau 0.2.0__py3-none-any.whl
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.
- cachau/__init__.py +25 -0
- cachau/backend.py +53 -0
- cachau/decorator.py +568 -0
- cachau/disk.py +193 -0
- cachau/durations.py +49 -0
- cachau/errors.py +25 -0
- cachau/explanation.py +98 -0
- cachau/fingerprint.py +169 -0
- cachau/flight.py +61 -0
- cachau/keys.py +216 -0
- cachau/memory.py +46 -0
- cachau/policy.py +62 -0
- cachau/sizes.py +101 -0
- cachau/stats.py +38 -0
- cachau-0.2.0.dist-info/METADATA +178 -0
- cachau-0.2.0.dist-info/RECORD +18 -0
- cachau-0.2.0.dist-info/WHEEL +4 -0
- cachau-0.2.0.dist-info/licenses/LICENSE +21 -0
cachau/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Cachau — delightful function caching for Python data workloads."""
|
|
2
|
+
|
|
3
|
+
from cachau.decorator import cache
|
|
4
|
+
from cachau.explanation import Explanation
|
|
5
|
+
from cachau.stats import CacheStats
|
|
6
|
+
from cachau.errors import (
|
|
7
|
+
CachauError,
|
|
8
|
+
ConfigurationError,
|
|
9
|
+
InvalidSizeError,
|
|
10
|
+
InvalidTTLError,
|
|
11
|
+
UnhashableArgumentError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
__all__ = [
|
|
16
|
+
"cache",
|
|
17
|
+
"CacheStats",
|
|
18
|
+
"CachauError",
|
|
19
|
+
"Explanation",
|
|
20
|
+
"ConfigurationError",
|
|
21
|
+
"InvalidSizeError",
|
|
22
|
+
"InvalidTTLError",
|
|
23
|
+
"UnhashableArgumentError",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
cachau/backend.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The minimal storage contract every backend implements.
|
|
2
|
+
|
|
3
|
+
Backends store and retrieve entries; they never decide function semantics
|
|
4
|
+
(keys, invalidation, TTL policy) — that belongs to the layers above
|
|
5
|
+
(GUIDELINES.md §13).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Iterator, NamedTuple, Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EntryMetadata(NamedTuple):
|
|
16
|
+
"""Lightweight per-entry facts readable without deserializing the value."""
|
|
17
|
+
|
|
18
|
+
key: str
|
|
19
|
+
namespace: str
|
|
20
|
+
size: int | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class CacheEntry:
|
|
25
|
+
"""An immutable stored result plus the metadata the policy layers need."""
|
|
26
|
+
|
|
27
|
+
value: Any
|
|
28
|
+
namespace: str
|
|
29
|
+
created_at: float = field(default_factory=time.time)
|
|
30
|
+
expires_at: float | None = None
|
|
31
|
+
size: int | None = None
|
|
32
|
+
|
|
33
|
+
def is_expired(self, now: float) -> bool:
|
|
34
|
+
return self.expires_at is not None and now >= self.expires_at
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CacheBackend(Protocol):
|
|
38
|
+
def get(self, key: str) -> CacheEntry | None: ...
|
|
39
|
+
|
|
40
|
+
def peek(self, key: str) -> CacheEntry | None:
|
|
41
|
+
"""Like ``get`` but guaranteed side-effect free (no corrupt-file
|
|
42
|
+
cleanup, no bookkeeping). Used by pure observers such as explain()."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def set(self, key: str, entry: CacheEntry) -> None: ...
|
|
46
|
+
|
|
47
|
+
def delete(self, key: str) -> None: ...
|
|
48
|
+
|
|
49
|
+
def clear(self, namespace: str | None = None) -> None: ...
|
|
50
|
+
|
|
51
|
+
def iter_entries(self) -> Iterator[tuple[str, CacheEntry]]: ...
|
|
52
|
+
|
|
53
|
+
def iter_metadata(self) -> Iterator[EntryMetadata]: ...
|
cachau/decorator.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""The public ``@cache`` decorator and the per-function control surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from typing import Any, Callable, Iterable, TypeVar, overload
|
|
12
|
+
|
|
13
|
+
from cachau.backend import CacheBackend, CacheEntry, EntryMetadata
|
|
14
|
+
from cachau.disk import DiskBackend
|
|
15
|
+
from cachau.durations import parse_ttl
|
|
16
|
+
from cachau.errors import ConfigurationError
|
|
17
|
+
from cachau.explanation import Explanation
|
|
18
|
+
from cachau.fingerprint import (
|
|
19
|
+
function_fingerprint,
|
|
20
|
+
function_namespace,
|
|
21
|
+
is_jit_dispatcher,
|
|
22
|
+
)
|
|
23
|
+
from cachau.flight import KeyedLocks
|
|
24
|
+
import inspect
|
|
25
|
+
|
|
26
|
+
from cachau.keys import digest_arguments, digest_custom_key
|
|
27
|
+
from cachau.memory import MemoryBackend
|
|
28
|
+
from cachau.policy import LRUBudget
|
|
29
|
+
from cachau.sizes import estimate_size, parse_size
|
|
30
|
+
from cachau.stats import CacheStats
|
|
31
|
+
|
|
32
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
33
|
+
Clock = Callable[[], float]
|
|
34
|
+
SizeOf = Callable[[Any], int]
|
|
35
|
+
KeyBuilder = Callable[..., str]
|
|
36
|
+
|
|
37
|
+
_default_backend = MemoryBackend()
|
|
38
|
+
_DEFAULT_PERSIST_DIR = ".cachau"
|
|
39
|
+
_perf_counter = time.perf_counter
|
|
40
|
+
# Bounded cap for miss-reason attribution markers (label-only: dropping one
|
|
41
|
+
# can mislabel a future miss as not_found, never affect correctness).
|
|
42
|
+
_INVALIDATION_MARKER_CAP = 4096
|
|
43
|
+
_MARKER_MISSING = object()
|
|
44
|
+
Persist = bool | str | os.PathLike
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CacheControl:
|
|
48
|
+
"""Attached to every cached function as ``func.cache``."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
namespace: str,
|
|
54
|
+
fingerprint: str,
|
|
55
|
+
backend: CacheBackend,
|
|
56
|
+
ttl_seconds: float | None,
|
|
57
|
+
max_memory_bytes: int | None,
|
|
58
|
+
budget: LRUBudget | None,
|
|
59
|
+
key_builder: KeyBuilder,
|
|
60
|
+
now: Clock,
|
|
61
|
+
flights: KeyedLocks,
|
|
62
|
+
code_change_invalidations: int = 0,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.namespace = namespace
|
|
65
|
+
self.fingerprint = fingerprint
|
|
66
|
+
self.ttl_seconds = ttl_seconds
|
|
67
|
+
self.max_memory_bytes = max_memory_bytes
|
|
68
|
+
self.hits = 0
|
|
69
|
+
self.coalesced_hits = 0
|
|
70
|
+
self.miss_not_found = 0
|
|
71
|
+
self.miss_expired = 0
|
|
72
|
+
self.miss_invalidated = 0
|
|
73
|
+
self.writes = 0
|
|
74
|
+
self.evictions = 0
|
|
75
|
+
self.skipped_oversized = 0
|
|
76
|
+
self.size_estimate_failures = 0
|
|
77
|
+
self.write_errors = 0
|
|
78
|
+
self.delete_errors = 0
|
|
79
|
+
self.invalidations = 0
|
|
80
|
+
self.code_change_invalidations = code_change_invalidations
|
|
81
|
+
self.total_compute_seconds = 0.0
|
|
82
|
+
self.compute_count = 0
|
|
83
|
+
self.estimated_saved_seconds = 0.0
|
|
84
|
+
self.cold_compute_seconds = 0.0
|
|
85
|
+
# Reason markers: the delete succeeded; remembering the key only
|
|
86
|
+
# attributes the next miss to miss_invalidated. Bounded LRU-style.
|
|
87
|
+
self._invalidation_markers: OrderedDict[str, None] = OrderedDict()
|
|
88
|
+
# Pending invalidations: the physical delete FAILED. Authoritative —
|
|
89
|
+
# the wrapper must never serve these keys from the backend, or an
|
|
90
|
+
# explicitly invalidated value would come back as a false HIT.
|
|
91
|
+
self._pending_invalidations: set[str] = set()
|
|
92
|
+
# Savings average uses only computations whose result was actually
|
|
93
|
+
# cached; skipped/oversized computes can never produce a future hit.
|
|
94
|
+
self._saved_basis_seconds = 0.0
|
|
95
|
+
self._saved_basis_count = 0
|
|
96
|
+
self._backend = backend
|
|
97
|
+
self._budget = budget
|
|
98
|
+
self._key_builder = key_builder
|
|
99
|
+
self._now = now
|
|
100
|
+
self._flights = flights
|
|
101
|
+
# Guards compound mutations of the invalidation bookkeeping, which is
|
|
102
|
+
# function-wide state reachable from concurrent flights of different
|
|
103
|
+
# keys. Never held across backend I/O or a computation.
|
|
104
|
+
self._mutation_guard = threading.Lock()
|
|
105
|
+
|
|
106
|
+
def explain(self, *args: Any, **kwargs: Any) -> Explanation:
|
|
107
|
+
"""Explain what a call with these arguments would do, and why.
|
|
108
|
+
|
|
109
|
+
Pure observation: never executes the function, never mutates cache
|
|
110
|
+
state, counters, invalidation bookkeeping, or LRU recency.
|
|
111
|
+
"""
|
|
112
|
+
key = self._key_builder(*args, **kwargs)
|
|
113
|
+
checked_at = self._now()
|
|
114
|
+
common = {
|
|
115
|
+
"key": key,
|
|
116
|
+
"namespace": self.namespace,
|
|
117
|
+
"fingerprint": self.fingerprint,
|
|
118
|
+
"checked_at": checked_at,
|
|
119
|
+
}
|
|
120
|
+
if key in self._pending_invalidations:
|
|
121
|
+
return Explanation(outcome="MISS", reason="invalidated", **common)
|
|
122
|
+
# peek() is the non-destructive read (DiskBackend.get removes corrupt
|
|
123
|
+
# files as read-side maintenance; observation must not).
|
|
124
|
+
peek = getattr(self._backend, "peek", self._backend.get)
|
|
125
|
+
entry = peek(key)
|
|
126
|
+
if entry is None:
|
|
127
|
+
reason = (
|
|
128
|
+
"invalidated" if key in self._invalidation_markers else "not_found"
|
|
129
|
+
)
|
|
130
|
+
return Explanation(outcome="MISS", reason=reason, **common)
|
|
131
|
+
facts = {
|
|
132
|
+
"created_at": entry.created_at,
|
|
133
|
+
"expires_at": entry.expires_at,
|
|
134
|
+
"size_bytes": entry.size,
|
|
135
|
+
}
|
|
136
|
+
if entry.is_expired(checked_at):
|
|
137
|
+
return Explanation(outcome="MISS", reason="expired", **common, **facts)
|
|
138
|
+
return Explanation(outcome="HIT", reason="found", **common, **facts)
|
|
139
|
+
|
|
140
|
+
def clear(self) -> None:
|
|
141
|
+
"""Forget every stored result for this function."""
|
|
142
|
+
self._backend.clear(namespace=self.namespace)
|
|
143
|
+
if self._budget is not None:
|
|
144
|
+
self._budget.reset()
|
|
145
|
+
with self._mutation_guard:
|
|
146
|
+
self._invalidation_markers.clear()
|
|
147
|
+
self._pending_invalidations.clear()
|
|
148
|
+
|
|
149
|
+
def invalidate(self, *args: Any, **kwargs: Any) -> None:
|
|
150
|
+
"""Forget the stored result for one specific invocation.
|
|
151
|
+
|
|
152
|
+
Serialized with any in-flight computation of the same key (per-key
|
|
153
|
+
lock), so it can never interleave with a commit and leave the budget
|
|
154
|
+
or bookkeeping inconsistent with the backend.
|
|
155
|
+
"""
|
|
156
|
+
key = self._key_builder(*args, **kwargs)
|
|
157
|
+
with self._flights.holding(key):
|
|
158
|
+
try:
|
|
159
|
+
self._backend.delete(key)
|
|
160
|
+
except Exception: # noqa: BLE001 - a failed delete never raises
|
|
161
|
+
self.delete_errors += 1
|
|
162
|
+
with self._mutation_guard:
|
|
163
|
+
self._pending_invalidations.add(key)
|
|
164
|
+
else:
|
|
165
|
+
with self._mutation_guard:
|
|
166
|
+
self._invalidation_markers[key] = None
|
|
167
|
+
self._invalidation_markers.move_to_end(key)
|
|
168
|
+
while len(self._invalidation_markers) > _INVALIDATION_MARKER_CAP:
|
|
169
|
+
self._invalidation_markers.popitem(last=False)
|
|
170
|
+
if self._budget is not None:
|
|
171
|
+
self._budget.forget(key)
|
|
172
|
+
self.invalidations += 1
|
|
173
|
+
|
|
174
|
+
def stats(self) -> CacheStats:
|
|
175
|
+
"""An immutable snapshot of this function's cache activity."""
|
|
176
|
+
entries = 0
|
|
177
|
+
current_bytes = 0
|
|
178
|
+
for row in self._iter_metadata():
|
|
179
|
+
if row.namespace == self.namespace:
|
|
180
|
+
entries += 1
|
|
181
|
+
if row.size is not None:
|
|
182
|
+
current_bytes += row.size
|
|
183
|
+
misses = self.miss_not_found + self.miss_expired + self.miss_invalidated
|
|
184
|
+
total_calls = self.hits + misses
|
|
185
|
+
return CacheStats(
|
|
186
|
+
hits=self.hits,
|
|
187
|
+
coalesced_hits=self.coalesced_hits,
|
|
188
|
+
misses=misses,
|
|
189
|
+
hit_rate=self.hits / total_calls if total_calls else 0.0,
|
|
190
|
+
miss_not_found=self.miss_not_found,
|
|
191
|
+
miss_expired=self.miss_expired,
|
|
192
|
+
miss_invalidated=self.miss_invalidated,
|
|
193
|
+
expirations=self.miss_expired,
|
|
194
|
+
writes=self.writes,
|
|
195
|
+
skipped_writes=(
|
|
196
|
+
self.skipped_oversized
|
|
197
|
+
+ self.size_estimate_failures
|
|
198
|
+
+ self.write_errors
|
|
199
|
+
),
|
|
200
|
+
skipped_oversized=self.skipped_oversized,
|
|
201
|
+
size_estimate_failures=self.size_estimate_failures,
|
|
202
|
+
write_errors=self.write_errors,
|
|
203
|
+
delete_errors=self.delete_errors,
|
|
204
|
+
evictions=self.evictions,
|
|
205
|
+
invalidations=self.invalidations,
|
|
206
|
+
code_change_invalidations=self.code_change_invalidations,
|
|
207
|
+
entries=entries,
|
|
208
|
+
current_bytes=current_bytes,
|
|
209
|
+
total_compute_seconds=self.total_compute_seconds,
|
|
210
|
+
estimated_saved_seconds=self.estimated_saved_seconds,
|
|
211
|
+
cold_compute_seconds=self.cold_compute_seconds,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def _iter_metadata(self) -> Iterable[EntryMetadata]:
|
|
215
|
+
iter_metadata = getattr(self._backend, "iter_metadata", None)
|
|
216
|
+
if iter_metadata is not None:
|
|
217
|
+
return iter_metadata()
|
|
218
|
+
return (
|
|
219
|
+
EntryMetadata(key, entry.namespace, entry.size)
|
|
220
|
+
for key, entry in self._backend.iter_entries()
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@overload
|
|
225
|
+
def cache(func: F) -> F: ...
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@overload
|
|
229
|
+
def cache(
|
|
230
|
+
*,
|
|
231
|
+
ttl: int | float | str | None = None,
|
|
232
|
+
max_memory: int | str | None = None,
|
|
233
|
+
persist: Persist | None = None,
|
|
234
|
+
namespace: str | None = None,
|
|
235
|
+
key: Callable[..., Any] | None = None,
|
|
236
|
+
ignore: list[str] | tuple[str, ...] | None = None,
|
|
237
|
+
backend: CacheBackend | None = None,
|
|
238
|
+
clock: Clock = ...,
|
|
239
|
+
size_of: SizeOf = ...,
|
|
240
|
+
) -> Callable[[F], F]: ...
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cache(
|
|
244
|
+
func: Callable[..., Any] | None = None,
|
|
245
|
+
*,
|
|
246
|
+
ttl: int | float | str | None = None,
|
|
247
|
+
max_memory: int | str | None = None,
|
|
248
|
+
persist: Persist | None = None,
|
|
249
|
+
namespace: str | None = None,
|
|
250
|
+
key: Callable[..., Any] | None = None,
|
|
251
|
+
ignore: list[str] | tuple[str, ...] | None = None,
|
|
252
|
+
backend: CacheBackend | None = None,
|
|
253
|
+
clock: Clock = time.time,
|
|
254
|
+
size_of: SizeOf = estimate_size,
|
|
255
|
+
) -> Any:
|
|
256
|
+
"""Cache a function's results, keyed by its normalized arguments.
|
|
257
|
+
|
|
258
|
+
Usable bare (``@cache``) or configured (``@cache(ttl="1h",
|
|
259
|
+
max_memory="2GB", persist=True)``). TTL accepts seconds or readable
|
|
260
|
+
strings and starts when the result is committed. ``max_memory`` accepts
|
|
261
|
+
bytes or size strings (``"512MB"``, ``"2GB"``; binary units) and bounds
|
|
262
|
+
this function's entries with LRU eviction — an entry larger than the whole
|
|
263
|
+
budget is computed and returned but never cached. ``persist=True`` stores
|
|
264
|
+
entries under ``./.cachau`` (or pass a directory) and survives process
|
|
265
|
+
restarts; a failed write never loses the computed result. Exceptions are
|
|
266
|
+
never cached; unhashable arguments fail loudly. ``func.cache`` exposes
|
|
267
|
+
``stats()``, ``clear()`` and ``invalidate(...)``.
|
|
268
|
+
"""
|
|
269
|
+
if func is not None:
|
|
270
|
+
return _wrap(
|
|
271
|
+
func, ttl, max_memory, persist, namespace, key, ignore, backend,
|
|
272
|
+
clock, size_of,
|
|
273
|
+
)
|
|
274
|
+
return lambda f: _wrap(
|
|
275
|
+
f, ttl, max_memory, persist, namespace, key, ignore, backend, clock, size_of
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _resolve_backend(
|
|
280
|
+
persist: Persist | None, backend: CacheBackend | None
|
|
281
|
+
) -> CacheBackend:
|
|
282
|
+
if persist:
|
|
283
|
+
if backend is not None:
|
|
284
|
+
raise ConfigurationError(
|
|
285
|
+
"persist= and backend= are mutually exclusive: persist creates "
|
|
286
|
+
"a DiskBackend; pass a custom backend without persist instead"
|
|
287
|
+
)
|
|
288
|
+
directory = _DEFAULT_PERSIST_DIR if persist is True else persist
|
|
289
|
+
return DiskBackend(pathlib.Path(directory))
|
|
290
|
+
return backend if backend is not None else _default_backend
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _purge_stale_fingerprints(
|
|
294
|
+
store: CacheBackend, namespace: str, fingerprint: str
|
|
295
|
+
) -> int:
|
|
296
|
+
"""Delete this namespace's entries written under a different fingerprint.
|
|
297
|
+
|
|
298
|
+
Redefining a function (notebook cell re-run, hot reload) changes its
|
|
299
|
+
fingerprint, so the old entries can never be read again — but on a shared
|
|
300
|
+
long-lived backend they would keep consuming memory outside any budget's
|
|
301
|
+
view. Code-change invalidation therefore reclaims the storage too.
|
|
302
|
+
Returns how many entries were invalidated.
|
|
303
|
+
"""
|
|
304
|
+
current_prefix = f"{namespace}:{fingerprint}:"
|
|
305
|
+
iter_metadata = getattr(store, "iter_metadata", None)
|
|
306
|
+
rows: Iterable[EntryMetadata] = (
|
|
307
|
+
iter_metadata()
|
|
308
|
+
if iter_metadata is not None
|
|
309
|
+
else (
|
|
310
|
+
EntryMetadata(key, entry.namespace, entry.size)
|
|
311
|
+
for key, entry in store.iter_entries()
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
purged = 0
|
|
315
|
+
for row in rows:
|
|
316
|
+
if row.namespace == namespace and not row.key.startswith(current_prefix):
|
|
317
|
+
try:
|
|
318
|
+
store.delete(row.key)
|
|
319
|
+
purged += 1
|
|
320
|
+
except Exception: # noqa: BLE001 - best-effort cleanup at decoration
|
|
321
|
+
pass
|
|
322
|
+
return purged
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _validate_ignore(
|
|
326
|
+
func: Callable[..., Any], ignore: list[str] | tuple[str, ...] | None
|
|
327
|
+
) -> frozenset[str]:
|
|
328
|
+
if not ignore:
|
|
329
|
+
return frozenset()
|
|
330
|
+
parameters = set(inspect.signature(func).parameters)
|
|
331
|
+
unknown = set(ignore) - parameters
|
|
332
|
+
if unknown:
|
|
333
|
+
raise ConfigurationError(
|
|
334
|
+
f"ignore= names parameters that {func.__qualname__}() does not "
|
|
335
|
+
f"have: {sorted(unknown)}"
|
|
336
|
+
)
|
|
337
|
+
return frozenset(ignore)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _wrap(
|
|
341
|
+
func: Callable[..., Any],
|
|
342
|
+
ttl: int | float | str | None,
|
|
343
|
+
max_memory: int | str | None,
|
|
344
|
+
persist: Persist | None,
|
|
345
|
+
namespace: str | None,
|
|
346
|
+
key: Callable[..., Any] | None,
|
|
347
|
+
ignore: list[str] | tuple[str, ...] | None,
|
|
348
|
+
backend: CacheBackend | None,
|
|
349
|
+
clock: Clock,
|
|
350
|
+
size_of: SizeOf,
|
|
351
|
+
) -> Callable[..., Any]:
|
|
352
|
+
# Fail fast: bad configuration breaks at decoration time, not on first call.
|
|
353
|
+
if key is not None and ignore:
|
|
354
|
+
raise ConfigurationError(
|
|
355
|
+
"key= and ignore= are mutually exclusive: an explicit key already "
|
|
356
|
+
"defines the full identity, so there is nothing left to ignore"
|
|
357
|
+
)
|
|
358
|
+
ignored_names = _validate_ignore(func, ignore)
|
|
359
|
+
ttl_seconds = parse_ttl(ttl)
|
|
360
|
+
max_memory_bytes = parse_size(max_memory)
|
|
361
|
+
resolved_namespace = namespace if namespace is not None else function_namespace(func)
|
|
362
|
+
fingerprint = function_fingerprint(func)
|
|
363
|
+
jit_boundary = is_jit_dispatcher(func)
|
|
364
|
+
store: CacheBackend = _resolve_backend(persist, backend)
|
|
365
|
+
budget = LRUBudget(max_memory_bytes) if max_memory_bytes is not None else None
|
|
366
|
+
flights = KeyedLocks()
|
|
367
|
+
purged = _purge_stale_fingerprints(store, resolved_namespace, fingerprint)
|
|
368
|
+
last_observed = float("-inf")
|
|
369
|
+
clock_guard = threading.Lock()
|
|
370
|
+
|
|
371
|
+
def now() -> float:
|
|
372
|
+
# TTL uses wall-clock time because expires_at must survive process
|
|
373
|
+
# restarts once persistence lands. Wall clocks can step backward (NTP,
|
|
374
|
+
# VM resume); clamping to the last observed reading keeps time monotone
|
|
375
|
+
# for this function so an entry can never appear to grow younger. The
|
|
376
|
+
# guard prevents concurrent flights from racing the read-modify-write
|
|
377
|
+
# and sliding the ratchet backward.
|
|
378
|
+
nonlocal last_observed
|
|
379
|
+
with clock_guard:
|
|
380
|
+
last_observed = max(last_observed, clock())
|
|
381
|
+
return last_observed
|
|
382
|
+
|
|
383
|
+
def peek_now() -> float:
|
|
384
|
+
# Read-only view of the same clamped clock, for pure observers
|
|
385
|
+
# (explain, and profile later): sees what the wrapper would see
|
|
386
|
+
# without advancing the ratchet, so a transient clock spike observed
|
|
387
|
+
# only through an observation call can never poison future writes.
|
|
388
|
+
return max(last_observed, clock())
|
|
389
|
+
|
|
390
|
+
def build_key(*args: Any, **kwargs: Any) -> str:
|
|
391
|
+
if key is not None:
|
|
392
|
+
digest = digest_custom_key(func, key(*args, **kwargs))
|
|
393
|
+
else:
|
|
394
|
+
digest = digest_arguments(func, args, kwargs, ignore=ignored_names)
|
|
395
|
+
return f"{resolved_namespace}:{fingerprint}:{digest}"
|
|
396
|
+
|
|
397
|
+
def safe_delete(key: str) -> None:
|
|
398
|
+
# A backend delete can fail on disk (locked file, permissions). The
|
|
399
|
+
# cache is an optimization: never let that block returning a value.
|
|
400
|
+
# A lingering entry is still-correct data (a budget overrun at worst),
|
|
401
|
+
# never a false HIT.
|
|
402
|
+
try:
|
|
403
|
+
store.delete(key)
|
|
404
|
+
except Exception:
|
|
405
|
+
control.delete_errors += 1
|
|
406
|
+
|
|
407
|
+
_NOT_SERVED = object()
|
|
408
|
+
|
|
409
|
+
def serve_if_fresh(key: str, *, coalesced: bool) -> Any:
|
|
410
|
+
"""Serve a fresh entry with hit bookkeeping, or return _NOT_SERVED.
|
|
411
|
+
|
|
412
|
+
Never classifies misses and never mutates entries — that belongs to
|
|
413
|
+
the locked flight, so the fast path and the post-lock re-check can
|
|
414
|
+
share this without double counting.
|
|
415
|
+
"""
|
|
416
|
+
if key in control._pending_invalidations:
|
|
417
|
+
return _NOT_SERVED
|
|
418
|
+
entry = store.get(key)
|
|
419
|
+
if entry is None or entry.is_expired(now()):
|
|
420
|
+
return _NOT_SERVED
|
|
421
|
+
if key in control._pending_invalidations:
|
|
422
|
+
# Re-check after the read: a concurrent invalidate whose physical
|
|
423
|
+
# delete failed may have quarantined the key while we were reading
|
|
424
|
+
# the backend. Never serve a condemned entry.
|
|
425
|
+
return _NOT_SERVED
|
|
426
|
+
if budget is not None:
|
|
427
|
+
budget.touch(key)
|
|
428
|
+
control.hits += 1
|
|
429
|
+
if coalesced:
|
|
430
|
+
control.coalesced_hits += 1
|
|
431
|
+
if control._saved_basis_count:
|
|
432
|
+
control.estimated_saved_seconds += (
|
|
433
|
+
control._saved_basis_seconds / control._saved_basis_count
|
|
434
|
+
)
|
|
435
|
+
return entry.value
|
|
436
|
+
|
|
437
|
+
@functools.wraps(func)
|
|
438
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
439
|
+
key = build_key(*args, **kwargs)
|
|
440
|
+
served = serve_if_fresh(key, coalesced=False)
|
|
441
|
+
if served is not _NOT_SERVED:
|
|
442
|
+
return served
|
|
443
|
+
with flights.holding(key):
|
|
444
|
+
return _compute_flight(key, args, kwargs)
|
|
445
|
+
|
|
446
|
+
def _compute_flight(key: str, args: tuple, kwargs: dict) -> Any:
|
|
447
|
+
# Re-check under the per-key lock: another flight may have committed
|
|
448
|
+
# while this caller was waiting — that is the single-flight reuse.
|
|
449
|
+
served = serve_if_fresh(key, coalesced=True)
|
|
450
|
+
if served is not _NOT_SERVED:
|
|
451
|
+
return served
|
|
452
|
+
if key in control._pending_invalidations:
|
|
453
|
+
# Authoritative: the caller invalidated this key but the physical
|
|
454
|
+
# delete failed. Never serve the backend's stale entry; retry the
|
|
455
|
+
# removal and recompute. The marker survives until either the
|
|
456
|
+
# delete or the overwriting set succeeds.
|
|
457
|
+
try:
|
|
458
|
+
store.delete(key)
|
|
459
|
+
control._pending_invalidations.discard(key)
|
|
460
|
+
except Exception:
|
|
461
|
+
control.delete_errors += 1
|
|
462
|
+
if budget is not None:
|
|
463
|
+
budget.forget(key)
|
|
464
|
+
control.miss_invalidated += 1
|
|
465
|
+
else:
|
|
466
|
+
entry = store.get(key)
|
|
467
|
+
if entry is not None:
|
|
468
|
+
# Fresh entries were served above; only expired ones reach here.
|
|
469
|
+
safe_delete(key)
|
|
470
|
+
if budget is not None:
|
|
471
|
+
budget.forget(key)
|
|
472
|
+
control.miss_expired += 1
|
|
473
|
+
else:
|
|
474
|
+
# Atomic pop: a concurrent clear() or marker-cap eviction must
|
|
475
|
+
# not turn check-then-delete into a KeyError.
|
|
476
|
+
with control._mutation_guard:
|
|
477
|
+
marker = control._invalidation_markers.pop(key, _MARKER_MISSING)
|
|
478
|
+
if marker is not _MARKER_MISSING:
|
|
479
|
+
control.miss_invalidated += 1
|
|
480
|
+
else:
|
|
481
|
+
control.miss_not_found += 1
|
|
482
|
+
specializations_before = -1
|
|
483
|
+
if jit_boundary:
|
|
484
|
+
known_signatures = getattr(func, "signatures", None)
|
|
485
|
+
if known_signatures is not None:
|
|
486
|
+
specializations_before = len(known_signatures)
|
|
487
|
+
compute_started = _perf_counter()
|
|
488
|
+
value = func(*args, **kwargs)
|
|
489
|
+
compute_elapsed = _perf_counter() - compute_started
|
|
490
|
+
control.total_compute_seconds += compute_elapsed
|
|
491
|
+
control.compute_count += 1
|
|
492
|
+
# Cold JIT is per SPECIALIZATION, not per function: a new dtype on a
|
|
493
|
+
# later call triggers a fresh compile. Detect it by whether the
|
|
494
|
+
# dispatcher grew a compiled signature during this execution, and
|
|
495
|
+
# keep that one-time cost out of the savings baseline so compile
|
|
496
|
+
# time is never counted as normal execution cost.
|
|
497
|
+
is_cold_jit = False
|
|
498
|
+
if jit_boundary:
|
|
499
|
+
known_signatures = getattr(func, "signatures", None)
|
|
500
|
+
if known_signatures is not None and specializations_before >= 0:
|
|
501
|
+
is_cold_jit = len(known_signatures) > specializations_before
|
|
502
|
+
else:
|
|
503
|
+
is_cold_jit = control.compute_count == 1
|
|
504
|
+
if is_cold_jit:
|
|
505
|
+
control.cold_compute_seconds += compute_elapsed
|
|
506
|
+
committed_at = now() # TTL starts at commit, not at call start
|
|
507
|
+
size: int | None = None
|
|
508
|
+
if budget is not None:
|
|
509
|
+
# The cache is an optimization, never a correctness dependency: a
|
|
510
|
+
# failing or nonsensical size estimate must not crash a call that
|
|
511
|
+
# already computed its result — skip caching instead.
|
|
512
|
+
try:
|
|
513
|
+
size = int(size_of(value))
|
|
514
|
+
except Exception:
|
|
515
|
+
control.size_estimate_failures += 1
|
|
516
|
+
return value
|
|
517
|
+
if size < 0:
|
|
518
|
+
control.size_estimate_failures += 1
|
|
519
|
+
return value
|
|
520
|
+
if not budget.fits(size):
|
|
521
|
+
# Oversized: compute, return, never cache, never flush the
|
|
522
|
+
# cache to make room for a pathological entry.
|
|
523
|
+
control.skipped_oversized += 1
|
|
524
|
+
return value
|
|
525
|
+
for evicted_key in budget.admit(key, size):
|
|
526
|
+
safe_delete(evicted_key)
|
|
527
|
+
control.evictions += 1
|
|
528
|
+
try:
|
|
529
|
+
store.set(
|
|
530
|
+
key,
|
|
531
|
+
CacheEntry(
|
|
532
|
+
value=value,
|
|
533
|
+
namespace=resolved_namespace,
|
|
534
|
+
created_at=committed_at,
|
|
535
|
+
expires_at=(
|
|
536
|
+
committed_at + ttl_seconds if ttl_seconds is not None else None
|
|
537
|
+
),
|
|
538
|
+
size=size,
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
control.writes += 1
|
|
542
|
+
control._pending_invalidations.discard(key) # fresh value overwrote it
|
|
543
|
+
if not is_cold_jit:
|
|
544
|
+
control._saved_basis_seconds += compute_elapsed
|
|
545
|
+
control._saved_basis_count += 1
|
|
546
|
+
except Exception:
|
|
547
|
+
# The cache is an optimization: a failed write (serialization,
|
|
548
|
+
# disk) never loses the computed result. Release the budget slot
|
|
549
|
+
# so a phantom entry cannot shrink future capacity.
|
|
550
|
+
control.write_errors += 1
|
|
551
|
+
if budget is not None:
|
|
552
|
+
budget.forget(key)
|
|
553
|
+
return value
|
|
554
|
+
|
|
555
|
+
control = CacheControl(
|
|
556
|
+
namespace=resolved_namespace,
|
|
557
|
+
fingerprint=fingerprint,
|
|
558
|
+
backend=store,
|
|
559
|
+
ttl_seconds=ttl_seconds,
|
|
560
|
+
max_memory_bytes=max_memory_bytes,
|
|
561
|
+
budget=budget,
|
|
562
|
+
key_builder=build_key,
|
|
563
|
+
now=peek_now,
|
|
564
|
+
flights=flights,
|
|
565
|
+
code_change_invalidations=purged,
|
|
566
|
+
)
|
|
567
|
+
wrapper.cache = control # type: ignore[attr-defined]
|
|
568
|
+
return wrapper
|