From 358e3df38b6ba0e8118872c69d285765c86a8d37 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sun, 3 May 2026 13:24:17 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(cache):=20add=20in-memory=20ca?= =?UTF-8?q?che=20service=20(ADR-0022=20Phase=201=20part=202)=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 part 2 of ADR-0022 (companion to PR #22 rate-limit). In-memory cache service via go-cache, used by /api/version (60s TTL). 6/6 unit tests pass. ~95% Mistral autonomous via ICM workspace, cost €2.50 stages 01-02 (50% reduction vs T5 thanks to pre-extracted snippets in shared/). Co-authored-by: Gabriel Radureau Co-committed-by: Gabriel Radureau --- config.yaml | 13 +++- go.mod | 1 + go.sum | 2 + pkg/cache/cache.go | 56 +++++++++++++++++ pkg/cache/cache_test.go | 135 ++++++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 39 ++++++++++++ pkg/server/server.go | 64 +++++++++++++++---- 7 files changed, 296 insertions(+), 14 deletions(-) create mode 100644 pkg/cache/cache.go create mode 100644 pkg/cache/cache_test.go diff --git a/config.yaml b/config.yaml index 7d10a05..5c7b8e2 100644 --- a/config.yaml +++ b/config.yaml @@ -87,4 +87,15 @@ database: # Maximum lifetime of connections (default: "1h") # Format: number + unit (s, m, h) - conn_max_lifetime: 1h \ No newline at end of file + 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 \ No newline at end of file diff --git a/go.mod b/go.mod index ad27f76..c85af0c 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-playground/validator/v10 v10.30.2 github.com/golang-jwt/jwt/v5 v5.3.1 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/spf13/cobra v1.8.0 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index f3dc9a5..71ee729 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/pkg/cache/cache.go b/pkg/cache/cache.go new file mode 100644 index 0000000..f448c3f --- /dev/null +++ b/pkg/cache/cache.go @@ -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() +} diff --git a/pkg/cache/cache_test.go b/pkg/cache/cache_test.go new file mode 100644 index 0000000..0b81567 --- /dev/null +++ b/pkg/cache/cache_test.go @@ -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") + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 3ca05b1..0db2f11 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -28,6 +28,7 @@ type Config struct { Auth AuthConfig `mapstructure:"auth"` Database DatabaseConfig `mapstructure:"database"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` + Cache CacheConfig `mapstructure:"cache"` } // ServerConfig holds server-related configuration @@ -105,6 +106,13 @@ type RateLimitConfig struct { 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 type VersionInfo struct { 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.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 v.SetDefault("auth.jwt_secret", "default-secret-key-please-change-in-production") 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.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 v.BindEnv("database.host", "DLC_DATABASE_HOST") v.BindEnv("database.port", "DLC_DATABASE_PORT") @@ -428,6 +446,27 @@ func (c *Config) GetRateLimitBurstSize() int { 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 func (c *Config) GetDatabaseHost() string { if c.Database.Host == "" { diff --git a/pkg/server/server.go b/pkg/server/server.go index 129f58d..1e2936f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/rs/zerolog/log" httpSwagger "github.com/swaggo/http-swagger" + "dance-lessons-coach/pkg/cache" "dance-lessons-coach/pkg/config" "dance-lessons-coach/pkg/greet" "dance-lessons-coach/pkg/middleware" @@ -65,6 +66,7 @@ type Server struct { validator *validation.Validator userRepo user.UserRepository userService user.UserService + cacheService cache.Service 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") } + // 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{ - router: chi.NewRouter(), - readyCtx: readyCtx, - withOTEL: cfg.GetTelemetryEnabled(), - config: cfg, - validator: validator, - userRepo: userRepo, - userService: userService, - startedAt: time.Now(), + router: chi.NewRouter(), + readyCtx: readyCtx, + withOTEL: cfg.GetTelemetryEnabled(), + config: cfg, + validator: validator, + userRepo: userRepo, + userService: userService, + cacheService: cacheService, + startedAt: time.Now(), } s.setupRoutes() return s @@ -351,26 +366,49 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { 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 { case "plain": w.Header().Set("Content-Type", "text/plain") - w.Write([]byte(version.Short())) + response = version.Short() case "full": w.Header().Set("Content-Type", "text/plain") - w.Write([]byte(version.Full())) + response = version.Full() case "json": w.Header().Set("Content-Type", "application/json") - jsonResponse := fmt.Sprintf(`{ + response = fmt.Sprintf(`{ "version": "%s", "commit": "%s", "built": "%s", "go": "%s" }`, version.Version, version.Commit, version.Date, version.GoVersion) - w.Write([]byte(jsonResponse)) default: 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