732 lines
19 KiB
Python
732 lines
19 KiB
Python
"""
|
|
Cache Service for AniWorld.
|
|
|
|
This module provides caching functionality with support for both
|
|
in-memory and Redis backends to improve application performance.
|
|
"""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
import pickle
|
|
from abc import ABC, abstractmethod
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CacheBackend(ABC):
|
|
"""Abstract base class for cache backends."""
|
|
|
|
@abstractmethod
|
|
async def get(self, key: str) -> Optional[Any]:
|
|
"""
|
|
Get value from cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
|
|
Returns:
|
|
Cached value or None if not found
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def set(
|
|
self, key: str, value: Any, ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""
|
|
Set value in cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
value: Value to cache
|
|
ttl: Time to live in seconds
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def delete(self, key: str) -> bool:
|
|
"""
|
|
Delete value from cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
|
|
Returns:
|
|
True if key was deleted
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def exists(self, key: str) -> bool:
|
|
"""
|
|
Check if key exists in cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
|
|
Returns:
|
|
True if key exists
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def clear(self) -> bool:
|
|
"""
|
|
Clear all cached values.
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_many(self, keys: List[str]) -> Dict[str, Any]:
|
|
"""
|
|
Get multiple values from cache.
|
|
|
|
Args:
|
|
keys: List of cache keys
|
|
|
|
Returns:
|
|
Dictionary mapping keys to values
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def set_many(
|
|
self, items: Dict[str, Any], ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""
|
|
Set multiple values in cache.
|
|
|
|
Args:
|
|
items: Dictionary of key-value pairs
|
|
ttl: Time to live in seconds
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def delete_pattern(self, pattern: str) -> int:
|
|
"""
|
|
Delete all keys matching pattern.
|
|
|
|
Args:
|
|
pattern: Pattern to match (supports wildcards)
|
|
|
|
Returns:
|
|
Number of keys deleted
|
|
"""
|
|
pass
|
|
|
|
|
|
class InMemoryCacheBackend(CacheBackend):
|
|
"""In-memory cache backend using dictionary."""
|
|
|
|
def __init__(self, max_size: int = 1000):
|
|
"""
|
|
Initialize in-memory cache.
|
|
|
|
Args:
|
|
max_size: Maximum number of items to cache
|
|
"""
|
|
self.cache: Dict[str, Dict[str, Any]] = {}
|
|
self.max_size = max_size
|
|
self._lock = asyncio.Lock()
|
|
|
|
def _is_expired(self, item: Dict[str, Any]) -> bool:
|
|
"""
|
|
Check if cache item is expired.
|
|
|
|
Args:
|
|
item: Cache item with expiry
|
|
|
|
Returns:
|
|
True if expired
|
|
"""
|
|
if item.get("expiry") is None:
|
|
return False
|
|
return datetime.utcnow() > item["expiry"]
|
|
|
|
def _evict_oldest(self) -> None:
|
|
"""Evict oldest cache item when cache is full."""
|
|
if len(self.cache) >= self.max_size:
|
|
# Remove oldest item
|
|
oldest_key = min(
|
|
self.cache.keys(),
|
|
key=lambda k: self.cache[k].get("created", datetime.utcnow()),
|
|
)
|
|
del self.cache[oldest_key]
|
|
|
|
async def get(self, key: str) -> Optional[Any]:
|
|
"""Get value from cache."""
|
|
async with self._lock:
|
|
if key not in self.cache:
|
|
logger.debug("Cache miss for key: %s", key)
|
|
return None
|
|
|
|
item = self.cache[key]
|
|
|
|
if self._is_expired(item):
|
|
logger.debug("Cache expired for key: %s", key)
|
|
del self.cache[key]
|
|
return None
|
|
|
|
logger.debug("Cache hit for key: %s", key)
|
|
return item["value"]
|
|
|
|
async def set(
|
|
self, key: str, value: Any, ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""Set value in cache."""
|
|
async with self._lock:
|
|
self._evict_oldest()
|
|
|
|
expiry = None
|
|
if ttl:
|
|
expiry = datetime.utcnow() + timedelta(seconds=ttl)
|
|
|
|
self.cache[key] = {
|
|
"value": value,
|
|
"expiry": expiry,
|
|
"created": datetime.utcnow(),
|
|
}
|
|
logger.debug("Cached key: %s (ttl=%s)", key, ttl)
|
|
return True
|
|
|
|
async def delete(self, key: str) -> bool:
|
|
"""Delete value from cache."""
|
|
async with self._lock:
|
|
if key in self.cache:
|
|
del self.cache[key]
|
|
logger.debug("Deleted cache key: %s", key)
|
|
return True
|
|
logger.debug("Cache delete skipped; key not found: %s", key)
|
|
return False
|
|
|
|
async def exists(self, key: str) -> bool:
|
|
"""Check if key exists in cache."""
|
|
async with self._lock:
|
|
if key not in self.cache:
|
|
return False
|
|
|
|
item = self.cache[key]
|
|
if self._is_expired(item):
|
|
del self.cache[key]
|
|
return False
|
|
|
|
return True
|
|
|
|
async def clear(self) -> bool:
|
|
"""Clear all cached values."""
|
|
async with self._lock:
|
|
self.cache.clear()
|
|
logger.debug("Cleared in-memory cache")
|
|
return True
|
|
|
|
async def get_many(self, keys: List[str]) -> Dict[str, Any]:
|
|
"""Get multiple values from cache."""
|
|
result = {}
|
|
for key in keys:
|
|
value = await self.get(key)
|
|
if value is not None:
|
|
result[key] = value
|
|
return result
|
|
|
|
async def set_many(
|
|
self, items: Dict[str, Any], ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""Set multiple values in cache."""
|
|
for key, value in items.items():
|
|
await self.set(key, value, ttl)
|
|
return True
|
|
|
|
async def delete_pattern(self, pattern: str) -> int:
|
|
"""Delete all keys matching pattern."""
|
|
import fnmatch
|
|
|
|
async with self._lock:
|
|
keys_to_delete = [
|
|
key for key in self.cache.keys() if fnmatch.fnmatch(key, pattern)
|
|
]
|
|
for key in keys_to_delete:
|
|
del self.cache[key]
|
|
return len(keys_to_delete)
|
|
|
|
|
|
class RedisCacheBackend(CacheBackend):
|
|
"""Redis cache backend."""
|
|
|
|
def __init__(
|
|
self,
|
|
redis_url: str = "redis://localhost:6379",
|
|
prefix: str = "aniworld:",
|
|
):
|
|
"""
|
|
Initialize Redis cache.
|
|
|
|
Args:
|
|
redis_url: Redis connection URL
|
|
prefix: Key prefix for namespacing
|
|
"""
|
|
self.redis_url = redis_url
|
|
self.prefix = prefix
|
|
self._redis = None
|
|
|
|
async def _get_redis(self):
|
|
"""Get Redis connection."""
|
|
if self._redis is None:
|
|
try:
|
|
import aioredis
|
|
|
|
self._redis = await aioredis.create_redis_pool(self.redis_url)
|
|
logger.debug("Connected to Redis at %s", self.redis_url)
|
|
except ImportError:
|
|
logger.error(
|
|
"aioredis not installed. Install with: pip install aioredis"
|
|
)
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to connect to Redis: %s", e)
|
|
raise
|
|
|
|
return self._redis
|
|
|
|
def _make_key(self, key: str) -> str:
|
|
"""Add prefix to key."""
|
|
return f"{self.prefix}{key}"
|
|
|
|
async def get(self, key: str) -> Optional[Any]:
|
|
"""Get value from cache."""
|
|
try:
|
|
redis = await self._get_redis()
|
|
data = await redis.get(self._make_key(key))
|
|
|
|
if data is None:
|
|
return None
|
|
|
|
return pickle.loads(data)
|
|
|
|
except Exception as e:
|
|
logger.error("Redis get error: %s", e)
|
|
return None
|
|
|
|
async def set(
|
|
self, key: str, value: Any, ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""Set value in cache."""
|
|
try:
|
|
redis = await self._get_redis()
|
|
data = pickle.dumps(value)
|
|
|
|
if ttl:
|
|
await redis.setex(self._make_key(key), ttl, data)
|
|
else:
|
|
await redis.set(self._make_key(key), data)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error("Redis set error: %s", e)
|
|
return False
|
|
|
|
async def delete(self, key: str) -> bool:
|
|
"""Delete value from cache."""
|
|
try:
|
|
redis = await self._get_redis()
|
|
result = await redis.delete(self._make_key(key))
|
|
return result > 0
|
|
|
|
except Exception as e:
|
|
logger.error("Redis delete error: %s", e)
|
|
return False
|
|
|
|
async def exists(self, key: str) -> bool:
|
|
"""Check if key exists in cache."""
|
|
try:
|
|
redis = await self._get_redis()
|
|
return await redis.exists(self._make_key(key))
|
|
|
|
except Exception as e:
|
|
logger.error("Redis exists error: %s", e)
|
|
return False
|
|
|
|
async def clear(self) -> bool:
|
|
"""Clear all cached values with prefix."""
|
|
try:
|
|
redis = await self._get_redis()
|
|
keys = await redis.keys(f"{self.prefix}*")
|
|
if keys:
|
|
await redis.delete(*keys)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error("Redis clear error: %s", e)
|
|
return False
|
|
|
|
async def get_many(self, keys: List[str]) -> Dict[str, Any]:
|
|
"""Get multiple values from cache."""
|
|
try:
|
|
redis = await self._get_redis()
|
|
prefixed_keys = [self._make_key(k) for k in keys]
|
|
values = await redis.mget(*prefixed_keys)
|
|
|
|
result = {}
|
|
for key, value in zip(keys, values):
|
|
if value is not None:
|
|
result[key] = pickle.loads(value)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error("Redis get_many error: %s", e)
|
|
return {}
|
|
|
|
async def set_many(
|
|
self, items: Dict[str, Any], ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""Set multiple values in cache."""
|
|
try:
|
|
for key, value in items.items():
|
|
await self.set(key, value, ttl)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error("Redis set_many error: %s", e)
|
|
return False
|
|
|
|
async def delete_pattern(self, pattern: str) -> int:
|
|
"""Delete all keys matching pattern."""
|
|
try:
|
|
redis = await self._get_redis()
|
|
full_pattern = f"{self.prefix}{pattern}"
|
|
keys = await redis.keys(full_pattern)
|
|
|
|
if keys:
|
|
await redis.delete(*keys)
|
|
return len(keys)
|
|
|
|
return 0
|
|
|
|
except Exception as e:
|
|
logger.error("Redis delete_pattern error: %s", e)
|
|
return 0
|
|
|
|
async def close(self) -> None:
|
|
"""Close Redis connection."""
|
|
if self._redis:
|
|
self._redis.close()
|
|
await self._redis.wait_closed()
|
|
|
|
|
|
class CacheService:
|
|
"""Main cache service with automatic key generation and TTL management."""
|
|
|
|
def __init__(
|
|
self,
|
|
backend: Optional[CacheBackend] = None,
|
|
default_ttl: int = 3600,
|
|
key_prefix: str = "",
|
|
):
|
|
"""
|
|
Initialize cache service.
|
|
|
|
Args:
|
|
backend: Cache backend to use
|
|
default_ttl: Default time to live in seconds
|
|
key_prefix: Prefix for all cache keys
|
|
"""
|
|
self.backend = backend or InMemoryCacheBackend()
|
|
self.default_ttl = default_ttl
|
|
self.key_prefix = key_prefix
|
|
|
|
def _make_key(self, *args: Any, **kwargs: Any) -> str:
|
|
"""
|
|
Generate cache key from arguments.
|
|
|
|
Args:
|
|
*args: Positional arguments
|
|
**kwargs: Keyword arguments
|
|
|
|
Returns:
|
|
Cache key string
|
|
"""
|
|
# Create a stable key from arguments
|
|
key_parts = [str(arg) for arg in args]
|
|
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
|
key_str = ":".join(key_parts)
|
|
|
|
# Hash long keys
|
|
if len(key_str) > 200:
|
|
key_hash = hashlib.md5(key_str.encode()).hexdigest()
|
|
return f"{self.key_prefix}{key_hash}"
|
|
|
|
return f"{self.key_prefix}{key_str}"
|
|
|
|
async def get(
|
|
self, key: str, default: Optional[Any] = None
|
|
) -> Optional[Any]:
|
|
"""
|
|
Get value from cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
default: Default value if not found
|
|
|
|
Returns:
|
|
Cached value or default
|
|
"""
|
|
value = await self.backend.get(key)
|
|
return value if value is not None else default
|
|
|
|
async def set(
|
|
self, key: str, value: Any, ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""
|
|
Set value in cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
value: Value to cache
|
|
ttl: Time to live in seconds (uses default if None)
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
if ttl is None:
|
|
ttl = self.default_ttl
|
|
return await self.backend.set(key, value, ttl)
|
|
|
|
async def delete(self, key: str) -> bool:
|
|
"""
|
|
Delete value from cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
|
|
Returns:
|
|
True if deleted
|
|
"""
|
|
return await self.backend.delete(key)
|
|
|
|
async def exists(self, key: str) -> bool:
|
|
"""
|
|
Check if key exists in cache.
|
|
|
|
Args:
|
|
key: Cache key
|
|
|
|
Returns:
|
|
True if exists
|
|
"""
|
|
return await self.backend.exists(key)
|
|
|
|
async def clear(self) -> bool:
|
|
"""
|
|
Clear all cached values.
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
return await self.backend.clear()
|
|
|
|
async def get_or_set(
|
|
self,
|
|
key: str,
|
|
factory,
|
|
ttl: Optional[int] = None,
|
|
) -> Any:
|
|
"""
|
|
Get value from cache or compute and cache it.
|
|
|
|
Args:
|
|
key: Cache key
|
|
factory: Callable to compute value if not cached
|
|
ttl: Time to live in seconds
|
|
|
|
Returns:
|
|
Cached or computed value
|
|
"""
|
|
value = await self.get(key)
|
|
|
|
if value is None:
|
|
# Compute value
|
|
if asyncio.iscoroutinefunction(factory):
|
|
value = await factory()
|
|
else:
|
|
value = factory()
|
|
|
|
# Cache it
|
|
await self.set(key, value, ttl)
|
|
|
|
return value
|
|
|
|
async def invalidate_pattern(self, pattern: str) -> int:
|
|
"""
|
|
Invalidate all keys matching pattern.
|
|
|
|
Args:
|
|
pattern: Pattern to match
|
|
|
|
Returns:
|
|
Number of keys invalidated
|
|
"""
|
|
return await self.backend.delete_pattern(pattern)
|
|
|
|
async def cache_anime_list(
|
|
self, anime_list: List[Dict[str, Any]], ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""
|
|
Cache anime list.
|
|
|
|
Args:
|
|
anime_list: List of anime data
|
|
ttl: Time to live in seconds
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
key = self._make_key("anime", "list")
|
|
return await self.set(key, anime_list, ttl)
|
|
|
|
async def get_anime_list(self) -> Optional[List[Dict[str, Any]]]:
|
|
"""
|
|
Get cached anime list.
|
|
|
|
Returns:
|
|
Cached anime list or None
|
|
"""
|
|
key = self._make_key("anime", "list")
|
|
return await self.get(key)
|
|
|
|
async def cache_anime_detail(
|
|
self, anime_id: str, data: Dict[str, Any], ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""
|
|
Cache anime detail.
|
|
|
|
Args:
|
|
anime_id: Anime identifier
|
|
data: Anime data
|
|
ttl: Time to live in seconds
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
key = self._make_key("anime", "detail", anime_id)
|
|
return await self.set(key, data, ttl)
|
|
|
|
async def get_anime_detail(self, anime_id: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get cached anime detail.
|
|
|
|
Args:
|
|
anime_id: Anime identifier
|
|
|
|
Returns:
|
|
Cached anime data or None
|
|
"""
|
|
key = self._make_key("anime", "detail", anime_id)
|
|
return await self.get(key)
|
|
|
|
async def invalidate_anime_cache(self) -> int:
|
|
"""
|
|
Invalidate all anime-related cache.
|
|
|
|
Returns:
|
|
Number of keys invalidated
|
|
"""
|
|
return await self.invalidate_pattern(f"{self.key_prefix}anime*")
|
|
|
|
async def cache_config(
|
|
self, config: Dict[str, Any], ttl: Optional[int] = None
|
|
) -> bool:
|
|
"""
|
|
Cache configuration.
|
|
|
|
Args:
|
|
config: Configuration data
|
|
ttl: Time to live in seconds
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
key = self._make_key("config")
|
|
return await self.set(key, config, ttl)
|
|
|
|
async def get_config(self) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get cached configuration.
|
|
|
|
Returns:
|
|
Cached configuration or None
|
|
"""
|
|
key = self._make_key("config")
|
|
return await self.get(key)
|
|
|
|
async def invalidate_config_cache(self) -> bool:
|
|
"""
|
|
Invalidate configuration cache.
|
|
|
|
Returns:
|
|
True if successful
|
|
"""
|
|
key = self._make_key("config")
|
|
return await self.delete(key)
|
|
|
|
|
|
# Global cache service instance
|
|
_cache_service: Optional[CacheService] = None
|
|
|
|
|
|
def get_cache_service() -> CacheService:
|
|
"""
|
|
Get the global cache service instance.
|
|
|
|
Returns:
|
|
CacheService instance
|
|
"""
|
|
global _cache_service
|
|
if _cache_service is None:
|
|
_cache_service = CacheService()
|
|
return _cache_service
|
|
|
|
|
|
def configure_cache_service(
|
|
backend_type: str = "memory",
|
|
redis_url: str = "redis://localhost:6379",
|
|
default_ttl: int = 3600,
|
|
max_size: int = 1000,
|
|
) -> CacheService:
|
|
"""
|
|
Configure the global cache service.
|
|
|
|
Args:
|
|
backend_type: Type of backend ("memory" or "redis")
|
|
redis_url: Redis connection URL (for redis backend)
|
|
default_ttl: Default time to live in seconds
|
|
max_size: Maximum cache size (for memory backend)
|
|
|
|
Returns:
|
|
Configured CacheService instance
|
|
"""
|
|
global _cache_service
|
|
|
|
if backend_type == "redis":
|
|
backend = RedisCacheBackend(redis_url=redis_url)
|
|
else:
|
|
backend = InMemoryCacheBackend(max_size=max_size)
|
|
|
|
_cache_service = CacheService(
|
|
backend=backend, default_ttl=default_ttl, key_prefix="aniworld:"
|
|
)
|
|
return _cache_service
|