✨ 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:
11
config.yaml
11
config.yaml
@@ -88,3 +88,14 @@ database:
|
||||
# Maximum lifetime of connections (default: "1h")
|
||||
# Format: number + unit (s, m, h)
|
||||
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
1
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
56
pkg/cache/cache.go
vendored
Normal file
56
pkg/cache/cache.go
vendored
Normal 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
135
pkg/cache/cache_test.go
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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,6 +85,18 @@ 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,
|
||||
@@ -91,6 +105,7 @@ func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
||||
validator: validator,
|
||||
userRepo: userRepo,
|
||||
userService: userService,
|
||||
cacheService: cacheService,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
s.setupRoutes()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user