diff --git a/internal/cache/engine_cache.go b/internal/cache/engine_cache.go new file mode 100644 index 0000000..edbebdd --- /dev/null +++ b/internal/cache/engine_cache.go @@ -0,0 +1,91 @@ +package cache + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/metamorphosis-dev/samsa/internal/contracts" +) + +// EngineCache wraps Cache with per-engine tier-aware Get/Set operations. +type EngineCache struct { + cache *Cache + overrides map[string]time.Duration +} + +// NewEngineCache creates a new EngineCache with optional TTL overrides. +// If overrides is nil, default tier durations are used. +func NewEngineCache(cache *Cache, overrides map[string]time.Duration) *EngineCache { + return &EngineCache{ + cache: cache, + overrides: overrides, + } +} + +// Get retrieves a cached engine response. Returns (zero value, false) if not +// found or if cache is disabled. +func (ec *EngineCache) Get(ctx context.Context, engine, queryHash string) (CachedEngineResponse, bool) { + key := engineCacheKey(engine, queryHash) + + data, ok := ec.cache.GetBytes(ctx, key) + if !ok { + return CachedEngineResponse{}, false + } + + var cached CachedEngineResponse + if err := json.Unmarshal(data, &cached); err != nil { + ec.cache.logger.Warn("engine cache hit but unmarshal failed", "key", key, "error", err) + return CachedEngineResponse{}, false + } + + ec.cache.logger.Debug("engine cache hit", "key", key, "engine", engine) + return cached, true +} + +// Set stores an engine response in the cache with the engine's tier TTL. +func (ec *EngineCache) Set(ctx context.Context, engine, queryHash string, resp contracts.SearchResponse) { + if !ec.cache.Enabled() { + return + } + + data, err := json.Marshal(resp) + if err != nil { + ec.cache.logger.Warn("engine cache set: marshal failed", "engine", engine, "error", err) + return + } + + tier := EngineTier(engine, ec.overrides) + key := engineCacheKey(engine, queryHash) + + cached := CachedEngineResponse{ + Engine: engine, + Response: data, + StoredAt: time.Now(), + } + + cachedData, err := json.Marshal(cached) + if err != nil { + ec.cache.logger.Warn("engine cache set: wrap marshal failed", "key", key, "error", err) + return + } + + ec.cache.SetBytes(ctx, key, cachedData, tier.Duration) +} + +// IsStale returns true if the cached response is older than the tier's TTL. +func (ec *EngineCache) IsStale(cached CachedEngineResponse, engine string) bool { + tier := EngineTier(engine, ec.overrides) + return time.Since(cached.StoredAt) > tier.Duration +} + +// Logger returns the logger for background refresh logging. +func (ec *EngineCache) Logger() *slog.Logger { + return ec.cache.logger +} + +// engineCacheKey builds the cache key for an engine+query combination. +func engineCacheKey(engine, queryHash string) string { + return "samsa:resp:" + engine + ":" + queryHash +} \ No newline at end of file diff --git a/internal/cache/engine_cache_test.go b/internal/cache/engine_cache_test.go new file mode 100644 index 0000000..721e1eb --- /dev/null +++ b/internal/cache/engine_cache_test.go @@ -0,0 +1,95 @@ +package cache + +import ( + "context" + "log/slog" + "testing" + "time" + + "github.com/metamorphosis-dev/samsa/internal/contracts" +) + +func TestEngineCacheGetSet(t *testing.T) { + // Create a disabled cache for unit testing (nil client) + c := &Cache{logger: slog.Default()} + ec := NewEngineCache(c, nil) + + ctx := context.Background() + cached, ok := ec.Get(ctx, "wikipedia", "abc123") + if ok { + t.Errorf("Get on disabled cache: expected false, got %v", ok) + } + _ = cached // unused when ok=false +} + +func TestEngineCacheKeyFormat(t *testing.T) { + key := engineCacheKey("wikipedia", "abc123") + if key != "samsa:resp:wikipedia:abc123" { + t.Errorf("engineCacheKey: expected samsa:resp:wikipedia:abc123, got %s", key) + } +} + +func TestEngineCacheIsStale(t *testing.T) { + c := &Cache{logger: slog.Default()} + ec := NewEngineCache(c, nil) + + // Fresh response (stored 1 minute ago, wikipedia has 24h TTL) + fresh := CachedEngineResponse{ + Engine: "wikipedia", + Response: []byte(`{}`), + StoredAt: time.Now().Add(-1 * time.Minute), + } + if ec.IsStale(fresh, "wikipedia") { + t.Errorf("IsStale: 1-minute-old wikipedia should NOT be stale") + } + + // Stale response (stored 25 hours ago) + stale := CachedEngineResponse{ + Engine: "wikipedia", + Response: []byte(`{}`), + StoredAt: time.Now().Add(-25 * time.Hour), + } + if !ec.IsStale(stale, "wikipedia") { + t.Errorf("IsStale: 25-hour-old wikipedia SHOULD be stale (24h TTL)") + } + + // Override: 30 minute TTL for reddit + overrides := map[string]time.Duration{"reddit": 30 * time.Minute} + ec2 := NewEngineCache(c, overrides) + + // 20 minutes old with 30m override should NOT be stale + redditFresh := CachedEngineResponse{ + Engine: "reddit", + Response: []byte(`{}`), + StoredAt: time.Now().Add(-20 * time.Minute), + } + if ec2.IsStale(redditFresh, "reddit") { + t.Errorf("IsStale: 20-min reddit with 30m override should NOT be stale") + } + + // 45 minutes old with 30m override SHOULD be stale + redditStale := CachedEngineResponse{ + Engine: "reddit", + Response: []byte(`{}`), + StoredAt: time.Now().Add(-45 * time.Minute), + } + if !ec2.IsStale(redditStale, "reddit") { + t.Errorf("IsStale: 45-min reddit with 30m override SHOULD be stale") + } +} + +func TestEngineCacheSetResponseType(t *testing.T) { + c := &Cache{logger: slog.Default()} + ec := NewEngineCache(c, nil) + + ctx := context.Background() + urlStr := "https://example.com" + resp := contracts.SearchResponse{ + Results: []contracts.MainResult{ + {Title: "Test", URL: &urlStr}, + }, + } + + // Should not panic on disabled cache + ec.Set(ctx, "wikipedia", "abc123", resp) +} \ No newline at end of file