Coverage for cuda / core / utils / _program_cache / _file_stream.py: 82.39%
284 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-22 01:37 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-22 01:37 +0000
1# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2#
3# SPDX-License-Identifier: Apache-2.0
5"""On-disk bytes-in / bytes-out program cache.
7Atomic writes via :func:`os.replace`. Concurrent readers see either the
8old entry or the new one, never a partial file. Each entry is the raw
9compiled binary so files are directly consumable by external NVIDIA
10tools (``cuobjdump``, ``nvdisasm``, ``cuda-gdb``).
11"""
13from __future__ import annotations
15import contextlib
16import errno
17import hashlib
18import os
19import tempfile
20import threading
21import time
22from pathlib import Path
23from typing import Iterable
25from cuda.core._module import ObjectCode
27from ._abc import ProgramCacheResource, _as_key_bytes, _extract_bytes
29_ENTRIES_SUBDIR = "entries"
30_TMP_SUBDIR = "tmp"
31# Temp files older than this are assumed to belong to a crashed writer and
32# are eligible for cleanup. Picked large enough that no real ``os.replace``
33# write should still be in flight (writes are bounded by mkstemp + write +
34# fsync + replace, all fast on healthy disks).
35_TMP_STALE_AGE_SECONDS = 3600
38_SHARING_VIOLATION_WINERRORS = (5, 32, 33) # ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION
39_REPLACE_RETRY_DELAYS = (0.0, 0.005, 0.010, 0.020, 0.050, 0.100) # ~185ms budget
42# Exposed as a module-level flag so tests can toggle it without monkeypatching
43# ``os.name`` itself (pathlib reads ``os.name`` at instantiation time).
44_IS_WINDOWS = os.name == "nt"
47def _stat_key(st: os.stat_result) -> tuple:
48 """Stat fingerprint used by every stat-guarded path.
50 ``(st_ino, st_size, st_mtime_ns)`` is the smallest triple that
51 distinguishes "same file" from "file replaced under us": ``st_ino``
52 catches replacement, ``st_size`` and ``st_mtime_ns`` catch a write
53 that happens to land on the same inode (e.g. truncate-and-write in
54 place). Centralised so all four readers compare the same fields.
55 """
56 return (st.st_ino, st.st_size, st.st_mtime_ns) 1quvwxbnolpfeyzmARBkCDacMgdEhFKVWrs
59def _default_cache_dir() -> Path:
60 """OS-conventional default location for the file-stream cache.
62 Resolves to the user-cache root for the calling user, with a
63 ``program-cache`` leaf so future tooling can place sibling caches
64 under the same ``cuda-python`` vendor directory:
66 * Linux: ``$XDG_CACHE_HOME/cuda-python/program-cache``
67 (default ``~/.cache/cuda-python/program-cache`` per the XDG Base
68 Directory spec).
69 * Windows: ``%LOCALAPPDATA%\\cuda-python\\program-cache``
70 (Windows uses local AppData -- caches don't roam; falls back to
71 ``~/AppData/Local`` if the env var is unset).
73 CUDA does not support macOS, so no macOS branch is provided.
74 """
75 if _IS_WINDOWS: 15
76 local_app_data = os.environ.get("LOCALAPPDATA") 15
77 root = Path(local_app_data) if local_app_data else Path.home() / "AppData" / "Local" 15
78 else:
79 xdg = os.environ.get("XDG_CACHE_HOME") 15
80 root = Path(xdg) if xdg else Path.home() / ".cache" 15
81 return root / "cuda-python" / "program-cache" 15
84def _with_sharing_retry(op, *args, on_exhausted=None, **kwargs):
85 """Run ``op(*args, **kwargs)`` retrying transient Windows sharing
86 violations under the bounded ``_REPLACE_RETRY_DELAYS`` budget.
88 On Windows, ``os.replace``/``read_bytes``/``unlink`` can surface
89 winerror 5/32/33 (or bare EACCES via ``_is_windows_sharing_violation``)
90 while another process briefly holds the file open without share-delete
91 rights. The retry hides that contention. Other ``PermissionError``s
92 (real ACLs, unexpected winerror) propagate immediately.
94 Successful returns and any non-``PermissionError`` exceptions
95 (including ``FileNotFoundError``) bubble up unchanged. After the
96 budget is exhausted, the helper either calls ``on_exhausted(last_exc)``
97 if provided, or re-raises the last sharing-violation exception.
98 """
99 last_exc: PermissionError | None = None 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs
100 for delay in _REPLACE_RETRY_DELAYS: 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs
101 if delay: 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs
102 time.sleep(delay) 1GHIktd
103 try: 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs
104 return op(*args, **kwargs) 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdELhFKXVWrs
105 except PermissionError as exc: 1qbOJZfeUGHPQIkacNtdrs
106 if not _is_windows_sharing_violation(exc): 1feUGHPQIkNtd
107 raise 1feUPQN
108 last_exc = exc 1GHIktd
109 if on_exhausted is not None: 1GHId
110 return on_exhausted(last_exc) 1GHI
111 assert last_exc is not None # at least one iteration ran and caught a PermissionError 1d
112 raise last_exc 1d
115def _replace_with_sharing_retry(tmp_path: Path, target: Path) -> bool:
116 """Atomic rename with Windows-specific retry on sharing/lock violations.
118 Returns True on success. Returns False only after the retry budget is
119 exhausted on Windows with a genuine sharing violation -- the caller then
120 treats the cache write as dropped. Any other ``PermissionError`` (ACLs,
121 read-only dir, unexpected winerror, or any POSIX failure) propagates.
123 ``ERROR_ACCESS_DENIED`` (winerror 5) is treated as a sharing violation
124 because Windows surfaces it when a file is held open without
125 ``FILE_SHARE_WRITE`` (Python's default for ``open(p, "wb")``) or while
126 a previous unlink is in ``PENDING_DELETE`` -- both are transient.
127 """
129 def _do_replace() -> bool: 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
130 os.replace(tmp_path, target) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
131 return True 1quvwxbnolpJfeyzSmARBkCDajcMigTNtdELhFXrs
133 return _with_sharing_retry(_do_replace, on_exhausted=lambda _exc: False) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
136def _stat_and_read_with_sharing_retry(path: Path) -> tuple[os.stat_result, bytes]:
137 """Snapshot stat and read bytes, retrying briefly on Windows transient
138 sharing-violation ``PermissionError``.
140 Reads race the rewriter's ``os.replace``: on Windows, the destination
141 can be momentarily inaccessible (winerror 5/32/33) while the rename
142 completes. Mirroring ``_replace_with_sharing_retry``'s budget keeps
143 transient contention from being mistaken for a real read failure.
145 Raises ``FileNotFoundError`` on miss or after exhausting the Windows
146 sharing-retry budget. Non-Windows ``PermissionError`` propagates.
148 On Windows, EACCES (errno 13) is treated as transient too: ``io.open``
149 sometimes surfaces a pending-delete or share-mode mismatch as bare
150 EACCES with no ``winerror`` attribute, indistinguishable here from
151 a true sharing violation. Real ACL problems on a path the cache owns
152 would surface consistently; the bounded retry budget keeps the cost
153 of treating them as transient negligible.
154 """
156 def _do_stat_and_read() -> tuple[os.stat_result, bytes]: 1quvwxbOJZyzmGHIABkCDactEFKVWrs
157 return path.stat(), path.read_bytes() 1quvwxbOJZyzmGHIABkCDactEFKVWrs
159 def _exhausted(last_exc): 1quvwxbOJZyzmGHIABkCDactEFKVWrs
160 raise FileNotFoundError(path) from last_exc
162 return _with_sharing_retry(_do_stat_and_read, on_exhausted=_exhausted) 1quvwxbOJZyzmGHIABkCDactEFKVWrs
165_UTIME_SUPPORTS_FD = os.utime in os.supports_fd
168def _touch_atime(path: Path, st_before: os.stat_result) -> None:
169 """Bump ``path``'s atime to "now", preserving its mtime, iff the
170 file's stat still matches ``st_before``.
172 Eviction sorts by ``st_atime`` so reads must reliably refresh atime
173 regardless of OS or filesystem default behavior:
175 * Linux ``relatime`` (default) only updates atime when the existing
176 atime is older than mtime, which would skew LRU once an entry has
177 been read once.
178 * NTFS on Windows Vista+ disables atime updates by default
179 (``NtfsDisableLastAccessUpdate``) and most modern installations
180 keep that off, so a bare read never bumps atime.
181 * ``noatime``-mounted filesystems disable updates entirely.
183 Calling ``os.utime`` with explicit times bypasses all of the above
184 and writes atime directly. The stat-guard is critical: if another
185 process ``os.replace``-d a fresh entry into ``path`` between the
186 read and this touch, blindly applying ``st_before.st_mtime_ns``
187 would roll the new entry's mtime back to the old value and confuse
188 the eviction stat-guard (which checks ``(ino, size, mtime_ns)``)
189 into deleting a freshly-committed file.
191 Where ``os.utime`` supports file descriptors (Linux, macOS), the
192 fstat-then-utime pair runs against the same open fd: even if another
193 writer replaces the path between our ``os.open`` and the ``fstat``,
194 the fd still refers to the file we opened, so the comparison and the
195 utime both target the same inode. This closes the residual TOCTOU
196 window that a path-based stat + path-based utime would have.
198 On Windows, ``os.utime`` is path-only; the fallback re-stats the
199 path and accepts a small TOCTOU window between the second stat and
200 the utime. That window is microseconds and the worst-case outcome
201 is the racing writer's mtime being rolled back by a few hundred
202 nanoseconds -- the eviction stat-guard would then refuse to evict
203 the slightly-stale entry, costing one cache miss (recompile) but
204 not a corrupt eviction.
206 Best-effort: any ``OSError`` (read-only mount, restrictive ACLs,
207 ...) is swallowed -- size enforcement still bounds the cache, but
208 eviction degrades toward FIFO.
209 """
210 new_atime_ns = time.time_ns() 1quvwxbyzmABkCDacMEFKVWrs
211 if _UTIME_SUPPORTS_FD: 1quvwxbyzmABkCDacMEFKVWrs
212 try: 1quvwxbyzmABkCDacMEFKVWrs
213 fd = os.open(path, os.O_RDONLY) 1quvwxbyzmABkCDacMEFKVWrs
214 except OSError:
215 return
216 try: 1quvwxbyzmABkCDacMEFKVWrs
217 try: 1quvwxbyzmABkCDacMEFKVWrs
218 st_now = os.fstat(fd) 1quvwxbyzmABkCDacMEFKVWrs
219 except OSError:
220 return
221 if _stat_key(st_now) != _stat_key(st_before): 1quvwxbyzmABkCDacMEFKVWrs
222 return 1M
223 with contextlib.suppress(OSError): 1quvwxbyzmABkCDacMEFKVWrs
224 os.utime(fd, ns=(new_atime_ns, st_before.st_mtime_ns)) 1quvwxbyzmABkCDacMEFKVWrs
225 finally:
226 os.close(fd) 1quvwxbyzmABkCDacMEFKVWrs
227 return 1quvwxbyzmABkCDacMEFKVWrs
229 # Path-based fallback (Windows). Best-effort -- residual TOCTOU window
230 # documented above.
231 try:
232 st_now = path.stat()
233 except OSError:
234 return
235 if _stat_key(st_now) != _stat_key(st_before):
236 return
237 with contextlib.suppress(OSError):
238 os.utime(path, ns=(new_atime_ns, st_before.st_mtime_ns))
241def _is_windows_sharing_violation(exc: BaseException) -> bool:
242 """Return True if ``exc`` is a Windows sharing/lock violation that
243 :func:`_unlink_with_sharing_retry` would have retried.
245 Used by best-effort callers to filter out the exhausted-retry case
246 while letting other ``PermissionError`` instances (POSIX ACL
247 issues, Windows non-sharing winerrors) propagate -- those are real
248 configuration problems, not transient contention.
250 The ``EACCES`` fallback only fires when ``winerror`` is absent: a
251 bare ``EACCES`` (no winerror attached) is the way ``io.open``
252 surfaces a pending-delete or share-mode mismatch on Windows. When
253 ``winerror`` IS set but is NOT in the sharing set, the OS told us
254 exactly what failed and it isn't a sharing violation -- treating it
255 as transient would silently swallow real errors like a corrupt
256 ACL.
257 """
258 if not _IS_WINDOWS: 1feUGHPQIkNtd4
259 return False 1fUN4
260 if not isinstance(exc, PermissionError): 1eGHPQIktd4
261 return False
262 winerror = getattr(exc, "winerror", None) 1eGHPQIktd4
263 if winerror in _SHARING_VIOLATION_WINERRORS: 1eGHPQIktd4
264 return True 1GHIktd4
265 return winerror is None and exc.errno == errno.EACCES 1ePQ4
268def _unlink_with_sharing_retry(path: Path) -> None:
269 """Unlink with Windows-specific retry on sharing/lock violations.
271 On Windows, ``Path.unlink`` raises ``PermissionError`` (winerror 5,
272 32, or 33; sometimes bare ``EACCES``) when another process holds
273 the file open without ``FILE_SHARE_DELETE``. Python's default
274 ``open(p, "rb")`` does not pass that flag, so a reader from another
275 process briefly blocks our unlink while it reads. Retry with the
276 same backoff budget as :func:`_replace_with_sharing_retry` so
277 transient contention is not turned into a propagated error.
279 Raises ``FileNotFoundError`` if the file is absent; the last
280 ``PermissionError`` if the Windows retry budget is exhausted; and
281 propagates any non-sharing ``PermissionError`` (or any non-Windows
282 ``PermissionError``) immediately. Best-effort callers should use
283 :func:`_is_windows_sharing_violation` to filter the exhausted-retry
284 case and re-raise any other ``PermissionError``.
285 """
286 _with_sharing_retry(path.unlink) 1bnolpJfeRacigNtdh
289def _prune_if_stat_unchanged(path: Path, st_before: os.stat_result) -> None:
290 """Unlink ``path`` iff its stat still matches ``st_before``.
292 Guards against a cross-process race: a reader that sees a corrupt
293 record can have it atomically replaced (via ``os.replace``) by a
294 writer before the reader decides to prune. Comparing
295 ``(ino, size, mtime_ns)`` before and after rules out that case --
296 any mismatch means someone else wrote a new file and we must not
297 delete their work. The residual TOCTOU window between stat and
298 unlink is narrow; worst case, a very-recently-written entry is
299 removed and the next read recompiles.
301 Best-effort: a Windows sharing violation that survives the retry
302 budget leaves the file in place. The caller is in an eviction or
303 cleanup pass, so re-trying on the next pass is the right outcome.
304 """
305 try: 1nolpR
306 st_now = path.stat() 1nolpR
307 except FileNotFoundError:
308 return
309 if _stat_key(st_before) != _stat_key(st_now): 1nolpR
310 return 1oR
311 try: 1nolpR
312 _unlink_with_sharing_retry(path) 1nolpR
313 except FileNotFoundError:
314 pass
315 except PermissionError as exc:
316 # Swallow only the exhausted-Windows-sharing case. POSIX ACL
317 # errors and Windows non-sharing winerrors are real configuration
318 # problems and must surface, not be silently lost during a prune.
319 if not _is_windows_sharing_violation(exc):
320 raise
323class FileStreamProgramCache(ProgramCacheResource):
324 """Persistent program cache backed by a directory of atomic files.
326 Designed for multi-process use: writes stage a temporary file and then
327 :func:`os.replace` it into place, so concurrent readers never observe a
328 partially-written entry. Each entry on disk is the raw compiled binary
329 -- cubin / PTX / LTO-IR -- with no header, framing, or pickle wrapper,
330 so the files are directly consumable by external NVIDIA tools
331 (``cuobjdump``, ``nvdisasm``, ``cuda-gdb``).
333 Eviction is by least-recently-*read* time: every successful read bumps
334 the entry's ``atime``, and the size enforcer evicts oldest atime
335 first.
337 .. note:: **Best-effort writes.**
339 On Windows, ``os.replace`` raises ``PermissionError`` (winerror
340 32 / 33) when another process holds the target file open. This
341 backend retries with bounded backoff (~185 ms) and, if still
342 failing, drops the cache write silently and returns success-shaped
343 control flow. The next call will see no entry and recompile. POSIX
344 and other ``PermissionError`` codes propagate.
346 .. note:: **Atomic for readers, not crash-durable.**
348 Each entry's temp file is ``fsync``-ed before ``os.replace``, but
349 the containing directory is **not** ``fsync``-ed. A host crash
350 between write and the next directory commit may lose recently
351 added entries; surviving entries remain consistent.
353 .. note:: **Cross-version sharing.**
355 The cache is safe to share across ``cuda.core`` patch releases:
356 every key produced by :func:`make_program_cache_key` encodes the
357 relevant backend/compiler/runtime fingerprints for its
358 compilation path (NVRTC entries pin the NVRTC version, NVVM
359 entries pin the libNVVM library and IR versions, PTX/linker
360 entries pin the chosen linker backend and its version -- and,
361 when the cuLink/driver backend is selected, the driver version
362 too; nvJitLink-backed PTX entries are deliberately
363 driver-version independent). Bumping ``_KEY_SCHEMA_VERSION``
364 (mixed into the digest by ``make_program_cache_key``) produces
365 new keys that don't collide with old entries: post-bump
366 lookups miss the old on-disk paths, and the orphaned files
367 are reaped on the next size-cap eviction pass. Entries are
368 stored verbatim as the compiled binary, so cross-patch sharing
369 only requires that the compiler-pinning surface above stays
370 stable -- there is no Python-pickle compatibility involved.
372 Parameters
373 ----------
374 path:
375 Directory that owns the cache. Created if missing. If omitted,
376 the OS-conventional user cache directory is used:
377 ``$XDG_CACHE_HOME/cuda-python/program-cache`` (Linux, defaulting
378 to ``~/.cache/cuda-python/program-cache``) or
379 ``%LOCALAPPDATA%\\cuda-python\\program-cache`` (Windows).
380 max_size_bytes:
381 Optional soft cap on total on-disk size. Enforced opportunistically
382 on writes; concurrent writers may briefly exceed it. Eviction is by
383 least-recently-read time (oldest ``st_atime`` first).
384 """
386 def __init__(
387 self,
388 path: str | os.PathLike | None = None,
389 *,
390 max_size_bytes: int | None = None,
391 ) -> None:
392 if max_size_bytes is not None and max_size_bytes <= 0: 1quvwxbOnolpJZfeyzSm2UGHPQIARB367kCDajc1MigTNtdE0LhFKXVWrs
393 raise ValueError("max_size_bytes must be positive or None (0 would evict every write)") 167
394 self._root = Path(path) if path is not None else _default_cache_dir() 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
395 self._entries = self._root / _ENTRIES_SUBDIR 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
396 self._tmp = self._root / _TMP_SUBDIR 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
397 self._max_size_bytes = max_size_bytes 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
398 self._root.mkdir(parents=True, exist_ok=True) 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
399 self._entries.mkdir(exist_ok=True) 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
400 self._tmp.mkdir(exist_ok=True) 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
401 # Opportunistic startup sweep of orphaned temp files left by any
402 # crashed writers. Age-based so concurrent in-flight writes from
403 # other processes are preserved.
404 self._sweep_stale_tmp_files() 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
405 # Incremental size tracker. Without it every ``__setitem__`` would
406 # walk ``entries/`` + ``tmp/`` to compute the total -- O(n) per
407 # write. With it: writes update the tracker by the net delta in O(1)
408 # and only walk on eviction (which already needs the scan to sort
409 # entries by atime). The tracker is seeded by one full scan at open
410 # time and refreshed on every eviction pass; cross-process drift
411 # (other writers/deleters) self-corrects the next time eviction
412 # fires. The lock guards mutations so multi-threaded writers in
413 # the same process don't interleave the read-modify-write on the
414 # int. Skipped entirely when ``max_size_bytes is None`` -- without
415 # a cap the tracker is dead weight.
416 self._size_lock = threading.Lock() 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
417 self._tracked_size_bytes = self._compute_total_size() if max_size_bytes is not None else 0 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
419 # -- key-to-path helpers -------------------------------------------------
421 def _path_for_key(self, key: object) -> Path:
422 k = _as_key_bytes(key) 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFKXVWrs
423 # Hash the key to a fixed-length identifier so arbitrary-length user
424 # keys never exceed per-component filename limits (typically 255 on
425 # ext4 / NTFS).
426 #
427 # FIPS: must use a FIPS-approved hash algorithm. FIPS-enforcing
428 # systems can disable non-approved hashlib algorithms (for example
429 # blake2b) at the OpenSSL level. See #2043.
430 #
431 # With a 256-bit SHA-256 digest, the cache relies on collision
432 # resistance for key uniqueness -- two distinct keys hashing to the
433 # same path is astronomically unlikely (~2^128 practical collision
434 # work).
435 digest = hashlib.sha256(k, usedforsecurity=False).hexdigest() 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFKXVWrs
436 return self._entries / digest[:2] / digest[2:] 1quvwxbOnolpJZfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFKXVWrs
438 # -- mapping API ---------------------------------------------------------
440 def __getitem__(self, key: object) -> bytes:
441 path = self._path_for_key(key) 1quvwxbOJZyzmGHIABkCDactEFKVWrs
442 try: 1quvwxbOJZyzmGHIABkCDactEFKVWrs
443 # The helper retries on Windows transient sharing-violation
444 # PermissionErrors so a racing rewriter doesn't turn a hit
445 # into a spurious propagated error.
446 st, data = _stat_and_read_with_sharing_retry(path) 1quvwxbOJZyzmGHIABkCDactEFKVWrs
447 except FileNotFoundError: 1qbOJZGHIactrs
448 raise KeyError(key) from None 1qbOJZGHIactrs
449 # Bump atime to "now" so eviction (which sorts by st_atime) treats
450 # this read as the entry's most recent use. Best-effort: filesystems
451 # mounted ``noatime`` or with restrictive ACLs may refuse, in which
452 # case the cap still bounds size but eviction degrades toward FIFO
453 # rather than true LRU.
454 _touch_atime(path, st) 1quvwxbyzmABkCDacEFKVWrs
455 return data 1quvwxbyzmABkCDacEFKVWrs
457 def __setitem__(self, key: object, value: bytes | bytearray | memoryview | ObjectCode) -> None:
458 data = _extract_bytes(value) 1quvwxbOnolpJfeyzSm2UGHPQIARB3kCDajcMigTNtdE0LhFXrs
459 target = self._path_for_key(key) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs
460 target.parent.mkdir(parents=True, exist_ok=True) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs
461 # Re-create ``tmp/`` if something deleted it after ``__init__``
462 # (operators clearing the cache by hand, ``rm -rf cache_dir/tmp``,
463 # another process's overzealous wipe). Cheap and idempotent;
464 # without it, every subsequent write would crash with
465 # FileNotFoundError even though we could trivially recover.
466 self._tmp.mkdir(parents=True, exist_ok=True) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs
468 # Stat the existing entry (if any) BEFORE the replace so we can
469 # update the tracker by the net delta. A racing writer that lands
470 # an ``os.replace`` between this stat and our own makes ``old_size``
471 # slightly off; the next ``_enforce_size_cap`` reconciles by
472 # re-scanning. Skipped when ``max_size_bytes is None`` (no tracker).
473 old_size = 0 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs
474 if self._max_size_bytes is not None: 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs
475 try: 1bfeajcigdLh
476 old_size = target.stat().st_size 1bfeajcigdLh
477 except FileNotFoundError: 1bfeajcigdLh
478 old_size = 0 1bfeajcigdLh
480 fd, tmp_name = tempfile.mkstemp(prefix="entry-", dir=self._tmp) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdE0LhFXrs
481 tmp_path = Path(tmp_name) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
482 try: 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
483 with os.fdopen(fd, "wb") as fh: 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
484 fh.write(data) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
485 fh.flush() 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
486 os.fsync(fh.fileno()) 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
487 # Retry os.replace under Windows sharing/lock violations; only
488 # give up (and drop the cache write) after a bounded backoff, so
489 # transient contention is not turned into a silent miss.
490 # Non-sharing PermissionErrors and all POSIX PermissionErrors
491 # propagate immediately (real config problem).
492 if not _replace_with_sharing_retry(tmp_path, target): 1quvwxbOnolpJfeyzSmUGHPQIARBkCDajcMigTNtdELhFXrs
493 with contextlib.suppress(FileNotFoundError): 1GHI
494 tmp_path.unlink() 1GHI
495 return 1GHI
496 except BaseException: 1OUPQ
497 with contextlib.suppress(FileNotFoundError): 1OUPQ
498 tmp_path.unlink() 1OUPQ
499 raise 1OUPQ
501 if self._max_size_bytes is None: 1quvwxbnolpJfeyzSmARBkCDajcMigTNtdELhFXrs
502 return 1quvwxnolpJyzSmARBkCDMTNtEFXrs
504 # O(1) tracker update. Only run the scan-heavy ``_enforce_size_cap``
505 # when this write actually pushes the running total above the cap.
506 new_size = len(data) 1bfeajcigdLh
507 with self._size_lock: 1bfeajcigdLh
508 self._tracked_size_bytes += new_size - old_size 1bfeajcigdLh
509 over_cap = self._tracked_size_bytes > self._max_size_bytes 1bfeajcigdLh
510 if over_cap: 1bfeajcigdLh
511 self._enforce_size_cap() 1bfeajcgdh
513 def __delitem__(self, key: object) -> None:
514 path = self._path_for_key(key) 1JiNt
515 # Stat before unlink so we can decrement the tracker by the actual
516 # on-disk size. Best-effort: if the file vanishes between stat and
517 # unlink (concurrent eviction), we treat the delete as a miss --
518 # matching the behaviour callers expect (KeyError) and leaving the
519 # tracker untouched (the racing eviction already accounted for it).
520 size = 0 1JiNt
521 if self._max_size_bytes is not None: 1JiNt
522 try: 1i
523 size = path.stat().st_size 1i
524 except FileNotFoundError:
525 raise KeyError(key) from None
526 try: 1JiNt
527 _unlink_with_sharing_retry(path) 1JiNt
528 except FileNotFoundError: 1JN
529 raise KeyError(key) from None 1J
530 if self._max_size_bytes is not None: 1Jit
531 with self._size_lock: 1i
532 # Clamp at zero. A racing ``_enforce_size_cap`` can re-seed the
533 # tracker between our stat and our subtract; if its scan ran
534 # AFTER we unlinked, its reseed value didn't include ``size``,
535 # so subtracting ``size`` again here would undercount reality
536 # by ``size``. Repeated under contention, an unclamped subtract
537 # walks the tracker negative -- and once negative, the
538 # ``tracker > cap`` check that gates ``_enforce_size_cap``
539 # never fires, so eviction dies silently and there is no
540 # self-healing path (the only reseed point is the function
541 # that no longer runs). Clamping leaves us at worst
542 # undercounting (the next reseed corrects it) instead of
543 # entering the permanently-broken negative state.
544 self._tracked_size_bytes = max(0, self._tracked_size_bytes - size) 1i
546 def __len__(self) -> int:
547 """Return the number of files currently in ``entries/``.
549 This is a count of on-disk files, not of keys reachable through
550 ``make_program_cache_key``. After a ``_KEY_SCHEMA_VERSION`` bump
551 old entries become unreachable by lookup but remain on disk
552 until eviction reaps them; ``__len__`` keeps counting them
553 until then. The same is true for entries written by callers
554 using arbitrary user keys -- the backend has no way to tell a
555 live entry from an orphan without knowing the caller's keying
556 scheme.
557 """
558 # ``_iter_entry_paths`` already filters with ``entry.is_file()``,
559 # so don't stat each path a second time here.
560 return sum(1 for _ in self._iter_entry_paths()) 1pZSmT
562 def clear(self) -> None:
563 # Snapshot stat alongside path so we can refuse to unlink an entry
564 # that was concurrently replaced by another process between the
565 # snapshot scan and the unlink. Same stat-guard contract as
566 # ``_prune_if_stat_unchanged`` and ``_enforce_size_cap``.
567 snapshot = [] 1nolp
568 for path in self._iter_entry_paths(): 1nolp
569 try: 1nolp
570 snapshot.append((path, path.stat())) 1nolp
571 except FileNotFoundError:
572 continue
573 for path, st_before in snapshot: 1nolp
574 _prune_if_stat_unchanged(path, st_before) 1nolp
575 # Sweep ONLY stale temp files. Deleting a young temp would race with
576 # another process between ``mkstemp`` and ``os.replace`` and turn its
577 # write into ``FileNotFoundError`` instead of a successful commit.
578 self._sweep_stale_tmp_files() 1nolp
579 # Remove empty subdirs (best-effort; concurrent writers may re-create).
580 if self._entries.exists(): 1nolp
581 for sub in sorted(self._entries.iterdir(), reverse=True): 1nolp
582 if sub.is_dir(): 1nolp
583 with contextlib.suppress(OSError): 1nolp
584 sub.rmdir() 1nolp
585 # The directory is now (almost) empty -- but a concurrent writer may
586 # have landed a fresh entry between the snapshot and the unlink, and
587 # young temp files were intentionally preserved. Re-derive the
588 # tracker from the post-clear state instead of zeroing blindly.
589 if self._max_size_bytes is not None: 1nolp
590 actual = self._compute_total_size()
591 with self._size_lock:
592 self._tracked_size_bytes = actual
594 # -- internals -----------------------------------------------------------
596 def _iter_entry_paths(self) -> Iterable[Path]:
597 # ``os.scandir`` returns ``DirEntry`` objects whose ``is_dir`` /
598 # ``is_file`` methods consult the cached dirent type from the
599 # ``readdir`` result on filesystems that report it (ext4, NTFS, ...),
600 # avoiding a per-entry ``stat`` syscall. ``Path.iterdir`` also wraps
601 # ``scandir`` but discards the cached type, forcing a separate
602 # ``stat`` for every ``Path.is_dir`` / ``Path.is_file``. The ``with``
603 # blocks release the underlying directory handle deterministically
604 # when the consumer stops early -- otherwise a leaked handle blocks
605 # deletes/renames on Windows until GC.
606 try: 1bnolpZfeSmajcigTdLhK
607 with os.scandir(self._entries) as outer: 1bnolpZfeSmajcigTdLhK
608 for sub in outer: 1bnolpZfeSmajcigTdLhK
609 if not sub.is_dir(follow_symlinks=False): 1bnolpfeSmajcigTdhK
610 continue
611 try: 1bnolpfeSmajcigTdhK
612 with os.scandir(sub.path) as inner: 1bnolpfeSmajcigTdhK
613 yield from (Path(entry.path) for entry in inner if entry.is_file(follow_symlinks=False)) 1bnolpfeSmajcigTdhK
614 except FileNotFoundError:
615 continue
616 except FileNotFoundError:
617 return
619 def _compute_total_size(self) -> int:
620 """Walk ``entries/`` + ``tmp/`` and return the on-disk byte total.
622 Used to seed the tracker at open time and to refresh it after every
623 eviction pass. Best-effort: files that vanish under us during the
624 walk (concurrent eviction by this or another process) are skipped.
625 Tracked total may briefly differ from this scan's result under
626 cross-process contention; the next eviction will reconcile.
627 """
628 total = 0 1bfeajcigdLhK
629 for path in self._iter_entry_paths(): 1bfeajcigdLhK
630 try: 1bajcK
631 total += path.stat().st_size 1bajcK
632 except FileNotFoundError:
633 continue
634 return total + self._sum_tmp_sizes() 1bfeajcigdLhK
636 def _iter_tmp_entries(self) -> Iterable[os.DirEntry]:
637 # Mirror ``_iter_entry_paths``: scandir + cached d_type for the
638 # file/dir filter + deterministic handle close on early exit.
639 # Yields ``DirEntry`` (not Path) so callers can use ``entry.stat``
640 # / ``entry.path`` directly without an extra wrap.
641 try: 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
642 with os.scandir(self._tmp) as it: 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
643 yield from (entry for entry in it if entry.is_file(follow_symlinks=False)) 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
644 except FileNotFoundError:
645 return
647 def _sum_tmp_sizes(self) -> int:
648 """Sum sizes of every file in ``tmp/``, skipping vanished entries.
650 Both ``_compute_total_size`` (open-time seed) and
651 ``_enforce_size_cap`` (eviction reconciliation) need this --
652 temp files occupy disk too, so undercounting them would let
653 bursts of in-flight writes silently exceed ``max_size_bytes``.
654 """
655 total = 0 1bfeajcigdLhK
656 for entry in self._iter_tmp_entries(): 1bfeajcigdLhK
657 try: 1a
658 total += entry.stat(follow_symlinks=False).st_size 1a
659 except FileNotFoundError:
660 continue
661 return total 1bfeajcigdLhK
663 def _sweep_stale_tmp_files(self) -> None:
664 """Remove temp files left behind by crashed writers.
666 Age threshold is conservative (``_TMP_STALE_AGE_SECONDS``) so an
667 in-flight write from another process is not interrupted. Best
668 effort: a missing file or a permission failure is ignored.
669 """
670 cutoff = time.time() - _TMP_STALE_AGE_SECONDS 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
671 for entry in self._iter_tmp_entries(): 1quvwxbOnolpJZfeyzSm2UGHPQIARB3kCDajc1MigTNtdE0LhFKXVWrs
672 try: 1nla1
673 if entry.stat(follow_symlinks=False).st_mtime < cutoff: 1nla1
674 os.unlink(entry.path) 1l1
675 except (FileNotFoundError, PermissionError):
676 continue
678 def _enforce_size_cap(self) -> None:
679 if self._max_size_bytes is None: 1bfeajcigdh
680 return
681 # Sweep stale temp files first so a long-dead writer's leftovers
682 # don't drag the apparent size up and force needless eviction.
683 self._sweep_stale_tmp_files() 1bfeajcigdh
684 entries = [] 1bfeajcigdh
685 total = 0 1bfeajcigdh
686 # Count both committed entries AND surviving temp files: temp files
687 # occupy disk too, even if they're young. Without this the soft cap
688 # silently undercounts in-flight writes.
689 #
690 # Trade-off under burst concurrency: many young temp files (each
691 # below the stale-sweep threshold) can push ``total`` above
692 # ``max_size_bytes`` with only committed entries left to evict.
693 # That can over-evict committed entries during the burst; once
694 # the burst subsides and the temps land via ``os.replace`` (or
695 # are reaped by a later sweep), the cap re-stabilises. This is
696 # consistent with the documented soft-cap contract -- callers
697 # that need a hard bound should leave the cap None and prune
698 # externally.
699 for path in self._iter_entry_paths(): 1bfeajcigdh
700 try: 1bfeajcigdh
701 st = path.stat() 1bfeajcigdh
702 except FileNotFoundError:
703 continue
704 # Carry the full stat so eviction can guard against a concurrent
705 # os.replace that swapped a fresh entry into this path between
706 # snapshot and unlink. Eviction below sorts by ``st_atime`` so
707 # entries that callers actually read recently survive
708 # write-only churn (true LRU instead of FIFO).
709 entries.append((st.st_atime, st.st_size, path, st)) 1bfeajcigdh
710 total += st.st_size 1bfeajcigdh
711 total += self._sum_tmp_sizes() 1bfeajcigdh
712 if total <= self._max_size_bytes: 1bfeajcigdh
713 # Re-seed the tracker from the scan: catches drift from
714 # cross-process writers/deleters that the per-write delta
715 # accounting wouldn't have observed. Reaching here means the
716 # tracker was over-cap but the disk truth is under-cap, so
717 # this assignment is the cheapest reconciliation point we get.
718 with self._size_lock: 1ji
719 self._tracked_size_bytes = total 1ji
720 return 1ji
721 entries.sort(key=lambda e: e[0]) # oldest atime first 1bfeacgdh
722 for _atime, size, path, st_before in entries: 1bfeacgdh
723 if total <= self._max_size_bytes: 1bfeacgdh
724 break 1bacgh
725 # _prune_if_stat_unchanged refuses if a writer replaced the file
726 # between snapshot and now, so eviction can't silently delete a
727 # freshly-committed entry from another process.
728 try: 1bfeacgdh
729 stat_now = path.stat() 1bfeacgdh
730 except FileNotFoundError:
731 total -= size
732 continue
733 if _stat_key(stat_now) != _stat_key(st_before): 1bfeacgdh
734 # File was replaced -- don't unlink, but update ``total`` to
735 # reflect the replacement's actual size or the cap check
736 # below could declare us done while still over the limit.
737 total += stat_now.st_size - size
738 continue
739 # Tolerate Windows sharing violations during eviction: another
740 # process may briefly hold the file open for a read. Skip this
741 # entry; a later eviction pass will retry. Same outcome as if
742 # the stat-guard above had triggered. Other PermissionErrors
743 # (POSIX ACL, Windows non-sharing winerrors) are real config
744 # problems -- surface them rather than silently exceed the cap.
745 try: 1bfeacgdh
746 _unlink_with_sharing_retry(path) 1bfeacgdh
747 total -= size 1bacgh
748 except FileNotFoundError: 1fed
749 pass
750 except PermissionError as exc: 1fed
751 if not _is_windows_sharing_violation(exc): 1fed
752 raise 1fe
753 # Reconcile: after the eviction pass, ``total`` reflects what we
754 # believe the disk now holds. Re-seed the tracker so the next write
755 # accumulates from a fresh baseline.
756 with self._size_lock: 1bacgdh
757 self._tracked_size_bytes = total 1bacgdh