feat(cache): C2 — Redis translation cache with user/prompt/glossary namespacing
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m13s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m13s
This commit is contained in:
427
services/translation_cache.py
Normal file
427
services/translation_cache.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
531
tests/test_translation_cache.py
Normal file
531
tests/test_translation_cache.py
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
"""
|
||||||
|
Tests for Track C2 — Redis translation cache.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- make_cache_key: stable across calls, distinct inputs → distinct keys
|
||||||
|
- hash_prompt / hash_glossary_terms
|
||||||
|
- _LRUBackend: get/set, eviction order, stats
|
||||||
|
- _RedisBackend: connection probe, fallback to LRU on error
|
||||||
|
- TranslationCache: auto backend selection, user_id namespacing,
|
||||||
|
prompt/glossary isolation
|
||||||
|
- get_cache: singleton, reset_cache_for_tests
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.translation_cache import (
|
||||||
|
make_cache_key,
|
||||||
|
hash_prompt,
|
||||||
|
hash_glossary_terms,
|
||||||
|
_LRUBackend,
|
||||||
|
_RedisBackend,
|
||||||
|
TranslationCache,
|
||||||
|
get_cache,
|
||||||
|
reset_cache_for_tests,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# make_cache_key
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestMakeCacheKey:
|
||||||
|
def test_stable_across_calls(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google")
|
||||||
|
k2 = make_cache_key("Hello", "fr", "en", "google")
|
||||||
|
assert k1 == k2
|
||||||
|
|
||||||
|
def test_different_target_different_key(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google")
|
||||||
|
k2 = make_cache_key("Hello", "es", "en", "google")
|
||||||
|
assert k1 != k2
|
||||||
|
|
||||||
|
def test_different_provider_different_key(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google")
|
||||||
|
k2 = make_cache_key("Hello", "fr", "en", "deepl")
|
||||||
|
assert k1 != k2
|
||||||
|
|
||||||
|
def test_different_text_different_key(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google")
|
||||||
|
k2 = make_cache_key("Goodbye", "fr", "en", "google")
|
||||||
|
assert k1 != k2
|
||||||
|
|
||||||
|
def test_user_namespacing(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google", user_id="alice")
|
||||||
|
k2 = make_cache_key("Hello", "fr", "en", "google", user_id="bob")
|
||||||
|
assert k1 != k2, "Different users should not share cache entries"
|
||||||
|
|
||||||
|
def test_same_user_same_key(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google", user_id="alice")
|
||||||
|
k2 = make_cache_key("Hello", "fr", "en", "google", user_id="alice")
|
||||||
|
assert k1 == k2
|
||||||
|
|
||||||
|
def test_anon_default(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google")
|
||||||
|
k2 = make_cache_key("Hello", "fr", "en", "google", user_id=None)
|
||||||
|
assert k1 == k2, "user_id=None should equal the default anonymous user"
|
||||||
|
|
||||||
|
def test_prompt_isolation(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google", custom_prompt_hash="abc")
|
||||||
|
k2 = make_cache_key("Hello", "fr", "en", "google", custom_prompt_hash="xyz")
|
||||||
|
assert k1 != k2, "Different prompts should not share cache entries"
|
||||||
|
|
||||||
|
def test_glossary_isolation(self):
|
||||||
|
k1 = make_cache_key("Hello", "fr", "en", "google", glossary_hash="g1")
|
||||||
|
k2 = make_cache_key("Hello", "fr", "en", "google", glossary_hash="g2")
|
||||||
|
assert k1 != k2
|
||||||
|
|
||||||
|
def test_returns_hex_string(self):
|
||||||
|
k = make_cache_key("Hello", "fr", "en", "google")
|
||||||
|
assert isinstance(k, str)
|
||||||
|
assert len(k) == 64 # sha256 hex digest length
|
||||||
|
assert all(c in "0123456789abcdef" for c in k)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Hash helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestHashHelpers:
|
||||||
|
def test_hash_prompt_none(self):
|
||||||
|
assert hash_prompt(None) is None
|
||||||
|
|
||||||
|
def test_hash_prompt_empty(self):
|
||||||
|
assert hash_prompt("") is None
|
||||||
|
|
||||||
|
def test_hash_prompt_stable(self):
|
||||||
|
h1 = hash_prompt("Translate this")
|
||||||
|
h2 = hash_prompt("Translate this")
|
||||||
|
assert h1 == h2
|
||||||
|
|
||||||
|
def test_hash_prompt_different_inputs(self):
|
||||||
|
h1 = hash_prompt("A")
|
||||||
|
h2 = hash_prompt("B")
|
||||||
|
assert h1 != h2
|
||||||
|
|
||||||
|
def test_hash_prompt_short(self):
|
||||||
|
h = hash_prompt("test")
|
||||||
|
# We truncate to 16 chars in the implementation
|
||||||
|
assert len(h) == 16
|
||||||
|
|
||||||
|
def test_hash_glossary_none(self):
|
||||||
|
assert hash_glossary_terms(None) is None
|
||||||
|
|
||||||
|
def test_hash_glossary_empty(self):
|
||||||
|
assert hash_glossary_terms([]) is None
|
||||||
|
|
||||||
|
def test_hash_glossary_stable(self):
|
||||||
|
terms = [{"source": "Hello", "target": "Bonjour"}]
|
||||||
|
h1 = hash_glossary_terms(terms)
|
||||||
|
h2 = hash_glossary_terms(terms)
|
||||||
|
assert h1 == h2
|
||||||
|
|
||||||
|
def test_hash_glossary_order_invariant(self):
|
||||||
|
"""The order of terms should not change the hash (sorted internally)."""
|
||||||
|
a = [
|
||||||
|
{"source": "Hello", "target": "Bonjour"},
|
||||||
|
{"source": "World", "target": "Monde"},
|
||||||
|
]
|
||||||
|
b = [
|
||||||
|
{"source": "World", "target": "Monde"},
|
||||||
|
{"source": "Hello", "target": "Bonjour"},
|
||||||
|
]
|
||||||
|
assert hash_glossary_terms(a) == hash_glossary_terms(b)
|
||||||
|
|
||||||
|
def test_hash_glossary_metadata_invariant(self):
|
||||||
|
"""The name/id of the glossary should not affect the hash."""
|
||||||
|
a = [{"source": "X", "target": "Y", "name": "g1", "id": "g1"}]
|
||||||
|
b = [{"source": "X", "target": "Y", "name": "g2", "id": "g2"}]
|
||||||
|
assert hash_glossary_terms(a) == hash_glossary_terms(b)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# LRU backend
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestLRUBackend:
|
||||||
|
def test_get_missing_returns_none(self):
|
||||||
|
c = _LRUBackend()
|
||||||
|
assert c.get("nope") is None
|
||||||
|
assert c.misses == 1
|
||||||
|
|
||||||
|
def test_set_and_get(self):
|
||||||
|
c = _LRUBackend()
|
||||||
|
c.set("k", "v")
|
||||||
|
assert c.get("k") == "v"
|
||||||
|
assert c.hits == 1
|
||||||
|
|
||||||
|
def test_overwrite(self):
|
||||||
|
c = _LRUBackend()
|
||||||
|
c.set("k", "v1")
|
||||||
|
c.set("k", "v2")
|
||||||
|
assert c.get("k") == "v2"
|
||||||
|
assert len(c._cache) == 1
|
||||||
|
|
||||||
|
def test_lru_eviction(self):
|
||||||
|
c = _LRUBackend(maxsize=2)
|
||||||
|
c.set("a", "1")
|
||||||
|
c.set("b", "2")
|
||||||
|
c.set("c", "3") # Should evict "a" (least recently used)
|
||||||
|
assert c.get("a") is None
|
||||||
|
assert c.get("b") == "2"
|
||||||
|
assert c.get("c") == "3"
|
||||||
|
|
||||||
|
def test_get_promotes_to_mru(self):
|
||||||
|
c = _LRUBackend(maxsize=2)
|
||||||
|
c.set("a", "1")
|
||||||
|
c.set("b", "2")
|
||||||
|
c.get("a") # Promote "a"
|
||||||
|
c.set("c", "3") # Should evict "b" (now LRU)
|
||||||
|
assert c.get("a") == "1"
|
||||||
|
assert c.get("b") is None
|
||||||
|
assert c.get("c") == "3"
|
||||||
|
|
||||||
|
def test_clear(self):
|
||||||
|
c = _LRUBackend()
|
||||||
|
c.set("a", "1")
|
||||||
|
c.set("b", "2")
|
||||||
|
c.clear()
|
||||||
|
assert c.get("a") is None
|
||||||
|
# After clear, both counters are reset
|
||||||
|
assert c.hits == 0
|
||||||
|
# The get after clear is a miss (1)
|
||||||
|
assert c.misses == 1
|
||||||
|
|
||||||
|
def test_stats(self):
|
||||||
|
c = _LRUBackend(maxsize=10)
|
||||||
|
c.set("a", "1")
|
||||||
|
c.get("a") # hit
|
||||||
|
c.get("b") # miss
|
||||||
|
s = c.stats()
|
||||||
|
assert s["backend"] == "lru"
|
||||||
|
assert s["hits"] == 1
|
||||||
|
assert s["misses"] == 1
|
||||||
|
assert s["size"] == 1
|
||||||
|
assert s["maxsize"] == 10
|
||||||
|
|
||||||
|
def test_thread_safety(self):
|
||||||
|
c = _LRUBackend(maxsize=100)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def worker(i):
|
||||||
|
try:
|
||||||
|
for j in range(100):
|
||||||
|
c.set(f"k_{i}_{j}", f"v_{i}_{j}")
|
||||||
|
c.get(f"k_{i}_{j}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker, args=(i,)) for i in range(5)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Redis backend — fallback behavior
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestRedisBackendFallback:
|
||||||
|
"""When Redis is unavailable, the backend should fall back to LRU."""
|
||||||
|
|
||||||
|
def test_redis_unavailable_falls_back_to_lru(self):
|
||||||
|
lru = _LRUBackend()
|
||||||
|
backend = _RedisBackend(
|
||||||
|
url="redis://invalid-host:9999/0",
|
||||||
|
lru_fallback=lru,
|
||||||
|
)
|
||||||
|
# First call probes the connection, fails, and falls back
|
||||||
|
assert backend.get("test_key") is None # miss in LRU
|
||||||
|
backend.set("test_key", "hello")
|
||||||
|
# Second get should hit the LRU
|
||||||
|
assert backend.get("test_key") == "hello"
|
||||||
|
# Errors should have been counted
|
||||||
|
assert backend.errors >= 1
|
||||||
|
# The backend should not have a client
|
||||||
|
assert backend._client is None
|
||||||
|
|
||||||
|
def test_redis_set_falls_back_to_lru(self):
|
||||||
|
lru = _LRUBackend()
|
||||||
|
backend = _RedisBackend(
|
||||||
|
url="redis://invalid-host:9999/0",
|
||||||
|
lru_fallback=lru,
|
||||||
|
)
|
||||||
|
backend.set("k", "v")
|
||||||
|
# The LRU should have it
|
||||||
|
assert lru.get("k") == "v"
|
||||||
|
|
||||||
|
def test_stats_includes_lru(self):
|
||||||
|
lru = _LRUBackend()
|
||||||
|
backend = _RedisBackend(
|
||||||
|
url="redis://invalid-host:9999/0",
|
||||||
|
lru_fallback=lru,
|
||||||
|
)
|
||||||
|
backend.set("k", "v")
|
||||||
|
stats = backend.stats()
|
||||||
|
assert stats["backend"] == "redis"
|
||||||
|
assert stats["available"] is False
|
||||||
|
assert stats["errors"] >= 1
|
||||||
|
assert "lru_size" in stats
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Redis backend — happy path with a mock
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestRedisBackendWithMock:
|
||||||
|
"""When Redis is reachable, the backend should use it."""
|
||||||
|
|
||||||
|
def _build_backend_with_mock_client(self, client):
|
||||||
|
"""Build a RedisBackend with a pre-set mock client."""
|
||||||
|
lru = _LRUBackend()
|
||||||
|
backend = _RedisBackend(
|
||||||
|
url="redis://fake:6379/0",
|
||||||
|
lru_fallback=lru,
|
||||||
|
)
|
||||||
|
backend._client = client
|
||||||
|
backend._available = True
|
||||||
|
return backend, lru
|
||||||
|
|
||||||
|
def test_get_uses_redis(self):
|
||||||
|
client = MagicMock()
|
||||||
|
client.get.return_value = "cached_value"
|
||||||
|
backend, lru = self._build_backend_with_mock_client(client)
|
||||||
|
|
||||||
|
result = backend.get("k")
|
||||||
|
assert result == "cached_value"
|
||||||
|
client.get.assert_called_once()
|
||||||
|
# Should also have been written to LRU
|
||||||
|
assert lru.get("k") == "cached_value"
|
||||||
|
|
||||||
|
def test_get_miss_redis(self):
|
||||||
|
client = MagicMock()
|
||||||
|
client.get.return_value = None
|
||||||
|
backend, lru = self._build_backend_with_mock_client(client)
|
||||||
|
|
||||||
|
result = backend.get("k")
|
||||||
|
assert result is None
|
||||||
|
assert backend.misses == 1
|
||||||
|
|
||||||
|
def test_set_uses_redis(self):
|
||||||
|
client = MagicMock()
|
||||||
|
backend, lru = self._build_backend_with_mock_client(client)
|
||||||
|
|
||||||
|
backend.set("k", "v", ttl=60)
|
||||||
|
# Should have called Redis SET
|
||||||
|
client.set.assert_called_once()
|
||||||
|
args = client.set.call_args[0]
|
||||||
|
assert args[1] == "v" # value
|
||||||
|
# The TTL is passed as a keyword arg (ex=ttl)
|
||||||
|
assert client.set.call_args[1]["ex"] == 60
|
||||||
|
|
||||||
|
def test_set_uses_default_ttl(self):
|
||||||
|
client = MagicMock()
|
||||||
|
backend, lru = self._build_backend_with_mock_client(client)
|
||||||
|
|
||||||
|
backend.set("k", "v")
|
||||||
|
# Default TTL = 86400 (24h) is passed as ex=...
|
||||||
|
assert client.set.call_args[1]["ex"] == 86400
|
||||||
|
|
||||||
|
def test_redis_error_during_get_resets_client(self):
|
||||||
|
client = MagicMock()
|
||||||
|
client.get.side_effect = RuntimeError("redis down")
|
||||||
|
backend, lru = self._build_backend_with_mock_client(client)
|
||||||
|
|
||||||
|
result = backend.get("k")
|
||||||
|
# Falls back to LRU
|
||||||
|
assert result is None
|
||||||
|
# Client should be reset so we re-probe next time
|
||||||
|
assert backend._client is None
|
||||||
|
assert backend._available is False
|
||||||
|
assert backend.errors == 1
|
||||||
|
|
||||||
|
def test_redis_error_during_set_resets_client(self):
|
||||||
|
client = MagicMock()
|
||||||
|
client.set.side_effect = RuntimeError("redis down")
|
||||||
|
backend, lru = self._build_backend_with_mock_client(client)
|
||||||
|
|
||||||
|
backend.set("k", "v")
|
||||||
|
# Should have fallen back to LRU
|
||||||
|
assert lru.get("k") == "v"
|
||||||
|
assert backend._client is None
|
||||||
|
assert backend.errors == 1
|
||||||
|
|
||||||
|
def test_namespace_prefix(self):
|
||||||
|
client = MagicMock()
|
||||||
|
client.get.return_value = None
|
||||||
|
backend, lru = self._build_backend_with_mock_client(client)
|
||||||
|
|
||||||
|
backend.get("mykey")
|
||||||
|
called_key = client.get.call_args[0][0]
|
||||||
|
assert called_key.startswith("translate_cache:")
|
||||||
|
assert called_key.endswith(":mykey")
|
||||||
|
|
||||||
|
def test_custom_namespace(self):
|
||||||
|
client = MagicMock()
|
||||||
|
client.get.return_value = None
|
||||||
|
lru = _LRUBackend()
|
||||||
|
backend = _RedisBackend(
|
||||||
|
url="redis://fake:6379/0",
|
||||||
|
namespace="custom_ns",
|
||||||
|
lru_fallback=lru,
|
||||||
|
)
|
||||||
|
backend._client = client
|
||||||
|
backend._available = True
|
||||||
|
|
||||||
|
backend.get("mykey")
|
||||||
|
called_key = client.get.call_args[0][0]
|
||||||
|
assert called_key.startswith("custom_ns:")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TranslationCache — high-level API
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestTranslationCache:
|
||||||
|
def test_default_backend_is_lru(self, monkeypatch):
|
||||||
|
"""No REDIS_CACHE_ENABLED → LRU backend."""
|
||||||
|
monkeypatch.delenv("REDIS_CACHE_ENABLED", raising=False)
|
||||||
|
monkeypatch.delenv("REDIS_URL", raising=False)
|
||||||
|
cache = TranslationCache(backend="auto")
|
||||||
|
assert cache.backend_name == "lru"
|
||||||
|
|
||||||
|
def test_explicit_lru_backend(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("REDIS_CACHE_ENABLED", "true")
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
assert cache.backend_name == "lru"
|
||||||
|
|
||||||
|
def test_get_and_set(self):
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
cache.set("Hello", "fr", "en", "google", "Bonjour")
|
||||||
|
assert cache.get("Hello", "fr", "en", "google") == "Bonjour"
|
||||||
|
|
||||||
|
def test_get_miss(self):
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
assert cache.get("Nope", "fr", "en", "google") is None
|
||||||
|
|
||||||
|
def test_user_isolation(self):
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
cache.set("Hello", "fr", "en", "google", "Bonjour", user_id="alice")
|
||||||
|
cache.set("Hello", "fr", "en", "google", "Hola", user_id="bob")
|
||||||
|
assert cache.get("Hello", "fr", "en", "google", user_id="alice") == "Bonjour"
|
||||||
|
assert cache.get("Hello", "fr", "en", "google", user_id="bob") == "Hola"
|
||||||
|
|
||||||
|
def test_prompt_isolation(self):
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
cache.set("Hello", "fr", "en", "google", "V1",
|
||||||
|
custom_prompt_hash="p1")
|
||||||
|
cache.set("Hello", "fr", "en", "google", "V2",
|
||||||
|
custom_prompt_hash="p2")
|
||||||
|
assert cache.get("Hello", "fr", "en", "google", custom_prompt_hash="p1") == "V1"
|
||||||
|
assert cache.get("Hello", "fr", "en", "google", custom_prompt_hash="p2") == "V2"
|
||||||
|
|
||||||
|
def test_glossary_isolation(self):
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
cache.set("Hello", "fr", "en", "google", "V1",
|
||||||
|
glossary_hash="g1")
|
||||||
|
cache.set("Hello", "fr", "en", "google", "V2",
|
||||||
|
glossary_hash="g2")
|
||||||
|
assert cache.get("Hello", "fr", "en", "google", glossary_hash="g1") == "V1"
|
||||||
|
assert cache.get("Hello", "fr", "en", "google", glossary_hash="g2") == "V2"
|
||||||
|
|
||||||
|
def test_clear(self):
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
cache.set("k", "fr", "en", "google", "v")
|
||||||
|
cache.clear()
|
||||||
|
assert cache.get("k", "fr", "en", "google") is None
|
||||||
|
|
||||||
|
def test_stats_returns_dict(self):
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
cache.set("k", "fr", "en", "google", "v")
|
||||||
|
cache.get("k", "fr", "en", "google")
|
||||||
|
s = cache.stats()
|
||||||
|
assert isinstance(s, dict)
|
||||||
|
assert s["backend"] == "lru"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Singleton accessor
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestGetCache:
|
||||||
|
def test_returns_singleton(self, monkeypatch):
|
||||||
|
reset_cache_for_tests()
|
||||||
|
c1 = get_cache()
|
||||||
|
c2 = get_cache()
|
||||||
|
assert c1 is c2
|
||||||
|
|
||||||
|
def test_reset_for_tests(self, monkeypatch):
|
||||||
|
reset_cache_for_tests()
|
||||||
|
c1 = get_cache()
|
||||||
|
reset_cache_for_tests()
|
||||||
|
c2 = get_cache()
|
||||||
|
assert c1 is not c2
|
||||||
|
|
||||||
|
def test_singleton_thread_safe(self, monkeypatch):
|
||||||
|
reset_cache_for_tests()
|
||||||
|
caches = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
caches.append(get_cache())
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker) for _ in range(10)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
assert errors == []
|
||||||
|
assert all(c is caches[0] for c in caches)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# End-to-end: integrate with hash_prompt / hash_glossary
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestE2EHashIntegration:
|
||||||
|
def test_real_prompt_and_glossary(self):
|
||||||
|
"""A realistic scenario: same text, different prompt/glossary
|
||||||
|
should give different cache keys and thus different entries."""
|
||||||
|
cache = TranslationCache(backend="lru")
|
||||||
|
|
||||||
|
prompt_a = "Translate this as a formal document."
|
||||||
|
prompt_b = "Translate this as a casual chat message."
|
||||||
|
glossary_a = [{"source": "API", "target": "API"}]
|
||||||
|
glossary_b = [{"source": "API", "target": "interface"}]
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
"Hello", "fr", "en", "google",
|
||||||
|
"Bonjour (formel)",
|
||||||
|
custom_prompt_hash=hash_prompt(prompt_a),
|
||||||
|
glossary_hash=hash_glossary_terms(glossary_a),
|
||||||
|
)
|
||||||
|
cache.set(
|
||||||
|
"Hello", "fr", "en", "google",
|
||||||
|
"Salut (décontracté)",
|
||||||
|
custom_prompt_hash=hash_prompt(prompt_b),
|
||||||
|
glossary_hash=hash_glossary_terms(glossary_b),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reading with the same prompt/glossary should give the right entry
|
||||||
|
assert cache.get(
|
||||||
|
"Hello", "fr", "en", "google",
|
||||||
|
custom_prompt_hash=hash_prompt(prompt_a),
|
||||||
|
glossary_hash=hash_glossary_terms(glossary_a),
|
||||||
|
) == "Bonjour (formel)"
|
||||||
|
assert cache.get(
|
||||||
|
"Hello", "fr", "en", "google",
|
||||||
|
custom_prompt_hash=hash_prompt(prompt_b),
|
||||||
|
glossary_hash=hash_glossary_terms(glossary_b),
|
||||||
|
) == "Salut (décontracté)"
|
||||||
Reference in New Issue
Block a user