""" Redis Caching Service - ServiceManagerWeb Servicio centralizado para manejo de caché con Redis. """ from redis import asyncio as aioredis from typing import Optional, Any, Union import json import structlog from functools import wraps from app.core.config import get_settings logger = structlog.get_logger(__name__) settings = get_settings() class CacheService: """ Servicio de caché usando Redis. Proporciona métodos para get/set/delete de datos con serialización JSON. Usa un singleton pattern para compartir la conexión Redis. """ _instance = None _redis = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance async def connect(self): """Conectar a Redis si aún no está conectado.""" if self._redis is None: try: self._redis = await aioredis.from_url( settings.REDIS_URL, encoding="utf-8", decode_responses=True, socket_connect_timeout=5, socket_timeout=5 ) logger.info("Redis cache connected", url=settings.REDIS_URL) except Exception as e: logger.error("Failed to connect to Redis", error=str(e)) self._redis = None async def disconnect(self): """Cerrar conexión Redis.""" if self._redis: await self._redis.close() self._redis = None logger.info("Redis cache disconnected") async def get(self, key: str) -> Optional[Any]: """ Obtener valor del cache. Args: key: Clave del cache Returns: Valor deserializado o None si no existe """ if self._redis is None: await self.connect() if self._redis is None: logger.warning("Redis not available, skipping cache get", key=key) return None try: value = await self._redis.get(key) if value: logger.debug("Cache hit", key=key) return json.loads(value) logger.debug("Cache miss", key=key) return None except Exception as e: logger.error("Cache get error", key=key, error=str(e)) return None async def set( self, key: str, value: Any, ttl: int = 300 ) -> bool: """ Guardar valor en cache. Args: key: Clave del cache value: Valor a guardar (será serializado a JSON) ttl: Tiempo de vida en segundos (default: 5 minutos) Returns: True si se guardó exitosamente """ if self._redis is None: await self.connect() if self._redis is None: logger.warning("Redis not available, skipping cache set", key=key) return False try: serialized = json.dumps(value, default=str) await self._redis.setex(key, ttl, serialized) logger.debug("Cache set", key=key, ttl=ttl) return True except Exception as e: logger.error("Cache set error", key=key, error=str(e)) return False async def delete(self, key: str) -> bool: """ Eliminar clave del cache. Args: key: Clave a eliminar Returns: True si se eliminó exitosamente """ if self._redis is None: await self.connect() if self._redis is None: logger.warning("Redis not available, skipping cache delete", key=key) return False try: await self._redis.delete(key) logger.debug("Cache delete", key=key) return True except Exception as e: logger.error("Cache delete error", key=key, error=str(e)) return False async def delete_pattern(self, pattern: str) -> int: """ Eliminar todas las claves que coincidan con el patrón. Args: pattern: Patrón de búsqueda (ej: "tickets:tenant:*") Returns: Número de claves eliminadas """ if self._redis is None: await self.connect() if self._redis is None: logger.warning("Redis not available, skipping pattern delete", pattern=pattern) return 0 try: keys = [] async for key in self._redis.scan_iter(pattern): keys.append(key) if keys: deleted = await self._redis.delete(*keys) logger.info("Cache pattern delete", pattern=pattern, deleted=deleted) return deleted return 0 except Exception as e: logger.error("Cache pattern delete error", pattern=pattern, error=str(e)) return 0 async def exists(self, key: str) -> bool: """ Verificar si una clave existe en cache. Args: key: Clave a verificar Returns: True si existe """ if self._redis is None: await self.connect() if self._redis is None: return False try: return await self._redis.exists(key) > 0 except Exception as e: logger.error("Cache exists error", key=key, error=str(e)) return False async def incr(self, key: str, amount: int = 1) -> Optional[int]: """ Incrementar un contador en cache. Args: key: Clave del contador amount: Cantidad a incrementar Returns: Nuevo valor del contador """ if self._redis is None: await self.connect() if self._redis is None: return None try: return await self._redis.incrby(key, amount) except Exception as e: logger.error("Cache incr error", key=key, error=str(e)) return None async def expire(self, key: str, ttl: int) -> bool: """ Establecer tiempo de expiración a una clave existente. Args: key: Clave a expirar ttl: Tiempo de vida en segundos Returns: True si se estableció exitosamente """ if self._redis is None: await self.connect() if self._redis is None: return False try: return await self._redis.expire(key, ttl) except Exception as e: logger.error("Cache expire error", key=key, error=str(e)) return False # Singleton instance cache = CacheService() def cache_key(*parts: str) -> str: """ Helper para construir claves de cache consistentes. Args: *parts: Partes de la clave a unir Returns: Clave formateada Example: cache_key("tickets", "tenant", tenant_id) -> "tickets:tenant:123" """ return ":".join(str(part) for part in parts) def cached( key_prefix: str, ttl: int = 300, key_builder: Optional[callable] = None ): """ Decorator para cachear resultados de funciones async. Args: key_prefix: Prefijo para la clave de cache ttl: Tiempo de vida en segundos key_builder: Función opcional para construir la clave Example: @cached("categories", ttl=600) async def get_categories(tenant_id: str): return await db.query(Category).all() """ def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): # Construir clave de cache if key_builder: key = key_builder(*args, **kwargs) else: # Default: usar nombre de función y args key_parts = [key_prefix, func.__name__] key_parts.extend(str(arg) for arg in args) key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items())) key = cache_key(*key_parts) # Intentar obtener del cache cached_value = await cache.get(key) if cached_value is not None: return cached_value # Si no está en cache, ejecutar función result = await func(*args, **kwargs) # Guardar en cache await cache.set(key, result, ttl=ttl) return result return wrapper return decorator