Files
delphi-database/app/services/cache.py
2025-08-14 19:16:28 -05:00

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:")