"""Redis hot cache for search results (master plan sec 7.2).""" from __future__ import annotations import hashlib import json import logging from functools import lru_cache from typing import Any import redis.asyncio as redis_async from app.core.config import settings logger = logging.getLogger(__name__) class SearchCache: """Async Redis cache wrapper. Stable sha256 hash keys, TTL per kind.""" TTL_SEARCH: int = 300 TTL_HOUSE_DETAIL: int = 3600 TTL_VALUATION: int = 86400 def __init__(self, url: str) -> None: self._client: redis_async.Redis = redis_async.from_url( url, decode_responses=True, socket_timeout=2.0, socket_connect_timeout=2.0 ) def search_key(self, params: dict[str, Any]) -> str: payload = json.dumps(params, sort_keys=True, default=str, ensure_ascii=False) digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32] return f"tradein:search:{digest}" async def get(self, key: str) -> dict[str, Any] | None: try: raw = await self._client.get(key) except Exception as e: logger.warning("redis GET failed key=%s: %s", key[:24], e) return None if raw is None: return None try: return json.loads(raw) except json.JSONDecodeError: logger.warning("redis cache corrupted key=%s", key[:24]) return None async def set(self, key: str, value: dict[str, Any], ttl: int) -> None: try: await self._client.set( key, json.dumps(value, default=str, ensure_ascii=False), ex=ttl ) except Exception as e: logger.warning("redis SET failed key=%s: %s", key[:24], e) async def close(self) -> None: await self._client.aclose() @lru_cache(maxsize=1) def get_search_cache() -> SearchCache: """Singleton — один Redis pool на процесс.""" return SearchCache(settings.redis_url)