feat(T2.1): implement activity result caching
- Add internal/cache package for deduplicating activity results - Implement ResultCache with MD5 hash-based cache keys - Support cache by activity type, task ID, input hash, model ID - Configurable max size with FIFO eviction policy - TTL support for automatic expiration - Persistence to JSON for recovery across runs - Query operations: by activity type, by task ID - Hit rate tracking and statistics - 13 cache tests, all passing Features: - ComputeHash() for input deduplication - Set/Get operations with TTL support - Invalidation by activity type or task ID - Cache stats with usage ratio - Full cache clear - Disk persistence with JSON storage - Hit rate calculation Performance: - Avoids redundant LLM calls - Reduces API costs - Faster workflow execution - Configurable eviction policies Test Coverage: - 13 cache tests (set/get, TTL, eviction, persistence) - Hit rate calculation verified - Invalidation tested - Multi-entry scenarios Next: T2.2 (Parallel task dispatch)
This commit is contained in:
Vendored
+316
@@ -0,0 +1,316 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCacheKeyString(t *testing.T) {
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "abc123",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
keyStr := key.String()
|
||||
assert.Contains(t, keyStr, "implementer")
|
||||
assert.Contains(t, keyStr, "T1.1")
|
||||
assert.Contains(t, keyStr, "abc123")
|
||||
assert.Contains(t, keyStr, "claude-opus")
|
||||
}
|
||||
|
||||
func TestComputeHash(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"task": "T1.1",
|
||||
"code": "package main",
|
||||
}
|
||||
|
||||
hash1, err := ComputeHash(data)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, hash1)
|
||||
|
||||
hash2, err := ComputeHash(data)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, hash1, hash2)
|
||||
}
|
||||
|
||||
func TestSetAndGet(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "abc123",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"output": "implementation code",
|
||||
"files": []string{"file1.go", "file2.go"},
|
||||
}
|
||||
|
||||
err := cache.Set(key, result)
|
||||
assert.NoError(t, err)
|
||||
|
||||
retrieved, found, err := cache.Get(key)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "implementation code", retrieved["output"])
|
||||
}
|
||||
|
||||
func TestCacheMiss(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "abc123",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
retrieved, found, err := cache.Get(key)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, found)
|
||||
assert.Nil(t, retrieved)
|
||||
}
|
||||
|
||||
func TestInvalidate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "abc123",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
cache.Set(key, map[string]interface{}{"output": "code"})
|
||||
assert.Equal(t, 1, cache.GetSize())
|
||||
|
||||
cache.Invalidate(key)
|
||||
assert.Equal(t, 0, cache.GetSize())
|
||||
|
||||
_, found, _ := cache.Get(key)
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: string(rune(48 + i)),
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
cache.Set(key, map[string]interface{}{"output": "code"})
|
||||
}
|
||||
|
||||
assert.Equal(t, 10, cache.GetSize())
|
||||
|
||||
cache.Clear()
|
||||
assert.Equal(t, 0, cache.GetSize())
|
||||
}
|
||||
|
||||
func TestGetStats(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "abc123",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
cache.Set(key, map[string]interface{}{"output": "code"})
|
||||
cache.Get(key) // Hit
|
||||
|
||||
stats := cache.GetStats()
|
||||
assert.Equal(t, 1, stats["size"])
|
||||
assert.Equal(t, 100, stats["max_size"])
|
||||
assert.Equal(t, 1, stats["total_hits"])
|
||||
}
|
||||
|
||||
func TestTTLExpiration(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 100*time.Millisecond)
|
||||
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "abc123",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
cache.Set(key, map[string]interface{}{"output": "code"})
|
||||
|
||||
// Should find immediately
|
||||
_, found, _ := cache.Get(key)
|
||||
assert.True(t, found)
|
||||
|
||||
// Wait for TTL to expire
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Should not find after TTL
|
||||
_, found, _ = cache.Get(key)
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
func TestMaxSizeEviction(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 3, 0)
|
||||
|
||||
// Add 3 entries
|
||||
for i := 0; i < 3; i++ {
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: string(rune(48 + i)),
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
cache.Set(key, map[string]interface{}{"output": "code"})
|
||||
}
|
||||
|
||||
assert.Equal(t, 3, cache.GetSize())
|
||||
|
||||
// Add 4th entry (should evict oldest)
|
||||
key4 := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "3",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
cache.Set(key4, map[string]interface{}{"output": "code"})
|
||||
|
||||
// Size should still be 3
|
||||
assert.Equal(t, 3, cache.GetSize())
|
||||
}
|
||||
|
||||
func TestInvalidateByActivity(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
// Add implementer entries
|
||||
for i := 0; i < 2; i++ {
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: string(rune(48 + i)),
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
cache.Set(key, map[string]interface{}{"output": "code"})
|
||||
}
|
||||
|
||||
// Add judge entries
|
||||
for i := 0; i < 2; i++ {
|
||||
key := &CacheKey{
|
||||
ActivityType: "judge",
|
||||
TaskID: "T1.1",
|
||||
InputHash: string(rune(48 + i)),
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
cache.Set(key, map[string]interface{}{"output": "verdict"})
|
||||
}
|
||||
|
||||
assert.Equal(t, 4, cache.GetSize())
|
||||
|
||||
// Invalidate implementer entries
|
||||
cache.InvalidateByActivity("implementer")
|
||||
|
||||
assert.Equal(t, 2, cache.GetSize())
|
||||
}
|
||||
|
||||
func TestInvalidateByTask(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
// Add entries for T1.1
|
||||
for i := 0; i < 2; i++ {
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: string(rune(48 + i)),
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
cache.Set(key, map[string]interface{}{"output": "code"})
|
||||
}
|
||||
|
||||
// Add entries for T1.2
|
||||
for i := 0; i < 2; i++ {
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.2",
|
||||
InputHash: string(rune(48 + i)),
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
cache.Set(key, map[string]interface{}{"output": "code"})
|
||||
}
|
||||
|
||||
assert.Equal(t, 4, cache.GetSize())
|
||||
|
||||
// Invalidate T1.1 entries
|
||||
cache.InvalidateByTask("T1.1")
|
||||
|
||||
assert.Equal(t, 2, cache.GetSize())
|
||||
}
|
||||
|
||||
func TestGetHitRate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
key1 := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "1",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
key2 := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "2",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
cache.Set(key1, map[string]interface{}{"output": "code"})
|
||||
cache.Set(key2, map[string]interface{}{"output": "code"})
|
||||
|
||||
cache.Get(key1)
|
||||
cache.Get(key1)
|
||||
cache.Get(key2)
|
||||
|
||||
hitRate, count := cache.GetHitRate()
|
||||
assert.Equal(t, 2, count)
|
||||
assert.GreaterOrEqual(t, hitRate, 1.0)
|
||||
}
|
||||
|
||||
func TestPersistence(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
cache1 := NewResultCache(tmpDir, 100, 0)
|
||||
|
||||
key := &CacheKey{
|
||||
ActivityType: "implementer",
|
||||
TaskID: "T1.1",
|
||||
InputHash: "abc123",
|
||||
ModelID: "claude-opus",
|
||||
}
|
||||
|
||||
cache1.Set(key, map[string]interface{}{"output": "code"})
|
||||
|
||||
// Create new cache and load
|
||||
cache2 := NewResultCache(tmpDir, 100, 0)
|
||||
cache2.Load()
|
||||
|
||||
retrieved, found, _ := cache2.Get(key)
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "code", retrieved["output"])
|
||||
}
|
||||
Reference in New Issue
Block a user