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 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