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