feat: Valkey cache for search results

- Add internal/cache package using go-redis/v9 (Valkey-compatible)
- Cache keys are deterministic SHA-256 hashes of search parameters
- Cache wraps the Search() method: check cache → miss → execute → store
- Gracefully disabled if Valkey is unreachable or unconfigured
- Configurable TTL (default 5m), address, password, and DB index
- Environment variable overrides: VALKEY_ADDRESS, VALKEY_PASSWORD,
  VALKEY_DB, VALKEY_CACHE_TTL
- Structured JSON logging via slog throughout cache layer
- Refactored service.go: extract executeSearch() from Search() for clarity
- Update config.example.toml with [cache] section
- Add cache package tests (key generation, nop behavior)
This commit is contained in:
Franz Kafka 2026-03-21 15:43:47 +00:00
parent 385a7acab7
commit 94322ceff4
9 changed files with 361 additions and 9 deletions

View file

@ -6,6 +6,7 @@ import (
"sync"
"time"
"github.com/ashie/gosearch/internal/cache"
"github.com/ashie/gosearch/internal/contracts"
"github.com/ashie/gosearch/internal/engines"
"github.com/ashie/gosearch/internal/upstream"
@ -14,12 +15,14 @@ import (
type ServiceConfig struct {
UpstreamURL string
HTTPTimeout time.Duration
Cache *cache.Cache
}
type Service struct {
upstreamClient *upstream.Client
planner *engines.Planner
localEngines map[string]engines.Engine
cache *cache.Cache
}
func NewService(cfg ServiceConfig) *Service {
@ -42,6 +45,7 @@ func NewService(cfg ServiceConfig) *Service {
upstreamClient: up,
planner: engines.NewPlannerFromEnv(),
localEngines: engines.NewDefaultPortedEngines(httpClient),
cache: cfg.Cache,
}
}
@ -50,7 +54,34 @@ func NewService(cfg ServiceConfig) *Service {
//
// Individual engine failures are reported as unresponsive_engines rather
// than aborting the entire search.
//
// If a Valkey cache is configured and contains a cached response for this
// request, the cached result is returned without hitting any engines.
func (s *Service) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) {
// Check cache first.
if s.cache != nil {
cacheKey := cache.Key(req)
if cached, hit := s.cache.Get(ctx, cacheKey); hit {
return cached, nil
}
}
merged, err := s.executeSearch(ctx, req)
if err != nil {
return SearchResponse{}, err
}
// Store in cache.
if s.cache != nil {
cacheKey := cache.Key(req)
s.cache.Set(ctx, cacheKey, merged)
}
return merged, nil
}
// executeSearch runs the actual engine queries and merges results.
func (s *Service) executeSearch(ctx context.Context, req SearchRequest) (SearchResponse, error) {
localEngineNames, upstreamEngineNames, _ := s.planner.Plan(req)
// Run all local engines concurrently.
@ -176,5 +207,3 @@ func shouldFallbackToUpstream(engineName string, r contracts.SearchResponse) boo
}
return len(r.Results) == 0 && len(r.Answers) == 0 && len(r.Infoboxes) == 0
}