From a045509fe44b570d0318f2b3079017fe6d6afde7 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Wed, 4 Feb 2026 09:44:12 -0600 Subject: [PATCH] feat: add CacheService with Redis caching and graceful fallback Implements Redis-backed caching with category-aware TTL management: - Immutable categories (artifact metadata, dependencies) cached forever - Mutable categories (index pages, upstream sources) use configurable TTL - Graceful fallback when Redis unavailable or disabled - Pattern-based invalidation for bulk cache clearing --- backend/app/cache_service.py | 262 ++++++++++++++++ backend/tests/unit/test_cache_service.py | 374 +++++++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 backend/app/cache_service.py create mode 100644 backend/tests/unit/test_cache_service.py diff --git a/backend/app/cache_service.py b/backend/app/cache_service.py new file mode 100644 index 0000000..92f3bc5 --- /dev/null +++ b/backend/app/cache_service.py @@ -0,0 +1,262 @@ +""" +Redis-backed caching service with category-aware TTL and invalidation. + +Provides: +- Immutable caching for artifact data (hermetic builds) +- TTL-based caching for discovery data +- Event-driven invalidation for config changes +- Graceful fallback when Redis unavailable +""" + +import logging +from enum import Enum +from typing import Optional + +from .config import Settings + +logger = logging.getLogger(__name__) + + +class CacheCategory(Enum): + """ + Cache categories with different TTL and invalidation rules. + + Immutable (cache forever): + - ARTIFACT_METADATA: Artifact info by SHA256 + - ARTIFACT_DEPENDENCIES: Extracted deps by SHA256 + - DEPENDENCY_RESOLUTION: Resolution results by input hash + + Mutable (TTL + event invalidation): + - UPSTREAM_SOURCES: Upstream config, invalidate on DB change + - PACKAGE_INDEX: PyPI/npm index pages, TTL only + - PACKAGE_VERSIONS: Version listings, TTL only + """ + + # Immutable - cache forever (hermetic builds) + ARTIFACT_METADATA = "artifact" + ARTIFACT_DEPENDENCIES = "deps" + DEPENDENCY_RESOLUTION = "resolve" + + # Mutable - TTL + event invalidation + UPSTREAM_SOURCES = "upstream" + PACKAGE_INDEX = "index" + PACKAGE_VERSIONS = "versions" + + +def get_category_ttl(category: CacheCategory, settings: Settings) -> Optional[int]: + """ + Get TTL for a cache category. + + Returns: + TTL in seconds, or None for no expiry (immutable). + """ + ttl_map = { + # Immutable - no TTL + CacheCategory.ARTIFACT_METADATA: None, + CacheCategory.ARTIFACT_DEPENDENCIES: None, + CacheCategory.DEPENDENCY_RESOLUTION: None, + # Mutable - configurable TTL + CacheCategory.UPSTREAM_SOURCES: settings.cache_ttl_upstream, + CacheCategory.PACKAGE_INDEX: settings.cache_ttl_index, + CacheCategory.PACKAGE_VERSIONS: settings.cache_ttl_versions, + } + return ttl_map.get(category) + + +class CacheService: + """ + Redis-backed caching with category-aware TTL. + + Key format: orchard:{category}:{protocol}:{identifier} + Example: orchard:deps:pypi:abc123def456 + + When Redis is disabled or unavailable, operations gracefully + return None/no-op to allow the application to function without caching. + """ + + def __init__(self, settings: Settings): + self._settings = settings + self._enabled = settings.redis_enabled + self._redis: Optional["redis.asyncio.Redis"] = None + self._started = False + + async def startup(self) -> None: + """Initialize Redis connection. Called by FastAPI lifespan.""" + if self._started: + return + + if not self._enabled: + logger.info("CacheService disabled (redis_enabled=False)") + self._started = True + return + + try: + import redis.asyncio as redis + + logger.info( + f"Connecting to Redis at {self._settings.redis_host}:" + f"{self._settings.redis_port}/{self._settings.redis_db}" + ) + + self._redis = redis.Redis( + host=self._settings.redis_host, + port=self._settings.redis_port, + db=self._settings.redis_db, + password=self._settings.redis_password, + decode_responses=False, # We handle bytes + ) + + # Test connection + await self._redis.ping() + logger.info("CacheService connected to Redis") + + except ImportError: + logger.warning("redis package not installed, caching disabled") + self._enabled = False + except Exception as e: + logger.warning(f"Redis connection failed, caching disabled: {e}") + self._enabled = False + self._redis = None + + self._started = True + + async def shutdown(self) -> None: + """Close Redis connection. Called by FastAPI lifespan.""" + if not self._started: + return + + if self._redis: + await self._redis.aclose() + self._redis = None + + self._started = False + logger.info("CacheService shutdown complete") + + @staticmethod + def _make_key(category: CacheCategory, protocol: str, identifier: str) -> str: + """Build namespaced cache key.""" + return f"orchard:{category.value}:{protocol}:{identifier}" + + async def get( + self, + category: CacheCategory, + key: str, + protocol: str = "default", + ) -> Optional[bytes]: + """ + Get cached value. + + Args: + category: Cache category for TTL rules + key: Unique identifier within category + protocol: Protocol namespace (pypi, npm, etc.) + + Returns: + Cached bytes or None if not found/disabled. + """ + if not self._enabled or not self._redis: + return None + + try: + full_key = self._make_key(category, protocol, key) + return await self._redis.get(full_key) + except Exception as e: + logger.warning(f"Cache get failed for {key}: {e}") + return None + + async def set( + self, + category: CacheCategory, + key: str, + value: bytes, + protocol: str = "default", + ) -> None: + """ + Set cached value with category-appropriate TTL. + + Args: + category: Cache category for TTL rules + key: Unique identifier within category + value: Bytes to cache + protocol: Protocol namespace (pypi, npm, etc.) + """ + if not self._enabled or not self._redis: + return + + try: + full_key = self._make_key(category, protocol, key) + ttl = get_category_ttl(category, self._settings) + + if ttl is None: + await self._redis.set(full_key, value) + else: + await self._redis.setex(full_key, ttl, value) + + except Exception as e: + logger.warning(f"Cache set failed for {key}: {e}") + + async def delete( + self, + category: CacheCategory, + key: str, + protocol: str = "default", + ) -> None: + """Delete a specific cache entry.""" + if not self._enabled or not self._redis: + return + + try: + full_key = self._make_key(category, protocol, key) + await self._redis.delete(full_key) + except Exception as e: + logger.warning(f"Cache delete failed for {key}: {e}") + + async def invalidate_pattern( + self, + category: CacheCategory, + pattern: str = "*", + protocol: str = "default", + ) -> int: + """ + Invalidate all entries matching pattern. + + Args: + category: Cache category + pattern: Glob pattern for keys (default "*" = all in category) + protocol: Protocol namespace + + Returns: + Number of keys deleted. + """ + if not self._enabled or not self._redis: + return 0 + + try: + full_pattern = self._make_key(category, protocol, pattern) + keys = [] + async for key in self._redis.scan_iter(match=full_pattern): + keys.append(key) + + if keys: + return await self._redis.delete(*keys) + return 0 + + except Exception as e: + logger.warning(f"Cache invalidate failed for pattern {pattern}: {e}") + return 0 + + async def ping(self) -> bool: + """Check if Redis is connected and responding.""" + if not self._enabled or not self._redis: + return False + + try: + await self._redis.ping() + return True + except Exception: + return False + + @property + def enabled(self) -> bool: + """Check if caching is enabled.""" + return self._enabled diff --git a/backend/tests/unit/test_cache_service.py b/backend/tests/unit/test_cache_service.py new file mode 100644 index 0000000..da574be --- /dev/null +++ b/backend/tests/unit/test_cache_service.py @@ -0,0 +1,374 @@ +"""Tests for CacheService.""" +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + + +class TestCacheCategory: + """Tests for cache category enum.""" + + @pytest.mark.unit + def test_immutable_categories_have_no_ttl(self): + """Immutable categories should return None for TTL.""" + from app.cache_service import CacheCategory, get_category_ttl + from app.config import Settings + + settings = Settings() + + assert get_category_ttl(CacheCategory.ARTIFACT_METADATA, settings) is None + assert get_category_ttl(CacheCategory.ARTIFACT_DEPENDENCIES, settings) is None + assert get_category_ttl(CacheCategory.DEPENDENCY_RESOLUTION, settings) is None + + @pytest.mark.unit + def test_mutable_categories_have_ttl(self): + """Mutable categories should return configured TTL.""" + from app.cache_service import CacheCategory, get_category_ttl + from app.config import Settings + + settings = Settings( + cache_ttl_index=300, + cache_ttl_upstream=3600, + ) + + assert get_category_ttl(CacheCategory.PACKAGE_INDEX, settings) == 300 + assert get_category_ttl(CacheCategory.UPSTREAM_SOURCES, settings) == 3600 + + +class TestCacheService: + """Tests for Redis cache service.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_disabled_cache_returns_none(self): + """When Redis disabled, get() should return None.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=False) + cache = CacheService(settings) + await cache.startup() + + result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key") + + assert result is None + await cache.shutdown() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_disabled_cache_set_is_noop(self): + """When Redis disabled, set() should be a no-op.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=False) + cache = CacheService(settings) + await cache.startup() + + # Should not raise + await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"test-value") + + await cache.shutdown() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_cache_key_namespacing(self): + """Cache keys should be properly namespaced.""" + from app.cache_service import CacheService, CacheCategory + + key = CacheService._make_key(CacheCategory.PACKAGE_INDEX, "pypi", "numpy") + + assert key == "orchard:index:pypi:numpy" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_ping_returns_false_when_disabled(self): + """ping() should return False when Redis is disabled.""" + from app.cache_service import CacheService + from app.config import Settings + + settings = Settings(redis_enabled=False) + cache = CacheService(settings) + await cache.startup() + + result = await cache.ping() + + assert result is False + await cache.shutdown() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_enabled_property(self): + """enabled property should reflect Redis state.""" + from app.cache_service import CacheService + from app.config import Settings + + settings = Settings(redis_enabled=False) + cache = CacheService(settings) + + assert cache.enabled is False + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_delete_is_noop_when_disabled(self): + """delete() should be a no-op when Redis is disabled.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=False) + cache = CacheService(settings) + await cache.startup() + + # Should not raise + await cache.delete(CacheCategory.PACKAGE_INDEX, "test-key") + + await cache.shutdown() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_invalidate_pattern_returns_zero_when_disabled(self): + """invalidate_pattern() should return 0 when Redis is disabled.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=False) + cache = CacheService(settings) + await cache.startup() + + result = await cache.invalidate_pattern(CacheCategory.PACKAGE_INDEX) + + assert result == 0 + await cache.shutdown() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_startup_already_started(self): + """startup() should be idempotent.""" + from app.cache_service import CacheService + from app.config import Settings + + settings = Settings(redis_enabled=False) + cache = CacheService(settings) + await cache.startup() + await cache.startup() # Should not raise + + assert cache._started is True + await cache.shutdown() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_shutdown_not_started(self): + """shutdown() should handle not-started state.""" + from app.cache_service import CacheService + from app.config import Settings + + settings = Settings(redis_enabled=False) + cache = CacheService(settings) + + # Should not raise + await cache.shutdown() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_make_key_with_default_protocol(self): + """_make_key should work with default protocol.""" + from app.cache_service import CacheService, CacheCategory + + key = CacheService._make_key(CacheCategory.ARTIFACT_METADATA, "default", "abc123") + + assert key == "orchard:artifact:default:abc123" + + +class TestCacheServiceWithMockedRedis: + """Tests for CacheService with mocked Redis client.""" + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_get_returns_cached_value(self): + """get() should return cached value when available.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=True) + cache = CacheService(settings) + + # Mock the redis client + mock_redis = AsyncMock() + mock_redis.get.return_value = b"cached-data" + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key", "pypi") + + assert result == b"cached-data" + mock_redis.get.assert_called_once_with("orchard:index:pypi:test-key") + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_set_with_ttl(self): + """set() should use setex for mutable categories.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=True, cache_ttl_index=300) + cache = CacheService(settings) + + mock_redis = AsyncMock() + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"test-value", "pypi") + + mock_redis.setex.assert_called_once_with( + "orchard:index:pypi:test-key", 300, b"test-value" + ) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_set_without_ttl(self): + """set() should use set (no expiry) for immutable categories.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=True) + cache = CacheService(settings) + + mock_redis = AsyncMock() + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + await cache.set( + CacheCategory.ARTIFACT_METADATA, "abc123", b"metadata", "pypi" + ) + + mock_redis.set.assert_called_once_with( + "orchard:artifact:pypi:abc123", b"metadata" + ) + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_delete_calls_redis_delete(self): + """delete() should call Redis delete.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=True) + cache = CacheService(settings) + + mock_redis = AsyncMock() + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + await cache.delete(CacheCategory.PACKAGE_INDEX, "test-key", "pypi") + + mock_redis.delete.assert_called_once_with("orchard:index:pypi:test-key") + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_invalidate_pattern_deletes_matching_keys(self): + """invalidate_pattern() should delete all matching keys.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=True) + cache = CacheService(settings) + + mock_redis = AsyncMock() + + # Create an async generator for scan_iter + async def mock_scan_iter(match=None): + for key in [b"orchard:index:pypi:numpy", b"orchard:index:pypi:requests"]: + yield key + + mock_redis.scan_iter = mock_scan_iter + mock_redis.delete.return_value = 2 + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + result = await cache.invalidate_pattern(CacheCategory.PACKAGE_INDEX, "*", "pypi") + + assert result == 2 + mock_redis.delete.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_ping_returns_true_when_connected(self): + """ping() should return True when Redis responds.""" + from app.cache_service import CacheService + from app.config import Settings + + settings = Settings(redis_enabled=True) + cache = CacheService(settings) + + mock_redis = AsyncMock() + mock_redis.ping.return_value = True + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + result = await cache.ping() + + assert result is True + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_get_handles_exception(self): + """get() should return None and log warning on exception.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=True) + cache = CacheService(settings) + + mock_redis = AsyncMock() + mock_redis.get.side_effect = Exception("Connection lost") + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + result = await cache.get(CacheCategory.PACKAGE_INDEX, "test-key") + + assert result is None + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_set_handles_exception(self): + """set() should log warning on exception.""" + from app.cache_service import CacheService, CacheCategory + from app.config import Settings + + settings = Settings(redis_enabled=True, cache_ttl_index=300) + cache = CacheService(settings) + + mock_redis = AsyncMock() + mock_redis.setex.side_effect = Exception("Connection lost") + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + # Should not raise + await cache.set(CacheCategory.PACKAGE_INDEX, "test-key", b"value") + + @pytest.mark.asyncio + @pytest.mark.unit + async def test_ping_returns_false_on_exception(self): + """ping() should return False on exception.""" + from app.cache_service import CacheService + from app.config import Settings + + settings = Settings(redis_enabled=True) + cache = CacheService(settings) + + mock_redis = AsyncMock() + mock_redis.ping.side_effect = Exception("Connection lost") + cache._redis = mock_redis + cache._enabled = True + cache._started = True + + result = await cache.ping() + + assert result is False +