diff --git a/go.mod b/go.mod index 9cd105d..b273192 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/stretchr/testify v1.11.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 - github.com/swaggo/swag v1.16.6 github.com/testcontainers/testcontainers-go v0.41.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 github.com/ulule/limiter/v3 v3.11.2 @@ -93,6 +92,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.11.1 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -124,6 +124,7 @@ require ( github.com/quic-go/quic-go v0.59.0 // indirect github.com/shirou/gopsutil/v4 v4.26.2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/internal/bootstrap/cache.go b/internal/bootstrap/cache.go index bd52be9..b9a18b3 100644 --- a/internal/bootstrap/cache.go +++ b/internal/bootstrap/cache.go @@ -27,6 +27,7 @@ func initializeMetrics(cfg *config.Config) core.Recorder { // cacheOpts holds the parameters needed to initialise any typed cache. type cacheOpts struct { cacheType string + cacheName string // Prometheus label for metrics (e.g. "token", "client") keyPrefix string clientTTL time.Duration sizePerConn int @@ -34,7 +35,7 @@ type cacheOpts struct { } // initializeCache is a generic helper that creates a typed cache according to -// the supplied cacheOpts. All three cache-init call-sites delegate to this. +// the supplied cacheOpts. All cache-init call-sites delegate to this. func initializeCache[T any]( ctx context.Context, cfg *config.Config, @@ -43,6 +44,9 @@ func initializeCache[T any]( ctx, cancel := context.WithTimeout(ctx, cfg.CacheInitTimeout) defer cancel() + var underlyingCache core.Cache[T] + var closeFunc func() error + switch opts.cacheType { case config.CacheTypeRedisAside: c, err := cache.NewRueidisAsideCache[T]( @@ -61,7 +65,8 @@ func initializeCache[T any]( "%s cache: redis-aside (addr=%s, db=%d, client_ttl=%s, cache_size_per_conn=%dMB)", opts.label, cfg.RedisAddr, cfg.RedisDB, opts.clientTTL, opts.sizePerConn, ) - return c, c.Close, nil + underlyingCache = c + closeFunc = c.Close case config.CacheTypeRedis: c, err := cache.NewRueidisCache[T]( @@ -73,13 +78,23 @@ func initializeCache[T any]( return nil, nil, fmt.Errorf("failed to initialize redis %s cache: %w", opts.label, err) } log.Printf("%s cache: redis (addr=%s, db=%d)", opts.label, cfg.RedisAddr, cfg.RedisDB) - return c, c.Close, nil + underlyingCache = c + closeFunc = c.Close default: // memory c := cache.NewMemoryCache[T]() log.Printf("%s cache: memory (single instance only)", opts.label) - return c, c.Close, nil + underlyingCache = c + closeFunc = c.Close } + + // Wrap with instrumentation if metrics are enabled + if cfg.MetricsEnabled { + instrumentedCache := cache.NewInstrumentedCache(underlyingCache, opts.cacheName) + return instrumentedCache, instrumentedCache.Close, nil + } + + return underlyingCache, closeFunc, nil } // initializeMetricsCache initializes the metrics cache based on configuration @@ -92,6 +107,7 @@ func initializeMetricsCache( } return initializeCache[int64](ctx, cfg, cacheOpts{ cacheType: cfg.MetricsCacheType, + cacheName: "metrics", keyPrefix: "authgate:metrics:", clientTTL: cfg.MetricsCacheClientTTL, sizePerConn: cfg.MetricsCacheSizePerConn, @@ -106,6 +122,7 @@ func initializeClientCountCache( ) (core.Cache[int64], func() error, error) { return initializeCache[int64](ctx, cfg, cacheOpts{ cacheType: cfg.ClientCountCacheType, + cacheName: "client_count", keyPrefix: "authgate:client-count:", clientTTL: cfg.ClientCountCacheClientTTL, sizePerConn: cfg.ClientCountCacheSizePerConn, @@ -124,6 +141,7 @@ func initializeTokenCache( } return initializeCache[models.AccessToken](ctx, cfg, cacheOpts{ cacheType: cfg.TokenCacheType, + cacheName: "token", keyPrefix: "authgate:tokens:", clientTTL: cfg.TokenCacheClientTTL, sizePerConn: cfg.TokenCacheSizePerConn, @@ -138,6 +156,7 @@ func initializeClientCache( ) (core.Cache[models.OAuthApplication], func() error, error) { return initializeCache[models.OAuthApplication](ctx, cfg, cacheOpts{ cacheType: cfg.ClientCacheType, + cacheName: "client", keyPrefix: "authgate:clients:", clientTTL: cfg.ClientCacheClientTTL, sizePerConn: cfg.ClientCacheSizePerConn, @@ -152,6 +171,7 @@ func initializeUserCache( ) (core.Cache[models.User], func() error, error) { return initializeCache[models.User](ctx, cfg, cacheOpts{ cacheType: cfg.UserCacheType, + cacheName: "user", keyPrefix: "authgate:users:", clientTTL: cfg.UserCacheClientTTL, sizePerConn: cfg.UserCacheSizePerConn, diff --git a/internal/bootstrap/cache_test.go b/internal/bootstrap/cache_test.go new file mode 100644 index 0000000..0bfb6c5 --- /dev/null +++ b/internal/bootstrap/cache_test.go @@ -0,0 +1,225 @@ +package bootstrap + +import ( + "context" + "testing" + "time" + + "github.com/go-authgate/authgate/internal/cache" + "github.com/go-authgate/authgate/internal/config" +) + +func TestInitializeCache_WithInstrumentation(t *testing.T) { + cfg := &config.Config{ + MetricsEnabled: true, + CacheInitTimeout: 5 * time.Second, + } + + ctx := context.Background() + c, closeFunc, err := initializeCache[int64](ctx, cfg, cacheOpts{ + cacheType: config.CacheTypeMemory, + cacheName: "test_with", + keyPrefix: "authgate:test:", + label: "Test", + }) + if err != nil { + t.Fatalf("Failed to initialize cache: %v", err) + } + defer closeFunc() + + // Verify the returned cache is an InstrumentedCache (wrapping happened) + if _, ok := c.(*cache.InstrumentedCache[int64]); !ok { + t.Errorf("Expected *InstrumentedCache, got %T", c) + } + + // Verify it still works + _ = c.Set(ctx, "key1", int64(42), time.Minute) + value, err := c.Get(ctx, "key1") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if value != 42 { + t.Errorf("Expected 42, got %d", value) + } +} + +func TestInitializeCache_NoInstrumentation(t *testing.T) { + cfg := &config.Config{ + MetricsEnabled: false, + CacheInitTimeout: 5 * time.Second, + } + + ctx := context.Background() + c, closeFunc, err := initializeCache[int64](ctx, cfg, cacheOpts{ + cacheType: config.CacheTypeMemory, + cacheName: "test_without", + keyPrefix: "authgate:test:", + label: "Test", + }) + if err != nil { + t.Fatalf("Failed to initialize cache: %v", err) + } + defer closeFunc() + + // Verify the returned cache is NOT instrumented + if _, ok := c.(*cache.InstrumentedCache[int64]); ok { + t.Error("Expected raw MemoryCache, got InstrumentedCache") + } + + _ = c.Set(ctx, "key1", int64(42), time.Minute) + value, err := c.Get(ctx, "key1") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if value != 42 { + t.Errorf("Expected 42, got %d", value) + } +} + +func TestInitializeTokenCache_Disabled(t *testing.T) { + cfg := &config.Config{ + MetricsEnabled: true, + TokenCacheEnabled: false, + CacheInitTimeout: 5 * time.Second, + } + + ctx := context.Background() + c, closeFunc, err := initializeTokenCache(ctx, cfg) + if err != nil { + t.Fatalf("Failed to initialize token cache: %v", err) + } + defer closeFunc() + + // Disabled token cache returns a NoopCache — not wrapped since we removed + // the pointless instrumentation of noop (it would only show 100% miss rate) + if c == nil { + t.Fatal("Expected non-nil cache") + } + + // NoopCache always returns cache miss + _, err = c.Get(ctx, "key1") + if err == nil { + t.Error("Expected cache miss from NoopCache, got nil error") + } +} + +func TestInitializeAllCaches(t *testing.T) { + cfg := &config.Config{ + MetricsEnabled: true, + MetricsGaugeUpdateEnabled: true, + TokenCacheEnabled: true, + CacheInitTimeout: 5 * time.Second, + + TokenCacheType: config.CacheTypeMemory, + TokenCacheClientTTL: time.Hour, + TokenCacheSizePerConn: 32, + ClientCacheType: config.CacheTypeMemory, + ClientCacheClientTTL: 30 * time.Second, + ClientCacheSizePerConn: 32, + UserCacheType: config.CacheTypeMemory, + UserCacheClientTTL: 30 * time.Second, + UserCacheSizePerConn: 32, + MetricsCacheType: config.CacheTypeMemory, + MetricsCacheClientTTL: 30 * time.Second, + MetricsCacheSizePerConn: 32, + ClientCountCacheType: config.CacheTypeMemory, + ClientCountCacheClientTTL: 10 * time.Minute, + ClientCountCacheSizePerConn: 32, + } + + ctx := context.Background() + + closers := make([]func() error, 0) + addCloser := func(f func() error) { + if f != nil { + closers = append(closers, f) + } + } + defer func() { + for _, f := range closers { + _ = f() + } + }() + + tc, f, err := initializeTokenCache(ctx, cfg) + if err != nil { + t.Fatalf("token cache: %v", err) + } + addCloser(f) + _ = tc // different type, just verify no error + + cc, f, err := initializeClientCache(ctx, cfg) + if err != nil { + t.Fatalf("client cache: %v", err) + } + addCloser(f) + _ = cc + + uc, f, err := initializeUserCache(ctx, cfg) + if err != nil { + t.Fatalf("user cache: %v", err) + } + addCloser(f) + _ = uc + + mc, f, err := initializeMetricsCache(ctx, cfg) + if err != nil { + t.Fatalf("metrics cache: %v", err) + } + addCloser(f) + _ = mc + + ccc, f, err := initializeClientCountCache(ctx, cfg) + if err != nil { + t.Fatalf("client count cache: %v", err) + } + addCloser(f) + + // Sanity check: client count cache should work + _ = ccc.Set(ctx, "count1", int64(5), time.Minute) + value, err := ccc.Get(ctx, "count1") + if err != nil { + t.Errorf("Expected value from client count cache, got error: %v", err) + } + if value != 5 { + t.Errorf("Expected 5, got %d", value) + } +} + +func TestInitializeCache_MemoryType(t *testing.T) { + for _, tc := range []struct { + name string + metricsEnabled bool + }{ + {"with metrics", true}, + {"without metrics", false}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{ + MetricsEnabled: tc.metricsEnabled, + CacheInitTimeout: 5 * time.Second, + } + + ctx := context.Background() + c, closeFunc, err := initializeCache[int64](ctx, cfg, cacheOpts{ + cacheType: config.CacheTypeMemory, + cacheName: "test_mem", + keyPrefix: "authgate:test:", + label: "Test", + }) + if err != nil { + t.Fatalf("Failed to initialize cache: %v", err) + } + defer closeFunc() + + _ = c.Set(ctx, "key", int64(123), time.Minute) + value, err := c.Get(ctx, "key") + if err != nil { + t.Fatalf("Expected value, got error: %v", err) + } + if value != 123 { + t.Errorf("Expected 123, got %d", value) + } + }) + } +} diff --git a/internal/cache/instrumented.go b/internal/cache/instrumented.go new file mode 100644 index 0000000..de7a9a4 --- /dev/null +++ b/internal/cache/instrumented.go @@ -0,0 +1,121 @@ +package cache + +import ( + "context" + "errors" + "sync/atomic" + "time" + + "github.com/go-authgate/authgate/internal/core" + + "github.com/prometheus/client_golang/prometheus" +) + +// Compile-time interface check. +var _ core.Cache[struct{}] = (*InstrumentedCache[struct{}])(nil) + +// InstrumentedCache wraps a Cache with Prometheus hit/miss/error counters. +type InstrumentedCache[T any] struct { + underlying core.Cache[T] + + // Pre-resolved counters (avoids WithLabelValues map lookup per call). + hitCounter prometheus.Counter + missCounter prometheus.Counter + errGet prometheus.Counter + errSet prometheus.Counter + errDelete prometheus.Counter + errHealth prometheus.Counter + errFetch prometheus.Counter +} + +// NewInstrumentedCache creates a new instrumented cache wrapper. +// cacheName is used as a Prometheus label to distinguish between different caches. +func NewInstrumentedCache[T any](underlying core.Cache[T], cacheName string) *InstrumentedCache[T] { + m := getMetrics() + return &InstrumentedCache[T]{ + underlying: underlying, + hitCounter: m.hits.WithLabelValues(cacheName), + missCounter: m.misses.WithLabelValues(cacheName), + errGet: m.errors.WithLabelValues(cacheName, opGet), + errSet: m.errors.WithLabelValues(cacheName, opSet), + errDelete: m.errors.WithLabelValues(cacheName, opDelete), + errHealth: m.errors.WithLabelValues(cacheName, opHealth), + errFetch: m.errors.WithLabelValues(cacheName, opGetWithFetch), + } +} + +func (i *InstrumentedCache[T]) Get(ctx context.Context, key string) (T, error) { + value, err := i.underlying.Get(ctx, key) + switch { + case err == nil: + i.hitCounter.Inc() + case errors.Is(err, ErrCacheMiss): + i.missCounter.Inc() + default: + i.errGet.Inc() + } + return value, err +} + +func (i *InstrumentedCache[T]) Set( + ctx context.Context, + key string, + value T, + ttl time.Duration, +) error { + err := i.underlying.Set(ctx, key, value, ttl) + if err != nil { + i.errSet.Inc() + } + return err +} + +func (i *InstrumentedCache[T]) Delete(ctx context.Context, key string) error { + err := i.underlying.Delete(ctx, key) + if err != nil { + i.errDelete.Inc() + } + return err +} + +func (i *InstrumentedCache[T]) Close() error { + return i.underlying.Close() +} + +func (i *InstrumentedCache[T]) Health(ctx context.Context) error { + err := i.underlying.Health(ctx) + if err != nil { + i.errHealth.Inc() + } + return err +} + +// GetWithFetch implements the cache-aside pattern with metrics instrumentation. +// Wraps fetchFunc to detect whether it was called (miss) or not (hit), without +// calling underlying.Get() a second time. This preserves optimizations in the +// underlying implementation (e.g., stampede protection in RueidisAsideCache). +// Uses atomic.Bool because singleflight may set fetchCalled from a shared +// goroutine while the caller returns early on context cancellation. +func (i *InstrumentedCache[T]) GetWithFetch( + ctx context.Context, + key string, + ttl time.Duration, + fetchFunc func(ctx context.Context, key string) (T, error), +) (T, error) { + var fetchCalled atomic.Bool + wrapped := func(ctx context.Context, key string) (T, error) { + fetchCalled.Store(true) + return fetchFunc(ctx, key) + } + + value, err := i.underlying.GetWithFetch(ctx, key, ttl, wrapped) + if fetchCalled.Load() { + i.missCounter.Inc() + } else if err == nil { + i.hitCounter.Inc() + } + if err != nil && !errors.Is(err, ErrCacheMiss) { + i.errFetch.Inc() + } + return value, err +} diff --git a/internal/cache/instrumented_test.go b/internal/cache/instrumented_test.go new file mode 100644 index 0000000..a464302 --- /dev/null +++ b/internal/cache/instrumented_test.go @@ -0,0 +1,439 @@ +package cache + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestInstrumentedCache_Get_Hit(t *testing.T) { + underlying := NewMemoryCache[int64]() + t.Cleanup(func() { _ = underlying.Close() }) + + ctx := context.Background() + _ = underlying.Set(ctx, "key1", int64(42), time.Minute) + + ic := NewInstrumentedCache(underlying, "test_get_hit") + + value, err := ic.Get(ctx, "key1") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if value != 42 { + t.Errorf("Expected value 42, got %d", value) + } + + if v := testutil.ToFloat64(ic.hitCounter); v != 1 { + t.Errorf("Expected 1 hit, got %f", v) + } + if v := testutil.ToFloat64(ic.missCounter); v != 0 { + t.Errorf("Expected 0 misses, got %f", v) + } +} + +func TestInstrumentedCache_Get_Miss(t *testing.T) { + underlying := NewMemoryCache[int64]() + t.Cleanup(func() { _ = underlying.Close() }) + + ic := NewInstrumentedCache(underlying, "test_get_miss") + + ctx := context.Background() + value, err := ic.Get(ctx, "nonexistent") + if !errors.Is(err, ErrCacheMiss) { + t.Fatalf("Expected ErrCacheMiss, got %v", err) + } + if value != 0 { + t.Errorf("Expected zero value, got %d", value) + } + + if v := testutil.ToFloat64(ic.missCounter); v != 1 { + t.Errorf("Expected 1 miss, got %f", v) + } + if v := testutil.ToFloat64(ic.hitCounter); v != 0 { + t.Errorf("Expected 0 hits, got %f", v) + } +} + +func TestInstrumentedCache_Get_Error(t *testing.T) { + mockErr := errors.New("mock error") + mc := &mockCache[int64]{ + getFunc: func(_ context.Context, _ string) (int64, error) { + return 0, mockErr + }, + } + + ic := NewInstrumentedCache[int64](mc, "test_get_error") + + ctx := context.Background() + _, err := ic.Get(ctx, "key") + if !errors.Is(err, mockErr) { + t.Fatalf("Expected mock error, got %v", err) + } + + if v := testutil.ToFloat64(ic.errGet); v != 1 { + t.Errorf("Expected 1 error, got %f", v) + } + if v := testutil.ToFloat64(ic.hitCounter); v != 0 { + t.Errorf("Expected 0 hits, got %f", v) + } + if v := testutil.ToFloat64(ic.missCounter); v != 0 { + t.Errorf("Expected 0 misses, got %f", v) + } +} + +func TestInstrumentedCache_GetWithFetch_Hit(t *testing.T) { + underlying := NewMemoryCache[int64]() + t.Cleanup(func() { _ = underlying.Close() }) + + ctx := context.Background() + _ = underlying.Set(ctx, "key1", int64(42), time.Minute) + + ic := NewInstrumentedCache(underlying, "test_gwf_hit") + + fetchCalled := false + fetchFunc := func(_ context.Context, _ string) (int64, error) { + fetchCalled = true + return 100, nil + } + + value, err := ic.GetWithFetch(ctx, "key1", time.Minute, fetchFunc) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if value != 42 { + t.Errorf("Expected cached value 42, got %d", value) + } + if fetchCalled { + t.Error("fetchFunc should not have been called on cache hit") + } + + if v := testutil.ToFloat64(ic.hitCounter); v != 1 { + t.Errorf("Expected 1 hit, got %f", v) + } + if v := testutil.ToFloat64(ic.missCounter); v != 0 { + t.Errorf("Expected 0 misses, got %f", v) + } +} + +func TestInstrumentedCache_GetWithFetch_Miss(t *testing.T) { + underlying := NewMemoryCache[int64]() + t.Cleanup(func() { _ = underlying.Close() }) + + ic := NewInstrumentedCache(underlying, "test_gwf_miss") + + fetchCalled := false + fetchFunc := func(_ context.Context, _ string) (int64, error) { + fetchCalled = true + return 100, nil + } + + ctx := context.Background() + value, err := ic.GetWithFetch(ctx, "key1", time.Minute, fetchFunc) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if value != 100 { + t.Errorf("Expected fetched value 100, got %d", value) + } + if !fetchCalled { + t.Error("fetchFunc should have been called on cache miss") + } + + if v := testutil.ToFloat64(ic.missCounter); v != 1 { + t.Errorf("Expected 1 miss, got %f", v) + } + if v := testutil.ToFloat64(ic.hitCounter); v != 0 { + t.Errorf("Expected 0 hits, got %f", v) + } +} + +func TestInstrumentedCache_GetWithFetch_FetchError(t *testing.T) { + underlying := NewMemoryCache[int64]() + t.Cleanup(func() { _ = underlying.Close() }) + + ic := NewInstrumentedCache(underlying, "test_gwf_error") + + fetchErr := errors.New("fetch failed") + fetchFunc := func(_ context.Context, _ string) (int64, error) { + return 0, fetchErr + } + + ctx := context.Background() + _, err := ic.GetWithFetch(ctx, "key1", time.Minute, fetchFunc) + if !errors.Is(err, fetchErr) { + t.Fatalf("Expected fetch error, got %v", err) + } + + // fetchFunc was called → miss recorded (cache didn't have the key) + // error also recorded (fetch failed) + if v := testutil.ToFloat64(ic.missCounter); v != 1 { + t.Errorf("Expected 1 miss, got %f", v) + } + if v := testutil.ToFloat64(ic.hitCounter); v != 0 { + t.Errorf("Expected 0 hits, got %f", v) + } + if v := testutil.ToFloat64(ic.errFetch); v != 1 { + t.Errorf("Expected 1 fetch error, got %f", v) + } +} + +func TestInstrumentedCache_GetWithFetch_ContextCanceled(t *testing.T) { + // Simulates context cancellation where GetWithFetch returns an error + // without ever calling fetchFunc (e.g., singleflight waiter timeout). + mc := &mockCache[int64]{ + getWithFetchFunc: func(_ context.Context, _ string, _ time.Duration, + _ func(context.Context, string) (int64, error), + ) (int64, error) { + return 0, context.Canceled + }, + } + + ic := NewInstrumentedCache[int64](mc, "test_gwf_ctx") + + ctx := context.Background() + _, err := ic.GetWithFetch( + ctx, + "key1", + time.Minute, + func(_ context.Context, _ string) (int64, error) { + t.Fatal("fetchFunc should not be called") + return 0, nil + }, + ) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Expected context.Canceled, got %v", err) + } + + // Neither hit nor miss — only error + if v := testutil.ToFloat64(ic.hitCounter); v != 0 { + t.Errorf("Expected 0 hits, got %f", v) + } + if v := testutil.ToFloat64(ic.missCounter); v != 0 { + t.Errorf("Expected 0 misses, got %f", v) + } + if v := testutil.ToFloat64(ic.errFetch); v != 1 { + t.Errorf("Expected 1 error, got %f", v) + } +} + +func TestInstrumentedCache_Set(t *testing.T) { + underlying := NewMemoryCache[int64]() + t.Cleanup(func() { _ = underlying.Close() }) + + ic := NewInstrumentedCache(underlying, "test_set") + + ctx := context.Background() + if err := ic.Set(ctx, "key1", int64(42), time.Minute); err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + value, err := underlying.Get(ctx, "key1") + if err != nil { + t.Fatalf("Expected value in cache, got error: %v", err) + } + if value != 42 { + t.Errorf("Expected value 42, got %d", value) + } +} + +func TestInstrumentedCache_Set_Error(t *testing.T) { + mockErr := errors.New("set failed") + mc := &mockCache[int64]{ + setFunc: func(_ context.Context, _ string, _ int64, _ time.Duration) error { + return mockErr + }, + } + + ic := NewInstrumentedCache[int64](mc, "test_set_error") + + ctx := context.Background() + err := ic.Set(ctx, "key1", int64(42), time.Minute) + if !errors.Is(err, mockErr) { + t.Fatalf("Expected mock error, got %v", err) + } + + if v := testutil.ToFloat64(ic.errSet); v != 1 { + t.Errorf("Expected 1 error, got %f", v) + } +} + +func TestInstrumentedCache_Delete(t *testing.T) { + underlying := NewMemoryCache[int64]() + t.Cleanup(func() { _ = underlying.Close() }) + + ctx := context.Background() + _ = underlying.Set(ctx, "key1", int64(42), time.Minute) + + ic := NewInstrumentedCache(underlying, "test_delete") + + if err := ic.Delete(ctx, "key1"); err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + _, err := underlying.Get(ctx, "key1") + if !errors.Is(err, ErrCacheMiss) { + t.Errorf("Expected ErrCacheMiss after delete, got %v", err) + } +} + +func TestInstrumentedCache_Delete_Error(t *testing.T) { + mockErr := errors.New("delete failed") + mc := &mockCache[int64]{ + deleteFunc: func(_ context.Context, _ string) error { + return mockErr + }, + } + + ic := NewInstrumentedCache[int64](mc, "test_delete_error") + + ctx := context.Background() + err := ic.Delete(ctx, "key1") + if !errors.Is(err, mockErr) { + t.Fatalf("Expected mock error, got %v", err) + } + + if v := testutil.ToFloat64(ic.errDelete); v != 1 { + t.Errorf("Expected 1 error, got %f", v) + } +} + +func TestInstrumentedCache_Health(t *testing.T) { + underlying := NewMemoryCache[int64]() + t.Cleanup(func() { _ = underlying.Close() }) + + ic := NewInstrumentedCache(underlying, "test_health") + + if err := ic.Health(context.Background()); err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestInstrumentedCache_Health_Error(t *testing.T) { + mockErr := errors.New("health check failed") + mc := &mockCache[int64]{ + healthFunc: func(_ context.Context) error { + return mockErr + }, + } + + ic := NewInstrumentedCache[int64](mc, "test_health_error") + + err := ic.Health(context.Background()) + if !errors.Is(err, mockErr) { + t.Fatalf("Expected mock error, got %v", err) + } + + if v := testutil.ToFloat64(ic.errHealth); v != 1 { + t.Errorf("Expected 1 error, got %f", v) + } +} + +func TestInstrumentedCache_MultipleCaches(t *testing.T) { + cache1 := NewInstrumentedCache(NewMemoryCache[int64](), "multi_cache1") + cache2 := NewInstrumentedCache(NewMemoryCache[int64](), "multi_cache2") + t.Cleanup(func() { + _ = cache1.Close() + _ = cache2.Close() + }) + + ctx := context.Background() + + _ = cache1.Set(ctx, "key", int64(1), time.Minute) + _, _ = cache1.Get(ctx, "key") // hit on cache1 + _, _ = cache2.Get(ctx, "nonexistent") // miss on cache2 + + if v := testutil.ToFloat64(cache1.hitCounter); v != 1 { + t.Errorf("Expected 1 hit for cache1, got %f", v) + } + if v := testutil.ToFloat64(cache2.hitCounter); v != 0 { + t.Errorf("Expected 0 hits for cache2, got %f", v) + } + if v := testutil.ToFloat64(cache1.missCounter); v != 0 { + t.Errorf("Expected 0 misses for cache1, got %f", v) + } + if v := testutil.ToFloat64(cache2.missCounter); v != 1 { + t.Errorf("Expected 1 miss for cache2, got %f", v) + } +} + +func TestInstrumentedCache_Close(t *testing.T) { + underlying := NewMemoryCache[int64]() + ic := NewInstrumentedCache(underlying, "test_close") + + if err := ic.Close(); err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +func TestCacheMetrics_Registration(t *testing.T) { + m1 := getMetrics() + m2 := getMetrics() + if m1 != m2 { + t.Error("Expected getMetrics to return the same instance") + } +} + +// mockCache is a test helper that implements core.Cache[T] with configurable behavior. +type mockCache[T any] struct { + getFunc func(ctx context.Context, key string) (T, error) + setFunc func(ctx context.Context, key string, value T, ttl time.Duration) error + deleteFunc func(ctx context.Context, key string) error + closeFunc func() error + healthFunc func(ctx context.Context) error + getWithFetchFunc func(ctx context.Context, key string, ttl time.Duration, fetchFunc func(ctx context.Context, key string) (T, error)) (T, error) +} + +func (m *mockCache[T]) Get(ctx context.Context, key string) (T, error) { + if m.getFunc != nil { + return m.getFunc(ctx, key) + } + var zero T + return zero, ErrCacheMiss +} + +func (m *mockCache[T]) Set(ctx context.Context, key string, value T, ttl time.Duration) error { + if m.setFunc != nil { + return m.setFunc(ctx, key, value, ttl) + } + return nil +} + +func (m *mockCache[T]) Delete(ctx context.Context, key string) error { + if m.deleteFunc != nil { + return m.deleteFunc(ctx, key) + } + return nil +} + +func (m *mockCache[T]) Close() error { + if m.closeFunc != nil { + return m.closeFunc() + } + return nil +} + +func (m *mockCache[T]) Health(ctx context.Context) error { + if m.healthFunc != nil { + return m.healthFunc(ctx) + } + return nil +} + +func (m *mockCache[T]) GetWithFetch( + ctx context.Context, + key string, + ttl time.Duration, + fetchFunc func(ctx context.Context, key string) (T, error), +) (T, error) { + if m.getWithFetchFunc != nil { + return m.getWithFetchFunc(ctx, key, ttl, fetchFunc) + } + value, err := m.Get(ctx, key) + if err == nil { + return value, nil + } + return fetchFunc(ctx, key) +} diff --git a/internal/cache/metrics.go b/internal/cache/metrics.go new file mode 100644 index 0000000..88e8b18 --- /dev/null +++ b/internal/cache/metrics.go @@ -0,0 +1,60 @@ +package cache + +import ( + "sync" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +// Operation label values for cache_errors_total. +const ( + opGet = "get" + opSet = "set" + opDelete = "delete" + opHealth = "health" + opGetWithFetch = "get_with_fetch" +) + +// Metrics holds Prometheus counters for cache operations. +type Metrics struct { + hits *prometheus.CounterVec + misses *prometheus.CounterVec + errors *prometheus.CounterVec +} + +var ( + cacheMetrics *Metrics + cacheMetricsOnce sync.Once +) + +// getMetrics returns the singleton Metrics instance. +// Uses sync.Once to ensure Prometheus metrics are only registered once. +func getMetrics() *Metrics { + cacheMetricsOnce.Do(func() { + cacheMetrics = &Metrics{ + hits: promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "cache_hits_total", + Help: "Total number of cache hits", + }, + []string{"cache_name"}, + ), + misses: promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "cache_misses_total", + Help: "Total number of cache misses", + }, + []string{"cache_name"}, + ), + errors: promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "cache_errors_total", + Help: "Total number of cache errors (excluding cache misses)", + }, + []string{"cache_name", "operation"}, + ), + } + }) + return cacheMetrics +}