225 lines
6.9 KiB
Python
225 lines
6.9 KiB
Python
"""
|
|
Redis-based cache manager for Media Downloader API
|
|
Provides caching for expensive queries with configurable TTL
|
|
"""
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Optional, Callable
|
|
from functools import wraps
|
|
import redis
|
|
from redis.exceptions import RedisError
|
|
|
|
# Add parent path to allow imports from modules
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
from modules.universal_logger import get_logger
|
|
from web.backend.core.config import settings
|
|
|
|
logger = get_logger('CacheManager')
|
|
|
|
|
|
class CacheManager:
|
|
"""Redis cache manager with automatic connection handling"""
|
|
|
|
def __init__(self, host: str = '127.0.0.1', port: int = 6379, db: int = 0, ttl: int = 300):
|
|
"""
|
|
Initialize cache manager
|
|
|
|
Args:
|
|
host: Redis host (default: 127.0.0.1)
|
|
port: Redis port (default: 6379)
|
|
db: Redis database number (default: 0)
|
|
ttl: Default TTL in seconds (default: 300 = 5 minutes)
|
|
"""
|
|
self.host = host
|
|
self.port = port
|
|
self.db = db
|
|
self.default_ttl = ttl
|
|
self._redis = None
|
|
self._connect()
|
|
|
|
def _connect(self):
|
|
"""Connect to Redis with error handling"""
|
|
try:
|
|
self._redis = redis.Redis(
|
|
host=self.host,
|
|
port=self.port,
|
|
db=self.db,
|
|
decode_responses=True,
|
|
socket_connect_timeout=2,
|
|
socket_timeout=2
|
|
)
|
|
# Test connection
|
|
self._redis.ping()
|
|
logger.info(f"Connected to Redis at {self.host}:{self.port}", module="Redis")
|
|
except RedisError as e:
|
|
logger.warning(f"Redis connection failed: {e}. Caching disabled.", module="Redis")
|
|
self._redis = None
|
|
|
|
@property
|
|
def is_available(self) -> bool:
|
|
"""Check if Redis is available"""
|
|
if self._redis is None:
|
|
return False
|
|
try:
|
|
self._redis.ping()
|
|
return True
|
|
except RedisError:
|
|
return False
|
|
|
|
def get(self, key: str) -> Optional[Any]:
|
|
"""
|
|
Get value from cache
|
|
|
|
Args:
|
|
key: Cache key
|
|
|
|
Returns:
|
|
Cached value (deserialized from JSON) or None if not found/error
|
|
"""
|
|
if not self.is_available:
|
|
return None
|
|
|
|
try:
|
|
value = self._redis.get(key)
|
|
if value is None:
|
|
return None
|
|
return json.loads(value)
|
|
except (RedisError, json.JSONDecodeError) as e:
|
|
logger.warning(f"Cache get error for key '{key}': {e}", module="Cache")
|
|
return None
|
|
|
|
def set(self, key: str, value: Any, ttl: Optional[int] = None):
|
|
"""
|
|
Set value in cache
|
|
|
|
Args:
|
|
key: Cache key
|
|
value: Value to cache (will be JSON serialized)
|
|
ttl: TTL in seconds (default: use default_ttl)
|
|
"""
|
|
if not self.is_available:
|
|
return
|
|
|
|
ttl = ttl if ttl is not None else self.default_ttl
|
|
|
|
try:
|
|
serialized = json.dumps(value)
|
|
self._redis.setex(key, ttl, serialized)
|
|
except (RedisError, TypeError) as e:
|
|
logger.warning(f"Cache set error for key '{key}': {e}", module="Cache")
|
|
|
|
def delete(self, key: str):
|
|
"""
|
|
Delete key from cache
|
|
|
|
Args:
|
|
key: Cache key to delete
|
|
"""
|
|
if not self.is_available:
|
|
return
|
|
|
|
try:
|
|
self._redis.delete(key)
|
|
except RedisError as e:
|
|
logger.warning(f"Cache delete error for key '{key}': {e}", module="Cache")
|
|
|
|
def clear(self, pattern: str = "*"):
|
|
"""
|
|
Clear cache keys matching pattern
|
|
|
|
Args:
|
|
pattern: Redis key pattern (default: "*" clears all)
|
|
"""
|
|
if not self.is_available:
|
|
return
|
|
|
|
try:
|
|
keys = self._redis.keys(pattern)
|
|
if keys:
|
|
self._redis.delete(*keys)
|
|
logger.info(f"Cleared {len(keys)} cache keys matching '{pattern}'", module="Cache")
|
|
except RedisError as e:
|
|
logger.warning(f"Cache clear error for pattern '{pattern}': {e}", module="Cache")
|
|
|
|
def cached(self, key_prefix: str, ttl: Optional[int] = None):
|
|
"""
|
|
Decorator for caching function results
|
|
|
|
Args:
|
|
key_prefix: Prefix for cache key (full key includes function args)
|
|
ttl: TTL in seconds (default: use default_ttl)
|
|
|
|
Example:
|
|
@cache_manager.cached('stats', ttl=300)
|
|
def get_download_stats(platform: str, days: int):
|
|
# Expensive query
|
|
return stats
|
|
"""
|
|
def decorator(func: Callable) -> Callable:
|
|
@wraps(func)
|
|
async def async_wrapper(*args, **kwargs):
|
|
# Build cache key from function args
|
|
cache_key = f"{key_prefix}:{func.__name__}:{hash((args, tuple(sorted(kwargs.items()))))}"
|
|
|
|
# Try to get from cache
|
|
cached_result = self.get(cache_key)
|
|
if cached_result is not None:
|
|
logger.debug(f"Cache HIT: {cache_key}", module="Cache")
|
|
return cached_result
|
|
|
|
# Cache miss - execute function
|
|
logger.debug(f"Cache MISS: {cache_key}", module="Cache")
|
|
result = await func(*args, **kwargs)
|
|
|
|
# Store in cache
|
|
self.set(cache_key, result, ttl)
|
|
|
|
return result
|
|
|
|
@wraps(func)
|
|
def sync_wrapper(*args, **kwargs):
|
|
# Build cache key from function args
|
|
cache_key = f"{key_prefix}:{func.__name__}:{hash((args, tuple(sorted(kwargs.items()))))}"
|
|
|
|
# Try to get from cache
|
|
cached_result = self.get(cache_key)
|
|
if cached_result is not None:
|
|
logger.debug(f"Cache HIT: {cache_key}", module="Cache")
|
|
return cached_result
|
|
|
|
# Cache miss - execute function
|
|
logger.debug(f"Cache MISS: {cache_key}", module="Cache")
|
|
result = func(*args, **kwargs)
|
|
|
|
# Store in cache
|
|
self.set(cache_key, result, ttl)
|
|
|
|
return result
|
|
|
|
# Return appropriate wrapper based on function type
|
|
import asyncio
|
|
if asyncio.iscoroutinefunction(func):
|
|
return async_wrapper
|
|
else:
|
|
return sync_wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
# Global cache manager instance (use centralized config)
|
|
cache_manager = CacheManager(
|
|
host=settings.REDIS_HOST,
|
|
port=settings.REDIS_PORT,
|
|
db=settings.REDIS_DB,
|
|
ttl=settings.REDIS_TTL
|
|
)
|
|
|
|
|
|
def invalidate_download_cache():
|
|
"""Invalidate all download-related caches"""
|
|
cache_manager.clear("downloads:*")
|
|
cache_manager.clear("stats:*")
|
|
cache_manager.clear("filters:*")
|
|
logger.info("Invalidated download-related caches", module="Cache")
|