fixes and refactor
This commit is contained in:
98
app/services/cache.py
Normal file
98
app/services/cache.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
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:")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user