MVP-Routing-Implementation #1
214
internal/cache/store.go
vendored
Normal file
214
internal/cache/store.go
vendored
Normal 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)
|
||||||
|
}
|
||||||
227
internal/cache/store_test.go
vendored
Normal file
227
internal/cache/store_test.go
vendored
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user