feat: implement Redis caching for analytics endpoints with fallback to database
Test / test (push) Successful in 15s
Test / test (push) Successful in 15s
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"""Redis cache utilities and availability tracking."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
|
||||
import redis.asyncio as redis
|
||||
from redis.asyncio.client import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisCacheManager:
|
||||
"""Manages lifecycle and availability of the Redis cache client."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client: Redis | None = None
|
||||
self._available: bool = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return settings.redis_enabled
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self._available and self._client is not None
|
||||
|
||||
def get_client(self) -> Redis | None:
|
||||
if not self.is_enabled:
|
||||
return None
|
||||
if self.is_available:
|
||||
return self._client
|
||||
return None
|
||||
|
||||
async def startup(self) -> None:
|
||||
if not self.is_enabled:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._client is not None:
|
||||
return
|
||||
self._client = redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=False)
|
||||
await self._refresh_availability()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
async with self._lock:
|
||||
if self._client is not None:
|
||||
await self._client.close()
|
||||
self._client = None
|
||||
self._available = False
|
||||
|
||||
async def reconnect(self) -> None:
|
||||
if not self.is_enabled:
|
||||
return
|
||||
async with self._lock:
|
||||
if self._client is None:
|
||||
self._client = redis.from_url(settings.redis_url, encoding="utf-8", decode_responses=False)
|
||||
await self._refresh_availability()
|
||||
|
||||
async def _refresh_availability(self) -> None:
|
||||
if self._client is None:
|
||||
self._available = False
|
||||
return
|
||||
try:
|
||||
await self._client.ping()
|
||||
except RedisError as exc: # pragma: no cover - logging only
|
||||
self._available = False
|
||||
logger.warning("Redis ping failed: %s", exc)
|
||||
else:
|
||||
self._available = True
|
||||
|
||||
def mark_unavailable(self) -> None:
|
||||
self._available = False
|
||||
|
||||
def mark_available(self) -> None:
|
||||
if self._client is not None:
|
||||
self._available = True
|
||||
|
||||
|
||||
cache_manager = RedisCacheManager()
|
||||
|
||||
|
||||
async def init_cache() -> None:
|
||||
"""Initialize Redis cache connection if enabled."""
|
||||
await cache_manager.startup()
|
||||
|
||||
|
||||
async def shutdown_cache() -> None:
|
||||
"""Close Redis cache connection."""
|
||||
await cache_manager.shutdown()
|
||||
|
||||
|
||||
def get_cache_client() -> Optional[Redis]:
|
||||
"""Expose the active Redis client for dependency injection."""
|
||||
return cache_manager.get_client()
|
||||
|
||||
|
||||
async def read_json(client: Redis, key: str) -> Any | None:
|
||||
"""Read and decode JSON payload from Redis."""
|
||||
try:
|
||||
raw = await client.get(key)
|
||||
except RedisError as exc: # pragma: no cover - network errors
|
||||
cache_manager.mark_unavailable()
|
||||
logger.debug("Redis GET failed for %s: %s", key, exc)
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
cache_manager.mark_available()
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc: # pragma: no cover - malformed payloads
|
||||
logger.warning("Discarding malformed cache entry %s: %s", key, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def write_json(client: Redis, key: str, value: Any, ttl_seconds: int, backoff_ms: int) -> None:
|
||||
"""Serialize data to JSON and store it with TTL using retry/backoff."""
|
||||
payload = json.dumps(value, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
|
||||
async def _operation() -> Any:
|
||||
return await client.set(name=key, value=payload, ex=ttl_seconds)
|
||||
|
||||
await _run_with_retry(_operation, backoff_ms)
|
||||
|
||||
|
||||
async def delete_keys(client: Redis, keys: list[str], backoff_ms: int) -> None:
|
||||
"""Delete cache keys with retry/backoff semantics."""
|
||||
if not keys:
|
||||
return
|
||||
|
||||
async def _operation() -> Any:
|
||||
return await client.delete(*keys)
|
||||
|
||||
await _run_with_retry(_operation, backoff_ms)
|
||||
|
||||
|
||||
async def _run_with_retry(operation: Callable[[], Awaitable[Any]], max_sleep_ms: int) -> None:
|
||||
try:
|
||||
await operation()
|
||||
cache_manager.mark_available()
|
||||
return
|
||||
except RedisError as exc: # pragma: no cover - network errors
|
||||
cache_manager.mark_unavailable()
|
||||
logger.debug("Redis cache operation failed: %s", exc)
|
||||
if max_sleep_ms <= 0:
|
||||
return
|
||||
sleep_seconds = min(max_sleep_ms / 1000, 0.1)
|
||||
await asyncio.sleep(sleep_seconds)
|
||||
await cache_manager.reconnect()
|
||||
try:
|
||||
await operation()
|
||||
cache_manager.mark_available()
|
||||
except RedisError as exc: # pragma: no cover - repeated network errors
|
||||
cache_manager.mark_unavailable()
|
||||
logger.warning("Redis cache operation failed after retry: %s", exc)
|
||||
Reference in New Issue
Block a user