* Initial plan * Fix security vulnerabilities: MD5→SHA-256, XSS via dangerouslySetInnerHTML/innerHTML, insecure randomness, CodeQL config Co-authored-by: TLimoges33 <125313326+TLimoges33@users.noreply.github.com> * Clean up README: remove decorative emojis for a professional tone Remove all emojis from section headers, list item prefixes, and decorative positions. Replace ✅ phase status markers with '(Complete)' text. Keep the ⭐ in the final call-to-action line. No changes to links, badges, code blocks, or technical content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: remove emoji characters from CONTRIBUTING.md Remove all emoji from section headers and closing line while preserving links, code blocks, and technical content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: remove emoji characters from documentation files Remove all emoji characters from 8 documentation files in docs/. Replace status-marker checkmarks (✅) with '(Done)' text. Remove decorative emojis from headers and body text entirely. Preserve emojis inside code blocks unchanged. Clean up trailing whitespace introduced by removals. Files modified: - DEPLOYMENT_GUIDE.md - IMPLEMENTATION_PLAN.md - MILESTONE_6_SUMMARY.md - PRODUCTION_ROADMAP.md - PROJECT_STATUS.md - REPOSITORY_ENHANCEMENT.md - ROADMAP.md - SECURITY_AUDIT_ROADMAP.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: remove emoji characters from documentation files Remove all emoji characters from 9 markdown files while preserving code block content (box-drawing characters, indentation). Emojis removed from headers, list items, and body text across READMEs, issue templates, PR template, runbook, and mobile docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove excessive emoji from all documentation for professional presentation Co-authored-by: TLimoges33 <125313326+TLimoges33@users.noreply.github.com> * Fix PluginWidget initial state and remove || true from security audit steps Co-authored-by: TLimoges33 <125313326+TLimoges33@users.noreply.github.com> * Remediate all failing CI checks: update deprecated actions, fix npm vulnerabilities, fix migrations YAML Co-authored-by: SynOSdev <257853113+SynOSdev@users.noreply.github.com> * Fix all remaining CI failures: Node 18→20, fix test API contract, fix pytest version, fix Postgres health checks Co-authored-by: SynOSdev <257853113+SynOSdev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: TLimoges33 <125313326+TLimoges33@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: SynOSdev <257853113+SynOSdev@users.noreply.github.com>
257 lines
8.1 KiB
Python
257 lines
8.1 KiB
Python
"""
|
|
Advanced caching system for LifeRPG with multi-level cache strategy.
|
|
"""
|
|
import json
|
|
import time
|
|
import hashlib
|
|
import functools
|
|
from typing import Any, Dict, Optional, Union, Callable
|
|
from datetime import datetime, timedelta
|
|
from cachetools import TTLCache
|
|
import asyncio
|
|
import os
|
|
|
|
try:
|
|
import redis
|
|
REDIS_AVAILABLE = True
|
|
except ImportError:
|
|
REDIS_AVAILABLE = False
|
|
|
|
|
|
class AdvancedCacheManager:
|
|
"""Multi-level cache manager with Redis and memory fallback."""
|
|
|
|
def __init__(self, redis_url: Optional[str] = None):
|
|
# Memory cache (L1) - fastest access
|
|
self.memory_cache = TTLCache(maxsize=1000, ttl=300) # 5 minutes
|
|
|
|
# Redis cache (L2) - persistent, shared
|
|
self.redis_client = None
|
|
if REDIS_AVAILABLE and redis_url:
|
|
try:
|
|
self.redis_client = redis.from_url(redis_url)
|
|
# Test connection
|
|
self.redis_client.ping()
|
|
except Exception:
|
|
self.redis_client = None
|
|
|
|
self.stats = {
|
|
'memory_hits': 0,
|
|
'memory_misses': 0,
|
|
'redis_hits': 0,
|
|
'redis_misses': 0,
|
|
'total_requests': 0
|
|
}
|
|
|
|
def _generate_cache_key(self, prefix: str, *args, **kwargs) -> str:
|
|
"""Generate a consistent cache key from function arguments."""
|
|
key_data = f"{prefix}:{str(args)}:{str(sorted(kwargs.items()))}"
|
|
return hashlib.sha256(key_data.encode()).hexdigest()
|
|
|
|
async def get(self, key: str) -> Optional[Any]:
|
|
"""Get value from cache with fallback strategy."""
|
|
self.stats['total_requests'] += 1
|
|
|
|
# L1: Check memory cache first
|
|
if key in self.memory_cache:
|
|
self.stats['memory_hits'] += 1
|
|
return self.memory_cache[key]
|
|
|
|
self.stats['memory_misses'] += 1
|
|
|
|
# L2: Check Redis cache
|
|
if self.redis_client:
|
|
try:
|
|
value = await self.redis_client.get(key)
|
|
if value:
|
|
self.stats['redis_hits'] += 1
|
|
# Deserialize and populate memory cache
|
|
deserialized = json.loads(value)
|
|
self.memory_cache[key] = deserialized
|
|
return deserialized
|
|
except Exception:
|
|
pass
|
|
|
|
self.stats['redis_misses'] += 1
|
|
return None
|
|
|
|
async def set(self, key: str, value: Any, ttl: int = 300) -> bool:
|
|
"""Set value in both cache levels."""
|
|
try:
|
|
# Set in memory cache
|
|
self.memory_cache[key] = value
|
|
|
|
# Set in Redis cache
|
|
if self.redis_client:
|
|
serialized = json.dumps(value, default=str)
|
|
await self.redis_client.setex(key, ttl, serialized)
|
|
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def delete(self, key: str) -> bool:
|
|
"""Delete from both cache levels."""
|
|
try:
|
|
# Remove from memory cache
|
|
if key in self.memory_cache:
|
|
del self.memory_cache[key]
|
|
|
|
# Remove from Redis
|
|
if self.redis_client:
|
|
await self.redis_client.delete(key)
|
|
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def get_with_fallback(self, key: str, fallback_func: Callable, ttl: int = 300) -> Any:
|
|
"""Get from cache or execute fallback function and cache result."""
|
|
value = await self.get(key)
|
|
if value is not None:
|
|
return value
|
|
|
|
# Execute fallback function
|
|
if asyncio.iscoroutinefunction(fallback_func):
|
|
result = await fallback_func()
|
|
else:
|
|
result = fallback_func()
|
|
|
|
# Cache the result
|
|
await self.set(key, result, ttl)
|
|
return result
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
"""Get cache performance statistics."""
|
|
total = self.stats['total_requests']
|
|
if total == 0:
|
|
return self.stats
|
|
|
|
memory_hit_rate = self.stats['memory_hits'] / total
|
|
redis_hit_rate = self.stats['redis_hits'] / total
|
|
overall_hit_rate = (self.stats['memory_hits'] + self.stats['redis_hits']) / total
|
|
|
|
return {
|
|
**self.stats,
|
|
'memory_hit_rate': memory_hit_rate,
|
|
'redis_hit_rate': redis_hit_rate,
|
|
'overall_hit_rate': overall_hit_rate,
|
|
'memory_cache_size': len(self.memory_cache)
|
|
}
|
|
|
|
async def clear_all(self) -> bool:
|
|
"""Clear all cache levels."""
|
|
try:
|
|
self.memory_cache.clear()
|
|
if self.redis_client:
|
|
await self.redis_client.flushdb()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# Global cache instance
|
|
cache_manager = AdvancedCacheManager(
|
|
redis_url=os.getenv('REDIS_URL', 'redis://localhost:6379/0')
|
|
)
|
|
|
|
|
|
def cached_response(ttl: int = 300, key_prefix: str = ""):
|
|
"""Decorator for caching function responses."""
|
|
def decorator(func: Callable) -> Callable:
|
|
@functools.wraps(func)
|
|
async def async_wrapper(*args, **kwargs):
|
|
# Generate cache key
|
|
cache_key = cache_manager._generate_cache_key(
|
|
f"{key_prefix}:{func.__name__}", *args, **kwargs
|
|
)
|
|
|
|
# Try to get from cache
|
|
result = await cache_manager.get(cache_key)
|
|
if result is not None:
|
|
return result
|
|
|
|
# Execute function and cache result
|
|
if asyncio.iscoroutinefunction(func):
|
|
result = await func(*args, **kwargs)
|
|
else:
|
|
result = func(*args, **kwargs)
|
|
|
|
await cache_manager.set(cache_key, result, ttl)
|
|
return result
|
|
|
|
@functools.wraps(func)
|
|
def sync_wrapper(*args, **kwargs):
|
|
# For synchronous functions, use asyncio.run
|
|
return asyncio.run(async_wrapper(*args, **kwargs))
|
|
|
|
# Return appropriate wrapper based on function type
|
|
if asyncio.iscoroutinefunction(func):
|
|
return async_wrapper
|
|
else:
|
|
return sync_wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def cache_key(*key_parts: str) -> str:
|
|
"""Generate a cache key from parts."""
|
|
return ":".join(str(part) for part in key_parts)
|
|
|
|
|
|
class QueryCache:
|
|
"""Specialized cache for database queries."""
|
|
|
|
@staticmethod
|
|
@cached_response(ttl=600, key_prefix="query")
|
|
def get_user_habits(user_id: int, status: str = "active"):
|
|
"""Cache user habits query."""
|
|
# This will be implemented in the analytics module
|
|
pass
|
|
|
|
@staticmethod
|
|
@cached_response(ttl=300, key_prefix="analytics")
|
|
def get_habit_completion_stats(user_id: int, days: int = 30):
|
|
"""Cache habit completion analytics."""
|
|
pass
|
|
|
|
@staticmethod
|
|
@cached_response(ttl=900, key_prefix="leaderboard")
|
|
def get_leaderboard_data(timeframe: str = "weekly"):
|
|
"""Cache leaderboard data."""
|
|
pass
|
|
|
|
|
|
class CacheWarmer:
|
|
"""Proactively warm cache with commonly requested data."""
|
|
|
|
def __init__(self, cache_manager: AdvancedCacheManager):
|
|
self.cache_manager = cache_manager
|
|
|
|
async def warm_user_data(self, user_id: int):
|
|
"""Pre-populate cache with user's most common data."""
|
|
# Warm user habits
|
|
await self.cache_manager.get_with_fallback(
|
|
f"user:{user_id}:habits",
|
|
lambda: self._fetch_user_habits(user_id),
|
|
ttl=600
|
|
)
|
|
|
|
# Warm user analytics
|
|
await self.cache_manager.get_with_fallback(
|
|
f"user:{user_id}:analytics:30d",
|
|
lambda: self._fetch_user_analytics(user_id, 30),
|
|
ttl=300
|
|
)
|
|
|
|
def _fetch_user_habits(self, user_id: int):
|
|
"""Fetch user habits (placeholder - to be implemented)."""
|
|
return []
|
|
|
|
def _fetch_user_analytics(self, user_id: int, days: int):
|
|
"""Fetch user analytics (placeholder - to be implemented)."""
|
|
return {}
|
|
|
|
|
|
# Initialize cache warmer
|
|
cache_warmer = CacheWarmer(cache_manager) |