Source code for pycsamt.forward.maxwell.cache

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Content-addressed cache for canonical Maxwell forward results.

Entries are keyed by ``MaxwellProblem.problem_hash``. Each result archive has
a SHA-256 sidecar, is written by atomic replacement, and is validated against
the requested problem when read. Lock files coordinate independent workers.
"""

from __future__ import annotations

import hashlib
import os
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from .backends import MaxwellBackend
from .contracts import ForwardResult, MaxwellProblem
from .contracts_tri import TriProblem

__all__ = [
    "CacheCorruptionError",
    "CacheLockTimeoutError",
    "CacheEntry",
    "CacheStatistics",
    "MaxwellResultCache",
]


[docs] class CacheCorruptionError(RuntimeError): """Indicate that a cached archive failed integrity validation. Examples -------- >>> isinstance(CacheCorruptionError("bad checksum"), RuntimeError) True """
[docs] class CacheLockTimeoutError(TimeoutError): """Indicate that a cache-key lock could not be acquired in time. Examples -------- >>> isinstance(CacheLockTimeoutError("busy"), TimeoutError) True """
def _cache_key(value: str) -> str: key = str(value).strip().lower() hexadecimal = set("0123456789abcdef") if len(key) != 64 or any(char not in hexadecimal for char in key): raise ValueError( "cache key must be a 64-character hexadecimal SHA-256 digest." ) return key def _file_digest(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() class _KeyLock: def __init__( self, path: Path, timeout_s: float, poll_interval_s: float, stale_after_s: float, ) -> None: self.path = path self.timeout_s = timeout_s self.poll_interval_s = poll_interval_s self.stale_after_s = stale_after_s self._owned = False def __enter__(self) -> _KeyLock: deadline = time.monotonic() + self.timeout_s payload = f"pid={os.getpid()} created={time.time():.6f}\n".encode( "ascii" ) while True: try: descriptor = os.open( str(self.path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, ) except FileExistsError: self._remove_stale_lock() if time.monotonic() >= deadline: raise CacheLockTimeoutError( f"timed out waiting for cache lock {self.path.name!r}." ) from None time.sleep(self.poll_interval_s) continue try: os.write(descriptor, payload) finally: os.close(descriptor) self._owned = True return self def __exit__(self, *args: Any) -> None: if self._owned: try: self.path.unlink() except FileNotFoundError: pass self._owned = False def _remove_stale_lock(self) -> None: try: age = time.time() - self.path.stat().st_mtime except FileNotFoundError: return if age <= self.stale_after_s: return try: self.path.unlink() except FileNotFoundError: pass
[docs] @dataclass(frozen=True) class CacheEntry: """Describe one complete cache entry. Parameters ---------- key : str Problem SHA-256 digest. archive_path : pathlib.Path Result archive location. size_bytes : int Combined archive and checksum size. modified_time_s : float Archive modification time as Unix seconds. Examples -------- >>> entry = CacheEntry("0" * 64, Path("result.npz"), 10, 1.0) >>> entry.size_bytes 10 """ key: str archive_path: Path size_bytes: int modified_time_s: float def __post_init__(self) -> None: key = _cache_key(self.key) path = Path(self.archive_path) if not isinstance(self.size_bytes, int) or self.size_bytes < 0: raise ValueError("size_bytes must be a non-negative integer.") modified = float(self.modified_time_s) if not modified >= 0 or not modified < float("inf"): raise ValueError( "modified_time_s must be finite and non-negative." ) object.__setattr__(self, "key", key) object.__setattr__(self, "archive_path", path) object.__setattr__(self, "modified_time_s", modified)
[docs] @property def checksum_path(self) -> Path: """Return the SHA-256 sidecar path. Returns ------- pathlib.Path Archive path with ``.sha256`` appended. Examples -------- >>> entry = CacheEntry("0" * 64, Path("r.npz"), 0, 0) >>> entry.checksum_path.name 'r.npz.sha256' """ suffix = self.archive_path.suffix + ".sha256" return self.archive_path.with_suffix(suffix)
[docs] def to_dict(self) -> dict[str, Any]: """Return JSON-compatible entry metadata. Returns ------- dict Key, path, size, and modification time. Examples -------- >>> entry = CacheEntry("0" * 64, Path("r.npz"), 5, 2) >>> entry.to_dict()["size_bytes"] 5 """ return { "key": self.key, "archive_path": str(self.archive_path), "size_bytes": self.size_bytes, "modified_time_s": self.modified_time_s, }
[docs] @dataclass(frozen=True) class CacheStatistics: """Summarize the current on-disk cache state. Parameters ---------- entry_count, total_bytes, orphan_count, corrupt_count : int Counts and storage for normal, incomplete, and quarantined files. Examples -------- >>> CacheStatistics(2, 100, 0, 1).entry_count 2 """ entry_count: int total_bytes: int orphan_count: int corrupt_count: int def __post_init__(self) -> None: names = ( "entry_count", "total_bytes", "orphan_count", "corrupt_count", ) for name in names: value = getattr(self, name) if not isinstance(value, int) or value < 0: raise ValueError(f"{name} must be a non-negative integer.")
[docs] def to_dict(self) -> dict[str, int]: """Return JSON-compatible cache statistics. Returns ------- dict Entry, byte, orphan, and corruption counts. Examples -------- >>> CacheStatistics(1, 20, 0, 0).to_dict()["total_bytes"] 20 """ return { "entry_count": self.entry_count, "total_bytes": self.total_bytes, "orphan_count": self.orphan_count, "corrupt_count": self.corrupt_count, }
[docs] class MaxwellResultCache: """Manage a validated, content-addressed result cache. Parameters ---------- root : str or pathlib.Path Dedicated cache directory. It is created when absent. lock_timeout_s : float, default=300 Maximum wait for another worker holding the same problem key. poll_interval_s : float, default=0.05 Delay between lock acquisition attempts. stale_lock_s : float, default=3600 Age after which an abandoned lock can be removed. quarantine_corrupt : bool, default=True Move corrupt files under ``root/corrupt``. When false, reads raise :class:`CacheCorruptionError` and leave the entry untouched. Examples -------- >>> from tempfile import TemporaryDirectory >>> with TemporaryDirectory() as directory: ... cache = MaxwellResultCache(directory) ... cache.statistics().entry_count 0 """ def __init__( self, root: str | Path, *, lock_timeout_s: float = 300.0, poll_interval_s: float = 0.05, stale_lock_s: float = 3600.0, quarantine_corrupt: bool = True, ) -> None: self._root = Path(root).expanduser().resolve() self._lock_timeout_s = self._positive_time( lock_timeout_s, "lock_timeout_s", ) self._poll_interval_s = self._positive_time( poll_interval_s, "poll_interval_s", ) self._stale_lock_s = self._positive_time( stale_lock_s, "stale_lock_s", ) self._quarantine_corrupt = bool(quarantine_corrupt) self._entries = self._root / "entries" self._locks = self._root / "locks" self._corrupt = self._root / "corrupt" self._entries.mkdir(parents=True, exist_ok=True) self._locks.mkdir(parents=True, exist_ok=True) self._corrupt.mkdir(parents=True, exist_ok=True) @staticmethod def _positive_time(value: float, name: str) -> float: result = float(value) if not result > 0 or not result < float("inf"): raise ValueError(f"{name} must be positive and finite.") return result
[docs] @property def root(self) -> Path: """Return the resolved cache root. Returns ------- pathlib.Path Dedicated cache directory. Examples -------- The returned path is always absolute. """ return self._root
[docs] def contains(self, problem: MaxwellProblem) -> bool: """Return whether a complete entry exists for a problem. Parameters ---------- problem : MaxwellProblem Problem whose content hash identifies the entry. Returns ------- bool True when both archive and checksum sidecar exist. Examples -------- A newly created cache contains no problems. """ self._require_problem(problem) archive, checksum = self._paths(problem.problem_hash) return archive.is_file() and checksum.is_file()
[docs] def get(self, problem: MaxwellProblem) -> ForwardResult | None: """Load and validate a cached result. Parameters ---------- problem : MaxwellProblem Exact problem expected by the caller. Returns ------- ForwardResult or None Valid result, or None when no complete entry exists. Corruption is quarantined and treated as a miss when configured. Raises ------ CacheCorruptionError If validation fails and quarantine is disabled. Examples -------- Cache misses return None rather than raising ``KeyError``. """ self._require_problem(problem) with self._lock(problem.problem_hash): return self._read_or_quarantine(problem)
def _read_or_quarantine( self, problem: MaxwellProblem, ) -> ForwardResult | None: try: return self._read(problem) except CacheCorruptionError: if not self._quarantine_corrupt: raise self._quarantine(problem.problem_hash) return None
[docs] def put( self, problem: MaxwellProblem, result: ForwardResult, *, overwrite: bool = False, ) -> CacheEntry: """Validate and atomically store one result. Parameters ---------- problem : MaxwellProblem Exact simulation input. result : ForwardResult Canonical result matching ``problem``. overwrite : bool, default=False Replace an existing complete entry. Otherwise the validated existing entry is retained. Returns ------- CacheEntry Metadata for the stored or retained entry. Examples -------- Invalid problem/result pairs are rejected before writing an archive. """ self._require_problem(problem) if not isinstance(result, ForwardResult): raise TypeError("result must be a ForwardResult.") result.validate_against(problem) with self._lock(problem.problem_hash): if not overwrite: try: existing = self._read(problem) except CacheCorruptionError: self._quarantine(problem.problem_hash) existing = None if existing is not None: return self.entry(problem.problem_hash) self._write(problem.problem_hash, result) return self.entry(problem.problem_hash)
[docs] def get_or_solve( self, problem: MaxwellProblem, backend: MaxwellBackend, ) -> ForwardResult: """Return a hit or solve and cache one problem under a key lock. Parameters ---------- problem : MaxwellProblem Simulation input and cache identity. backend : MaxwellBackend Conforming backend used only after a cache miss. Returns ------- ForwardResult Valid cached or newly computed result. Notes ----- The second read after locking prevents duplicate concurrent work. Examples -------- Backend invocation is skipped whenever a validated hit exists. """ self._require_problem(problem) if not isinstance(backend, MaxwellBackend): raise TypeError("backend must implement MaxwellBackend.") hit = self.get(problem) if hit is not None: return hit with self._lock(problem.problem_hash): hit = self._read_or_quarantine(problem) if hit is not None: return hit result = backend.solve(problem) if not isinstance(result, ForwardResult): raise TypeError("backend must return a ForwardResult.") result.validate_against(problem) self._write(problem.problem_hash, result) return result
[docs] def entry(self, key: str) -> CacheEntry: """Return filesystem metadata for a complete entry. Parameters ---------- key : str Problem SHA-256 digest. Returns ------- CacheEntry Entry paths, size, and modification time. Raises ------ KeyError If archive or checksum sidecar is missing. Examples -------- This method does not deserialize the result archive. """ normalized = _cache_key(key) archive, checksum = self._paths(normalized) if not archive.is_file() or not checksum.is_file(): raise KeyError( f"cache entry {normalized!r} is incomplete or missing." ) size = archive.stat().st_size + checksum.stat().st_size return CacheEntry( normalized, archive, size, archive.stat().st_mtime, )
[docs] def entries(self) -> tuple[CacheEntry, ...]: """Return complete entries sorted by problem key. Returns ------- tuple of CacheEntry Stable snapshot of complete cache entries. Examples -------- An empty cache returns an empty tuple. """ found = [] for archive in self._entries.glob("*/*.npz"): try: found.append(self.entry(archive.stem)) except (KeyError, ValueError): continue return tuple(sorted(found, key=lambda value: value.key))
[docs] def remove(self, problem: MaxwellProblem) -> bool: """Remove one problem entry under its key lock. Parameters ---------- problem : MaxwellProblem Exact problem identifying the entry. Returns ------- bool True when at least one entry file was removed. Examples -------- Removing an absent problem is a no-op returning False. """ self._require_problem(problem) removed = False with self._lock(problem.problem_hash): for path in self._paths(problem.problem_hash): try: path.unlink() removed = True except FileNotFoundError: pass return removed
[docs] def prune(self, maximum_bytes: int) -> tuple[CacheEntry, ...]: """Remove oldest entries until storage is within a byte budget. Parameters ---------- maximum_bytes : int Non-negative archive and checksum budget. Returns ------- tuple of CacheEntry Entries removed, oldest first. Examples -------- ``prune(0)`` removes every complete entry but leaves infrastructure. """ if not isinstance(maximum_bytes, int) or maximum_bytes < 0: raise ValueError("maximum_bytes must be a non-negative integer.") entries = sorted( self.entries(), key=lambda value: (value.modified_time_s, value.key), ) total = sum(value.size_bytes for value in entries) removed = [] for value in entries: if total <= maximum_bytes: break archive, checksum = self._paths(value.key) with self._lock(value.key): for path in (archive, checksum): try: path.unlink() except FileNotFoundError: pass removed.append(value) total -= value.size_bytes return tuple(removed)
[docs] def statistics(self) -> CacheStatistics: """Inspect complete, orphaned, and quarantined cache files. Returns ------- CacheStatistics Current cache counts and complete-entry storage. Examples -------- Statistics inspect metadata without deserializing archives. """ entries = self.entries() archives = set(self._entries.glob("*/*.npz")) checksums = set(self._entries.glob("*/*.npz.sha256")) complete_archives = {value.archive_path for value in entries} complete_checksums = {value.checksum_path for value in entries} orphan_count = len(archives - complete_archives) orphan_count += len(checksums - complete_checksums) corrupt_count = sum(1 for path in self._corrupt.iterdir()) return CacheStatistics( len(entries), sum(value.size_bytes for value in entries), orphan_count, corrupt_count, )
@staticmethod def _require_problem(problem: MaxwellProblem) -> None: if not isinstance(problem, (MaxwellProblem, TriProblem)): raise TypeError("problem must be a MaxwellProblem or TriProblem.") def _paths(self, key: str) -> tuple[Path, Path]: normalized = _cache_key(key) directory = self._entries / normalized[:2] archive = directory / f"{normalized}.npz" checksum = directory / f"{normalized}.npz.sha256" return archive, checksum def _lock(self, key: str) -> _KeyLock: normalized = _cache_key(key) return _KeyLock( self._locks / f"{normalized}.lock", self._lock_timeout_s, self._poll_interval_s, self._stale_lock_s, ) def _read(self, problem: MaxwellProblem) -> ForwardResult | None: archive, checksum = self._paths(problem.problem_hash) if not archive.exists() and not checksum.exists(): return None if not archive.is_file() or not checksum.is_file(): raise CacheCorruptionError("cache entry is incomplete.") try: expected = checksum.read_text(encoding="ascii").strip().lower() except OSError as exc: raise CacheCorruptionError( "cannot read checksum sidecar." ) from exc try: expected = _cache_key(expected) except ValueError as exc: raise CacheCorruptionError("checksum sidecar is invalid.") from exc if _file_digest(archive) != expected: raise CacheCorruptionError("result archive checksum mismatch.") try: result = ForwardResult.from_npz(archive) result.validate_against(problem) except Exception as exc: raise CacheCorruptionError( "result archive failed contract validation." ) from exc return result def _write(self, key: str, result: ForwardResult) -> None: archive, checksum = self._paths(key) archive.parent.mkdir(parents=True, exist_ok=True) token = uuid.uuid4().hex temporary_archive = archive.parent / (f".{archive.name}.{token}.npz") temporary_checksum = archive.parent / (f".{checksum.name}.{token}.tmp") try: result.to_npz(temporary_archive) digest = _file_digest(temporary_archive) temporary_checksum.write_text( digest + "\n", encoding="ascii", ) os.replace(temporary_archive, archive) os.replace(temporary_checksum, checksum) finally: for path in (temporary_archive, temporary_checksum): try: path.unlink() except FileNotFoundError: pass def _quarantine(self, key: str) -> None: archive, checksum = self._paths(key) token = uuid.uuid4().hex for path in (archive, checksum): if not path.exists(): continue target = self._corrupt / f"{path.name}.{token}.corrupt" try: os.replace(path, target) except FileNotFoundError: pass