cache: add EngineCache with tier-aware Get/Set
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ff4149ecbd
commit
e9625441cc
2 changed files with 186 additions and 0 deletions
91
internal/cache/engine_cache.go
vendored
Normal file
91
internal/cache/engine_cache.go
vendored
Normal file
|
|
@ -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
|
||||
}
|
||||
95
internal/cache/engine_cache_test.go
vendored
Normal file
95
internal/cache/engine_cache_test.go
vendored
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue