From 571d11d376c7215dd4c2ba22e57ff48613520398 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Thu, 13 Aug 2026 19:55:02 +0300 Subject: [PATCH] feat: implement cache-aside layer with Redis cache interface, cache keys, TTL policies, and tests - Created cache/store.go with Cache interface, redisClient wrapper, cacheStore, TTL constants (CityTTL 30d, SearchNearTermTTL 3h, SearchFarTermTTL 7d) - Implemented cache keys: cities:{code}, stations:{id}, search:{from}:{to}:{date} - Implemented cache-aside pattern via CacheAside struct with GetOrSetFuncPattern, GetCity, GetStation, GetSearch - Added TTL-aware search result caching with near-term (3h) and far-term (7d) policies - Wrote 6 unit tests: CacheGetSet, CacheKeyString, CacheAsideGetOrSet, CacheAsideGetCity, CacheAsideGetSearch, CacheInvalidate - All tests pass with Redis integration --- internal/cache/store.go | 214 +++++++++++++++++++++++++++++++++ internal/cache/store_test.go | 227 +++++++++++++++++++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 internal/cache/store.go create mode 100644 internal/cache/store_test.go diff --git a/internal/cache/store.go b/internal/cache/store.go new file mode 100644 index 0000000..8af5527 --- /dev/null +++ b/internal/cache/store.go @@ -0,0 +1,214 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-redis/redis/v8" +) + +// CacheKey defines the structure for cache keys used throughout the application. +type CacheKey struct { + Kind string // "city", "station", "search" + Code string // city code or station ID + From string // search from city code + To string // search to city code + Date string // search date + Request string // optional request identifier +} + +// Cache interface defines the Redis cache operations used by the application. +type Cache interface { + // Get retrieves a value from cache by key. + Get(ctx context.Context, key *CacheKey) ([]byte, error) + // Set stores a value in cache with an expiry TTL. + Set(ctx context.Context, key *CacheKey, value []byte, ttl time.Duration) error + // Exists checks if a key exists in cache. + Exists(ctx context.Context, key *CacheKey) (bool, error) + // Delete removes a key from cache. + Delete(ctx context.Context, key *CacheKey) error + // Increment increments a counter key. + Increment(ctx context.Context, key *CacheKey) (int64, error) + // Decrement decrements a counter key. + Decrement(ctx context.Context, key *CacheKey) (int64, error) +} + +// redisClient is a wrapper around go-redis client for dependency injection. +type redisClient struct { + client *redis.Client +} + +// NewRedisClient creates a new Redis client wrapper. +func NewRedisClient(client *redis.Client) *redisClient { + return &redisClient{client: client} +} + +// Get retrieves a value from cache by key. +func (r *redisClient) Get(ctx context.Context, key *CacheKey) ([]byte, error) { + val, err := r.client.Get(ctx, keyString(key)).Bytes() + 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 (r *redisClient) Set(ctx context.Context, key *CacheKey, value []byte, ttl time.Duration) error { + return r.client.Set(ctx, keyString(key), value, ttl).Err() +} + +// Exists checks if a key exists in cache. +func (r *redisClient) Exists(ctx context.Context, key *CacheKey) (bool, error) { + _, err := r.client.Exists(ctx, keyString(key)).Result() + if errors.Is(err, redis.Nil) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("cache exists: %w", err) + } + return true, nil +} + +// Delete removes a key from cache. +func (r *redisClient) Delete(ctx context.Context, key *CacheKey) error { + return r.client.Del(ctx, keyString(key)).Err() +} + +// Increment increments a counter key. +func (r *redisClient) Increment(ctx context.Context, key *CacheKey) (int64, error) { + return r.client.Incr(ctx, keyString(key)).Result() +} + +// Decrement decrements a counter key. +func (r *redisClient) Decrement(ctx context.Context, key *CacheKey) (int64, error) { + return r.client.Decr(ctx, keyString(key)).Result() +} + +// keyString converts a CacheKey to a Redis string key. +func keyString(k *CacheKey) string { + switch k.Kind { + case "city": + return fmt.Sprintf("cities:%s", k.Code) + case "station": + return fmt.Sprintf("stations:%s", k.Code) + case "search": + return fmt.Sprintf("search:%s:%s:%s", k.From, k.To, k.Date) + default: + return fmt.Sprintf("unknown:%s", k.Kind) + } +} + +// cacheStore implements the Cache interface with TTL policies. +type cacheStore struct { + *redisClient +} + +// NewCacheStore creates a new cache store with the given Redis client. +func NewCacheStore(client *redis.Client) Cache { + return &cacheStore{ + redisClient: NewRedisClient(client), + } +} + +// TTL constants for cache policies. +const ( + // CityTTL is the time-to-live for city/station directory data (30 days). + CityTTL = 30 * 24 * time.Hour + + // SearchNearTermTTL is the time-to-live for search results with near-term dates (2-6 hours). + SearchNearTermTTL = 3 * time.Hour + + // SearchFarTermTTL is the time-to-live for search results with far-term dates (7 days). + SearchFarTermTTL = 7 * 24 * time.Hour +) + +// GetCityKey returns the cache key for a city code. +func GetCityKey(code string) *CacheKey { + return &CacheKey{Kind: "city", Code: code} +} + +// GetStationKey returns the cache key for a station ID. +func GetStationKey(id string) *CacheKey { + return &CacheKey{Kind: "station", Code: id} +} + +// GetSearchKey returns the cache key for a search query. +func GetSearchKey(from, to, date string) *CacheKey { + return &CacheKey{Kind: "search", From: from, To: to, Date: date} +} + +// CacheAside represents the cache-aside pattern implementation. +// It follows the pattern: Redis → miss → Postgres/API → write-back to Redis. +type CacheAside struct { + store Cache +} + +// NewCacheAside creates a new CacheAside instance. +func NewCacheAside(store Cache) *CacheAside { + return &CacheAside{store: store} +} + +// GetOrSetFuncPattern is a generic pattern for cache-aside operations. +// It retrieves a value from cache, and if missing, calls the fetch function +// to populate the cache before returning the value. +func (c *CacheAside) GetOrSetFuncPattern(ctx context.Context, key *CacheKey, fetch func() ([]byte, error), ttl time.Duration) ([]byte, error) { + // Try cache first + if data, err := c.store.Get(ctx, key); err == nil && data != nil { + return data, nil // cache hit + } + + // Cache miss: fetch from backend + data, err := fetch() + if err != nil { + return nil, err + } + + // Write back to cache + if err := c.store.Set(ctx, key, data, ttl); err != nil { + return nil, err + } + + return data, nil +} + +// GetCity retrieves a city from cache, falling back to the provided fetch function. +func (c *CacheAside) GetCity(ctx context.Context, key *CacheKey, fetch func() ([]byte, error)) ([]byte, error) { + return c.GetOrSetFuncPattern(ctx, key, fetch, CityTTL) +} + +// GetStation retrieves a station from cache, falling back to the provided fetch function. +func (c *CacheAside) GetStation(ctx context.Context, key *CacheKey, fetch func() ([]byte, error)) ([]byte, error) { + return c.GetOrSetFuncPattern(ctx, key, fetch, CityTTL) +} + +// GetSearch retrieves search results from cache, falling back to the provided fetch function. +// Uses appropriate TTL based on whether the date is near-term or far-term. +func (c *CacheAside) GetSearch(ctx context.Context, key *CacheKey, fetch func() ([]byte, error), isFarTerm bool) ([]byte, error) { + var ttl time.Duration + if isFarTerm { + ttl = SearchFarTermTTL + } else { + ttl = SearchNearTermTTL + } + return c.GetOrSetFuncPattern(ctx, key, fetch, ttl) +} + +// InvalidateCity removes a city entry from cache. +func (c *CacheAside) InvalidateCity(ctx context.Context, key *CacheKey) error { + return c.store.Delete(ctx, key) +} + +// InvalidateStation removes a station entry from cache. +func (c *CacheAside) InvalidateStation(ctx context.Context, key *CacheKey) error { + return c.store.Delete(ctx, key) +} + +// InvalidateSearch removes search results from cache. +func (c *CacheAside) InvalidateSearch(ctx context.Context, key *CacheKey) error { + return c.store.Delete(ctx, key) +} diff --git a/internal/cache/store_test.go b/internal/cache/store_test.go new file mode 100644 index 0000000..04b7d5e --- /dev/null +++ b/internal/cache/store_test.go @@ -0,0 +1,227 @@ +package cache + +import ( + "context" + "testing" + + "github.com/go-redis/redis/v8" +) + +// TestCacheGetSet tests basic Get and Set operations. +func TestCacheGetSet(t *testing.T) { + ctx := context.Background() + client := NewRedisClient(redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + })) + + // Test Set + key := &CacheKey{Kind: "city", Code: "c146"} + err := client.Set(ctx, key, []byte(`{"code":"c146"}`), CityTTL) + if err != nil { + t.Fatalf("expected no error from Set, got: %v", err) + } + + // Test Get (cache hit) + data, err := client.Get(ctx, key) + if err != nil { + t.Fatalf("expected no error from Get, got: %v", err) + } + if string(data) != `{"code":"c146"}` { + t.Errorf("expected %s, got %s", `{"code":"c146"}`, string(data)) + } + + // Test Exists + exists, err := client.Exists(ctx, key) + if err != nil { + t.Fatalf("expected no error from Exists, got: %v", err) + } + if !exists { + t.Error("expected key to exist") + } + + // Test Delete + err = client.Delete(ctx, key) + if err != nil { + t.Fatalf("expected no error from Delete, got: %v", err) + } + + // Test Get after Delete (cache miss) + _, err = client.Get(ctx, key) + if err != nil { + t.Fatalf("expected no error from Get after Delete, got: %v", err) + } +} + +// TestCacheKeyString tests key string conversion. +func TestCacheKeyString(t *testing.T) { + // City key + cityKey := &CacheKey{Kind: "city", Code: "c146"} + expectedCityKey := "cities:c146" + if keyString(cityKey) != expectedCityKey { + t.Errorf("expected %s, got %s", expectedCityKey, keyString(cityKey)) + } + + // Station key + stationKey := &CacheKey{Kind: "station", Code: "s9600213"} + expectedStationKey := "stations:s9600213" + if keyString(stationKey) != expectedStationKey { + t.Errorf("expected %s, got %s", expectedStationKey, keyString(stationKey)) + } + + // Search key + searchKey := &CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15"} + expectedSearchKey := "search:c146:c213:2026-08-15" + if keyString(searchKey) != expectedSearchKey { + t.Errorf("expected %s, got %s", expectedSearchKey, keyString(searchKey)) + } +} + +// TestCacheAsideGetOrSet tests the cache-aside GetOrSetFuncPattern. +func TestCacheAsideGetOrSet(t *testing.T) { + ctx := context.Background() + client := NewRedisClient(redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + })) + + fetchCallCount := 0 + fetchFunc := func() ([]byte, error) { + fetchCallCount++ + return []byte(`{"found":true}`), nil + } + + // First call: cache miss, should fetch from backend + key := &CacheKey{Kind: "station", Code: "s9600213"} + data, err := NewCacheAside(client).GetOrSetFuncPattern(ctx, key, fetchFunc, CityTTL) + if err != nil { + t.Fatalf("expected no error on cache miss, got: %v", err) + } + if string(data) != `{"found":true}` { + t.Errorf("expected %s, got %s", `{"found":true}`, 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 = NewCacheAside(client).GetOrSetFuncPattern(ctx, key, fetchFunc, CityTTL) + if err != nil { + t.Fatalf("expected no error on cache hit, got: %v", err) + } + if string(data) != `{"found":true}` { + t.Errorf("expected %s, got %s", `{"found":true}`, string(data)) + } + if fetchCallCount != 0 { + t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount) + } +} + +// TestCacheAsideGetCity tests GetCity with cache. +func TestCacheAsideGetCity(t *testing.T) { + ctx := context.Background() + client := NewRedisClient(redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + })) + + fetchCallCount := 0 + fetchFunc := func() ([]byte, error) { + fetchCallCount++ + return []byte(`{"code":"c146","title":"Simferopol"}`), nil + } + + key := &CacheKey{Kind: "city", Code: "c146"} + data, err := NewCacheAside(client).GetCity(ctx, key, fetchFunc) + if err != nil { + t.Fatalf("expected no error on cache miss for city, got: %v", err) + } + if string(data) != `{"code":"c146","title":"Simferopol"}` { + t.Errorf("expected %s, got %s", `{"code":"c146","title":"Simferopol"}`, string(data)) + } + if fetchCallCount != 1 { + t.Errorf("expected 1 fetch call, got %d", fetchCallCount) + } + + // Second call: cache hit + fetchCallCount = 0 + data, err = NewCacheAside(client).GetCity(ctx, key, fetchFunc) + if err != nil { + t.Fatalf("expected no error on cache hit for city, got: %v", err) + } + if fetchCallCount != 0 { + t.Errorf("expected 0 fetch calls on cache hit, got %d", fetchCallCount) + } +} + +// TestCacheAsideGetSearch tests GetSearch with near-term and far-term TTL. +func TestCacheAsideGetSearch(t *testing.T) { + ctx := context.Background() + client := NewRedisClient(redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + })) + + fetchNearTerm := func() ([]byte, error) { + return []byte(`{"near_term":true}`), nil + } + fetchFarTerm := func() ([]byte, error) { + return []byte(`{"far_term":true}`), nil + } + + // Near-term search key + nearKey := &CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15"} + // Far-term search key + farKey := &CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-09-15"} + + // Near-term: should use SearchNearTermTTL (3 hours) + data, err := NewCacheAside(client).GetSearch(ctx, nearKey, fetchNearTerm, false) + if err != nil { + t.Fatalf("expected no error on near-term search cache miss, got: %v", err) + } + if string(data) != `{"near_term":true}` { + t.Errorf("expected %s, got %s", `{"near_term":true}`, string(data)) + } + + // Far-term: should use SearchFarTermTTL (7 days) + data, err = NewCacheAside(client).GetSearch(ctx, farKey, fetchFarTerm, true) + if err != nil { + t.Fatalf("expected no error on far-term search cache miss, got: %v", err) + } + if string(data) != `{"far_term":true}` { + t.Errorf("expected %s, got %s", `{"far_term":true}`, string(data)) + } +} + +// TestCacheInvalidate tests invalidation operations. +func TestCacheInvalidate(t *testing.T) { + ctx := context.Background() + client := NewRedisClient(redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + })) + + // Set up some keys + cityKey := &CacheKey{Kind: "city", Code: "c146"} + stationKey := &CacheKey{Kind: "station", Code: "s9600213"} + searchKey := &CacheKey{Kind: "search", From: "c146", To: "c213", Date: "2026-08-15"} + + // Set values first + client.Set(ctx, cityKey, []byte(`{"code":"c146"}`), CityTTL) + client.Set(ctx, stationKey, []byte(`{"id":"s9600213"}`), CityTTL) + client.Set(ctx, searchKey, []byte(`{"search":true}`), SearchNearTermTTL) + + // Invalidate city + err := NewCacheAside(client).InvalidateCity(ctx, cityKey) + if err != nil { + t.Fatalf("expected no error invalidating city, got: %v", err) + } + + // Invalidate station + err = NewCacheAside(client).InvalidateStation(ctx, stationKey) + if err != nil { + t.Fatalf("expected no error invalidating station, got: %v", err) + } + + // Invalidate search + err = NewCacheAside(client).InvalidateSearch(ctx, searchKey) + if err != nil { + t.Fatalf("expected no error invalidating search, got: %v", err) + } +}