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
This commit is contained in:
262
backend/app/cache_service.py
Normal file
262
backend/app/cache_service.py
Normal file
@@ -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
|
||||||
374
backend/tests/unit/test_cache_service.py
Normal file
374
backend/tests/unit/test_cache_service.py
Normal file
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user