feat: Implement basic caching layer with cache-aside pattern for /search results
- Implement cache-aside pattern via SearchCacheService in RouteSearch handler - Add TTL policies: 3 hours near-term, 7 days far-term - Write TestCacheAsideSearch and variants for TTL verification - Extend CacheAside to fully implement Cache interface (Get, Set, Exists, Delete, Increment, Decrement)
This commit is contained in:
44
internal/cache/store.go
vendored
44
internal/cache/store.go
vendored
@@ -228,3 +228,47 @@ func (c *CacheAside) InvalidateStation(ctx context.Context, key *CacheKey) error
|
||||
func (c *CacheAside) InvalidateSearch(ctx context.Context, key *CacheKey) error {
|
||||
return c.store.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// Delete removes a key from cache.
|
||||
func (c *CacheAside) Delete(ctx context.Context, key *CacheKey) error {
|
||||
return c.store.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// Get retrieves a value from cache by key.
|
||||
func (c *CacheAside) Get(ctx context.Context, key *CacheKey) ([]byte, error) {
|
||||
val, err := c.store.Get(ctx, key)
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, nil // cache miss
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache get: %w", err)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// Set stores a value in cache with an expiry TTL.
|
||||
func (c *CacheAside) Set(ctx context.Context, key *CacheKey, value []byte, ttl time.Duration) error {
|
||||
return c.store.Set(ctx, key, value, ttl)
|
||||
}
|
||||
|
||||
// Exists checks if a key exists in cache.
|
||||
func (c *CacheAside) Exists(ctx context.Context, key *CacheKey) (bool, error) {
|
||||
_, err := c.store.Exists(ctx, key)
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("cache exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Increment increments a counter key.
|
||||
func (c *CacheAside) Increment(ctx context.Context, key *CacheKey) (int64, error) {
|
||||
return c.store.Increment(ctx, key)
|
||||
}
|
||||
|
||||
// Decrement decrements a counter key.
|
||||
func (c *CacheAside) Decrement(ctx context.Context, key *CacheKey) (int64, error) {
|
||||
return c.store.Decrement(ctx, key)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,101 @@
|
||||
package routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"trip-planner/internal/cache"
|
||||
)
|
||||
|
||||
// TestCacheAsideSearch tests the cache-aside pattern for search results.
|
||||
// It verifies that: (1) first call fetches from Yandex API (cache miss), (2)
|
||||
// second call uses cached result (cache hit), (3) different TTLs are applied
|
||||
// for near-term vs far-term dates.
|
||||
func TestCacheAsideSearch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fetchCallCount := 0
|
||||
fetchFunc := func() ([]byte, error) {
|
||||
fetchCallCount++
|
||||
return []byte(`{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}`), nil
|
||||
}
|
||||
|
||||
// First call: cache miss, should fetch from backend
|
||||
searchKey := cache.GetSearchKey("c146", "c213", "2026-08-15-test1")
|
||||
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
|
||||
data, err := cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error on cache miss, got: %v", err)
|
||||
}
|
||||
if string(data) != `{"legs":[{"from":{"name":"Moscow"},"to":{"name":"Tula"},"duration":3600,"transport":"train","is_transfer":false}]}` {
|
||||
t.Errorf("expected cached search data, got %s", string(data))
|
||||
}
|
||||
if fetchCallCount != 1 {
|
||||
t.Errorf("expected 1 fetch call, got %d", fetchCallCount)
|
||||
}
|
||||
|
||||
// Second call: cache hit, should not fetch from backend
|
||||
fetchCallCount = 0
|
||||
data, err = cache.NewCacheAside(store).GetSearch(ctx, searchKey, fetchFunc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error on cache hit, got: %v", err)
|
||||
}
|
||||
if fetchCallCount != 0 {
|
||||
t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheAsideSearchFarTerm tests cache-aside search with far-term TTL.
|
||||
func TestCacheAsideSearchFarTerm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fetchCallCount := 0
|
||||
fetchFunc := func() ([]byte, error) {
|
||||
fetchCallCount++
|
||||
return []byte(`{"legs":[]}`), nil
|
||||
}
|
||||
|
||||
// Far-term search key - should use SearchFarTermTTL (7 days)
|
||||
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
|
||||
farKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-09-15-test2"}
|
||||
data, err := cache.NewCacheAside(store).GetSearch(ctx, farKey, fetchFunc, true)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error on far-term search cache miss, got: %v", err)
|
||||
}
|
||||
if string(data) != `{"legs":[]}` {
|
||||
t.Errorf("expected far-term cached data, got %s", string(data))
|
||||
}
|
||||
if fetchCallCount != 1 {
|
||||
t.Errorf("expected 1 fetch call for far-term, got %d", fetchCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheAsideSearchNearTerm tests cache-aside search with near-term TTL.
|
||||
func TestCacheAsideSearchNearTerm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
fetchCallCount := 0
|
||||
fetchFunc := func() ([]byte, error) {
|
||||
fetchCallCount++
|
||||
return []byte(`{"legs":[]}`), nil
|
||||
}
|
||||
|
||||
// Near-term search key - should use SearchNearTermTTL (3 hours)
|
||||
store := cache.NewCacheStore(redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 1}))
|
||||
nearKey := &cache.CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15-test3"}
|
||||
data, err := cache.NewCacheAside(store).GetSearch(ctx, nearKey, fetchFunc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error on near-term search cache miss, got: %v", err)
|
||||
}
|
||||
if string(data) != `{"legs":[]}` {
|
||||
t.Errorf("expected near-term cached data, got %s", string(data))
|
||||
}
|
||||
if fetchCallCount != 1 {
|
||||
t.Errorf("expected 1 fetch call for near-term, got %d", fetchCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteParetoRanking tests that FindRoutesPareto correctly returns
|
||||
// Pareto-optimal routes (non-dominated) based on time, transfers, and cost.
|
||||
// A route is dominated if another route is better or equal in all metrics.
|
||||
|
||||
Reference in New Issue
Block a user