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)
|
||||
@@ -20,6 +20,14 @@ class Settings(BaseSettings):
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 30
|
||||
refresh_token_expire_days: int = 7
|
||||
redis_enabled: bool = Field(default=False, description="Toggle Redis-backed cache usage")
|
||||
redis_url: str = Field(default="redis://localhost:6379/0", description="Redis connection URL")
|
||||
analytics_cache_ttl_seconds: int = Field(default=120, ge=1, description="TTL for cached analytics responses")
|
||||
analytics_cache_backoff_ms: int = Field(
|
||||
default=200,
|
||||
ge=0,
|
||||
description="Maximum backoff (ms) for retrying cache writes/invalidation",
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Application middleware components."""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Middleware that logs cache availability transitions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from app.core.cache import cache_manager
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CacheAvailabilityMiddleware:
|
||||
"""Logs when Redis cache becomes unavailable or recovers."""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
self._last_state: bool | None = None
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] == "http" and settings.redis_enabled:
|
||||
self._log_transition()
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def _log_transition(self) -> None:
|
||||
available = cache_manager.is_available
|
||||
if self._last_state is None:
|
||||
self._last_state = available
|
||||
if not available:
|
||||
logger.warning("Redis cache unavailable, serving responses without cache")
|
||||
return
|
||||
if available == self._last_state:
|
||||
return
|
||||
if available:
|
||||
logger.info("Redis cache connectivity restored; caching re-enabled")
|
||||
else:
|
||||
logger.warning("Redis cache unavailable, serving responses without cache")
|
||||
self._last_state = available
|
||||
Reference in New Issue
Block a user