""" Track C2 — Translation cache with Redis backend. This module provides a translation cache that: - Defaults to in-process LRU (same as the previous behavior) - Optionally uses Redis when REDIS_CACHE_ENABLED=true - Namespaces keys by user_id (so free and pro users don't share) - Includes custom_prompt_hash + glossary_hash in the key (so two users with different prompts/glossaries don't get cross-contamination) - Falls back to LRU if Redis is unavailable (transient connection error, etc.) — a Redis outage MUST NOT break translation jobs Public API: TranslationCache — single instance per process, async-friendly. get_cache() — singleton accessor. Usage: cache = get_cache() cached = await cache.get(text, target, source, provider, user_id, custom_prompt_hash, glossary_hash) if cached is None: translated = await provider.translate(text, target, source) await cache.set(text, target, source, provider, user_id, custom_prompt_hash, glossary_hash, translated) """ from __future__ import annotations import hashlib import json import os import time import asyncio import threading from collections import OrderedDict from typing import Optional, Dict, List from core.logging import get_logger logger = get_logger(__name__) # ============================================================================= # Key construction # ============================================================================= def make_cache_key( text: str, target_language: str, source_language: str, provider: str, user_id: Optional[str] = None, custom_prompt_hash: Optional[str] = None, glossary_hash: Optional[str] = None, ) -> str: """Build a stable cache key. The key includes: - The text being translated - The language pair - The provider - The user (so free/pro users don't collide) - The custom_prompt hash (so different prompts are isolated) - The glossary hash (so different glossaries are isolated) """ parts = [ f"provider={provider}", f"src={source_language or 'auto'}", f"tgt={target_language}", f"user={user_id or 'anon'}", f"prompt={custom_prompt_hash or 'none'}", f"glossary={glossary_hash or 'none'}", f"text={text}", ] content = "|".join(parts) return hashlib.sha256(content.encode("utf-8")).hexdigest() def hash_prompt(prompt: Optional[str]) -> Optional[str]: """Hash a custom prompt for use in a cache key. Returns None for None/empty.""" if not prompt: return None return hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:16] def hash_glossary_terms(terms: Optional[List[dict]]) -> Optional[str]: """Hash a glossary's term list for use in a cache key. We only hash the source/target pairs (not metadata) so that two glossaries with the same content but different names still share cache entries. """ if not terms: return None # Stable representation: list of (source, target) tuples normalized = sorted( (str(t.get("source", "")), str(t.get("target", ""))) for t in terms if t.get("source") and t.get("target") ) content = json.dumps(normalized, ensure_ascii=False, sort_keys=True) return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16] # ============================================================================= # In-process LRU fallback # ============================================================================= class _LRUBackend: """Thread-safe LRU cache, used as a fallback when Redis is unavailable.""" def __init__(self, maxsize: int = 5000): self._cache: "OrderedDict[str, str]" = OrderedDict() self._maxsize = maxsize self._lock = threading.RLock() self.hits = 0 self.misses = 0 def get(self, key: str) -> Optional[str]: with self._lock: if key in self._cache: self.hits += 1 self._cache.move_to_end(key) return self._cache[key] self.misses += 1 return None def set(self, key: str, value: str, ttl: Optional[int] = None) -> None: # LRU doesn't honor TTL — it's a process-lifetime cache. with self._lock: if key in self._cache: self._cache.move_to_end(key) self._cache[key] = value while len(self._cache) > self._maxsize: self._cache.popitem(last=False) def clear(self) -> None: with self._lock: self._cache.clear() self.hits = 0 self.misses = 0 def stats(self) -> Dict: with self._lock: total = self.hits + self.misses hit_rate = (self.hits / total * 100) if total > 0 else 0 return { "backend": "lru", "size": len(self._cache), "maxsize": self._maxsize, "hits": self.hits, "misses": self.misses, "hit_rate": f"{hit_rate:.1f}%", } # ============================================================================= # Redis backend # ============================================================================= class _RedisBackend: """Redis-backed cache with TTL and async support. Connection is lazy: we don't connect on import. If Redis is unreachable, every operation falls back to the LRU backend. """ def __init__( self, url: str, namespace: str = "translate_cache", default_ttl: int = 86400, # 24 hours lru_fallback: Optional[_LRUBackend] = None, socket_timeout: float = 2.0, ): self._url = url self._namespace = namespace self._default_ttl = default_ttl self._lru_fallback = lru_fallback self._socket_timeout = socket_timeout self._client = None self._lock = threading.RLock() self.hits = 0 self.misses = 0 self.errors = 0 self._available = None # None = unknown, True/False = probed def _get_client(self): """Lazy client init. Never raises; returns None on failure.""" if self._client is not None: return self._client with self._lock: if self._client is not None: return self._client try: import redis client = redis.from_url( self._url, socket_timeout=self._socket_timeout, socket_connect_timeout=self._socket_timeout, decode_responses=True, ) # Probe once client.ping() self._client = client self._available = True logger.info("redis_cache_connected", url=self._url.split("@")[-1]) return client except Exception as e: self._available = False self.errors += 1 logger.warning( "redis_cache_unavailable", error=str(e)[:200], fallback="lru", ) return None def _key(self, key: str) -> str: return f"{self._namespace}:{key}" def get(self, key: str) -> Optional[str]: client = self._get_client() if client is None: # Fall back to LRU if self._lru_fallback is not None: v = self._lru_fallback.get(key) if v is not None: self.hits += 1 else: self.misses += 1 return v return None try: v = client.get(self._key(key)) if v is not None: self.hits += 1 # Promote to LRU too (in-process L1 cache) if self._lru_fallback is not None: self._lru_fallback.set(key, v) return v self.misses += 1 return None except Exception as e: self.errors += 1 self._available = False self._client = None # force reconnect on next call logger.warning("redis_cache_get_error", error=str(e)[:200]) if self._lru_fallback is not None: return self._lru_fallback.get(key) return None def set(self, key: str, value: str, ttl: Optional[int] = None) -> None: client = self._get_client() if client is not None: try: client.set( self._key(key), value, ex=ttl if ttl is not None else self._default_ttl, ) # Also write to LRU if self._lru_fallback is not None: self._lru_fallback.set(key, value) return except Exception as e: self.errors += 1 self._available = False self._client = None logger.warning("redis_cache_set_error", error=str(e)[:200]) # Fall through to LRU if self._lru_fallback is not None: self._lru_fallback.set(key, value, ttl=ttl) def clear(self) -> None: if self._lru_fallback is not None: self._lru_fallback.clear() client = self._get_client() if client is not None: try: # Delete only keys in our namespace cursor = 0 pattern = f"{self._namespace}:*" while True: cursor, keys = client.scan(cursor=cursor, match=pattern, count=500) if keys: client.delete(*keys) if cursor == 0: break except Exception as e: logger.warning("redis_cache_clear_error", error=str(e)[:200]) def stats(self) -> Dict: out = { "backend": "redis", "available": self._available, "url": self._url.split("@")[-1] if self._url else None, "namespace": self._namespace, "default_ttl": self._default_ttl, "hits": self.hits, "misses": self.misses, "errors": self.errors, } if self._lru_fallback is not None: lru_stats = self._lru_fallback.stats() out["lru_size"] = lru_stats["size"] return out # ============================================================================= # TranslationCache — public API # ============================================================================= class TranslationCache: """High-level translation cache. Delegates to a backend (Redis or LRU) based on configuration. Provides an async-friendly API (sync methods, but no blocking Redis I/O — Redis client is async-compatible via redis.asyncio if we want to add that later). """ def __init__( self, backend: str = "auto", redis_url: Optional[str] = None, redis_namespace: str = "translate_cache", default_ttl: int = 86400, lru_maxsize: int = 5000, ): # Decide backend if backend == "auto": backend = "redis" if ( os.getenv("REDIS_CACHE_ENABLED", "false").lower() == "true" and (redis_url or os.getenv("REDIS_URL", "")) ) else "lru" self._lru = _LRUBackend(maxsize=lru_maxsize) if backend == "redis" and (redis_url or os.getenv("REDIS_URL", "")): self._backend: object = _RedisBackend( url=redis_url or os.getenv("REDIS_URL", ""), namespace=redis_namespace, default_ttl=default_ttl, lru_fallback=self._lru, ) self._backend_name = "redis" else: self._backend = self._lru self._backend_name = "lru" @property def backend_name(self) -> str: return self._backend_name def get( self, text: str, target_language: str, source_language: str, provider: str, user_id: Optional[str] = None, custom_prompt_hash: Optional[str] = None, glossary_hash: Optional[str] = None, ) -> Optional[str]: key = make_cache_key( text, target_language, source_language, provider, user_id, custom_prompt_hash, glossary_hash, ) return self._backend.get(key) def set( self, text: str, target_language: str, source_language: str, provider: str, translation: str, user_id: Optional[str] = None, custom_prompt_hash: Optional[str] = None, glossary_hash: Optional[str] = None, ttl: Optional[int] = None, ) -> None: key = make_cache_key( text, target_language, source_language, provider, user_id, custom_prompt_hash, glossary_hash, ) self._backend.set(key, translation, ttl=ttl) def clear(self) -> None: self._backend.clear() def stats(self) -> Dict: return self._backend.stats() # ============================================================================= # Singleton accessor # ============================================================================= _cache: Optional[TranslationCache] = None _cache_lock = threading.RLock() def get_cache() -> TranslationCache: """Get the process-wide translation cache (lazy init).""" global _cache if _cache is not None: return _cache with _cache_lock: if _cache is None: _cache = TranslationCache( backend="auto", redis_url=os.getenv("REDIS_URL", "") or None, redis_namespace=os.getenv("REDIS_CACHE_NAMESPACE", "translate_cache"), default_ttl=int(os.getenv("REDIS_CACHE_TTL", "86400")), lru_maxsize=int(os.getenv("LRU_CACHE_MAXSIZE", "5000")), ) logger.info( "translation_cache_initialized", backend=_cache.backend_name, ) return _cache def reset_cache_for_tests() -> None: """Reset the singleton — only for tests.""" global _cache with _cache_lock: _cache = None