import asyncio import logging import sys import time from typing import Any, Optional from abc import ABC, abstractmethod from collections import OrderedDict logger = logging.getLogger(__name__) class CacheInterface(ABC): @abstractmethod async def get(self, key: str) -> Optional[Any]: pass @abstractmethod async def set(self, key: str, value: Any, ttl_seconds: int = 60) -> None: pass @abstractmethod async def delete(self, key: str) -> None: pass @abstractmethod async def clear(self) -> None: pass @abstractmethod async def clear_prefix(self, prefix: str) -> int: pass @abstractmethod async def cleanup_expired(self) -> int: pass @abstractmethod def size(self) -> int: pass @abstractmethod def estimate_memory_bytes(self) -> int: pass class CacheEntry: __slots__ = ('value', 'expires_at') def __init__(self, value: Any, ttl_seconds: int): self.value = value self.expires_at = time.time() + ttl_seconds def is_expired(self) -> bool: return time.time() > self.expires_at class InMemoryCache(CacheInterface): def __init__(self, max_entries: int = 10000): self._cache: OrderedDict[str, CacheEntry] = OrderedDict() self._lock = asyncio.Lock() self._max_entries = max_entries self._evictions = 0 self._hits = 0 self._misses = 0 async def get(self, key: str) -> Optional[Any]: async with self._lock: entry = self._cache.get(key) if entry is None: self._misses += 1 return None if entry.is_expired(): self._cache.pop(key, None) self._misses += 1 return None self._cache.move_to_end(key) self._hits += 1 return entry.value async def set(self, key: str, value: Any, ttl_seconds: int = 60) -> None: async with self._lock: if key not in self._cache and len(self._cache) >= self._max_entries: oldest_key, _ = self._cache.popitem(last=False) self._evictions += 1 if self._evictions % 100 == 0: logger.info(f"Cache LRU evictions: {self._evictions}, current size: {len(self._cache)}") self._cache[key] = CacheEntry(value, ttl_seconds) self._cache.move_to_end(key) async def delete(self, key: str) -> None: async with self._lock: self._cache.pop(key, None) async def clear(self) -> None: async with self._lock: self._cache.clear() async def clear_prefix(self, prefix: str) -> int: async with self._lock: keys_to_remove = [k for k in self._cache.keys() if k.startswith(prefix)] for key in keys_to_remove: self._cache.pop(key, None) if keys_to_remove: logger.info(f"Cleared {len(keys_to_remove)} cache entries with prefix '{prefix}'") return len(keys_to_remove) async def cleanup_expired(self) -> int: now = time.time() async with self._lock: expired_keys = [ key for key, entry in self._cache.items() if now > entry.expires_at ] for key in expired_keys: self._cache.pop(key, None) if expired_keys: logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries") return len(expired_keys) def size(self) -> int: return len(self._cache) def estimate_memory_bytes(self) -> int: total_size = 0 total_size += sys.getsizeof(self._cache) for key, entry in self._cache.items(): total_size += sys.getsizeof(key) total_size += sys.getsizeof(entry) total_size += sys.getsizeof(entry.value) return total_size def get_stats(self) -> dict[str, Any]: total = self._hits + self._misses hit_rate = (self._hits / total * 100) if total > 0 else 0.0 return { "size": len(self._cache), "max_entries": self._max_entries, "hits": self._hits, "misses": self._misses, "hit_rate_percent": round(hit_rate, 2), "evictions": self._evictions, "memory_bytes": self.estimate_memory_bytes() }