caching.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. # -*- coding: utf-8 -*-
  2. """
  3. Caching-Decorators mit TTL und LRU-Unterstützung.
  4. Bietet flexible Caching-Optionen für Funktionsergebnisse mit
  5. Zeit- und Größenlimits.
  6. """
  7. from __future__ import annotations
  8. import asyncio
  9. import functools
  10. import hashlib
  11. import json
  12. import threading
  13. import time
  14. from collections import OrderedDict
  15. from dataclasses import dataclass, field
  16. from datetime import datetime
  17. from typing import Any, Callable, Generic, Hashable, ParamSpec, TypeVar
  18. P = ParamSpec("P")
  19. R = TypeVar("R")
  20. @dataclass
  21. class CacheEntry(Generic[R]):
  22. """
  23. Ein Eintrag im Cache.
  24. Speichert den Wert zusammen mit Metadaten wie Erstellungszeit
  25. und Ablaufzeit.
  26. """
  27. value: R
  28. created_at: float = field(default_factory=time.time)
  29. expires_at: float | None = None
  30. hit_count: int = 0
  31. last_accessed: float = field(default_factory=time.time)
  32. def is_expired(self) -> bool:
  33. """Prüft, ob der Eintrag abgelaufen ist."""
  34. if self.expires_at is None:
  35. return False
  36. return time.time() > self.expires_at
  37. def access(self) -> R:
  38. """Markiert den Eintrag als zugegriffen und gibt den Wert zurück."""
  39. self.hit_count += 1
  40. self.last_accessed = time.time()
  41. return self.value
  42. @dataclass
  43. class CacheStats:
  44. """
  45. Statistiken für einen Cache.
  46. Bietet Einblick in die Cache-Nutzung und Effektivität.
  47. """
  48. hits: int = 0
  49. misses: int = 0
  50. size: int = 0
  51. max_size: int = 0
  52. evictions: int = 0
  53. expired: int = 0
  54. created_at: datetime = field(default_factory=datetime.now)
  55. @property
  56. def total_requests(self) -> int:
  57. """Gesamtzahl der Anfragen."""
  58. return self.hits + self.misses
  59. @property
  60. def hit_rate(self) -> float:
  61. """Cache-Trefferquote in Prozent."""
  62. if self.total_requests == 0:
  63. return 0.0
  64. return (self.hits / self.total_requests) * 100
  65. @property
  66. def miss_rate(self) -> float:
  67. """Cache-Fehlschlagquote in Prozent."""
  68. return 100.0 - self.hit_rate
  69. def record_hit(self) -> None:
  70. """Zeichnet einen Cache-Hit auf."""
  71. self.hits += 1
  72. def record_miss(self) -> None:
  73. """Zeichnet einen Cache-Miss auf."""
  74. self.misses += 1
  75. def record_eviction(self) -> None:
  76. """Zeichnet eine Eviction auf."""
  77. self.evictions += 1
  78. def record_expiration(self) -> None:
  79. """Zeichnet eine Expiration auf."""
  80. self.expired += 1
  81. def reset(self) -> None:
  82. """Setzt alle Statistiken zurück."""
  83. self.hits = 0
  84. self.misses = 0
  85. self.evictions = 0
  86. self.expired = 0
  87. def to_dict(self) -> dict[str, Any]:
  88. """Konvertiert die Statistiken in ein Dictionary."""
  89. return {
  90. "hits": self.hits,
  91. "misses": self.misses,
  92. "total_requests": self.total_requests,
  93. "hit_rate": self.hit_rate,
  94. "size": self.size,
  95. "max_size": self.max_size,
  96. "evictions": self.evictions,
  97. "expired": self.expired,
  98. }
  99. class LRUCache(Generic[R]):
  100. """
  101. Thread-sicherer LRU-Cache mit TTL-Unterstützung.
  102. Implementiert Least-Recently-Used Eviction-Strategie mit
  103. optionaler Zeit-basierter Expiration.
  104. """
  105. def __init__(
  106. self,
  107. max_size: int = 128,
  108. ttl: float | None = None,
  109. ) -> None:
  110. """
  111. Initialisiert den Cache.
  112. Args:
  113. max_size: Maximale Anzahl von Einträgen.
  114. ttl: Time-to-Live in Sekunden (None = kein Ablauf).
  115. """
  116. self._cache: OrderedDict[Hashable, CacheEntry[R]] = OrderedDict()
  117. self._max_size = max_size
  118. self._ttl = ttl
  119. self._lock = threading.RLock()
  120. self._stats = CacheStats(max_size=max_size)
  121. @property
  122. def stats(self) -> CacheStats:
  123. """Gibt die Cache-Statistiken zurück."""
  124. with self._lock:
  125. self._stats.size = len(self._cache)
  126. return self._stats
  127. def _make_key(self, args: tuple, kwargs: dict) -> str:
  128. """
  129. Erstellt einen Cache-Schlüssel aus Funktionsargumenten.
  130. Args:
  131. args: Positionsargumente.
  132. kwargs: Schlüsselwortargumente.
  133. Returns:
  134. Hash-String als Schlüssel.
  135. """
  136. key_data = {
  137. "args": args,
  138. "kwargs": sorted(kwargs.items()),
  139. }
  140. try:
  141. key_str = json.dumps(key_data, sort_keys=True, default=str)
  142. except (TypeError, ValueError):
  143. # Fallback für nicht-serialisierbare Objekte
  144. key_str = str(key_data)
  145. return hashlib.md5(key_str.encode()).hexdigest()
  146. def get(self, key: Hashable) -> tuple[bool, R | None]:
  147. """
  148. Holt einen Wert aus dem Cache.
  149. Args:
  150. key: Cache-Schlüssel.
  151. Returns:
  152. Tupel (gefunden, Wert).
  153. """
  154. with self._lock:
  155. if key not in self._cache:
  156. self._stats.record_miss()
  157. return False, None
  158. entry = self._cache[key]
  159. # Expiration prüfen
  160. if entry.is_expired():
  161. del self._cache[key]
  162. self._stats.record_expiration()
  163. self._stats.record_miss()
  164. return False, None
  165. # An das Ende verschieben (LRU)
  166. self._cache.move_to_end(key)
  167. self._stats.record_hit()
  168. return True, entry.access()
  169. def set(self, key: Hashable, value: R) -> None:
  170. """
  171. Speichert einen Wert im Cache.
  172. Args:
  173. key: Cache-Schlüssel.
  174. value: Zu speichernder Wert.
  175. """
  176. with self._lock:
  177. # Eviction falls nötig
  178. while len(self._cache) >= self._max_size:
  179. self._cache.popitem(last=False)
  180. self._stats.record_eviction()
  181. # Eintrag erstellen
  182. expires_at = time.time() + self._ttl if self._ttl else None
  183. entry = CacheEntry(value=value, expires_at=expires_at)
  184. self._cache[key] = entry
  185. def invalidate(self, key: Hashable) -> bool:
  186. """
  187. Invalidiert einen Cache-Eintrag.
  188. Args:
  189. key: Cache-Schlüssel.
  190. Returns:
  191. True wenn der Eintrag existierte.
  192. """
  193. with self._lock:
  194. if key in self._cache:
  195. del self._cache[key]
  196. return True
  197. return False
  198. def clear(self) -> int:
  199. """
  200. Löscht alle Cache-Einträge.
  201. Returns:
  202. Anzahl der gelöschten Einträge.
  203. """
  204. with self._lock:
  205. count = len(self._cache)
  206. self._cache.clear()
  207. self._stats.reset()
  208. return count
  209. def cleanup_expired(self) -> int:
  210. """
  211. Entfernt abgelaufene Einträge.
  212. Returns:
  213. Anzahl der entfernten Einträge.
  214. """
  215. with self._lock:
  216. expired_keys = [
  217. key for key, entry in self._cache.items()
  218. if entry.is_expired()
  219. ]
  220. for key in expired_keys:
  221. del self._cache[key]
  222. self._stats.record_expiration()
  223. return len(expired_keys)
  224. def __len__(self) -> int:
  225. """Gibt die aktuelle Cache-Größe zurück."""
  226. with self._lock:
  227. return len(self._cache)
  228. def __contains__(self, key: Hashable) -> bool:
  229. """Prüft, ob ein Schlüssel im Cache ist."""
  230. with self._lock:
  231. if key not in self._cache:
  232. return False
  233. entry = self._cache[key]
  234. return not entry.is_expired()
  235. # Globale Cache-Registry
  236. _caches: dict[str, LRUCache] = {}
  237. def get_cache(name: str) -> LRUCache | None:
  238. """
  239. Gibt einen Cache anhand seines Namens zurück.
  240. Args:
  241. name: Name des Caches.
  242. Returns:
  243. Der Cache oder None.
  244. """
  245. return _caches.get(name)
  246. def cache_clear(name: str | None = None) -> int:
  247. """
  248. Löscht einen oder alle Caches.
  249. Args:
  250. name: Optional spezifischer Cache-Name.
  251. Returns:
  252. Anzahl der gelöschten Einträge.
  253. """
  254. if name:
  255. cache = _caches.get(name)
  256. if cache:
  257. return cache.clear()
  258. return 0
  259. total = 0
  260. for cache in _caches.values():
  261. total += cache.clear()
  262. return total
  263. def cached(
  264. ttl: float | None = None,
  265. max_size: int = 128,
  266. key_func: Callable[..., Hashable] | None = None,
  267. cache_name: str | None = None,
  268. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  269. """
  270. Decorator für Funktions-Caching mit TTL und LRU.
  271. Args:
  272. ttl: Time-to-Live in Sekunden (None = kein Ablauf).
  273. max_size: Maximale Cache-Größe.
  274. key_func: Optionale Funktion zur Schlüssel-Generierung.
  275. cache_name: Optionaler Name für den Cache (für Zugriff).
  276. Returns:
  277. Decorator-Funktion.
  278. Example:
  279. @cached(ttl=300, max_size=100)
  280. def expensive_calculation(x, y):
  281. return x ** y
  282. @cached(key_func=lambda user_id: user_id)
  283. def get_user(user_id):
  284. return database.get(user_id)
  285. """
  286. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  287. name = cache_name or func.__qualname__
  288. cache: LRUCache[R] = LRUCache(max_size=max_size, ttl=ttl)
  289. _caches[name] = cache
  290. @functools.wraps(func)
  291. def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  292. # Schlüssel generieren
  293. if key_func:
  294. key = key_func(*args, **kwargs)
  295. else:
  296. key = cache._make_key(args, kwargs)
  297. # Cache-Lookup
  298. found, value = cache.get(key)
  299. if found:
  300. return value # type: ignore
  301. # Wert berechnen und cachen
  302. result = func(*args, **kwargs)
  303. cache.set(key, result)
  304. return result
  305. # Cache-Zugriff über die Funktion
  306. wrapper.cache = cache # type: ignore
  307. wrapper.cache_clear = cache.clear # type: ignore
  308. wrapper.cache_stats = lambda: cache.stats # type: ignore
  309. return wrapper
  310. return decorator
  311. def async_cached(
  312. ttl: float | None = None,
  313. max_size: int = 128,
  314. key_func: Callable[..., Hashable] | None = None,
  315. cache_name: str | None = None,
  316. ) -> Callable[[Callable[P, R]], Callable[P, R]]:
  317. """
  318. Async-Decorator für Funktions-Caching.
  319. Wie @cached, aber für async Funktionen.
  320. Args:
  321. ttl: Time-to-Live in Sekunden.
  322. max_size: Maximale Cache-Größe.
  323. key_func: Optionale Funktion zur Schlüssel-Generierung.
  324. cache_name: Optionaler Name für den Cache.
  325. Returns:
  326. Decorator-Funktion.
  327. Example:
  328. @async_cached(ttl=60)
  329. async def fetch_data(url):
  330. async with aiohttp.ClientSession() as session:
  331. async with session.get(url) as response:
  332. return await response.json()
  333. """
  334. def decorator(func: Callable[P, R]) -> Callable[P, R]:
  335. name = cache_name or func.__qualname__
  336. cache: LRUCache[R] = LRUCache(max_size=max_size, ttl=ttl)
  337. _caches[name] = cache
  338. # Lock für async-sichere Operationen
  339. _async_lock = asyncio.Lock()
  340. @functools.wraps(func)
  341. async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  342. if key_func:
  343. key = key_func(*args, **kwargs)
  344. else:
  345. key = cache._make_key(args, kwargs)
  346. # Erst synchron prüfen
  347. found, value = cache.get(key)
  348. if found:
  349. return value # type: ignore
  350. # Async-Lock für die Berechnung
  351. async with _async_lock:
  352. # Nochmal prüfen (könnte sich geändert haben)
  353. found, value = cache.get(key)
  354. if found:
  355. return value # type: ignore
  356. result = await func(*args, **kwargs)
  357. cache.set(key, result)
  358. return result
  359. wrapper.cache = cache # type: ignore
  360. wrapper.cache_clear = cache.clear # type: ignore
  361. wrapper.cache_stats = lambda: cache.stats # type: ignore
  362. return wrapper
  363. return decorator
  364. def memoize(func: Callable[P, R]) -> Callable[P, R]:
  365. """
  366. Einfacher Memoization-Decorator ohne TTL oder Größenlimit.
  367. Cacht Ergebnisse permanent für die Lebensdauer des Prozesses.
  368. Args:
  369. func: Die zu cachende Funktion.
  370. Returns:
  371. Gecachte Funktion.
  372. Example:
  373. @memoize
  374. def fibonacci(n):
  375. if n < 2:
  376. return n
  377. return fibonacci(n - 1) + fibonacci(n - 2)
  378. """
  379. cache: dict[Hashable, R] = {}
  380. @functools.wraps(func)
  381. def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
  382. # Einfacher Schlüssel für hashbare Argumente
  383. key = (args, tuple(sorted(kwargs.items())))
  384. if key in cache:
  385. return cache[key]
  386. result = func(*args, **kwargs)
  387. cache[key] = result
  388. return result
  389. wrapper.cache = cache # type: ignore
  390. wrapper.cache_clear = cache.clear # type: ignore
  391. return wrapper