Skip to content

Commit b6c29de

Browse files
authored
feat(api): add cache (#15)
* feat(api): add cache interface and valkey impl The cache interface is composed of a class that forces implementations to provide save, get, and delete methods. Currently, the only implementation is valkey which has a docker service limiting its memory. Application connects to the cache instance via CACHE_HOST and CACHE_PORT variables with CACHE_TTL_SECONDS set to 1h by default * feat(api): cache in translate and detect Now translate and detect routes will save the response to cache and retrieve to reply faster to users. The cache key is: - translate:src_lang:tgt_lang:text - detect:text Where text is a sanitized version of the input text. By sanitizations the app will do: - Normalize Unicode characters (NFC form) - Trim excess whitespaces - Covert to lowercase
1 parent 183659e commit b6c29de

21 files changed

+1046
-179
lines changed

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ COPY babeltron ./babeltron
1414
RUN poetry config virtualenvs.create false \
1515
&& poetry install --without dev --no-interaction --no-ansi
1616

17+
# Pin NumPy to a version below 2.0 to avoid compatibility issues
18+
RUN pip install numpy==1.26.4 --force-reinstall
19+
1720
# Install CUDA-enabled PyTorch (replacing the CPU-only version)
1821
RUN pip uninstall -y torch torchvision torchaudio && \
1922
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: check-poetry install test lint format help system-deps coverage coverage-html download-model download-model-m2m-small download-model-m2m-medium download-model-m2m-large download-model-nllb download-model-nllb-small download-model-nllb-medium download-model-nllb-large serve serve-prod docker-build docker-run docker compose-up compose-down pre-commit-install pre-commit-run docker-build-with-model docker-up docker-down
1+
.PHONY: check-poetry install test lint format help system-deps coverage coverage-html download-model download-model-m2m-small download-model-m2m-medium download-model-m2m-large download-model-nllb download-model-nllb-small download-model-nllb-medium download-model-nllb-large serve serve-prod docker-build docker-run docker compose-up compose-down pre-commit-install pre-commit-run docker-build-with-model docker-up docker-down test-cache test-cache-comprehensive
22

33
# Define model path variable with default value, can be overridden by environment
44
MODEL_PATH ?= ./models
@@ -242,3 +242,11 @@ docker compose-up: ## Start services with Docker Compose
242242
@echo "Starting services with Docker Compose..."
243243
@PORT=$(PORT) docker compose up -d
244244
@echo "Services started successfully. API is available at http://localhost:$(PORT)/api/docs"
245+
246+
test-cache: ## Run basic cache end-to-end tests
247+
@echo "Running basic cache end-to-end tests..."
248+
@BABELTRON_MODEL_TYPE=m2m100 ./tests/e2e_cache_test.sh
249+
250+
test-cache-comprehensive: ## Run comprehensive cache end-to-end tests
251+
@echo "Running comprehensive cache end-to-end tests..."
252+
@BABELTRON_MODEL_TYPE=m2m100 ./tests/e2e_cache_comprehensive_test.sh

babeltron/app/cache/__init__.py

Whitespace-only changes.

babeltron/app/cache/base.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Any
3+
4+
5+
class CacheInterface(ABC):
6+
"""
7+
Abstract base class for cache interface.
8+
9+
Any concrete implementation must provide methods for:
10+
- Save data to cache with a TTL
11+
- Get data from cache
12+
- Delete data from cache
13+
"""
14+
15+
@abstractmethod
16+
def save(self, key: str, value: Any, ttl: int) -> None:
17+
"""
18+
Save data to cache with a TTL.
19+
20+
Args:
21+
key: The key to save the data to
22+
value: The data to save
23+
ttl: The time to live for the data
24+
"""
25+
pass
26+
27+
@abstractmethod
28+
def get(self, key: str) -> Any:
29+
"""
30+
Get data from cache.
31+
32+
Args:
33+
key: The key to retrieve the data from
34+
35+
Returns:
36+
The data retrieved from cache
37+
"""
38+
pass
39+
40+
@abstractmethod
41+
def delete(self, key: str) -> None:
42+
"""
43+
Delete data from cache.
44+
"""
45+
pass

babeltron/app/cache/service.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import logging
2+
from typing import Generic, Optional, TypeVar, cast
3+
4+
from babeltron.app.cache.base import CacheInterface
5+
from babeltron.app.cache.utils import generate_cache_key
6+
from babeltron.app.cache.valkey import ValkeyCache
7+
from babeltron.app.config import CACHE_TTL_SECONDS
8+
9+
# Type variable for generic response types
10+
T = TypeVar("T")
11+
12+
13+
class CacheService(Generic[T]):
14+
"""
15+
Service for caching translation and detection results
16+
"""
17+
18+
def __init__(
19+
self,
20+
cache_client: Optional[CacheInterface] = None,
21+
ttl: int = CACHE_TTL_SECONDS,
22+
):
23+
"""
24+
Initialize the cache service
25+
26+
Args:
27+
cache_client: Optional cache client, defaults to ValkeyCache
28+
ttl: Time to live for cache entries in seconds
29+
"""
30+
self.cache = cache_client or ValkeyCache()
31+
self.ttl = ttl
32+
self.logger = logging.getLogger(__name__)
33+
34+
def get_translation(self, text: str, src_lang: str, tgt_lang: str) -> Optional[T]:
35+
"""
36+
Get a cached translation result
37+
38+
Args:
39+
text: The text to translate
40+
src_lang: Source language
41+
tgt_lang: Target language
42+
43+
Returns:
44+
Cached translation result or None if not found
45+
"""
46+
cache_key = generate_cache_key("translate", text, src_lang, tgt_lang)
47+
self.logger.debug(f"Looking up translation in cache with key: {cache_key}")
48+
49+
cached_result = self.cache.get(cache_key)
50+
51+
if cached_result:
52+
self.logger.info(f"Cache hit for translation: {src_lang} -> {tgt_lang}")
53+
return cast(T, cached_result)
54+
55+
self.logger.info(f"Cache miss for translation: {src_lang} -> {tgt_lang}")
56+
return None
57+
58+
def save_translation(
59+
self, text: str, src_lang: str, tgt_lang: str, result: T
60+
) -> None:
61+
"""
62+
Save a translation result to cache
63+
64+
Args:
65+
text: The text that was translated
66+
src_lang: Source language
67+
tgt_lang: Target language
68+
result: Translation result to cache
69+
"""
70+
cache_key = generate_cache_key("translate", text, src_lang, tgt_lang)
71+
self.logger.debug(f"Saving translation to cache with key: {cache_key}")
72+
73+
try:
74+
self.cache.save(cache_key, result, self.ttl)
75+
self.logger.info(f"Cached translation result: {src_lang} -> {tgt_lang}")
76+
except Exception as e:
77+
self.logger.error(f"Failed to cache translation result: {str(e)}")
78+
79+
def get_detection(self, text: str) -> Optional[T]:
80+
"""
81+
Get a cached language detection result
82+
83+
Args:
84+
text: The text to detect language for
85+
86+
Returns:
87+
Cached detection result or None if not found
88+
"""
89+
cache_key = generate_cache_key("detect", text)
90+
self.logger.debug(
91+
f"Looking up language detection in cache with key: {cache_key}"
92+
)
93+
94+
cached_result = self.cache.get(cache_key)
95+
96+
if cached_result:
97+
self.logger.info("Cache hit for language detection")
98+
return cast(T, cached_result)
99+
100+
self.logger.info("Cache miss for language detection")
101+
return None
102+
103+
def save_detection(self, text: str, result: T) -> None:
104+
"""
105+
Save a language detection result to cache
106+
107+
Args:
108+
text: The text that was analyzed
109+
result: Detection result to cache
110+
"""
111+
cache_key = generate_cache_key("detect", text)
112+
self.logger.debug(f"Saving language detection to cache with key: {cache_key}")
113+
114+
try:
115+
self.cache.save(cache_key, result, self.ttl)
116+
self.logger.info("Cached language detection result")
117+
except Exception as e:
118+
self.logger.error(f"Failed to cache language detection result: {str(e)}")

babeltron/app/cache/utils.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import hashlib
2+
import re
3+
import unicodedata
4+
from typing import Optional
5+
6+
7+
def sanitize_text(text: str) -> str:
8+
"""
9+
Sanitize text for cache key generation:
10+
1. Normalize Unicode characters to their canonical form
11+
2. Preserve important punctuation
12+
3. Trim excess whitespace
13+
4. Convert to lowercase for consistency
14+
15+
Args:
16+
text: The text to sanitize
17+
18+
Returns:
19+
Sanitized text
20+
"""
21+
# Normalize Unicode characters (NFC form)
22+
normalized = unicodedata.normalize("NFC", text)
23+
24+
# Trim excess whitespace (replace multiple spaces with a single space)
25+
# but preserve newlines and other meaningful spacing
26+
trimmed = re.sub(r"\s+", " ", normalized).strip()
27+
28+
# Convert to lowercase for consistency
29+
lowercased = trimmed.lower()
30+
31+
return lowercased
32+
33+
34+
def generate_cache_key(
35+
prefix: str,
36+
text: str,
37+
src_lang: Optional[str] = None,
38+
tgt_lang: Optional[str] = None,
39+
) -> str:
40+
"""
41+
Generate a cache key for the given text and languages.
42+
43+
Args:
44+
prefix: The prefix for the cache key (e.g., 'translate', 'detect')
45+
text: The text to generate a key for
46+
src_lang: Optional source language
47+
tgt_lang: Optional target language
48+
49+
Returns:
50+
A cache key string
51+
"""
52+
# Sanitize the text
53+
sanitized = sanitize_text(text)
54+
55+
# Build the key components
56+
key_parts = [prefix]
57+
58+
if src_lang:
59+
key_parts.append(src_lang)
60+
61+
if tgt_lang:
62+
key_parts.append(tgt_lang)
63+
64+
key_parts.append(sanitized)
65+
66+
# Join the parts with a colon
67+
key_string = ":".join(key_parts)
68+
69+
# Generate MD5 hash
70+
md5_hash = hashlib.md5(key_string.encode("utf-8")).hexdigest()
71+
72+
return md5_hash

0 commit comments

Comments
 (0)