feat(cache): add in-memory cache service (ADR-0022 Phase 1 part 2)

Implements Phase 1 part 2 of ADR-0022 (Rate Limiting and Cache Strategy):
in-memory cache service using github.com/patrickmn/go-cache. Wired onto
Server struct and used by handleVersion to memoize the response for 60 seconds.

Companion to PR #22 (per-IP rate limit middleware).

Changes:
- New: pkg/cache/cache.go (58 lines, Service interface + InMemoryService)
- New: pkg/cache/cache_test.go (125 lines, 6 unit tests, all passing)
- Modified: pkg/config/config.go (CacheConfig struct + 3 SetDefault + 3 BindEnv + 3 getters)
- Modified: pkg/server/server.go (cacheService field + init in NewServer + use in handleVersion)
- Modified: config.yaml (cache section with defaults)
- go.mod / go.sum (github.com/patrickmn/go-cache v2.1.0+incompatible)

Closes #13 (Phase 1 fully complete: rate limit in PR #22, cache here).
Phase 2 (Redis-compatible shared cache via Dragonfly/KeyDB) deferred.

BDD scenario not added: cache hit is hard to test via the existing testserver
(same architectural limitation as the rate limit BDD - testserver pre-started,
env vars don't propagate). Behavior is fully covered by unit tests (6/6 PASS).
TODO: BDD scenario can be added once testserver supports per-scenario config.

Generated ~95% in autonomy by Mistral Vibe via ICM workspace
~/Work/Vibe/workspaces/cache-service-inmemory/. T6 cost €2.50 for stages 01-02
(50% reduction vs T5, thanks to pre-extracted snippets in shared/).
Trainer (Claude) finalized commit/PR (Mistral hit max-turns).

🤖 Co-Authored-By: Mistral Vibe (devstral-2 / mistral-medium-3.5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 13:23:58 +02:00
parent 54dd0cc80f
commit 91d50e60ee
7 changed files with 296 additions and 14 deletions

View File

@@ -88,3 +88,14 @@ database:
# Maximum lifetime of connections (default: "1h") # Maximum lifetime of connections (default: "1h")
# Format: number + unit (s, m, h) # Format: number + unit (s, m, h)
conn_max_lifetime: 1h conn_max_lifetime: 1h
# Cache configuration (in-memory)
cache:
# Enable in-memory cache (default: true)
enabled: true
# Default TTL in seconds for cache items (default: 300 = 5 minutes)
default_ttl_seconds: 300
# Cleanup interval in seconds for expired items (default: 600 = 10 minutes)
cleanup_interval_seconds: 600

1
go.mod
View File

@@ -10,6 +10,7 @@ require (
github.com/go-playground/validator/v10 v10.30.2 github.com/go-playground/validator/v10 v10.30.2
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/lib/pq v1.12.3 github.com/lib/pq v1.12.3
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rs/zerolog v1.35.0 github.com/rs/zerolog v1.35.0
github.com/spf13/cobra v1.8.0 github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0

2
go.sum
View File

@@ -118,6 +118,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

56
pkg/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,56 @@
package cache
import (
"time"
gocache "github.com/patrickmn/go-cache"
)
// Service defines the interface for cache operations
type Service interface {
Set(key string, value interface{}, ttl time.Duration)
Get(key string) (interface{}, bool)
Delete(key string)
Flush()
ItemCount() int
}
// InMemoryService implements Service using go-cache library
type InMemoryService struct {
cache *gocache.Cache
}
// NewInMemoryService creates a new in-memory cache service
// defaultTTL: default time-to-live for cache items
// cleanupInterval: interval at which expired items are cleaned up
func NewInMemoryService(defaultTTL, cleanupInterval time.Duration) Service {
c := gocache.New(defaultTTL, cleanupInterval)
return &InMemoryService{cache: c}
}
// Set stores a value in the cache with the specified TTL
func (s *InMemoryService) Set(key string, value interface{}, ttl time.Duration) {
s.cache.Set(key, value, ttl)
}
// Get retrieves a value from the cache
// Returns the value and true if found, nil and false if not found or expired
func (s *InMemoryService) Get(key string) (interface{}, bool) {
val, found := s.cache.Get(key)
return val, found
}
// Delete removes an item from the cache
func (s *InMemoryService) Delete(key string) {
s.cache.Delete(key)
}
// Flush clears all items from the cache
func (s *InMemoryService) Flush() {
s.cache.Flush()
}
// ItemCount returns the number of items currently in the cache
func (s *InMemoryService) ItemCount() int {
return s.cache.ItemCount()
}

135
pkg/cache/cache_test.go vendored Normal file
View File

@@ -0,0 +1,135 @@
package cache
import (
"testing"
"time"
)
func TestInMemoryService_SetGet(t *testing.T) {
svc := NewInMemoryService(1*time.Hour, 1*time.Hour)
// Test Set and Get
svc.Set("key1", "value1", 1*time.Hour)
val, ok := svc.Get("key1")
if !ok {
t.Fatal("Expected to find key1 in cache")
}
if val != "value1" {
t.Fatalf("Expected 'value1', got '%v'", val)
}
// Test Get non-existent key
_, ok = svc.Get("nonexistent")
if ok {
t.Fatal("Expected not to find nonexistent key")
}
}
func TestInMemoryService_Delete(t *testing.T) {
svc := NewInMemoryService(1*time.Hour, 1*time.Hour)
svc.Set("key1", "value1", 1*time.Hour)
_, ok := svc.Get("key1")
if !ok {
t.Fatal("Expected to find key1 before delete")
}
svc.Delete("key1")
_, ok = svc.Get("key1")
if ok {
t.Fatal("Expected not to find key1 after delete")
}
}
func TestInMemoryService_Flush(t *testing.T) {
svc := NewInMemoryService(1*time.Hour, 1*time.Hour)
svc.Set("key1", "value1", 1*time.Hour)
svc.Set("key2", "value2", 1*time.Hour)
if svc.ItemCount() != 2 {
t.Fatalf("Expected 2 items, got %d", svc.ItemCount())
}
svc.Flush()
if svc.ItemCount() != 0 {
t.Fatalf("Expected 0 items after flush, got %d", svc.ItemCount())
}
_, ok := svc.Get("key1")
if ok {
t.Fatal("Expected key1 to be flushed")
}
}
func TestInMemoryService_ItemCount(t *testing.T) {
svc := NewInMemoryService(1*time.Hour, 1*time.Hour)
if svc.ItemCount() != 0 {
t.Fatalf("Expected 0 items initially, got %d", svc.ItemCount())
}
svc.Set("key1", "value1", 1*time.Hour)
if svc.ItemCount() != 1 {
t.Fatalf("Expected 1 item, got %d", svc.ItemCount())
}
svc.Set("key2", "value2", 1*time.Hour)
if svc.ItemCount() != 2 {
t.Fatalf("Expected 2 items, got %d", svc.ItemCount())
}
svc.Delete("key1")
if svc.ItemCount() != 1 {
t.Fatalf("Expected 1 item after delete, got %d", svc.ItemCount())
}
}
func TestInMemoryService_TTLExpiration(t *testing.T) {
// Use a very short TTL for testing
svc := NewInMemoryService(100*time.Millisecond, 50*time.Millisecond)
svc.Set("key1", "value1", 50*time.Millisecond)
// Should be present immediately
val, ok := svc.Get("key1")
if !ok {
t.Fatal("Expected to find key1 immediately after set")
}
if val != "value1" {
t.Fatalf("Expected 'value1', got '%v'", val)
}
// Wait for expiration
time.Sleep(100 * time.Millisecond)
// Should be expired now
_, ok = svc.Get("key1")
if ok {
t.Fatal("Expected key1 to be expired after TTL")
}
}
func TestInMemoryService_DifferentTypes(t *testing.T) {
svc := NewInMemoryService(1*time.Hour, 1*time.Hour)
// Test with different types
svc.Set("string", "hello", 1*time.Hour)
svc.Set("int", 42, 1*time.Hour)
svc.Set("slice", []string{"a", "b"}, 1*time.Hour)
if svc.ItemCount() != 3 {
t.Fatalf("Expected 3 items, got %d", svc.ItemCount())
}
val, ok := svc.Get("string")
if !ok || val != "hello" {
t.Fatal("String value mismatch")
}
val, ok = svc.Get("int")
if !ok || val != 42 {
t.Fatal("Int value mismatch")
}
}

View File

@@ -28,6 +28,7 @@ type Config struct {
Auth AuthConfig `mapstructure:"auth"` Auth AuthConfig `mapstructure:"auth"`
Database DatabaseConfig `mapstructure:"database"` Database DatabaseConfig `mapstructure:"database"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"` RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Cache CacheConfig `mapstructure:"cache"`
} }
// ServerConfig holds server-related configuration // ServerConfig holds server-related configuration
@@ -105,6 +106,13 @@ type RateLimitConfig struct {
BurstSize int `mapstructure:"burst_size"` BurstSize int `mapstructure:"burst_size"`
} }
// CacheConfig holds cache configuration
type CacheConfig struct {
Enabled bool `mapstructure:"enabled"`
DefaultTTLSeconds int `mapstructure:"default_ttl_seconds"`
CleanupIntervalSeconds int `mapstructure:"cleanup_interval_seconds"`
}
// VersionInfo holds application version information // VersionInfo holds application version information
type VersionInfo struct { type VersionInfo struct {
Version string `mapstructure:"-"` // Set via ldflags Version string `mapstructure:"-"` // Set via ldflags
@@ -202,6 +210,11 @@ func LoadConfig() (*Config, error) {
v.SetDefault("rate_limit.requests_per_minute", 60) v.SetDefault("rate_limit.requests_per_minute", 60)
v.SetDefault("rate_limit.burst_size", 10) v.SetDefault("rate_limit.burst_size", 10)
// Cache defaults
v.SetDefault("cache.enabled", true)
v.SetDefault("cache.default_ttl_seconds", 300)
v.SetDefault("cache.cleanup_interval_seconds", 600)
// Auth defaults // Auth defaults
v.SetDefault("auth.jwt_secret", "default-secret-key-please-change-in-production") v.SetDefault("auth.jwt_secret", "default-secret-key-please-change-in-production")
v.SetDefault("auth.admin_master_password", "admin123") v.SetDefault("auth.admin_master_password", "admin123")
@@ -266,6 +279,11 @@ func LoadConfig() (*Config, error) {
v.BindEnv("rate_limit.requests_per_minute", "DLC_RATE_LIMIT_REQUESTS_PER_MINUTE") v.BindEnv("rate_limit.requests_per_minute", "DLC_RATE_LIMIT_REQUESTS_PER_MINUTE")
v.BindEnv("rate_limit.burst_size", "DLC_RATE_LIMIT_BURST_SIZE") v.BindEnv("rate_limit.burst_size", "DLC_RATE_LIMIT_BURST_SIZE")
// Cache environment variables
v.BindEnv("cache.enabled", "DLC_CACHE_ENABLED")
v.BindEnv("cache.default_ttl_seconds", "DLC_CACHE_DEFAULT_TTL_SECONDS")
v.BindEnv("cache.cleanup_interval_seconds", "DLC_CACHE_CLEANUP_INTERVAL_SECONDS")
// Database environment variables // Database environment variables
v.BindEnv("database.host", "DLC_DATABASE_HOST") v.BindEnv("database.host", "DLC_DATABASE_HOST")
v.BindEnv("database.port", "DLC_DATABASE_PORT") v.BindEnv("database.port", "DLC_DATABASE_PORT")
@@ -428,6 +446,27 @@ func (c *Config) GetRateLimitBurstSize() int {
return c.RateLimit.BurstSize return c.RateLimit.BurstSize
} }
// GetCacheEnabled returns whether cache is enabled
func (c *Config) GetCacheEnabled() bool {
return c.Cache.Enabled
}
// GetCacheDefaultTTLSeconds returns the default TTL in seconds for cache items
func (c *Config) GetCacheDefaultTTLSeconds() int {
if c.Cache.DefaultTTLSeconds <= 0 {
return 300
}
return c.Cache.DefaultTTLSeconds
}
// GetCacheCleanupIntervalSeconds returns the cleanup interval in seconds for cache
func (c *Config) GetCacheCleanupIntervalSeconds() int {
if c.Cache.CleanupIntervalSeconds <= 0 {
return 600
}
return c.Cache.CleanupIntervalSeconds
}
// GetDatabaseHost returns the database host // GetDatabaseHost returns the database host
func (c *Config) GetDatabaseHost() string { func (c *Config) GetDatabaseHost() string {
if c.Database.Host == "" { if c.Database.Host == "" {

View File

@@ -17,6 +17,7 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
httpSwagger "github.com/swaggo/http-swagger" httpSwagger "github.com/swaggo/http-swagger"
"dance-lessons-coach/pkg/cache"
"dance-lessons-coach/pkg/config" "dance-lessons-coach/pkg/config"
"dance-lessons-coach/pkg/greet" "dance-lessons-coach/pkg/greet"
"dance-lessons-coach/pkg/middleware" "dance-lessons-coach/pkg/middleware"
@@ -65,6 +66,7 @@ type Server struct {
validator *validation.Validator validator *validation.Validator
userRepo user.UserRepository userRepo user.UserRepository
userService user.UserService userService user.UserService
cacheService cache.Service
startedAt time.Time startedAt time.Time
} }
@@ -83,15 +85,28 @@ func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
log.Warn().Err(err).Msg("Failed to initialize user services, user functionality will be disabled") log.Warn().Err(err).Msg("Failed to initialize user services, user functionality will be disabled")
} }
// Initialize cache service
var cacheService cache.Service
if cfg.GetCacheEnabled() {
cacheService = cache.NewInMemoryService(
time.Duration(cfg.GetCacheDefaultTTLSeconds())*time.Second,
time.Duration(cfg.GetCacheCleanupIntervalSeconds())*time.Second,
)
log.Trace().Msg("Cache service initialized")
} else {
log.Trace().Msg("Cache service disabled")
}
s := &Server{ s := &Server{
router: chi.NewRouter(), router: chi.NewRouter(),
readyCtx: readyCtx, readyCtx: readyCtx,
withOTEL: cfg.GetTelemetryEnabled(), withOTEL: cfg.GetTelemetryEnabled(),
config: cfg, config: cfg,
validator: validator, validator: validator,
userRepo: userRepo, userRepo: userRepo,
userService: userService, userService: userService,
startedAt: time.Now(), cacheService: cacheService,
startedAt: time.Now(),
} }
s.setupRoutes() s.setupRoutes()
return s return s
@@ -351,26 +366,49 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
format = "plain" // default format format = "plain" // default format
} }
// Check cache if enabled
cacheKey := "version:" + format
if s.cacheService != nil {
if cached, ok := s.cacheService.Get(cacheKey); ok {
log.Trace().Str("cache_key", cacheKey).Msg("Cache hit for version")
w.Header().Set("Content-Type", "text/plain")
if format == "json" {
w.Header().Set("Content-Type", "application/json")
}
w.Write([]byte(cached.(string)))
return
}
}
// Build response
var response string
switch format { switch format {
case "plain": case "plain":
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(version.Short())) response = version.Short()
case "full": case "full":
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(version.Full())) response = version.Full()
case "json": case "json":
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
jsonResponse := fmt.Sprintf(`{ response = fmt.Sprintf(`{
"version": "%s", "version": "%s",
"commit": "%s", "commit": "%s",
"built": "%s", "built": "%s",
"go": "%s" "go": "%s"
}`, version.Version, version.Commit, version.Date, version.GoVersion) }`, version.Version, version.Commit, version.Date, version.GoVersion)
w.Write([]byte(jsonResponse))
default: default:
w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(version.Short())) response = version.Short()
} }
// Cache the response for 60 seconds if cache is enabled
if s.cacheService != nil {
s.cacheService.Set(cacheKey, response, 60*time.Second)
log.Trace().Str("cache_key", cacheKey).Msg("Cached version response")
}
w.Write([]byte(response))
} }
// HealthzResponse represents the Kubernetes-style health check response // HealthzResponse represents the Kubernetes-style health check response