"""Services package.""" from __future__ import annotations import json import os import logging from typing import Any, Optional, Dict try: import redis # type: ignore except Exception: # pragma: no cover redis = None # type: ignore logger = logging.getLogger(__name__) class RedisCache: """Lightweight Redis cache wrapper with graceful in-memory fallback.""" def __init__(self, url_env: str = "REDIS_URL", default_ttl_seconds: Optional[int] = None) -> None: import threading self._lock = threading.Lock() self._memory_cache: Dict[str, tuple[float, str]] = {} # key -> (expire_time, serialized_value) # Allow TTL to be configurable via env var (default 300s = 5 min, or 86400 = 24h) ttl_env = os.getenv("REDIS_CACHE_TTL_SECONDS") if default_ttl_seconds is None: default_ttl_seconds = int(ttl_env) if ttl_env else 300 self.default_ttl_seconds = default_ttl_seconds self._enabled = False self._client = None self._stats = {"hits": 0, "misses": 0, "sets": 0} url = os.getenv(url_env) if not url or redis is None: logger.warning("Redis not configured or client unavailable; falling back to local thread-safe in-memory cache") return try: self._client = redis.Redis.from_url(url, decode_responses=True) self._client.ping() self._enabled = True logger.info(f"Redis cache connected (TTL: {self.default_ttl_seconds}s)") except Exception as exc: logger.warning(f"Redis connection failed: {exc}; falling back to local thread-safe in-memory cache") self._enabled = False self._client = None @property def enabled(self) -> bool: return self._enabled and self._client is not None def get_json(self, key: str) -> Optional[Any]: if self.enabled: try: raw = self._client.get(key) # type: ignore[union-attr] if raw: self._stats["hits"] += 1 return json.loads(raw) else: self._stats["misses"] += 1 return None except Exception as exc: logger.debug(f"Redis get_json error for key={key}: {exc}") self._stats["misses"] += 1 return None else: import time with self._lock: if key in self._memory_cache: expire_time, raw = self._memory_cache[key] if expire_time < 0 or expire_time > time.time(): self._stats["hits"] += 1 return json.loads(raw) else: del self._memory_cache[key] self._stats["misses"] += 1 return None def set_json(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None: payload = json.dumps(value, default=lambda o: getattr(o, "model_dump", lambda: o)()) ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl_seconds if self.enabled: try: if ttl > 0: self._client.setex(key, ttl, payload) # type: ignore[union-attr] else: self._client.set(key, payload) # type: ignore[union-attr] self._stats["sets"] += 1 except Exception as exc: logger.debug(f"Redis set_json error for key={key}: {exc}") else: import time expire_time = (time.time() + ttl) if ttl > 0 else -1.0 with self._lock: # Evict oldest keys if cache grows too large to prevent leak if len(self._memory_cache) >= 2000: now = time.time() expired_keys = [k for k, (exp, _) in self._memory_cache.items() if exp > 0 and exp < now] for k in expired_keys: del self._memory_cache[k] if len(self._memory_cache) >= 2000: first_key = next(iter(self._memory_cache)) del self._memory_cache[first_key] self._memory_cache[key] = (expire_time, payload) self._stats["sets"] += 1 def delete(self, pattern: str) -> int: """Delete keys matching pattern (e.g., 'routes:*'). Returns count deleted.""" if self.enabled: try: keys = list(self._client.scan_iter(match=pattern)) # type: ignore[union-attr] if keys: return self._client.delete(*keys) # type: ignore[union-attr] return 0 except Exception as exc: logger.error(f"Redis delete error for pattern={pattern}: {exc}") return 0 else: import fnmatch deleted = 0 with self._lock: keys_to_del = [k for k in self._memory_cache.keys() if fnmatch.fnmatchcase(k, pattern)] for k in keys_to_del: del self._memory_cache[k] deleted += 1 return deleted def get_stats(self) -> Dict[str, Any]: """Get cache statistics.""" stats = self._stats.copy() if self.enabled: try: # Count cache keys route_keys = list(self._client.scan_iter(match="routes:*")) # type: ignore[union-attr] stats["total_keys"] = len(route_keys) stats["enabled"] = True stats["type"] = "Redis" except Exception: stats["total_keys"] = 0 stats["enabled"] = True stats["type"] = "Redis" else: import time now = time.time() with self._lock: active_keys = [k for k, (exp, _) in self._memory_cache.items() if exp < 0 or exp > now] stats["total_keys"] = len(active_keys) stats["enabled"] = True stats["type"] = "In-Memory Fallback" return stats def get_keys(self, pattern: str = "routes:*") -> list[str]: """Get list of cache keys matching pattern.""" if self.enabled: try: return list(self._client.scan_iter(match=pattern)) # type: ignore[union-attr] except Exception as exc: logger.error(f"Redis get_keys error for pattern={pattern}: {exc}") return [] else: import fnmatch import time now = time.time() with self._lock: return [ k for k, (exp, _) in self._memory_cache.items() if (exp < 0 or exp > now) and fnmatch.fnmatchcase(k, pattern) ] # Singleton cache instance for app cache = RedisCache()