mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-16 01:00:16 -06:00
274 lines
7.9 KiB
Python
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()
|