Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,27 @@ func NewApp(cfg *config.Config) (*App, error) {
return app, nil
}

// initCache initializes the cache system
// initCache initializes the cache system.
// When Redis is configured and reachable, memory cache is disabled entirely
// to avoid TTL mismatches on Redis→memory promotion (group.go Get/MGet).
// Memory cache is only used as a fallback when Redis is unavailable.
func initCache(cfg *config.Config, logger *logging.Logger) (*cache.CacheGroup, error) {
var memoryCache cache.Cache = cache.NewMemoryCache()
var memoryCache cache.Cache
var redisCache cache.Cache

// Get Redis URL from config (supports both direct URL and constructed from config)
redisURL := cfg.Cache.GetRedisURL()
if redisURL != "" {
redis, err := cache.NewRedisCache(redisURL)
if err != nil {
logger.Warn().Err(err).Msg("Failed to connect to Redis, using memory cache only")
logger.Warn().Err(err).Msg("Failed to connect to Redis, falling back to memory cache")
memoryCache = cache.NewMemoryCache()
} else {
redisCache = redis
logger.Info().Msg("Redis cache initialized")
logger.Info().Msg("Redis cache initialized (memory cache disabled)")
}
} else {
memoryCache = cache.NewMemoryCache()
logger.Info().Msg("No Redis configured, using memory cache")
}

return cache.NewCacheGroup(memoryCache, redisCache), nil
Expand Down
16 changes: 11 additions & 5 deletions internal/cache/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import (
"time"
)

// CacheGroup manages multiple cache tiers (memory + Redis)
// CacheGroup manages multiple cache tiers (memory + Redis).
// At most one of memoryCache or redisCache is non-nil in normal
// operation (see app.initCache), but the code supports both being
// set for the fallback case where Redis goes down at startup.
type CacheGroup struct {
memoryCache Cache
redisCache Cache
Expand Down Expand Up @@ -33,9 +36,12 @@ func (cg *CacheGroup) Get(ctx context.Context, key string) (interface{}, error)
if cg.redisCache != nil {
value, err := cg.redisCache.Get(ctx, key)
if err == nil && value != nil {
// Store in memory cache for next time
// TTL=0 means "cache indefinitely" in MemoryCache, which causes
// stale reads on short-TTL entries (e.g. get_dynamic_global_properties
// with TTL=1s). Promote with a bounded TTL (5s) so the memory layer
// doesn't serve stale data for longer than one Redis TTL cycle.
if cg.memoryCache != nil {
_ = cg.memoryCache.Set(ctx, key, value, 0) // No expiration for memory
_ = cg.memoryCache.Set(ctx, key, value, 5*time.Second)
}
return value, nil
}
Expand Down Expand Up @@ -80,9 +86,9 @@ func (cg *CacheGroup) MGet(ctx context.Context, keys []string) ([]interface{}, e
if results[i] == nil && missingIndex < len(redisResults) {
value := redisResults[missingIndex]
results[i] = value
// Store in memory if found
// Store in memory with bounded TTL to prevent staleness
if value != nil && cg.memoryCache != nil {
_ = cg.memoryCache.Set(ctx, key, value, 0)
_ = cg.memoryCache.Set(ctx, key, value, 5*time.Second)
}
missingIndex++
}
Expand Down
95 changes: 95 additions & 0 deletions internal/cache/group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,98 @@ func TestCacheGroupGetPriority(t *testing.T) {
}
}

func TestCacheGroupGet_TTLExpiration(t *testing.T) {
ctx := context.Background()
mem := NewMemoryCache()
cg := NewCacheGroup(mem, nil)

err := cg.Set(ctx, "short-lived", "data", 1*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

value, err := cg.Get(ctx, "short-lived")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != "data" {
t.Errorf("expected 'data', got %v", value)
}

time.Sleep(1100 * time.Millisecond)

value, err = cg.Get(ctx, "short-lived")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != nil {
t.Errorf("expected nil after TTL expiration, got %v", value)
}
}

func TestCacheGroupGet_RedisBackfillTTL(t *testing.T) {
ctx := context.Background()
mem := NewMemoryCache()
redis := NewMemoryCache() // simulate Redis with memory cache

cg := NewCacheGroup(mem, redis)

// Set in "redis" with short TTL
err := redis.Set(ctx, "dgp", "block-100", 1*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

// Get triggers Redis→memory promotion
value, err := cg.Get(ctx, "dgp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != "block-100" {
t.Errorf("expected 'block-100', got %v", value)
}

// Wait for promotion TTL to expire
time.Sleep(6 * time.Second)

// Memory cache entry should have expired
value, err = mem.Get(ctx, "dgp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != nil {
t.Errorf("expected memory entry to expire after promotion TTL, got %v", value)
}
}

func TestCacheGroupGet_NilMemoryWithRedis(t *testing.T) {
ctx := context.Background()
redis := NewMemoryCache() // simulate Redis

// Production config: memory=nil, redis=Redis
cg := NewCacheGroup(nil, redis)

err := cg.Set(ctx, "key", "value", 1*time.Second)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

value, err := cg.Get(ctx, "key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != "value" {
t.Errorf("expected 'value', got %v", value)
}

time.Sleep(1100 * time.Millisecond)

value, err = cg.Get(ctx, "key")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if value != nil {
t.Errorf("expected nil after TTL expiration, got %v", value)
}
}

Loading