Files
syndarix/backend/tests/services/context/test_cache.py
Felipe Cardoso c2466ab401 feat(context): implement Redis-based caching layer (#84)
Phase 6 of Context Management Engine - Caching Layer:

- Add ContextCache with Redis integration
- Support fingerprint-based assembled context caching
- Support token count caching (model-specific)
- Support score caching (scorer + context + query)
- Add in-memory fallback with LRU eviction
- Add cache invalidation with pattern matching
- Add cache statistics reporting

Key features:
- Hierarchical cache key structure (ctx:type:hash)
- Automatic TTL expiration
- Memory cache for fast repeated access
- Graceful degradation when Redis unavailable

Tests: 29 new tests, 285 total context tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 02:41:21 +01:00

480 lines
15 KiB
Python

"""Tests for context cache module."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.services.context.cache import ContextCache
from app.services.context.config import ContextSettings
from app.services.context.exceptions import CacheError
from app.services.context.types import (
AssembledContext,
ContextPriority,
KnowledgeContext,
SystemContext,
TaskContext,
)
class TestContextCacheBasics:
"""Basic tests for ContextCache."""
def test_creation(self) -> None:
"""Test cache creation without Redis."""
cache = ContextCache()
assert cache._redis is None
assert not cache.is_enabled
def test_creation_with_settings(self) -> None:
"""Test cache creation with custom settings."""
settings = ContextSettings(
cache_prefix="test",
cache_ttl_seconds=60,
)
cache = ContextCache(settings=settings)
assert cache._prefix == "test"
assert cache._ttl == 60
def test_set_redis(self) -> None:
"""Test setting Redis connection."""
cache = ContextCache()
mock_redis = MagicMock()
cache.set_redis(mock_redis)
assert cache._redis is mock_redis
def test_is_enabled(self) -> None:
"""Test is_enabled property."""
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(settings=settings)
assert not cache.is_enabled # No Redis
cache.set_redis(MagicMock())
assert cache.is_enabled
# Disabled in settings
settings2 = ContextSettings(cache_enabled=False)
cache2 = ContextCache(redis=MagicMock(), settings=settings2)
assert not cache2.is_enabled
def test_cache_key(self) -> None:
"""Test cache key generation."""
cache = ContextCache()
key = cache._cache_key("assembled", "abc123")
assert key == "ctx:assembled:abc123"
def test_hash_content(self) -> None:
"""Test content hashing."""
hash1 = ContextCache._hash_content("hello world")
hash2 = ContextCache._hash_content("hello world")
hash3 = ContextCache._hash_content("different")
assert hash1 == hash2
assert hash1 != hash3
assert len(hash1) == 32
class TestFingerprintComputation:
"""Tests for fingerprint computation."""
def test_compute_fingerprint(self) -> None:
"""Test fingerprint computation."""
cache = ContextCache()
contexts = [
SystemContext(content="System", source="system"),
TaskContext(content="Task", source="task"),
]
fp1 = cache.compute_fingerprint(contexts, "query", "claude-3")
fp2 = cache.compute_fingerprint(contexts, "query", "claude-3")
fp3 = cache.compute_fingerprint(contexts, "different", "claude-3")
assert fp1 == fp2 # Same inputs = same fingerprint
assert fp1 != fp3 # Different query = different fingerprint
assert len(fp1) == 32
def test_fingerprint_includes_priority(self) -> None:
"""Test that fingerprint changes with priority."""
cache = ContextCache()
# Use KnowledgeContext since SystemContext has __post_init__ that may override
ctx1 = [
KnowledgeContext(
content="Knowledge",
source="docs",
priority=ContextPriority.NORMAL.value,
)
]
ctx2 = [
KnowledgeContext(
content="Knowledge",
source="docs",
priority=ContextPriority.HIGH.value,
)
]
fp1 = cache.compute_fingerprint(ctx1, "query", "claude-3")
fp2 = cache.compute_fingerprint(ctx2, "query", "claude-3")
assert fp1 != fp2
def test_fingerprint_includes_model(self) -> None:
"""Test that fingerprint changes with model."""
cache = ContextCache()
contexts = [SystemContext(content="System", source="system")]
fp1 = cache.compute_fingerprint(contexts, "query", "claude-3")
fp2 = cache.compute_fingerprint(contexts, "query", "gpt-4")
assert fp1 != fp2
class TestMemoryCache:
"""Tests for in-memory caching."""
def test_memory_cache_fallback(self) -> None:
"""Test memory cache when Redis unavailable."""
cache = ContextCache()
# Should use memory cache
cache._set_memory("test-key", "42")
assert "test-key" in cache._memory_cache
assert cache._memory_cache["test-key"][0] == "42"
def test_memory_cache_eviction(self) -> None:
"""Test memory cache eviction."""
cache = ContextCache()
cache._max_memory_items = 10
# Fill cache
for i in range(15):
cache._set_memory(f"key-{i}", f"value-{i}")
# Should have evicted some items
assert len(cache._memory_cache) < 15
class TestAssembledContextCache:
"""Tests for assembled context caching."""
@pytest.mark.asyncio
async def test_get_assembled_no_redis(self) -> None:
"""Test get_assembled without Redis returns None."""
cache = ContextCache()
result = await cache.get_assembled("fingerprint")
assert result is None
@pytest.mark.asyncio
async def test_get_assembled_not_found(self) -> None:
"""Test get_assembled when key not found."""
mock_redis = AsyncMock()
mock_redis.get.return_value = None
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
result = await cache.get_assembled("fingerprint")
assert result is None
@pytest.mark.asyncio
async def test_get_assembled_found(self) -> None:
"""Test get_assembled when key found."""
# Create a context
ctx = AssembledContext(
content="Test content",
total_tokens=100,
context_count=2,
)
mock_redis = AsyncMock()
mock_redis.get.return_value = ctx.to_json()
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
result = await cache.get_assembled("fingerprint")
assert result is not None
assert result.content == "Test content"
assert result.total_tokens == 100
assert result.cache_hit is True
assert result.cache_key == "fingerprint"
@pytest.mark.asyncio
async def test_set_assembled(self) -> None:
"""Test set_assembled."""
mock_redis = AsyncMock()
settings = ContextSettings(cache_enabled=True, cache_ttl_seconds=60)
cache = ContextCache(redis=mock_redis, settings=settings)
ctx = AssembledContext(
content="Test content",
total_tokens=100,
context_count=2,
)
await cache.set_assembled("fingerprint", ctx)
mock_redis.setex.assert_called_once()
call_args = mock_redis.setex.call_args
assert call_args[0][0] == "ctx:assembled:fingerprint"
assert call_args[0][1] == 60 # TTL
@pytest.mark.asyncio
async def test_set_assembled_custom_ttl(self) -> None:
"""Test set_assembled with custom TTL."""
mock_redis = AsyncMock()
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
ctx = AssembledContext(
content="Test",
total_tokens=10,
context_count=1,
)
await cache.set_assembled("fp", ctx, ttl=120)
call_args = mock_redis.setex.call_args
assert call_args[0][1] == 120
@pytest.mark.asyncio
async def test_cache_error_on_get(self) -> None:
"""Test CacheError raised on Redis error."""
mock_redis = AsyncMock()
mock_redis.get.side_effect = Exception("Redis error")
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
with pytest.raises(CacheError):
await cache.get_assembled("fingerprint")
@pytest.mark.asyncio
async def test_cache_error_on_set(self) -> None:
"""Test CacheError raised on Redis error."""
mock_redis = AsyncMock()
mock_redis.setex.side_effect = Exception("Redis error")
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
ctx = AssembledContext(
content="Test",
total_tokens=10,
context_count=1,
)
with pytest.raises(CacheError):
await cache.set_assembled("fp", ctx)
class TestTokenCountCache:
"""Tests for token count caching."""
@pytest.mark.asyncio
async def test_get_token_count_memory_fallback(self) -> None:
"""Test get_token_count uses memory cache."""
cache = ContextCache()
# Set in memory
key = cache._cache_key("tokens", "default", cache._hash_content("hello"))
cache._set_memory(key, "42")
result = await cache.get_token_count("hello")
assert result == 42
@pytest.mark.asyncio
async def test_set_token_count_memory(self) -> None:
"""Test set_token_count stores in memory."""
cache = ContextCache()
await cache.set_token_count("hello", 42)
result = await cache.get_token_count("hello")
assert result == 42
@pytest.mark.asyncio
async def test_set_token_count_with_model(self) -> None:
"""Test set_token_count with model-specific tokenization."""
mock_redis = AsyncMock()
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
await cache.set_token_count("hello", 42, model="claude-3")
await cache.set_token_count("hello", 50, model="gpt-4")
# Different models should have different keys
assert mock_redis.setex.call_count == 2
calls = mock_redis.setex.call_args_list
key1 = calls[0][0][0]
key2 = calls[1][0][0]
assert "claude-3" in key1
assert "gpt-4" in key2
class TestScoreCache:
"""Tests for score caching."""
@pytest.mark.asyncio
async def test_get_score_memory_fallback(self) -> None:
"""Test get_score uses memory cache."""
cache = ContextCache()
# Set in memory
query_hash = cache._hash_content("query")[:16]
key = cache._cache_key("score", "relevance", "ctx-123", query_hash)
cache._set_memory(key, "0.85")
result = await cache.get_score("relevance", "ctx-123", "query")
assert result == 0.85
@pytest.mark.asyncio
async def test_set_score_memory(self) -> None:
"""Test set_score stores in memory."""
cache = ContextCache()
await cache.set_score("relevance", "ctx-123", "query", 0.85)
result = await cache.get_score("relevance", "ctx-123", "query")
assert result == 0.85
@pytest.mark.asyncio
async def test_set_score_with_redis(self) -> None:
"""Test set_score with Redis."""
mock_redis = AsyncMock()
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
await cache.set_score("relevance", "ctx-123", "query", 0.85)
mock_redis.setex.assert_called_once()
class TestCacheInvalidation:
"""Tests for cache invalidation."""
@pytest.mark.asyncio
async def test_invalidate_pattern(self) -> None:
"""Test invalidate with pattern."""
mock_redis = AsyncMock()
# Set up scan_iter to return matching keys
async def mock_scan_iter(match=None):
for key in ["ctx:assembled:1", "ctx:assembled:2"]:
yield key
mock_redis.scan_iter = mock_scan_iter
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
deleted = await cache.invalidate("assembled:*")
assert deleted == 2
assert mock_redis.delete.call_count == 2
@pytest.mark.asyncio
async def test_clear_all(self) -> None:
"""Test clear_all."""
mock_redis = AsyncMock()
async def mock_scan_iter(match=None):
for key in ["ctx:1", "ctx:2", "ctx:3"]:
yield key
mock_redis.scan_iter = mock_scan_iter
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
# Add to memory cache
cache._set_memory("test", "value")
assert len(cache._memory_cache) > 0
deleted = await cache.clear_all()
assert deleted == 3
assert len(cache._memory_cache) == 0
class TestCacheStats:
"""Tests for cache statistics."""
@pytest.mark.asyncio
async def test_get_stats_no_redis(self) -> None:
"""Test get_stats without Redis."""
cache = ContextCache()
cache._set_memory("key", "value")
stats = await cache.get_stats()
assert stats["enabled"] is True
assert stats["redis_available"] is False
assert stats["memory_items"] == 1
@pytest.mark.asyncio
async def test_get_stats_with_redis(self) -> None:
"""Test get_stats with Redis."""
mock_redis = AsyncMock()
mock_redis.info.return_value = {"used_memory_human": "1.5M"}
settings = ContextSettings(cache_enabled=True, cache_ttl_seconds=300)
cache = ContextCache(redis=mock_redis, settings=settings)
stats = await cache.get_stats()
assert stats["enabled"] is True
assert stats["redis_available"] is True
assert stats["ttl_seconds"] == 300
assert stats["redis_memory_used"] == "1.5M"
class TestCacheIntegration:
"""Integration tests for cache."""
@pytest.mark.asyncio
async def test_full_workflow(self) -> None:
"""Test complete cache workflow."""
mock_redis = AsyncMock()
mock_redis.get.return_value = None
settings = ContextSettings(cache_enabled=True)
cache = ContextCache(redis=mock_redis, settings=settings)
contexts = [
SystemContext(content="System", source="system"),
KnowledgeContext(content="Knowledge", source="docs"),
]
# Compute fingerprint
fp = cache.compute_fingerprint(contexts, "query", "claude-3")
assert len(fp) == 32
# Check cache (miss)
result = await cache.get_assembled(fp)
assert result is None
# Create and cache assembled context
assembled = AssembledContext(
content="Assembled content",
total_tokens=100,
context_count=2,
model="claude-3",
)
await cache.set_assembled(fp, assembled)
# Verify setex was called
mock_redis.setex.assert_called_once()
# Mock cache hit
mock_redis.get.return_value = assembled.to_json()
result = await cache.get_assembled(fp)
assert result is not None
assert result.cache_hit is True
assert result.content == "Assembled content"