rss2/cache.py

188 lines
5.7 KiB
Python

"""
Redis cache module for high-traffic endpoints.
Provides caching decorator and invalidation utilities.
"""
import redis
import json
import logging
import hashlib
import time
from functools import wraps
from config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_TTL_DEFAULT
logger = logging.getLogger(__name__)
_redis_client = None
_redis_last_fail = 0
def get_redis():
"""Get Redis client singleton with failure backoff."""
global _redis_client, _redis_last_fail
if _redis_client is not None:
return _redis_client
# Prevent retrying too often if it's failing (60s backoff)
now = time.time()
if now - _redis_last_fail < 60:
return None
try:
redis_config = {
'host': REDIS_HOST,
'port': REDIS_PORT,
'decode_responses': True,
'socket_connect_timeout': 1, # Faster timeout
'socket_timeout': 1
}
if REDIS_PASSWORD:
redis_config['password'] = REDIS_PASSWORD
_redis_client = redis.Redis(**redis_config)
_redis_client.ping()
_redis_last_fail = 0
return _redis_client
except Exception as e:
logger.warning(f"Redis connection failed: {e}. Caching disabled for 60s.")
_redis_client = None
_redis_last_fail = now
return None
def cached(ttl_seconds=None, prefix="cache"):
"""
Decorator for caching function results in Redis.
Falls back to calling function directly if Redis is unavailable.
Args:
ttl_seconds: Time to live in seconds (default from config)
prefix: Key prefix for cache entries
"""
if ttl_seconds is None:
ttl_seconds = REDIS_TTL_DEFAULT
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
r = get_redis()
if r is None:
# Redis unavailable, call function directly
return func(*args, **kwargs)
# Build cache key from function name and arguments
# Use md5 for deterministic hash across processes
key_data = f"{args}:{sorted(kwargs.items())}"
# Add flask request args if available to prevent collision on filtered routes
try:
from flask import request
if request:
key_data += f":args:{sorted(request.args.items())}"
except Exception:
pass
key_hash = hashlib.md5(key_data.encode('utf-8')).hexdigest()
cache_key = f"cache:{prefix}:{func.__name__}:{key_hash}"
try:
# Try to get from cache
cached_value = r.get(cache_key)
if cached_value is not None:
# If it's a JSON response, we might need to return it correctly
try:
data = json.loads(cached_value)
# Detect if we should return as JSON
from flask import jsonify
return jsonify(data)
except (json.JSONDecodeError, ImportError):
return cached_value
# Cache miss - call function and cache result
result = func(*args, **kwargs)
# Handle Flask Response objects
cache_data = result
try:
from flask import Response
if isinstance(result, Response):
if result.is_json:
cache_data = result.get_json()
else:
cache_data = result.get_data(as_text=True)
except (ImportError, Exception):
pass
r.setex(cache_key, ttl_seconds, json.dumps(cache_data, default=str))
return result
except (redis.RedisError, json.JSONDecodeError) as e:
logger.warning(f"Cache error for {func.__name__}: {e}")
return func(*args, **kwargs)
return wrapper
return decorator
def invalidate_pattern(pattern):
"""
Invalidate all cache keys matching pattern.
Args:
pattern: Pattern to match (e.g., "home:*" or "stats:*")
"""
r = get_redis()
if r is None:
return
try:
cursor = 0
deleted = 0
while True:
cursor, keys = r.scan(cursor, match=f"cache:{pattern}", count=100)
if keys:
r.delete(*keys)
deleted += len(keys)
if cursor == 0:
break
if deleted > 0:
logger.info(f"Invalidated {deleted} cache keys matching '{pattern}'")
except redis.RedisError as e:
logger.warning(f"Cache invalidation failed: {e}")
def cache_get(key):
"""Get value from cache by key."""
r = get_redis()
if r is None:
return None
try:
value = r.get(f"cache:{key}")
return json.loads(value) if value else None
except (redis.RedisError, json.JSONDecodeError):
return None
def cache_set(key, value, ttl_seconds=None):
"""Set value in cache with optional TTL."""
if ttl_seconds is None:
ttl_seconds = REDIS_TTL_DEFAULT
r = get_redis()
if r is None:
return False
try:
r.setex(f"cache:{key}", ttl_seconds, json.dumps(value, default=str))
return True
except redis.RedisError:
return False
def cache_del(key):
"""Delete a key from cache."""
r = get_redis()
if r is None:
return False
try:
r.delete(f"cache:{key}")
return True
except redis.RedisError:
return False