package cache import ( "context" "errors" "fmt" "strings" "time" "github.com/go-redis/redis/v8" "trip-planner/internal/metrics" ) // 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 metrics *metrics.Metrics } // NewRedisClient creates a new Redis client wrapper. func NewRedisClient(client *redis.Client, m *metrics.Metrics) *redisClient { return &redisClient{client: client, metrics: m} } // 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) { r.metrics.RecordCacheMiss("cache") // record cache miss at redis client level return nil, nil // cache miss } if err != nil { return nil, fmt.Errorf("cache get: %w", err) } r.metrics.RecordCacheHit("cache") // record cache hit at redis client level 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. // Sanitizes key components to prevent key corruption via special characters. func sanitizeKeyComponent(s string) string { // Replace characters that could corrupt Redis key format s = strings.ReplaceAll(s, ":", "_colon_") s = strings.ReplaceAll(s, "/", "_slash_") s = strings.ReplaceAll(s, " ", "_") s = strings.ReplaceAll(s, "\t", "_tab_") s = strings.ReplaceAll(s, "\n", "_newline_") s = strings.ReplaceAll(s, "\r", "_cr_") return s } func keyString(k *CacheKey) string { switch k.Kind { case "city": return fmt.Sprintf("cities:%s", sanitizeKeyComponent(k.Code)) case "station": return fmt.Sprintf("stations:%s", sanitizeKeyComponent(k.Code)) case "search": return fmt.Sprintf("search:%s:%s:%s", sanitizeKeyComponent(k.From), sanitizeKeyComponent(k.To), sanitizeKeyComponent(k.Date)) default: return fmt.Sprintf("unknown:%s", sanitizeKeyComponent(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, m *metrics.Metrics) Cache { return &cacheStore{ redisClient: NewRedisClient(client, m), } } // 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 metrics *metrics.Metrics } // NewCacheAside creates a new CacheAside instance. func NewCacheAside(store Cache, m *metrics.Metrics) *CacheAside { return &CacheAside{store: store, metrics: m} } // 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. // Records cache hit/miss metrics. 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 } // Try cache first data, err := c.store.Get(ctx, key) if err == nil && data != nil { c.metrics.RecordCacheHit("search") // cache hit return data, nil } // Cache miss: fetch from backend if errors.Is(err, redis.Nil) { c.metrics.RecordCacheMiss("search") // record search cache miss } else if err != nil { return nil, err } // 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 } // 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) } // 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) { c.metrics.RecordCacheMiss("cache_aside") // record cache aside miss return nil, nil // cache miss } if err != nil { return nil, fmt.Errorf("cache get: %w", err) } c.metrics.RecordCacheHit("cache_aside") // record cache aside hit 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) }