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