Files
TimeTracker/app/utils/cache.py
2025-11-29 07:13:23 +01:00

274 lines
7.9 KiB
Python

"""
Caching utilities with Redis support.
Falls back to in-memory cache if Redis is not available.
"""
from typing import Any, Optional, Callable, Dict
from functools import wraps
import time
import hashlib
import json
import pickle
from flask import current_app
# Try to import Redis
try:
import redis
REDIS_AVAILABLE = True
except ImportError:
REDIS_AVAILABLE = False
redis = None
class InMemoryCache:
"""Simple in-memory cache fallback"""
def __init__(self, default_ttl: int = 3600):
self._cache: Dict[str, tuple[Any, float]] = {}
self._default_ttl = default_ttl
def get(self, key: str) -> Optional[Any]:
"""Get a value from cache"""
if key not in self._cache:
return None
value, expiry = self._cache[key]
if time.time() > expiry:
del self._cache[key]
return None
return value
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
"""Set a value in cache"""
ttl = ttl or self._default_ttl
expiry = time.time() + ttl
self._cache[key] = (value, expiry)
def delete(self, key: str) -> None:
"""Delete a value from cache"""
if key in self._cache:
del self._cache[key]
def clear(self) -> None:
"""Clear all cache"""
self._cache.clear()
def exists(self, key: str) -> bool:
"""Check if a key exists in cache"""
if key not in self._cache:
return False
_, expiry = self._cache[key]
if time.time() > expiry:
del self._cache[key]
return False
return True
class RedisCache:
"""Redis-backed cache implementation"""
def __init__(self, redis_url: str, default_ttl: int = 3600):
"""Initialize Redis cache connection"""
self._default_ttl = default_ttl
try:
# Parse Redis URL
from urllib.parse import urlparse
parsed = urlparse(redis_url)
# Extract password from URL if present
password = parsed.password or None
self._client = redis.Redis(
host=parsed.hostname or "localhost",
port=parsed.port or 6379,
password=password,
db=int(parsed.path.lstrip("/")) if parsed.path else 0,
decode_responses=False, # We'll handle serialization ourselves
socket_connect_timeout=5,
socket_timeout=5,
retry_on_timeout=True,
)
# Test connection
self._client.ping()
self._connected = True
except Exception as e:
# Fallback to in-memory if Redis connection fails
if current_app:
current_app.logger.warning(f"Redis connection failed, using in-memory cache: {e}")
self._connected = False
self._fallback = InMemoryCache(default_ttl)
def get(self, key: str) -> Optional[Any]:
"""Get a value from cache"""
if not self._connected:
return self._fallback.get(key)
try:
data = self._client.get(key)
if data is None:
return None
return pickle.loads(data)
except Exception as e:
if current_app:
current_app.logger.error(f"Redis get error: {e}")
return None
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
"""Set a value in cache"""
if not self._connected:
self._fallback.set(key, value, ttl)
return
try:
ttl = ttl or self._default_ttl
data = pickle.dumps(value)
self._client.setex(key, ttl, data)
except Exception as e:
if current_app:
current_app.logger.error(f"Redis set error: {e}")
def delete(self, key: str) -> None:
"""Delete a value from cache"""
if not self._connected:
self._fallback.delete(key)
return
try:
self._client.delete(key)
except Exception as e:
if current_app:
current_app.logger.error(f"Redis delete error: {e}")
def clear(self) -> None:
"""Clear all cache"""
if not self._connected:
self._fallback.clear()
return
try:
self._client.flushdb()
except Exception as e:
if current_app:
current_app.logger.error(f"Redis clear error: {e}")
def exists(self, key: str) -> bool:
"""Check if a key exists in cache"""
if not self._connected:
return self._fallback.exists(key)
try:
return bool(self._client.exists(key))
except Exception as e:
if current_app:
current_app.logger.error(f"Redis exists error: {e}")
return False
# Global cache instance (initialized on first use)
_cache: Optional[Any] = None
def get_cache():
"""Get the global cache instance (Redis if available, otherwise in-memory)"""
global _cache
if _cache is not None:
return _cache
# Try to initialize Redis if enabled
try:
if current_app and current_app.config.get("REDIS_ENABLED", True) and REDIS_AVAILABLE:
redis_url = current_app.config.get("REDIS_URL", "redis://localhost:6379/0")
default_ttl = current_app.config.get("REDIS_DEFAULT_TTL", 3600)
_cache = RedisCache(redis_url, default_ttl)
if _cache._connected:
return _cache
except RuntimeError:
# Outside application context
pass
except Exception as e:
if current_app:
current_app.logger.warning(f"Failed to initialize Redis cache: {e}")
# Fallback to in-memory cache
default_ttl = 3600
if current_app:
default_ttl = current_app.config.get("REDIS_DEFAULT_TTL", 3600)
_cache = InMemoryCache(default_ttl)
return _cache
def cache_key(*args, **kwargs) -> str:
"""Generate a cache key from arguments"""
key_data = {"args": args, "kwargs": sorted(kwargs.items())}
key_str = json.dumps(key_data, sort_keys=True, default=str)
return hashlib.md5(key_str.encode()).hexdigest()
def cached(ttl: int = 3600, key_prefix: str = ""):
"""
Decorator to cache function results.
Args:
ttl: Time to live in seconds
key_prefix: Prefix for cache key
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
cache = get_cache()
key = f"{key_prefix}:{func.__name__}:{cache_key(*args, **kwargs)}"
# Try to get from cache
cached_value = cache.get(key)
if cached_value is not None:
return cached_value
# Call function and cache result
result = func(*args, **kwargs)
cache.set(key, result, ttl=ttl)
return result
return wrapper
return decorator
def invalidate_cache(pattern: str) -> None:
"""
Invalidate cache entries matching a pattern.
Note: This is a simple implementation. Redis would use pattern matching.
"""
cache = get_cache()
# Simple implementation - in production, use Redis pattern matching
cache.clear() # For now, just clear all (can be improved)
def invalidate_pattern(pattern: str) -> None:
"""
Invalidate cache entries matching a pattern.
Args:
pattern: Pattern to match (supports * wildcard)
"""
cache = get_cache()
if hasattr(cache, "_client") and cache._connected:
# Redis pattern matching
try:
keys = cache._client.keys(pattern)
if keys:
cache._client.delete(*keys)
except Exception as e:
if current_app:
current_app.logger.error(f"Redis pattern delete error: {e}")
else:
# For in-memory, use simple clear (can be improved)
cache.clear()