diff --git a/app/ai-service/.env.example b/app/ai-service/.env.example index 8de6d603..7019f95b 100644 --- a/app/ai-service/.env.example +++ b/app/ai-service/.env.example @@ -18,7 +18,7 @@ LOG_LEVEL=INFO HOST=0.0.0.0 PORT=8000 -# Redis Configuration (for Celery task queue) +# Redis Configuration (for Celery task queue and response caching) # Format: redis://[username]:[password]@[host]:[port]/[database] REDIS_URL=redis://localhost:6379/0 @@ -31,3 +31,9 @@ BACKEND_WEBHOOK_URL=http://localhost:3001/ai/webhook PROOF_OF_LIFE_CONFIDENCE_THRESHOLD=0.65 # Minimum face bounding-box size in pixels for detection PROOF_OF_LIFE_MIN_FACE_SIZE=80 + +# Cache TTL Settings (in seconds) +# Task status polling - short TTL for responsive updates +CACHE_TTL_TASK_STATUS=30 +# Artifact access metadata - moderate TTL +CACHE_TTL_ARTIFACT_ACCESS=60 diff --git a/app/ai-service/api/v1/inference.py b/app/ai-service/api/v1/inference.py index c3bf21ab..6eb24fe0 100644 --- a/app/ai-service/api/v1/inference.py +++ b/app/ai-service/api/v1/inference.py @@ -9,6 +9,8 @@ from pydantic import BaseModel import tasks +from services.cache import cached_response +from config import settings logger = logging.getLogger(__name__) @@ -90,6 +92,7 @@ async def get_job_status(task_id: str): return await _get_task_status(task_id) +@cached_response(prefix="task_status", ttl_seconds=settings.cache_ttl_task_status) async def _get_task_status(task_id: str): logger.info(f"Checking status for task: {task_id}") diff --git a/app/ai-service/config.py b/app/ai-service/config.py index a590acaa..ba470830 100644 --- a/app/ai-service/config.py +++ b/app/ai-service/config.py @@ -51,6 +51,10 @@ class Settings(BaseSettings): circuit_breaker_failure_threshold: int = 3 circuit_breaker_recovery_timeout_seconds: float = 30.0 + # Cache TTL settings (in seconds) + cache_ttl_task_status: int = 30 # Short TTL for responsive polling + cache_ttl_artifact_access: int = 60 # 1 minute for artifact metadata + # Application settings app_env: Literal["development", "staging", "production", "test"] = "development" log_level: str = "INFO" diff --git a/app/ai-service/main.py b/app/ai-service/main.py index 48414a10..1a532714 100644 --- a/app/ai-service/main.py +++ b/app/ai-service/main.py @@ -114,6 +114,14 @@ async def lifespan(app: FastAPI): logger.info(f"Redis configured: {settings.redis_url}") logger.info(f"Backend webhook URL: {settings.backend_webhook_url}") + # Initialize cache service + from services.cache import CacheService + app.state.cache = CacheService(settings) + if app.state.cache.enabled: + logger.info("Response caching enabled with Redis") + else: + logger.warning("Response caching disabled (Redis unavailable)") + yield logger.info("Shutting down Soter AI Service...") diff --git a/app/ai-service/services/cache.py b/app/ai-service/services/cache.py new file mode 100644 index 00000000..dc4a030d --- /dev/null +++ b/app/ai-service/services/cache.py @@ -0,0 +1,251 @@ +""" +Redis-based caching service for AI task responses. +Provides response caching for safe read operations with configurable TTL. +""" + +import json +import hashlib +import logging +from typing import Optional, Any, Callable +from functools import wraps +import redis +from config import Settings + +logger = logging.getLogger(__name__) + + +class CacheService: + """ + Redis-based cache service with automatic serialization and TTL support. + """ + + def __init__(self, settings: Settings): + """ + Initialize the cache service with Redis connection. + + Args: + settings: Application settings containing Redis configuration + """ + self.settings = settings + self.enabled = True + + try: + # Parse Redis URL + self.client = redis.from_url( + settings.redis_url, + decode_responses=True, + socket_connect_timeout=2, + socket_timeout=2, + ) + # Test connection + self.client.ping() + logger.info("Cache service initialized successfully") + except Exception as e: + logger.warning(f"Cache service disabled due to Redis error: {e}") + self.enabled = False + self.client = None + + def _generate_key(self, prefix: str, *args: Any, **kwargs: Any) -> str: + """ + Generate a deterministic cache key from function arguments. + + Args: + prefix: Namespace prefix for the key + *args: Positional arguments + **kwargs: Keyword arguments + + Returns: + SHA256 hash-based cache key + """ + # Sort kwargs for consistent key generation + sorted_kwargs = sorted(kwargs.items()) + + # Create a deterministic string representation + key_data = { + "args": args, + "kwargs": sorted_kwargs, + } + + # Hash the serialized data + key_str = json.dumps(key_data, sort_keys=True, default=str) + key_hash = hashlib.sha256(key_str.encode()).hexdigest() + + return f"cache:ai:{prefix}:{key_hash}" + + def get(self, key: str) -> Optional[Any]: + """ + Retrieve a value from cache. + + Args: + key: Cache key + + Returns: + Cached value or None if not found/expired + """ + if not self.enabled or not self.client: + return None + + try: + raw = self.client.get(key) + if raw is None: + return None + + return json.loads(raw) + except Exception as e: + logger.warning(f"Cache GET failed for key {key}: {e}") + return None + + def set(self, key: str, value: Any, ttl_seconds: int) -> bool: + """ + Store a value in cache with TTL. + + Args: + key: Cache key + value: Value to cache (must be JSON-serializable) + ttl_seconds: Time-to-live in seconds + + Returns: + True if successful, False otherwise + """ + if not self.enabled or not self.client: + return False + + try: + serialized = json.dumps(value, default=str) + self.client.setex(key, ttl_seconds, serialized) + logger.debug(f"Cached key {key} with TTL {ttl_seconds}s") + return True + except Exception as e: + logger.warning(f"Cache SET failed for key {key}: {e}") + return False + + def delete(self, key: str) -> bool: + """ + Delete a key from cache. + + Args: + key: Cache key to delete + + Returns: + True if successful, False otherwise + """ + if not self.enabled or not self.client: + return False + + try: + self.client.delete(key) + return True + except Exception as e: + logger.warning(f"Cache DELETE failed for key {key}: {e}") + return False + + def delete_pattern(self, pattern: str) -> int: + """ + Delete all keys matching a pattern using SCAN (non-blocking). + + Args: + pattern: Redis glob pattern (e.g., "cache:ai:task:*") + + Returns: + Number of keys deleted + """ + if not self.enabled or not self.client: + return 0 + + try: + keys = [] + for key in self.client.scan_iter(match=pattern, count=100): + keys.append(key) + + if keys: + deleted = self.client.delete(*keys) + logger.info(f"Deleted {deleted} keys matching pattern {pattern}") + return deleted + return 0 + except Exception as e: + logger.warning(f"Cache DELETE_PATTERN failed for {pattern}: {e}") + return 0 + + +def cached_response(prefix: str, ttl_seconds: int): + """ + Decorator to cache function responses based on normalized inputs. + + Args: + prefix: Cache key namespace prefix + ttl_seconds: Time-to-live for cached responses + + Example: + @cached_response(prefix="task_status", ttl_seconds=30) + async def get_task_status(task_id: str): + return await fetch_task_status(task_id) + """ + + def decorator(func: Callable): + @wraps(func) + async def async_wrapper(*args, **kwargs): + # Get or create cache service instance + from main import app + + cache: CacheService = getattr(app.state, "cache", None) + if not cache or not cache.enabled: + # Cache not available, execute function directly + return await func(*args, **kwargs) + + # Generate cache key + cache_key = cache._generate_key(prefix, *args, **kwargs) + + # Try to retrieve from cache + cached_value = cache.get(cache_key) + if cached_value is not None: + logger.debug(f"Cache HIT: {cache_key}") + return cached_value + + logger.debug(f"Cache MISS: {cache_key}") + + # Execute function and cache result + result = await func(*args, **kwargs) + + # Cache the result + cache.set(cache_key, result, ttl_seconds) + + return result + + @wraps(func) + def sync_wrapper(*args, **kwargs): + # Get or create cache service instance + from main import app + + cache: CacheService = getattr(app.state, "cache", None) + if not cache or not cache.enabled: + # Cache not available, execute function directly + return func(*args, **kwargs) + + # Generate cache key + cache_key = cache._generate_key(prefix, *args, **kwargs) + + # Try to retrieve from cache + cached_value = cache.get(cache_key) + if cached_value is not None: + logger.debug(f"Cache HIT: {cache_key}") + return cached_value + + logger.debug(f"Cache MISS: {cache_key}") + + # Execute function and cache result + result = func(*args, **kwargs) + + # Cache the result + cache.set(cache_key, result, ttl_seconds) + + return result + + # Return appropriate wrapper based on function type + import asyncio + + if asyncio.iscoroutinefunction(func): + return async_wrapper + else: + return sync_wrapper + + return decorator diff --git a/app/ai-service/services/cache_invalidation.py b/app/ai-service/services/cache_invalidation.py new file mode 100644 index 00000000..78399f67 --- /dev/null +++ b/app/ai-service/services/cache_invalidation.py @@ -0,0 +1,96 @@ +""" +Cache invalidation helpers for AI service +""" + +import logging +from typing import Optional +from services.cache import CacheService + +logger = logging.getLogger(__name__) + + +class CacheInvalidationHelper: + """ + Helper class for invalidating specific cache patterns. + Provides convenient methods for common invalidation scenarios. + """ + + def __init__(self, cache_service: CacheService): + self.cache = cache_service + + def invalidate_task_status(self, task_id: str) -> int: + """ + Invalidate cache for a specific task status. + + Args: + task_id: The task ID to invalidate + + Returns: + Number of keys deleted + """ + pattern = f"cache:ai:task_status:*{task_id}*" + deleted = self.cache.delete_pattern(pattern) + if deleted > 0: + logger.info(f"Invalidated {deleted} task status cache entries for task {task_id}") + return deleted + + def invalidate_all_task_statuses(self) -> int: + """ + Invalidate all task status caches. + + Returns: + Number of keys deleted + """ + pattern = "cache:ai:task_status:*" + deleted = self.cache.delete_pattern(pattern) + if deleted > 0: + logger.info(f"Invalidated {deleted} task status cache entries") + return deleted + + def invalidate_artifact_access(self, artifact_id: str) -> int: + """ + Invalidate cache for artifact access checks. + + Args: + artifact_id: The artifact ID to invalidate + + Returns: + Number of keys deleted + """ + pattern = f"cache:ai:artifact_access:*{artifact_id}*" + deleted = self.cache.delete_pattern(pattern) + if deleted > 0: + logger.info(f"Invalidated {deleted} artifact access cache entries for {artifact_id}") + return deleted + + def invalidate_all(self) -> int: + """ + Invalidate all AI service caches (nuclear option). + + Returns: + Number of keys deleted + """ + pattern = "cache:ai:*" + deleted = self.cache.delete_pattern(pattern) + logger.warning(f"Invalidated ALL AI cache entries ({deleted} keys)") + return deleted + + +def get_invalidation_helper(cache_service: Optional[CacheService] = None) -> CacheInvalidationHelper: + """ + Get a cache invalidation helper instance. + + Args: + cache_service: Optional CacheService instance. If not provided, + will attempt to get from app.state.cache + + Returns: + CacheInvalidationHelper instance + """ + if cache_service is None: + from main import app + cache_service = getattr(app.state, "cache", None) + if cache_service is None: + raise RuntimeError("Cache service not available") + + return CacheInvalidationHelper(cache_service) diff --git a/app/ai-service/tests/test_cache_service.py b/app/ai-service/tests/test_cache_service.py new file mode 100644 index 00000000..404fbf18 --- /dev/null +++ b/app/ai-service/tests/test_cache_service.py @@ -0,0 +1,273 @@ +""" +Tests for the cache service +""" + +import pytest +from unittest.mock import Mock, patch +from services.cache import CacheService, cached_response +from config import Settings + + +@pytest.fixture +def mock_settings(): + """Create mock settings for testing""" + settings = Mock(spec=Settings) + settings.redis_url = "redis://localhost:6379/0" + settings.cache_ttl_task_status = 30 + return settings + + +@pytest.fixture +def cache_service_with_mock_redis(mock_settings): + """Create a cache service with mocked Redis client""" + with patch("services.cache.redis") as mock_redis: + # Mock successful Redis connection + mock_client = Mock() + mock_client.ping.return_value = True + mock_redis.from_url.return_value = mock_client + + cache = CacheService(mock_settings) + cache.client = mock_client + return cache + + +class TestCacheService: + def test_cache_service_initialization_success(self, mock_settings): + """Test that cache service initializes successfully with valid Redis""" + with patch("services.cache.redis") as mock_redis: + mock_client = Mock() + mock_client.ping.return_value = True + mock_redis.from_url.return_value = mock_client + + cache = CacheService(mock_settings) + + assert cache.enabled is True + assert cache.client is not None + mock_client.ping.assert_called_once() + + def test_cache_service_initialization_failure(self, mock_settings): + """Test that cache service handles Redis connection failure gracefully""" + with patch("services.cache.redis") as mock_redis: + mock_redis.from_url.side_effect = Exception("Connection refused") + + cache = CacheService(mock_settings) + + assert cache.enabled is False + assert cache.client is None + + def test_generate_key_deterministic(self, cache_service_with_mock_redis): + """Test that cache key generation is deterministic""" + cache = cache_service_with_mock_redis + + key1 = cache._generate_key("test", "arg1", kwarg1="value1") + key2 = cache._generate_key("test", "arg1", kwarg1="value1") + + assert key1 == key2 + assert key1.startswith("cache:ai:test:") + + def test_generate_key_different_args(self, cache_service_with_mock_redis): + """Test that different args generate different keys""" + cache = cache_service_with_mock_redis + + key1 = cache._generate_key("test", "arg1", kwarg1="value1") + key2 = cache._generate_key("test", "arg2", kwarg1="value1") + + assert key1 != key2 + + def test_get_cache_hit(self, cache_service_with_mock_redis): + """Test successful cache retrieval""" + cache = cache_service_with_mock_redis + cache.client.get.return_value = '{"result": "test_value"}' + + result = cache.get("test_key") + + assert result == {"result": "test_value"} + cache.client.get.assert_called_once_with("test_key") + + def test_get_cache_miss(self, cache_service_with_mock_redis): + """Test cache miss returns None""" + cache = cache_service_with_mock_redis + cache.client.get.return_value = None + + result = cache.get("test_key") + + assert result is None + + def test_get_handles_errors(self, cache_service_with_mock_redis): + """Test that get() handles Redis errors gracefully""" + cache = cache_service_with_mock_redis + cache.client.get.side_effect = Exception("Redis error") + + result = cache.get("test_key") + + assert result is None + + def test_set_cache(self, cache_service_with_mock_redis): + """Test successful cache storage""" + cache = cache_service_with_mock_redis + + result = cache.set("test_key", {"data": "value"}, 300) + + assert result is True + cache.client.setex.assert_called_once() + call_args = cache.client.setex.call_args[0] + assert call_args[0] == "test_key" + assert call_args[1] == 300 + assert '"data": "value"' in call_args[2] + + def test_set_handles_errors(self, cache_service_with_mock_redis): + """Test that set() handles Redis errors gracefully""" + cache = cache_service_with_mock_redis + cache.client.setex.side_effect = Exception("Redis error") + + result = cache.set("test_key", {"data": "value"}, 300) + + assert result is False + + def test_delete_cache(self, cache_service_with_mock_redis): + """Test cache key deletion""" + cache = cache_service_with_mock_redis + + result = cache.delete("test_key") + + assert result is True + cache.client.delete.assert_called_once_with("test_key") + + def test_delete_pattern(self, cache_service_with_mock_redis): + """Test pattern-based cache deletion""" + cache = cache_service_with_mock_redis + cache.client.scan_iter.return_value = ["key1", "key2", "key3"] + cache.client.delete.return_value = 3 + + result = cache.delete_pattern("cache:ai:*") + + assert result == 3 + cache.client.scan_iter.assert_called_once_with(match="cache:ai:*", count=100) + cache.client.delete.assert_called_once_with("key1", "key2", "key3") + + def test_cache_disabled_get(self, mock_settings): + """Test that get() returns None when cache is disabled""" + cache = CacheService.__new__(CacheService) + cache.enabled = False + cache.client = None + + result = cache.get("test_key") + + assert result is None + + def test_cache_disabled_set(self, mock_settings): + """Test that set() returns False when cache is disabled""" + cache = CacheService.__new__(CacheService) + cache.enabled = False + cache.client = None + + result = cache.set("test_key", {"data": "value"}, 300) + + assert result is False + + +class TestCachedResponseDecorator: + @pytest.mark.asyncio + async def test_cached_response_async_function_cache_miss(self): + """Test async function with cache miss""" + # Create a mock cache service directly + mock_cache = Mock() + mock_cache.enabled = True + mock_cache.get.return_value = None + mock_cache._generate_key = Mock(return_value="test_key") + + call_count = 0 + + @cached_response(prefix="test", ttl_seconds=60) + async def test_func(cache_service, arg1): + nonlocal call_count + call_count += 1 + return f"result_{arg1}" + + # Temporarily inject cache into function's closure + with patch("main.app") as mock_app: + mock_app.state.cache = mock_cache + result = await test_func(arg1="value1") + + assert result == "result_value1" + assert call_count == 1 + mock_cache.get.assert_called_once() + mock_cache.set.assert_called_once() + + @pytest.mark.asyncio + async def test_cached_response_async_function_cache_hit(self): + """Test async function with cache hit""" + # Create a mock cache service directly + mock_cache = Mock() + mock_cache.enabled = True + mock_cache.get.return_value = "cached_result" + mock_cache._generate_key = Mock(return_value="test_key") + + call_count = 0 + + @cached_response(prefix="test", ttl_seconds=60) + async def test_func(arg1): + nonlocal call_count + call_count += 1 + return f"result_{arg1}" + + # Temporarily inject cache into function's closure + with patch("main.app") as mock_app: + mock_app.state.cache = mock_cache + result = await test_func("value1") + + assert result == "cached_result" + assert call_count == 0 # Function not called + mock_cache.get.assert_called_once() + mock_cache.set.assert_not_called() + + def test_cached_response_sync_function(self): + """Test sync function caching""" + # Create a mock cache service directly + mock_cache = Mock() + mock_cache.enabled = True + mock_cache.get.return_value = None + mock_cache._generate_key = Mock(return_value="test_key") + + call_count = 0 + + @cached_response(prefix="test", ttl_seconds=60) + def test_func(arg1): + nonlocal call_count + call_count += 1 + return f"result_{arg1}" + + # Temporarily inject cache into function's closure + with patch("main.app") as mock_app: + mock_app.state.cache = mock_cache + result = test_func("value1") + + assert result == "result_value1" + assert call_count == 1 + mock_cache.get.assert_called_once() + mock_cache.set.assert_called_once() + + @pytest.mark.asyncio + async def test_cached_response_cache_disabled(self): + """Test that function executes normally when cache is disabled""" + # Create a mock cache service that's disabled + mock_cache = Mock() + mock_cache.enabled = False + + call_count = 0 + + @cached_response(prefix="test", ttl_seconds=60) + async def test_func(arg1): + nonlocal call_count + call_count += 1 + return f"result_{arg1}" + + # Temporarily inject cache into function's closure + with patch("main.app") as mock_app: + mock_app.state.cache = mock_cache + result = await test_func("value1") + + assert result == "result_value1" + assert call_count == 1 + mock_cache.get.assert_not_called() + mock_cache.set.assert_not_called() diff --git a/app/backend/.env.example b/app/backend/.env.example index a080c8e5..b104cb3f 100644 --- a/app/backend/.env.example +++ b/app/backend/.env.example @@ -2,4 +2,30 @@ DATABASE_URL="file:./prisma/dev.db" WEBHOOK_SECRET="change-me-to-a-strong-random-secret" SOROBAN_NETWORK="testnet" AID_ESCROW_CONTRACT_ID="" -VERIFICATION_MODE="mock" \ No newline at end of file +VERIFICATION_MODE="mock" + +# Redis Configuration +REDIS_HOST="localhost" +REDIS_PORT="6379" + +# Response Cache TTL Settings (in seconds) +# Verification status/details - once decided, rarely changes +CACHE_TTL_VERIFICATION_STATUS=300 +# Verification metrics - aggregate counts +CACHE_TTL_VERIFICATION_METRICS=60 +# Aid package details - changes only on blockchain events +CACHE_TTL_AID_PACKAGE_DETAILS=300 +# Aid package statistics - aggregate data +CACHE_TTL_AID_PACKAGE_STATS=600 +# Transaction status - immutable once confirmed +CACHE_TTL_TRANSACTION_STATUS=1800 +# Global analytics - expensive queries +CACHE_TTL_ANALYTICS_GLOBAL=600 +# Map data - geographic aggregations +CACHE_TTL_ANALYTICS_MAP_DATA=900 +# Internal notes - staff-only +CACHE_TTL_INTERNAL_NOTES=120 +# User verification history - append-only +CACHE_TTL_USER_VERIFICATION_HISTORY=180 +# AI task status - polled frequently +CACHE_TTL_AI_TASK_STATUS=30 diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 63637956..2fee3e5b 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -44,6 +44,8 @@ import { RedisModule } from '@liaoliaots/nestjs-redis'; import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guard'; import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor'; import { SandboxModule } from './sandbox/sandbox.module'; +import { CacheModule } from './common/cache/cache.module'; +import { CacheResponseInterceptor } from './common/interceptors/cache-response.interceptor'; @Module({ imports: [ @@ -90,6 +92,7 @@ import { SandboxModule } from './sandbox/sandbox.module'; LoggerModule, PrismaModule, + CacheModule, HealthModule, AidModule, VerificationModule, @@ -158,6 +161,10 @@ import { SandboxModule } from './sandbox/sandbox.module'; provide: APP_INTERCEPTOR, useClass: DeprecationInterceptor, }, + { + provide: APP_INTERCEPTOR, + useClass: CacheResponseInterceptor, + }, ], }) export class AppModule implements NestModule { diff --git a/app/backend/src/common/cache/cache.module.ts b/app/backend/src/common/cache/cache.module.ts new file mode 100644 index 00000000..64db0bb7 --- /dev/null +++ b/app/backend/src/common/cache/cache.module.ts @@ -0,0 +1,14 @@ +import { Module, Global } from '@nestjs/common'; +import { RedisService } from '../../../cache/redis.service'; +import { CacheInvalidationService } from '../services/cache-invalidation.service'; + +/** + * Global cache module that provides RedisService and cache utilities + * to all application modules without explicit imports. + */ +@Global() +@Module({ + providers: [RedisService, CacheInvalidationService], + exports: [RedisService, CacheInvalidationService], +}) +export class CacheModule {} diff --git a/app/backend/src/common/config/cache.config.ts b/app/backend/src/common/config/cache.config.ts new file mode 100644 index 00000000..04e8554d --- /dev/null +++ b/app/backend/src/common/config/cache.config.ts @@ -0,0 +1,110 @@ +/** + * Cache TTL configuration for different response types + * All values in seconds + */ +export const CACHE_TTL = { + /** + * Verification status/details - once decided, rarely changes + * Default: 5 minutes + */ + VERIFICATION_STATUS: parseInt( + process.env.CACHE_TTL_VERIFICATION_STATUS || '300', + 10, + ), + + /** + * Verification metrics - aggregate counts, acceptable staleness + * Default: 1 minute + */ + VERIFICATION_METRICS: parseInt( + process.env.CACHE_TTL_VERIFICATION_METRICS || '60', + 10, + ), + + /** + * Aid package details - changes only on blockchain events + * Default: 5 minutes + */ + AID_PACKAGE_DETAILS: parseInt( + process.env.CACHE_TTL_AID_PACKAGE_DETAILS || '300', + 10, + ), + + /** + * Aid package statistics - aggregate data, high read volume + * Default: 10 minutes + */ + AID_PACKAGE_STATS: parseInt( + process.env.CACHE_TTL_AID_PACKAGE_STATS || '600', + 10, + ), + + /** + * Transaction status - immutable once confirmed + * Default: 30 minutes (very safe for confirmed txs) + */ + TRANSACTION_STATUS: parseInt( + process.env.CACHE_TTL_TRANSACTION_STATUS || '1800', + 10, + ), + + /** + * Global analytics - expensive queries, acceptable staleness + * Default: 10 minutes + */ + ANALYTICS_GLOBAL: parseInt( + process.env.CACHE_TTL_ANALYTICS_GLOBAL || '600', + 10, + ), + + /** + * Map data - geographic aggregations, low change frequency + * Default: 15 minutes + */ + ANALYTICS_MAP_DATA: parseInt( + process.env.CACHE_TTL_ANALYTICS_MAP_DATA || '900', + 10, + ), + + /** + * Internal notes - staff-only, rarely updated + * Default: 2 minutes + */ + INTERNAL_NOTES: parseInt(process.env.CACHE_TTL_INTERNAL_NOTES || '120', 10), + + /** + * User verification history - append-only, safe to cache + * Default: 3 minutes + */ + USER_VERIFICATION_HISTORY: parseInt( + process.env.CACHE_TTL_USER_VERIFICATION_HISTORY || '180', + 10, + ), + + /** + * AI task status - polled frequently during execution + * Default: 30 seconds (short TTL for responsive updates) + */ + AI_TASK_STATUS: parseInt(process.env.CACHE_TTL_AI_TASK_STATUS || '30', 10), +} as const; + +/** + * Cache configuration for testnet environments + * More aggressive caching for testing with lower traffic + */ +export const TESTNET_CACHE_TTL = { + ...CACHE_TTL, + VERIFICATION_STATUS: 600, // 10 minutes + AID_PACKAGE_STATS: 1200, // 20 minutes + ANALYTICS_GLOBAL: 1800, // 30 minutes +} as const; + +/** + * Get cache TTL based on environment + */ +export function getCacheTTL() { + const isTestnet = + process.env.SOROBAN_NETWORK === 'testnet' || + process.env.NODE_ENV === 'test'; + return isTestnet ? TESTNET_CACHE_TTL : CACHE_TTL; +} diff --git a/app/backend/src/common/decorators/cache-response.decorator.ts b/app/backend/src/common/decorators/cache-response.decorator.ts new file mode 100644 index 00000000..8c3a240b --- /dev/null +++ b/app/backend/src/common/decorators/cache-response.decorator.ts @@ -0,0 +1,65 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * Metadata key for response caching configuration + */ +export const CACHE_RESPONSE_KEY = 'cache:response'; + +/** + * Options for the CacheResponse decorator + */ +export interface CacheResponseOptions { + /** + * Time-to-live in seconds for the cached response + * @example 300 // 5 minutes + */ + ttl: number; + + /** + * Optional key generator function to create cache keys from request context. + * If not provided, defaults to normalizing route + query params + body. + * + * @param req - Express request object + * @returns Cache key string + */ + keyGenerator?: (req: any) => string; + + /** + * Optional flag to include request body in cache key generation. + * Default: false (only route + query params) + */ + includeBody?: boolean; + + /** + * Cache key prefix for namespacing. Default: 'cache:response' + */ + prefix?: string; +} + +/** + * Decorator to enable response caching for GET endpoints. + * Uses Redis to cache responses based on normalized request inputs. + * + * @example + * ```typescript + * @Get('verification/:id') + * @CacheResponse({ ttl: 300 }) // Cache for 5 minutes + * async getVerification(@Param('id') id: string) { + * return this.verificationService.getVerification(id); + * } + * ``` + * + * @example Custom key generator + * ```typescript + * @Get('stats') + * @CacheResponse({ + * ttl: 600, + * keyGenerator: (req) => `stats:${req.user?.id || 'anonymous'}` + * }) + * async getStats() { + * return this.analyticsService.getStats(); + * } + * ``` + */ +export const CacheResponse = (options: CacheResponseOptions) => + SetMetadata(CACHE_RESPONSE_KEY, options); diff --git a/app/backend/src/common/interceptors/cache-response.interceptor.spec.ts b/app/backend/src/common/interceptors/cache-response.interceptor.spec.ts new file mode 100644 index 00000000..1ec2d310 --- /dev/null +++ b/app/backend/src/common/interceptors/cache-response.interceptor.spec.ts @@ -0,0 +1,179 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, CallHandler } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { of } from 'rxjs'; +import { CacheResponseInterceptor } from './cache-response.interceptor'; +import { RedisService } from '../../../cache/redis.service'; + +describe('CacheResponseInterceptor', () => { + let interceptor: CacheResponseInterceptor; + let reflector: Reflector; + let redisService: RedisService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CacheResponseInterceptor, + { + provide: Reflector, + useValue: { + get: jest.fn(), + }, + }, + { + provide: RedisService, + useValue: { + get: jest.fn(), + set: jest.fn(), + }, + }, + ], + }).compile(); + + interceptor = module.get( + CacheResponseInterceptor, + ); + reflector = module.get(Reflector); + redisService = module.get(RedisService); + }); + + it('should be defined', () => { + expect(interceptor).toBeDefined(); + }); + + describe('intercept', () => { + let mockExecutionContext: ExecutionContext; + let mockCallHandler: CallHandler; + + beforeEach(() => { + mockExecutionContext = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ + method: 'GET', + route: { path: '/test' }, + path: '/test', + query: {}, + body: {}, + }), + }), + getHandler: jest.fn(), + } as any; + + mockCallHandler = { + handle: jest.fn().mockReturnValue(of({ data: 'test' })), + }; + }); + + it('should skip caching when no metadata is present', (done) => { + jest.spyOn(reflector, 'get').mockReturnValue(undefined); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toEqual({ data: 'test' }); + expect(redisService.get).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should return cached response on cache hit', (done) => { + const cacheOptions = { ttl: 300 }; + const cachedData = { data: 'cached' }; + + jest.spyOn(reflector, 'get').mockReturnValue(cacheOptions); + jest.spyOn(redisService, 'get').mockResolvedValue(cachedData); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toEqual(cachedData); + expect(redisService.get).toHaveBeenCalled(); + expect(mockCallHandler.handle).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should execute handler and cache result on cache miss', (done) => { + const cacheOptions = { ttl: 300 }; + const handlerData = { data: 'fresh' }; + + jest.spyOn(reflector, 'get').mockReturnValue(cacheOptions); + jest.spyOn(redisService, 'get').mockResolvedValue(null); + jest.spyOn(redisService, 'set').mockResolvedValue(undefined); + mockCallHandler.handle = jest.fn().mockReturnValue(of(handlerData)); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe((result) => { + expect(result).toEqual(handlerData); + expect(redisService.get).toHaveBeenCalled(); + expect(mockCallHandler.handle).toHaveBeenCalled(); + + // Set is called asynchronously, give it a moment + setTimeout(() => { + expect(redisService.set).toHaveBeenCalledWith( + expect.any(String), + handlerData, + 300, + ); + done(); + }, 10); + }); + }); + + it('should use custom key generator when provided', (done) => { + const customKey = 'custom:key:123'; + const cacheOptions = { + ttl: 300, + keyGenerator: jest.fn().mockReturnValue(customKey), + }; + + jest.spyOn(reflector, 'get').mockReturnValue(cacheOptions); + jest.spyOn(redisService, 'get').mockResolvedValue(null); + jest.spyOn(redisService, 'set').mockResolvedValue(undefined); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe({ + next: () => { + expect(cacheOptions.keyGenerator).toHaveBeenCalled(); + expect(redisService.get).toHaveBeenCalledWith( + expect.stringContaining(customKey), + ); + done(); + }, + error: (err) => done(err), + }); + }); + + it('should include query params in cache key', (done) => { + const cacheOptions = { ttl: 300 }; + mockExecutionContext.switchToHttp = jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue({ + method: 'GET', + route: { path: '/test' }, + path: '/test', + query: { page: '1', limit: '10' }, + body: {}, + }), + }); + + jest.spyOn(reflector, 'get').mockReturnValue(cacheOptions); + jest.spyOn(redisService, 'get').mockResolvedValue(null); + jest.spyOn(redisService, 'set').mockResolvedValue(undefined); + + interceptor + .intercept(mockExecutionContext, mockCallHandler) + .subscribe({ + next: () => { + expect(redisService.get).toHaveBeenCalled(); + // Key should be different due to query params + const cacheKey = (redisService.get as jest.Mock).mock.calls[0][0]; + expect(cacheKey).toBeTruthy(); + done(); + }, + error: (err) => done(err), + }); + }); + }); +}); diff --git a/app/backend/src/common/interceptors/cache-response.interceptor.ts b/app/backend/src/common/interceptors/cache-response.interceptor.ts new file mode 100644 index 00000000..430706b8 --- /dev/null +++ b/app/backend/src/common/interceptors/cache-response.interceptor.ts @@ -0,0 +1,134 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Observable, of, from } from 'rxjs'; +import { tap, switchMap } from 'rxjs/operators'; +import { Request } from 'express'; +import { RedisService } from '../../../cache/redis.service'; +import { + CACHE_RESPONSE_KEY, + CacheResponseOptions, +} from '../decorators/cache-response.decorator'; +import * as crypto from 'crypto'; + +@Injectable() +export class CacheResponseInterceptor implements NestInterceptor { + private readonly logger = new Logger(CacheResponseInterceptor.name); + + constructor( + private readonly reflector: Reflector, + private readonly redisService: RedisService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const options = this.reflector.get( + CACHE_RESPONSE_KEY, + context.getHandler(), + ); + + // If no cache metadata, skip caching + if (!options) { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + const cacheKey = this.generateCacheKey(request, options); + + // Try to retrieve from cache + return from(this.redisService.get(cacheKey)).pipe( + switchMap((cachedResponse) => { + if (cachedResponse !== null) { + this.logger.debug(`Cache HIT: ${cacheKey}`); + return of(cachedResponse); + } + + this.logger.debug(`Cache MISS: ${cacheKey}`); + + // Cache miss: execute handler and cache the result + return next.handle().pipe( + tap((response) => { + // Fire-and-forget cache set (don't await in tap) + void this.redisService + .set(cacheKey, response, options.ttl) + .then(() => { + this.logger.debug( + `Cached response for key: ${cacheKey} (TTL: ${options.ttl}s)`, + ); + }) + .catch((err) => { + this.logger.warn( + `Failed to cache response for key ${cacheKey}: ${String(err)}`, + ); + }); + }), + ); + }), + ); + } + + /** + * Generate a normalized cache key from the request + */ + private generateCacheKey( + request: Request, + options: CacheResponseOptions, + ): string { + const prefix = options.prefix || 'cache:response'; + + // Use custom key generator if provided + if (options.keyGenerator) { + return `${prefix}:${options.keyGenerator(request)}`; + } + + // Default key generation: normalize route + query + optionally body + const parts: string[] = [ + request.method, + request.route?.path || request.path, + ]; + + // Sort and serialize query params for consistency + if (Object.keys(request.query).length > 0) { + const sortedQuery = this.sortObject(request.query); + parts.push(JSON.stringify(sortedQuery)); + } + + // Include body if requested (useful for POST with read semantics) + if (options.includeBody && request.body) { + const sortedBody = this.sortObject(request.body); + parts.push(JSON.stringify(sortedBody)); + } + + // Hash the key to keep it manageable + const rawKey = parts.join('::'); + const hash = crypto.createHash('sha256').update(rawKey).digest('hex'); + + return `${prefix}:${hash}`; + } + + /** + * Recursively sort object keys for consistent serialization + */ + private sortObject(obj: any): any { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => this.sortObject(item)); + } + + const sorted: any = {}; + Object.keys(obj) + .sort() + .forEach((key) => { + sorted[key] = this.sortObject(obj[key]); + }); + + return sorted; + } +} diff --git a/app/backend/src/common/services/cache-invalidation.service.ts b/app/backend/src/common/services/cache-invalidation.service.ts new file mode 100644 index 00000000..84c50e55 --- /dev/null +++ b/app/backend/src/common/services/cache-invalidation.service.ts @@ -0,0 +1,153 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { RedisService } from '../../../cache/redis.service'; + +/** + * Service for managing cache invalidation across the application. + * Provides convenient methods to invalidate specific cache patterns. + */ +@Injectable() +export class CacheInvalidationService { + private readonly logger = new Logger(CacheInvalidationService.name); + + constructor(private readonly redisService: RedisService) {} + + /** + * Invalidate all verification-related caches for a specific verification ID + */ + async invalidateVerification(verificationId: string): Promise { + const patterns = [ + `cache:response:*verification*${verificationId}*`, + `cache:response:*verification/${verificationId}*`, + `cache:response:*claims/${verificationId}*`, + ]; + + for (const pattern of patterns) { + const deleted = await this.redisService.delByPattern(pattern); + if (deleted > 0) { + this.logger.debug( + `Invalidated ${deleted} verification cache entries for ID ${verificationId}`, + ); + } + } + } + + /** + * Invalidate all verification metrics caches + */ + async invalidateVerificationMetrics(): Promise { + const deleted = await this.redisService.delByPattern( + 'cache:response:*verification*metrics*', + ); + if (deleted > 0) { + this.logger.debug( + `Invalidated ${deleted} verification metrics cache entries`, + ); + } + } + + /** + * Invalidate all caches for a specific user + */ + async invalidateUserCaches(userId: string): Promise { + const patterns = [ + `cache:response:*user/${userId}*`, + `cache:response:*userId=${userId}*`, + ]; + + for (const pattern of patterns) { + const deleted = await this.redisService.delByPattern(pattern); + if (deleted > 0) { + this.logger.debug( + `Invalidated ${deleted} user cache entries for user ${userId}`, + ); + } + } + } + + /** + * Invalidate all aid package caches for a specific package ID + */ + async invalidateAidPackage(packageId: string): Promise { + const patterns = [ + `cache:response:*packages/${packageId}*`, + `cache:response:*aid-escrow*${packageId}*`, + ]; + + for (const pattern of patterns) { + const deleted = await this.redisService.delByPattern(pattern); + if (deleted > 0) { + this.logger.debug( + `Invalidated ${deleted} aid package cache entries for ID ${packageId}`, + ); + } + } + } + + /** + * Invalidate all aid package statistics caches + */ + async invalidateAidPackageStats(): Promise { + const deleted = await this.redisService.delByPattern( + 'cache:response:*aid-escrow*stats*', + ); + if (deleted > 0) { + this.logger.debug( + `Invalidated ${deleted} aid package stats cache entries`, + ); + } + } + + /** + * Invalidate transaction status caches for a specific transaction hash + */ + async invalidateTransaction(txHash: string): Promise { + const patterns = [ + `cache:response:*transactions/${txHash}*`, + `cache:response:*transaction*${txHash}*`, + ]; + + for (const pattern of patterns) { + const deleted = await this.redisService.delByPattern(pattern); + if (deleted > 0) { + this.logger.debug( + `Invalidated ${deleted} transaction cache entries for hash ${txHash}`, + ); + } + } + } + + /** + * Invalidate all analytics caches + */ + async invalidateAnalytics(): Promise { + const deleted = await this.redisService.delByPattern( + 'cache:response:*analytics*', + ); + if (deleted > 0) { + this.logger.debug(`Invalidated ${deleted} analytics cache entries`); + } + } + + /** + * Invalidate all response caches (nuclear option) + */ + async invalidateAll(): Promise { + const deleted = await this.redisService.delByPattern('cache:response:*'); + this.logger.warn(`Invalidated ALL cache entries (${deleted} keys)`); + } + + /** + * Get cache statistics + */ + getCacheStats(): { + totalKeys: number; + patterns: { pattern: string; count: number }[]; + } { + // This would require implementing a SCAN-based key counter + // For now, return a placeholder + return { + totalKeys: 0, + patterns: [], + }; + } +} diff --git a/app/backend/src/onchain/aid-escrow.controller.ts b/app/backend/src/onchain/aid-escrow.controller.ts index a61abd41..bf80bf8c 100644 --- a/app/backend/src/onchain/aid-escrow.controller.ts +++ b/app/backend/src/onchain/aid-escrow.controller.ts @@ -27,6 +27,8 @@ import { BatchCreateAidPackagesDto, } from './dto/aid-escrow.dto'; import { SorobanErrorMapper } from './utils/soroban-error.mapper'; +import { CacheResponse } from '../common/decorators/cache-response.decorator'; +import { getCacheTTL } from '../common/config/cache.config'; /** * AidEscrowController @@ -250,6 +252,7 @@ export class AidEscrowController { */ @Get('packages/:id') @HttpCode(HttpStatus.OK) + @CacheResponse({ ttl: getCacheTTL().AID_PACKAGE_DETAILS }) @ApiOperation({ summary: 'Get aid package details', description: @@ -294,6 +297,7 @@ export class AidEscrowController { */ @Get('stats') @HttpCode(HttpStatus.OK) + @CacheResponse({ ttl: getCacheTTL().AID_PACKAGE_STATS }) @ApiOperation({ summary: 'Get aid package statistics', description: @@ -337,6 +341,7 @@ export class AidEscrowController { */ @Get('transactions/:hash/status') @HttpCode(HttpStatus.OK) + @CacheResponse({ ttl: getCacheTTL().TRANSACTION_STATUS }) @ApiOperation({ summary: 'Get transaction status', description: diff --git a/app/backend/src/verification/verification.controller.ts b/app/backend/src/verification/verification.controller.ts index d4c327fc..e7f9bc88 100644 --- a/app/backend/src/verification/verification.controller.ts +++ b/app/backend/src/verification/verification.controller.ts @@ -37,6 +37,8 @@ import { AppRole } from 'src/auth/app-role.enum'; import { InternalNotesService } from 'src/common/services/internal-notes.service'; import { CreateInternalNoteDto } from 'src/common/dto/create-internal-note.dto'; import { InternalNoteResponseDto } from 'src/common/dto/internal-note-response.dto'; +import { CacheResponse } from 'src/common/decorators/cache-response.decorator'; +import { getCacheTTL } from 'src/common/config/cache.config'; @ApiTags('Verification') @ApiSecurity('x-api-key') @@ -93,6 +95,7 @@ export class VerificationController { @Get('metrics') @Version('1') + @CacheResponse({ ttl: getCacheTTL().VERIFICATION_METRICS }) @ApiOperation({ summary: 'Get verification queue metrics', description: @@ -252,6 +255,7 @@ export class VerificationController { @Get('claims/:id') @Version('1') + @CacheResponse({ ttl: getCacheTTL().VERIFICATION_STATUS }) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get claim verification status', @@ -298,6 +302,7 @@ export class VerificationController { @Get(':id') @Version(API_VERSIONS.V1) + @CacheResponse({ ttl: getCacheTTL().VERIFICATION_STATUS }) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get verification status (v1)', @@ -336,6 +341,7 @@ export class VerificationController { @Get('user/:userId') @Version(API_VERSIONS.V1) + @CacheResponse({ ttl: getCacheTTL().USER_VERIFICATION_HISTORY }) @ApiBearerAuth('JWT-auth') @ApiOperation({ summary: 'Get user verification history (v1)', @@ -418,6 +424,7 @@ export class VerificationController { @Get(':id/notes') @Roles(AppRole.operator, AppRole.admin) + @CacheResponse({ ttl: getCacheTTL().INTERNAL_NOTES }) @ApiOperation({ summary: 'List internal notes for a verification record', description: 'Retrieves all internal notes for a specific verification.', diff --git a/package-lock.json b/package-lock.json index a4691a4b..be848856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2155,9 +2155,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2172,9 +2169,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2189,9 +2183,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2206,9 +2197,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2223,9 +2211,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2240,9 +2225,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2257,9 +2239,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2274,9 +2253,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2291,9 +2267,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2308,9 +2281,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [