""" In-memory TTL cache. Keys are strings (typically image hashes + provider name + capability). Values are arbitrary JSON-serializable objects. The cache tracks hit/miss counters which the metrics subsystem reads. """ from __future__ import annotations import threading import time from collections import OrderedDict from typing import Any, Optional class Cache: """LRU + TTL cache. Thread-safe.""" def __init__(self, ttl_seconds: int = 3600, max_entries: int = 1000) -> None: self._ttl = ttl_seconds self._max = max_entries self._data: OrderedDict[str, tuple[float, Any]] = OrderedDict() self._lock = threading.RLock() self._hits = 0 self._misses = 0 self._evictions = 0 # ------------------------------------------------------------------ # # Core ops # ------------------------------------------------------------------ # def get(self, key: str) -> Optional[Any]: with self._lock: entry = self._data.get(key) if entry is None: self._misses += 1 return None expires_at, value = entry if time.time() > expires_at: self._data.pop(key, None) self._misses += 1 return None self._data.move_to_end(key) self._hits += 1 return value def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None: with self._lock: expires_at = time.time() + (ttl if ttl is not None else self._ttl) self._data[key] = (expires_at, value) self._data.move_to_end(key) while len(self._data) > self._max: self._data.popitem(last=False) self._evictions += 1 def invalidate(self, key: str) -> bool: with self._lock: return self._data.pop(key, None) is not None def clear(self) -> int: with self._lock: n = len(self._data) self._data.clear() return n # ------------------------------------------------------------------ # # Stats (consumed by metrics + /cache endpoint) # ------------------------------------------------------------------ # def stats(self) -> dict: with self._lock: total = self._hits + self._misses return { "entries": len(self._data), "max_entries": self._max, "ttl_seconds": self._ttl, "hits": self._hits, "misses": self._misses, "hit_ratio": round(self._hits / total, 4) if total else 0.0, "evictions": self._evictions, }