99 lines
2.7 KiB
Python
99 lines
2.7 KiB
Python
"""
|
|
Cache utilities with optional Redis backend.
|
|
|
|
If Redis is not configured or unavailable, all functions degrade to no-ops.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import hashlib
|
|
from typing import Any, Optional
|
|
|
|
try:
|
|
import redis.asyncio as redis # type: ignore
|
|
except Exception: # pragma: no cover - allow running without redis installed
|
|
redis = None # type: ignore
|
|
|
|
from app.config import settings
|
|
|
|
|
|
_client: Optional["redis.Redis"] = None # type: ignore
|
|
_lock = asyncio.Lock()
|
|
|
|
|
|
async def _get_client() -> Optional["redis.Redis"]: # type: ignore
|
|
"""Lazily initialize and return a shared Redis client if enabled."""
|
|
global _client
|
|
if not getattr(settings, "redis_url", None) or not getattr(settings, "cache_enabled", False):
|
|
return None
|
|
if redis is None:
|
|
return None
|
|
if _client is not None:
|
|
return _client
|
|
async with _lock:
|
|
if _client is None:
|
|
try:
|
|
_client = redis.from_url(settings.redis_url, decode_responses=True) # type: ignore
|
|
except Exception:
|
|
_client = None
|
|
return _client
|
|
|
|
|
|
def _stable_hash(obj: Any) -> str:
|
|
data = json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
return hashlib.sha1(data.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def build_key(kind: str, user_id: Optional[str], parts: dict) -> str:
|
|
payload = {"u": user_id or "anon", "p": parts}
|
|
return f"search:{kind}:v1:{_stable_hash(payload)}"
|
|
|
|
|
|
async def cache_get_json(kind: str, user_id: Optional[str], parts: dict) -> Optional[Any]:
|
|
client = await _get_client()
|
|
if client is None:
|
|
return None
|
|
key = build_key(kind, user_id, parts)
|
|
try:
|
|
raw = await client.get(key)
|
|
if raw is None:
|
|
return None
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def cache_set_json(kind: str, user_id: Optional[str], parts: dict, value: Any, ttl_seconds: int) -> None:
|
|
client = await _get_client()
|
|
if client is None:
|
|
return
|
|
key = build_key(kind, user_id, parts)
|
|
try:
|
|
await client.set(key, json.dumps(value, separators=(",", ":")), ex=ttl_seconds)
|
|
except Exception:
|
|
return
|
|
|
|
|
|
async def invalidate_prefix(prefix: str) -> None:
|
|
client = await _get_client()
|
|
if client is None:
|
|
return
|
|
try:
|
|
# Use SCAN to avoid blocking Redis
|
|
async for key in client.scan_iter(match=f"{prefix}*"):
|
|
try:
|
|
await client.delete(key)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
return
|
|
|
|
|
|
async def invalidate_search_cache() -> None:
|
|
# Wipe both global search and suggestions namespaces
|
|
await invalidate_prefix("search:global:")
|
|
await invalidate_prefix("search:suggestions:")
|
|
|
|
|