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
This commit is contained in:
2026-08-13 19:55:02 +03:00
parent 6a5c586187
commit 571d11d376
2 changed files with 441 additions and 0 deletions

214
internal/cache/store.go vendored Normal file
View File

@@ -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)
}