Add redis support for distributed caching

This commit is contained in:
2025-10-19 01:05:41 +01:00
parent e45b06c86d
commit 92a058da83
238 changed files with 78024 additions and 124 deletions
+90
View File
@@ -0,0 +1,90 @@
package backends
import "time"
// BackendType represents the type of cache backend
type BackendType string
const (
BackendTypeMemory BackendType = "memory"
BackendTypeRedis BackendType = "redis"
BackendTypeHybrid BackendType = "hybrid"
// Aliases for backward compatibility
TypeMemory BackendType = "memory"
TypeRedis BackendType = "redis"
TypeHybrid BackendType = "hybrid"
)
// Config provides common configuration for cache backends
type Config struct {
// Type specifies the backend type
Type BackendType
// Memory backend settings
MaxSize int
MaxMemoryBytes int64
CleanupInterval time.Duration
// Redis backend settings
RedisAddr string
RedisPassword string
RedisDB int
RedisPrefix string
PoolSize int
// Hybrid backend settings
L1Config *Config // Memory cache (L1)
L2Config *Config // Redis cache (L2)
AsyncWrites bool // Write to L2 asynchronously
// Resilience settings
EnableCircuitBreaker bool
EnableHealthCheck bool
HealthCheckInterval time.Duration
// Metrics
EnableMetrics bool
}
// DefaultConfig returns a default configuration for in-memory caching
func DefaultConfig() *Config {
return &Config{
Type: BackendTypeMemory,
MaxSize: 1000,
MaxMemoryBytes: 50 * 1024 * 1024, // 50MB
CleanupInterval: 5 * time.Minute,
EnableMetrics: true,
}
}
// DefaultRedisConfig returns a default configuration for Redis caching
func DefaultRedisConfig(addr string) *Config {
return &Config{
Type: BackendTypeRedis,
RedisAddr: addr,
RedisDB: 0,
RedisPrefix: "traefikoidc:",
PoolSize: 10,
EnableCircuitBreaker: true,
EnableHealthCheck: true,
HealthCheckInterval: 30 * time.Second,
EnableMetrics: true,
}
}
// DefaultHybridConfig returns a default configuration for hybrid caching
func DefaultHybridConfig(redisAddr string) *Config {
return &Config{
Type: BackendTypeHybrid,
L1Config: &Config{
Type: BackendTypeMemory,
MaxSize: 500,
MaxMemoryBytes: 10 * 1024 * 1024, // 10MB for L1
CleanupInterval: 1 * time.Minute,
},
L2Config: DefaultRedisConfig(redisAddr),
AsyncWrites: true,
EnableMetrics: true,
}
}
+38
View File
@@ -0,0 +1,38 @@
package backends
import "errors"
var (
// ErrBackendClosed is returned when operating on a closed backend
ErrBackendClosed = errors.New("cache backend is closed")
// ErrKeyNotFound is returned when a key doesn't exist
ErrKeyNotFound = errors.New("key not found")
// ErrCacheMiss indicates the requested key was not found in the cache
ErrCacheMiss = errors.New("cache miss")
// ErrBackendUnavailable indicates the cache backend is not available
ErrBackendUnavailable = errors.New("cache backend unavailable")
// ErrInvalidValue indicates the cached value is invalid or corrupted
ErrInvalidValue = errors.New("invalid cached value")
// ErrInvalidTTL is returned when TTL is invalid
ErrInvalidTTL = errors.New("invalid TTL")
// ErrConnectionFailed is returned when connection fails
ErrConnectionFailed = errors.New("connection failed")
// ErrCircuitOpen is returned when circuit breaker is open
ErrCircuitOpen = errors.New("circuit breaker is open")
// ErrTimeout is returned when operation times out
ErrTimeout = errors.New("operation timeout")
// ErrSerializationFailed is returned when serialization fails
ErrSerializationFailed = errors.New("serialization failed")
// ErrDeserializationFailed is returned when deserialization fails
ErrDeserializationFailed = errors.New("deserialization failed")
)
+695
View File
@@ -0,0 +1,695 @@
// Package backend provides cache backend implementations for the Traefik OIDC plugin.
package backends
import (
"context"
"fmt"
"log"
"sync"
"sync/atomic"
"time"
)
// HybridBackend implements a two-tier cache with L1 (memory) and L2 (Redis) backends
// It provides automatic failover, async writes for non-critical data, and optimized read paths
type HybridBackend struct {
primary CacheBackend // L1: Memory cache for fast access
secondary CacheBackend // L2: Redis cache for distributed access
// Configuration
syncWriteCacheTypes map[string]bool // Which cache types require synchronous writes
asyncWriteBuffer chan *asyncWriteItem
// Metrics
l1Hits atomic.Int64
l2Hits atomic.Int64
misses atomic.Int64
l1Writes atomic.Int64
l2Writes atomic.Int64
errors atomic.Int64
// Fallback tracking
fallbackMode atomic.Bool // True when operating in degraded mode (L1 only)
lastL2Error atomic.Value // Stores last L2 error timestamp
// Lifecycle
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
// Logging
logger Logger
}
// asyncWriteItem represents an async write operation
type asyncWriteItem struct {
key string
value []byte
ttl time.Duration
ctx context.Context
}
// Logger interface for structured logging
type Logger interface {
Debugf(format string, args ...interface{})
Infof(format string, args ...interface{})
Warnf(format string, args ...interface{})
Errorf(format string, args ...interface{})
}
// defaultLogger provides a basic logger implementation
type defaultLogger struct {
*log.Logger
}
func (l *defaultLogger) Debugf(format string, args ...interface{}) {
l.Printf("[DEBUG] "+format, args...)
}
func (l *defaultLogger) Infof(format string, args ...interface{}) {
l.Printf("[INFO] "+format, args...)
}
func (l *defaultLogger) Warnf(format string, args ...interface{}) {
l.Printf("[WARN] "+format, args...)
}
func (l *defaultLogger) Errorf(format string, args ...interface{}) {
l.Printf("[ERROR] "+format, args...)
}
// HybridConfig provides configuration for the hybrid backend
type HybridConfig struct {
Primary CacheBackend
Secondary CacheBackend
SyncWriteCacheTypes map[string]bool // Cache types requiring synchronous L2 writes
AsyncBufferSize int
Logger Logger
}
// NewHybridBackend creates a new hybrid cache backend with L1 (memory) and L2 (Redis) tiers
func NewHybridBackend(config *HybridConfig) (*HybridBackend, error) {
if config == nil {
return nil, fmt.Errorf("config is required")
}
if config.Primary == nil {
return nil, fmt.Errorf("primary (L1) backend is required")
}
if config.Secondary == nil {
return nil, fmt.Errorf("secondary (L2) backend is required")
}
if config.Logger == nil {
config.Logger = &defaultLogger{Logger: log.New(log.Writer(), "[HybridCache] ", log.LstdFlags)}
}
if config.AsyncBufferSize <= 0 {
config.AsyncBufferSize = 1000
}
// Default critical cache types that require synchronous writes
if config.SyncWriteCacheTypes == nil {
config.SyncWriteCacheTypes = map[string]bool{
"blacklist": true, // Token blacklist must be immediately consistent
"token": true, // Token validation is critical
}
}
ctx, cancel := context.WithCancel(context.Background())
h := &HybridBackend{
primary: config.Primary,
secondary: config.Secondary,
syncWriteCacheTypes: config.SyncWriteCacheTypes,
asyncWriteBuffer: make(chan *asyncWriteItem, config.AsyncBufferSize),
ctx: ctx,
cancel: cancel,
logger: config.Logger,
}
// Start async write worker
h.wg.Add(1)
go h.asyncWriteWorker()
// Start health monitoring
h.wg.Add(1)
go h.healthMonitor()
h.logger.Infof("HybridBackend initialized with L1 (memory) and L2 (Redis) tiers")
h.logger.Infof("Sync write cache types: %v", config.SyncWriteCacheTypes)
h.logger.Infof("Async write buffer size: %d", config.AsyncBufferSize)
return h, nil
}
// Set stores a value in both L1 and L2 caches
func (h *HybridBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
// Always write to L1 first (synchronous)
if err := h.primary.Set(ctx, key, value, ttl); err != nil {
h.errors.Add(1)
h.logger.Warnf("Failed to write to L1 cache: %v", err)
// Continue to try L2 even if L1 fails
} else {
h.l1Writes.Add(1)
}
// Check if we're in fallback mode
if h.fallbackMode.Load() {
h.logger.Debugf("Operating in fallback mode, skipping L2 write for key: %s", key)
return nil // Don't fail the operation if L2 is down
}
// Determine if this should be a sync or async write based on cache type
cacheType := h.extractCacheType(key)
requiresSync := h.syncWriteCacheTypes[cacheType]
if requiresSync {
// Synchronous write for critical cache types
if err := h.secondary.Set(ctx, key, value, ttl); err != nil {
h.errors.Add(1)
h.logger.Warnf("Failed to write to L2 cache (sync) for key %s: %v", key, err)
h.recordL2Error()
// Don't fail the operation - L1 write succeeded
return nil
}
h.l2Writes.Add(1)
h.logger.Debugf("Synchronous write to L2 completed for critical key: %s", key)
} else {
// Asynchronous write for non-critical cache types
select {
case h.asyncWriteBuffer <- &asyncWriteItem{
key: key,
value: value,
ttl: ttl,
ctx: ctx,
}:
h.logger.Debugf("Queued async write to L2 for key: %s", key)
default:
// Buffer is full, log and continue
h.logger.Warnf("Async write buffer full, dropping L2 write for key: %s", key)
h.errors.Add(1)
}
}
return nil
}
// Get retrieves a value from cache, checking L1 first, then L2
func (h *HybridBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
// Try L1 first
value, ttl, exists, err := h.primary.Get(ctx, key)
if err != nil {
h.errors.Add(1)
h.logger.Debugf("L1 get error for key %s: %v", key, err)
}
if exists {
h.l1Hits.Add(1)
return value, ttl, true, nil
}
// Check if we're in fallback mode
if h.fallbackMode.Load() {
h.misses.Add(1)
return nil, 0, false, nil
}
// Try L2
value, ttl, exists, err = h.secondary.Get(ctx, key)
if err != nil {
h.errors.Add(1)
h.logger.Debugf("L2 get error for key %s: %v", key, err)
h.recordL2Error()
h.misses.Add(1)
return nil, 0, false, nil // Don't propagate L2 errors
}
if !exists {
h.misses.Add(1)
return nil, 0, false, nil
}
h.l2Hits.Add(1)
// Populate L1 cache with value from L2 (write-through on read)
// Use goroutine to avoid blocking the read path
go func() {
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if err := h.primary.Set(writeCtx, key, value, ttl); err != nil {
h.logger.Debugf("Failed to populate L1 cache from L2 for key %s: %v", key, err)
} else {
h.logger.Debugf("Populated L1 cache from L2 for key: %s", key)
}
}()
return value, ttl, true, nil
}
// Delete removes a key from both L1 and L2 caches
func (h *HybridBackend) Delete(ctx context.Context, key string) (bool, error) {
var deleted bool
// Delete from L1
if d, err := h.primary.Delete(ctx, key); err != nil {
h.logger.Debugf("Failed to delete from L1 cache: %v", err)
} else if d {
deleted = true
}
// Delete from L2 if not in fallback mode
if !h.fallbackMode.Load() {
if d, err := h.secondary.Delete(ctx, key); err != nil {
h.logger.Debugf("Failed to delete from L2 cache: %v", err)
h.recordL2Error()
} else if d {
deleted = true
}
}
return deleted, nil
}
// Exists checks if a key exists in either cache
func (h *HybridBackend) Exists(ctx context.Context, key string) (bool, error) {
// Check L1 first
if exists, err := h.primary.Exists(ctx, key); err == nil && exists {
return true, nil
}
// Check L2 if not in fallback mode
if !h.fallbackMode.Load() {
if exists, err := h.secondary.Exists(ctx, key); err == nil && exists {
return true, nil
}
}
return false, nil
}
// Clear removes all keys from both caches
func (h *HybridBackend) Clear(ctx context.Context) error {
var lastErr error
// Clear L1
if err := h.primary.Clear(ctx); err != nil {
h.logger.Errorf("Failed to clear L1 cache: %v", err)
lastErr = err
}
// Clear L2 if not in fallback mode
if !h.fallbackMode.Load() {
if err := h.secondary.Clear(ctx); err != nil {
h.logger.Errorf("Failed to clear L2 cache: %v", err)
h.recordL2Error()
lastErr = err
}
}
return lastErr
}
// GetStats returns statistics for the hybrid cache
func (h *HybridBackend) GetStats() map[string]interface{} {
l1Hits := h.l1Hits.Load()
l2Hits := h.l2Hits.Load()
misses := h.misses.Load()
total := l1Hits + l2Hits + misses
stats := map[string]interface{}{
"type": TypeHybrid,
"l1_hits": l1Hits,
"l2_hits": l2Hits,
"misses": misses,
"total": total,
"l1_writes": h.l1Writes.Load(),
"l2_writes": h.l2Writes.Load(),
"errors": h.errors.Load(),
"fallback_mode": h.fallbackMode.Load(),
}
if total > 0 {
stats["l1_hit_rate"] = float64(l1Hits) / float64(total)
stats["l2_hit_rate"] = float64(l2Hits) / float64(total)
stats["overall_hit_rate"] = float64(l1Hits+l2Hits) / float64(total)
}
// Add sub-backend stats
stats["l1_stats"] = h.primary.GetStats()
stats["l2_stats"] = h.secondary.GetStats()
// Add last L2 error time if available
if lastErr := h.lastL2Error.Load(); lastErr != nil {
if t, ok := lastErr.(time.Time); ok {
stats["last_l2_error"] = t.Format(time.RFC3339)
stats["seconds_since_l2_error"] = time.Since(t).Seconds()
}
}
return stats
}
// Ping checks if both backends are healthy
func (h *HybridBackend) Ping(ctx context.Context) error {
// Check L1
if err := h.primary.Ping(ctx); err != nil {
return fmt.Errorf("L1 ping failed: %w", err)
}
// Check L2 (but don't fail if it's down)
if err := h.secondary.Ping(ctx); err != nil {
h.logger.Warnf("L2 ping failed: %v", err)
h.recordL2Error()
// Don't return error - we can operate with L1 only
} else {
// L2 is healthy, clear fallback mode if it was set
if h.fallbackMode.CompareAndSwap(true, false) {
h.logger.Infof("L2 backend recovered, exiting fallback mode")
}
}
return nil
}
// Close shuts down the hybrid backend
func (h *HybridBackend) Close() error {
// Cancel context to stop workers
h.cancel()
// Close async write channel
close(h.asyncWriteBuffer)
// Wait for workers to finish with timeout
done := make(chan struct{})
go func() {
h.wg.Wait()
close(done)
}()
select {
case <-done:
// Workers finished
case <-time.After(5 * time.Second):
h.logger.Warnf("Timeout waiting for workers to finish")
}
var lastErr error
// Close backends
if err := h.primary.Close(); err != nil {
h.logger.Errorf("Failed to close L1 backend: %v", err)
lastErr = err
}
if err := h.secondary.Close(); err != nil {
h.logger.Errorf("Failed to close L2 backend: %v", err)
lastErr = err
}
h.logger.Infof("HybridBackend closed")
return lastErr
}
// GetMany retrieves multiple values efficiently
func (h *HybridBackend) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
if len(keys) == 0 {
return make(map[string][]byte), nil
}
results := make(map[string][]byte, len(keys))
missingKeys := make([]string, 0)
// Try L1 first for all keys
for _, key := range keys {
if value, _, exists, _ := h.primary.Get(ctx, key); exists {
results[key] = value
h.l1Hits.Add(1)
} else {
missingKeys = append(missingKeys, key)
}
}
// If all found in L1 or in fallback mode, return
if len(missingKeys) == 0 || h.fallbackMode.Load() {
return results, nil
}
// Try L2 for missing keys using batch operation if available
if batcher, ok := h.secondary.(interface {
GetMany(context.Context, []string) (map[string][]byte, error)
}); ok {
l2Results, err := batcher.GetMany(ctx, missingKeys)
if err != nil {
h.logger.Debugf("L2 batch get error: %v", err)
h.recordL2Error()
} else {
for key, value := range l2Results {
results[key] = value
h.l2Hits.Add(1)
// Asynchronously populate L1
go func(k string, v []byte) {
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_ = h.primary.Set(writeCtx, k, v, 0) // Use default TTL
}(key, value)
}
}
} else {
// Fallback to individual gets
for _, key := range missingKeys {
if value, ttl, exists, err := h.secondary.Get(ctx, key); err == nil && exists {
results[key] = value
h.l2Hits.Add(1)
// Asynchronously populate L1
go func(k string, v []byte, t time.Duration) {
writeCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_ = h.primary.Set(writeCtx, k, v, t)
}(key, value, ttl)
}
}
}
// Count misses for keys not found anywhere
for _, key := range keys {
if _, found := results[key]; !found {
h.misses.Add(1)
}
}
return results, nil
}
// SetMany stores multiple key-value pairs efficiently
func (h *HybridBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
if len(items) == 0 {
return nil
}
// Write to L1 first
for key, value := range items {
if err := h.primary.Set(ctx, key, value, ttl); err != nil {
h.logger.Debugf("Failed to write to L1 in batch: %v", err)
} else {
h.l1Writes.Add(1)
}
}
// Skip L2 if in fallback mode
if h.fallbackMode.Load() {
return nil
}
// Check if L2 supports batch operations
if batcher, ok := h.secondary.(interface {
SetMany(context.Context, map[string][]byte, time.Duration) error
}); ok {
if err := batcher.SetMany(ctx, items, ttl); err != nil {
h.logger.Warnf("Failed to batch write to L2: %v", err)
h.recordL2Error()
} else {
h.l2Writes.Add(int64(len(items)))
}
} else {
// Fallback to individual sets
for key, value := range items {
cacheType := h.extractCacheType(key)
if h.syncWriteCacheTypes[cacheType] {
// Sync write for critical types
if err := h.secondary.Set(ctx, key, value, ttl); err != nil {
h.logger.Debugf("Failed to write to L2: %v", err)
h.recordL2Error()
} else {
h.l2Writes.Add(1)
}
} else {
// Async write for non-critical types
select {
case h.asyncWriteBuffer <- &asyncWriteItem{
key: key,
value: value,
ttl: ttl,
ctx: ctx,
}:
// Queued
default:
h.logger.Warnf("Async buffer full for batch write")
}
}
}
}
return nil
}
// asyncWriteWorker processes asynchronous writes to L2
func (h *HybridBackend) asyncWriteWorker() {
defer h.wg.Done()
for {
select {
case <-h.ctx.Done():
// Drain remaining items with best effort
for len(h.asyncWriteBuffer) > 0 {
select {
case item := <-h.asyncWriteBuffer:
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
_ = h.secondary.Set(ctx, item.key, item.value, item.ttl)
cancel()
default:
return
}
}
return
case item, ok := <-h.asyncWriteBuffer:
if !ok {
return
}
// Skip if in fallback mode
if h.fallbackMode.Load() {
continue
}
// Perform the write with a timeout
writeCtx, cancel := context.WithTimeout(item.ctx, 500*time.Millisecond)
if err := h.secondary.Set(writeCtx, item.key, item.value, item.ttl); err != nil {
h.errors.Add(1)
h.logger.Debugf("Async write to L2 failed for key %s: %v", item.key, err)
h.recordL2Error()
} else {
h.l2Writes.Add(1)
h.logger.Debugf("Async write to L2 completed for key: %s", item.key)
}
cancel()
}
}
}
// healthMonitor periodically checks L2 health and manages fallback mode
func (h *HybridBackend) healthMonitor() {
defer h.wg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-h.ctx.Done():
return
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := h.secondary.Ping(ctx); err != nil {
if !h.fallbackMode.Load() {
h.fallbackMode.Store(true)
h.logger.Warnf("L2 backend unhealthy, entering fallback mode: %v", err)
}
} else {
if h.fallbackMode.CompareAndSwap(true, false) {
h.logger.Infof("L2 backend healthy, exiting fallback mode")
}
}
cancel()
}
}
}
// recordL2Error records the timestamp of an L2 error
func (h *HybridBackend) recordL2Error() {
h.lastL2Error.Store(time.Now())
// Check if we should enter fallback mode based on recent errors
if !h.fallbackMode.Load() {
// Simple heuristic: if we've had an error in the last second, consider L2 unhealthy
if lastErr := h.lastL2Error.Load(); lastErr != nil {
if t, ok := lastErr.(time.Time); ok && time.Since(t) < time.Second {
h.fallbackMode.Store(true)
h.logger.Warnf("Multiple L2 errors detected, entering fallback mode")
}
}
}
}
// extractCacheType attempts to determine the cache type from the key
func (h *HybridBackend) extractCacheType(key string) string {
// Simple heuristic based on key prefixes
// This should match the actual cache type strategy in the main application
if len(key) > 10 {
prefix := key[:10]
switch {
case contains(prefix, "blacklist"):
return "blacklist"
case contains(prefix, "token"):
return "token"
case contains(prefix, "metadata"):
return "metadata"
case contains(prefix, "jwk"):
return "jwk"
case contains(prefix, "session"):
return "session"
case contains(prefix, "introspect"):
return "introspection"
}
}
return "general"
}
// contains checks if a string contains a substring (case-insensitive)
func contains(s, substr string) bool {
if len(substr) > len(s) {
return false
}
for i := 0; i <= len(s)-len(substr); i++ {
match := true
for j := 0; j < len(substr); j++ {
if toLower(s[i+j]) != toLower(substr[j]) {
match = false
break
}
}
if match {
return true
}
}
return false
}
// toLower converts a byte to lowercase
func toLower(b byte) byte {
if b >= 'A' && b <= 'Z' {
return b + 32
}
return b
}
+133
View File
@@ -0,0 +1,133 @@
// Package backend provides cache backend implementations for the Traefik OIDC plugin.
package backends
import (
"context"
"time"
)
// CacheBackend defines the interface for all cache backend implementations
// Implementations include: MemoryBackend, RedisBackend, and HybridBackend
type CacheBackend interface {
// Set stores a value in the cache with the specified TTL
// Returns an error if the operation fails
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
// Get retrieves a value from the cache
// Returns: value, remaining TTL, exists flag, and error
// If the key doesn't exist, exists will be false
Get(ctx context.Context, key string) (value []byte, ttl time.Duration, exists bool, err error)
// Delete removes a key from the cache
// Returns true if the key was deleted, false if it didn't exist
Delete(ctx context.Context, key string) (bool, error)
// Exists checks if a key exists in the cache
Exists(ctx context.Context, key string) (bool, error)
// Clear removes all keys from the cache
Clear(ctx context.Context) error
// GetStats returns cache statistics
// Stats include: hits, misses, size, memory usage, etc.
GetStats() map[string]interface{}
// Close shuts down the cache backend and releases resources
Close() error
// Ping checks if the backend is healthy and responsive
Ping(ctx context.Context) error
}
// BackendStats represents statistics for a cache backend
type BackendStats struct {
// Type is the backend type
Type BackendType
// Hits is the number of cache hits
Hits int64
// Misses is the number of cache misses
Misses int64
// Sets is the number of set operations
Sets int64
// Deletes is the number of delete operations
Deletes int64
// Errors is the number of errors
Errors int64
// Evictions is the number of evicted items
Evictions int64
// CurrentSize is the current number of items in cache
CurrentSize int64
// MaxSize is the maximum number of items (0 means unlimited)
MaxSize int64
// MemoryUsage is the approximate memory usage in bytes
MemoryUsage int64
// AverageGetLatency is the average latency for get operations
AverageGetLatency time.Duration
// AverageSetLatency is the average latency for set operations
AverageSetLatency time.Duration
// LastError is the last error encountered
LastError string
// LastErrorTime is when the last error occurred
LastErrorTime time.Time
// Uptime is how long the backend has been running
Uptime time.Duration
// StartTime is when the backend was started
StartTime time.Time
}
// BackendCapabilities describes the capabilities of a cache backend
type BackendCapabilities struct {
// Distributed indicates if the backend is distributed across multiple instances
Distributed bool
// Persistent indicates if the backend persists data across restarts
Persistent bool
// Eviction indicates if the backend supports automatic eviction
Eviction bool
// TTL indicates if the backend supports TTL (time-to-live)
TTL bool
// MaxKeySize is the maximum size of a key in bytes (0 = unlimited)
MaxKeySize int64
// MaxValueSize is the maximum size of a value in bytes (0 = unlimited)
MaxValueSize int64
// MaxKeys is the maximum number of keys (0 = unlimited)
MaxKeys int64
// SupportsExpire indicates if the backend supports expiration
SupportsExpire bool
// SupportsMultiGet indicates if the backend supports batch get operations
SupportsMultiGet bool
// SupportsTransaction indicates if the backend supports transactions
SupportsTransaction bool
// SupportsCompression indicates if the backend supports compression
SupportsCompression bool
// RequiresSerialize indicates if values must be serialized
RequiresSerialize bool
// AtomicOperations indicates if the backend supports atomic operations
AtomicOperations bool
}
+402
View File
@@ -0,0 +1,402 @@
package backends
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestCacheBackendContract defines a set of tests that all CacheBackend implementations must pass
// This ensures that Memory, Redis, and Hybrid backends all behave consistently
func TestCacheBackendContract(t *testing.T) {
// Test suite will be run against each backend type
t.Run("MemoryBackend", func(t *testing.T) {
backend := setupMemoryBackend(t)
runContractTests(t, backend)
})
t.Run("RedisBackend", func(t *testing.T) {
backend := setupRedisBackend(t)
runContractTests(t, backend)
})
t.Run("HybridBackend", func(t *testing.T) {
backend := setupHybridBackend(t)
runContractTests(t, backend)
})
}
// runContractTests executes all contract tests against a backend
func runContractTests(t *testing.T, backend CacheBackend) {
t.Helper()
ctx := context.Background()
t.Run("BasicSetGet", func(t *testing.T) {
testBasicSetGet(t, ctx, backend)
})
t.Run("GetNonExistent", func(t *testing.T) {
testGetNonExistent(t, ctx, backend)
})
t.Run("UpdateExisting", func(t *testing.T) {
testUpdateExisting(t, ctx, backend)
})
t.Run("Delete", func(t *testing.T) {
testDelete(t, ctx, backend)
})
t.Run("DeleteNonExistent", func(t *testing.T) {
testDeleteNonExistent(t, ctx, backend)
})
t.Run("Exists", func(t *testing.T) {
testExists(t, ctx, backend)
})
t.Run("TTLExpiration", func(t *testing.T) {
testTTLExpiration(t, ctx, backend)
})
t.Run("Clear", func(t *testing.T) {
testClear(t, ctx, backend)
})
t.Run("Ping", func(t *testing.T) {
testPing(t, ctx, backend)
})
t.Run("Stats", func(t *testing.T) {
testStats(t, ctx, backend)
})
t.Run("ConcurrentAccess", func(t *testing.T) {
testConcurrentAccess(t, ctx, backend)
})
t.Run("LargeValues", func(t *testing.T) {
testLargeValues(t, ctx, backend)
})
t.Run("EmptyValues", func(t *testing.T) {
testEmptyValues(t, ctx, backend)
})
t.Run("SpecialCharactersInKeys", func(t *testing.T) {
testSpecialCharactersInKeys(t, ctx, backend)
})
}
// testBasicSetGet verifies basic set and get operations
func testBasicSetGet(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "test-key-1"
value := []byte("test-value-1")
ttl := 1 * time.Minute
// Set value
err := backend.Set(ctx, key, value, ttl)
require.NoError(t, err, "Set should not return error")
// Get value
retrieved, remainingTTL, exists, err := backend.Get(ctx, key)
require.NoError(t, err, "Get should not return error")
assert.True(t, exists, "Key should exist")
assert.Equal(t, value, retrieved, "Retrieved value should match")
assert.Greater(t, remainingTTL, 50*time.Second, "TTL should be close to original")
assert.LessOrEqual(t, remainingTTL, ttl, "TTL should not exceed original")
}
// testGetNonExistent verifies behavior when getting non-existent keys
func testGetNonExistent(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "non-existent-key"
retrieved, ttl, exists, err := backend.Get(ctx, key)
require.NoError(t, err, "Get should not return error for non-existent key")
assert.False(t, exists, "Key should not exist")
assert.Nil(t, retrieved, "Value should be nil")
assert.Equal(t, time.Duration(0), ttl, "TTL should be zero")
}
// testUpdateExisting verifies updating an existing key
func testUpdateExisting(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "update-key"
value1 := []byte("original-value")
value2 := []byte("updated-value")
ttl := 1 * time.Minute
// Set initial value
err := backend.Set(ctx, key, value1, ttl)
require.NoError(t, err)
// Update value
err = backend.Set(ctx, key, value2, ttl)
require.NoError(t, err)
// Verify updated value
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value2, retrieved, "Value should be updated")
}
// testDelete verifies delete operation
func testDelete(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "delete-key"
value := []byte("delete-value")
// Set value
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
// Verify exists
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Delete
deleted, err := backend.Delete(ctx, key)
require.NoError(t, err)
assert.True(t, deleted, "Delete should return true for existing key")
// Verify deleted
exists, err = backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists, "Key should not exist after delete")
}
// testDeleteNonExistent verifies deleting non-existent keys
func testDeleteNonExistent(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "non-existent-delete-key"
deleted, err := backend.Delete(ctx, key)
require.NoError(t, err)
assert.False(t, deleted, "Delete should return false for non-existent key")
}
// testExists verifies the Exists operation
func testExists(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "exists-key"
value := []byte("exists-value")
// Check non-existent key
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists, "Key should not exist initially")
// Set value
err = backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
// Check existing key
exists, err = backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists, "Key should exist after Set")
}
// testTTLExpiration verifies TTL expiration behavior
func testTTLExpiration(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "ttl-key"
value := []byte("ttl-value")
shortTTL := 100 * time.Millisecond
// Set with short TTL
err := backend.Set(ctx, key, value, shortTTL)
require.NoError(t, err)
// Verify exists immediately
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists, "Key should exist immediately after Set")
// Wait for expiration
time.Sleep(200 * time.Millisecond)
// Verify expired
exists, err = backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists, "Key should not exist after TTL expiration")
}
// testClear verifies Clear operation
func testClear(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
// Set multiple keys
for i := 0; i < 5; i++ {
key := fmt.Sprintf("clear-key-%d", i)
value := []byte(fmt.Sprintf("clear-value-%d", i))
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
}
// Clear all
err := backend.Clear(ctx)
require.NoError(t, err)
// Verify all keys are gone
for i := 0; i < 5; i++ {
key := fmt.Sprintf("clear-key-%d", i)
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists, "Key should not exist after Clear")
}
}
// testPing verifies Ping operation
func testPing(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
err := backend.Ping(ctx)
assert.NoError(t, err, "Ping should succeed on healthy backend")
}
// testStats verifies GetStats operation
func testStats(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
stats := backend.GetStats()
assert.NotNil(t, stats, "Stats should not be nil")
// Stats should contain basic metrics
_, hasHits := stats["hits"]
_, hasMisses := stats["misses"]
assert.True(t, hasHits || hasMisses, "Stats should contain hits or misses")
}
// testConcurrentAccess verifies thread safety
func testConcurrentAccess(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
var wg sync.WaitGroup
goroutines := 10
iterations := 20
// Concurrent writes
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
key := fmt.Sprintf("concurrent-key-%d-%d", id, j)
value := []byte(fmt.Sprintf("concurrent-value-%d-%d", id, j))
err := backend.Set(ctx, key, value, 1*time.Minute)
assert.NoError(t, err)
// Read back
retrieved, _, exists, err := backend.Get(ctx, key)
assert.NoError(t, err)
if exists {
assert.Equal(t, value, retrieved)
}
}
}(i)
}
wg.Wait()
}
// testLargeValues verifies handling of large values
func testLargeValues(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "large-value-key"
value := GenerateLargeValue(1024 * 1024) // 1MB
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err, "Should handle large values")
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, len(value), len(retrieved), "Large value should be retrieved intact")
}
// testEmptyValues verifies handling of empty values
func testEmptyValues(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
key := "empty-value-key"
value := []byte{}
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err, "Should handle empty values")
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists, "Empty value should exist")
assert.Equal(t, 0, len(retrieved), "Retrieved value should be empty")
}
// testSpecialCharactersInKeys verifies handling of special characters in keys
func testSpecialCharactersInKeys(t *testing.T, ctx context.Context, backend CacheBackend) {
t.Helper()
specialKeys := []string{
"key:with:colons",
"key/with/slashes",
"key-with-dashes",
"key_with_underscores",
"key.with.dots",
"key|with|pipes",
}
for _, key := range specialKeys {
value := []byte(fmt.Sprintf("value-for-%s", key))
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err, "Should handle special character in key: %s", key)
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists, "Key with special characters should exist: %s", key)
assert.Equal(t, value, retrieved)
}
}
// Helper functions to setup different backend types
// These will be implemented in respective test files
func setupMemoryBackend(t *testing.T) CacheBackend {
t.Helper()
// This will be implemented in memory_test.go
// For now, return nil to allow compilation
t.Skip("MemoryBackend implementation pending")
return nil
}
func setupRedisBackend(t *testing.T) CacheBackend {
t.Helper()
// This will be implemented in redis_test.go
// For now, return nil to allow compilation
t.Skip("RedisBackend implementation pending")
return nil
}
func setupHybridBackend(t *testing.T) CacheBackend {
t.Helper()
// This will be implemented in hybrid_test.go
// For now, return nil to allow compilation
t.Skip("HybridBackend implementation pending")
return nil
}
+516
View File
@@ -0,0 +1,516 @@
// Package backend provides cache backend implementations for the Traefik OIDC plugin.
package backends
import (
"container/list"
"context"
"sync"
"sync/atomic"
"time"
)
// memoryCacheItem represents an item in the memory cache
type memoryCacheItem struct {
key string
value interface{}
expiresAt time.Time
createdAt time.Time
accessedAt time.Time
accessCount int64
size int64
element *list.Element // for LRU tracking
}
// isExpired checks if the item is expired
func (item *memoryCacheItem) isExpired() bool {
if item.expiresAt.IsZero() {
return false
}
return time.Now().After(item.expiresAt)
}
// MemoryCacheBackend implements the CacheBackend interface using in-memory storage
type MemoryCacheBackend struct {
mu sync.RWMutex
items map[string]*memoryCacheItem
lruList *list.List
maxSize int64
maxMemory int64
currentSize int64
currentMemory int64
// Statistics
hits atomic.Int64
misses atomic.Int64
sets atomic.Int64
deletes atomic.Int64
evictions atomic.Int64
errors atomic.Int64
// Latency tracking
totalGetTime atomic.Int64
totalSetTime atomic.Int64
getCount atomic.Int64
setCount atomic.Int64
// Status
startTime time.Time
lastError string
lastErrorTime time.Time
cleanupTicker *time.Ticker
cleanupDone chan bool
closed atomic.Bool
// Configuration
cleanupInterval time.Duration
evictionPolicy string // "lru", "lfu", "fifo"
}
// NewMemoryCacheBackend creates a new memory cache backend
func NewMemoryCacheBackend(maxSize int64, maxMemory int64, cleanupInterval time.Duration) *MemoryCacheBackend {
if maxSize <= 0 {
maxSize = 10000 // Default to 10k items
}
if maxMemory <= 0 {
maxMemory = 100 * 1024 * 1024 // Default to 100MB
}
if cleanupInterval <= 0 {
cleanupInterval = 5 * time.Minute
}
m := &MemoryCacheBackend{
items: make(map[string]*memoryCacheItem),
lruList: list.New(),
maxSize: maxSize,
maxMemory: maxMemory,
startTime: time.Now(),
cleanupInterval: cleanupInterval,
evictionPolicy: "lru",
cleanupDone: make(chan bool),
}
// Start cleanup goroutine
m.cleanupTicker = time.NewTicker(cleanupInterval)
go m.cleanupLoop()
return m
}
// cleanupLoop runs periodic cleanup of expired items
func (m *MemoryCacheBackend) cleanupLoop() {
for {
select {
case <-m.cleanupTicker.C:
m.cleanupExpired()
case <-m.cleanupDone:
return
}
}
}
// cleanupExpired removes all expired items from the cache
func (m *MemoryCacheBackend) cleanupExpired() {
m.mu.Lock()
defer m.mu.Unlock()
var keysToDelete []string
for key, item := range m.items {
if item.isExpired() {
keysToDelete = append(keysToDelete, key)
}
}
for _, key := range keysToDelete {
m.deleteItemLocked(key)
}
}
// Get retrieves a value from the cache
func (m *MemoryCacheBackend) Get(ctx context.Context, key string) (interface{}, error) {
if m.closed.Load() {
return nil, ErrBackendUnavailable
}
start := time.Now()
defer func() {
duration := time.Since(start).Nanoseconds()
m.totalGetTime.Add(duration)
m.getCount.Add(1)
}()
m.mu.RLock()
item, exists := m.items[key]
m.mu.RUnlock()
if !exists {
m.misses.Add(1)
return nil, ErrCacheMiss
}
if item.isExpired() {
m.mu.Lock()
m.deleteItemLocked(key)
m.mu.Unlock()
m.misses.Add(1)
return nil, ErrCacheMiss
}
// Update access time and count
m.mu.Lock()
item.accessedAt = time.Now()
item.accessCount++
// Move to front of LRU list
if m.evictionPolicy == "lru" && item.element != nil {
m.lruList.MoveToFront(item.element)
}
m.mu.Unlock()
m.hits.Add(1)
return item.value, nil
}
// Set stores a value in the cache with optional TTL
func (m *MemoryCacheBackend) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
start := time.Now()
defer func() {
duration := time.Since(start).Nanoseconds()
m.totalSetTime.Add(duration)
m.setCount.Add(1)
}()
// Calculate item size (simplified estimation)
itemSize := int64(len(key)) + estimateValueSize(value)
m.mu.Lock()
defer m.mu.Unlock()
// Check if we need to evict items
if m.currentSize >= m.maxSize || m.currentMemory+itemSize > m.maxMemory {
m.evictLocked()
}
// Check if key exists
if oldItem, exists := m.items[key]; exists {
m.currentMemory -= oldItem.size
if oldItem.element != nil {
m.lruList.Remove(oldItem.element)
}
} else {
m.currentSize++
}
now := time.Now()
var expiresAt time.Time
if ttl > 0 {
expiresAt = now.Add(ttl)
}
item := &memoryCacheItem{
key: key,
value: value,
expiresAt: expiresAt,
createdAt: now,
accessedAt: now,
accessCount: 0,
size: itemSize,
}
// Add to LRU list
if m.evictionPolicy == "lru" {
item.element = m.lruList.PushFront(item)
}
m.items[key] = item
m.currentMemory += itemSize
m.sets.Add(1)
return nil
}
// Delete removes a key from the cache
func (m *MemoryCacheBackend) Delete(ctx context.Context, key string) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.items[key]; !exists {
return nil
}
m.deleteItemLocked(key)
m.deletes.Add(1)
return nil
}
// deleteItemLocked deletes an item without acquiring the lock (must be called with lock held)
func (m *MemoryCacheBackend) deleteItemLocked(key string) {
if item, exists := m.items[key]; exists {
m.currentMemory -= item.size
m.currentSize--
if item.element != nil {
m.lruList.Remove(item.element)
}
delete(m.items, key)
}
}
// evictLocked evicts items based on the eviction policy (must be called with lock held)
func (m *MemoryCacheBackend) evictLocked() {
if m.evictionPolicy == "lru" && m.lruList.Len() > 0 {
// Evict least recently used item
element := m.lruList.Back()
if element != nil {
item := element.Value.(*memoryCacheItem)
m.deleteItemLocked(item.key)
m.evictions.Add(1)
}
}
}
// Exists checks if a key exists in the cache
func (m *MemoryCacheBackend) Exists(ctx context.Context, key string) (bool, error) {
if m.closed.Load() {
return false, ErrBackendUnavailable
}
m.mu.RLock()
item, exists := m.items[key]
m.mu.RUnlock()
if !exists {
return false, nil
}
return !item.isExpired(), nil
}
// Clear removes all items from the cache
func (m *MemoryCacheBackend) Clear(ctx context.Context) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
m.mu.Lock()
defer m.mu.Unlock()
m.items = make(map[string]*memoryCacheItem)
m.lruList = list.New()
m.currentSize = 0
m.currentMemory = 0
return nil
}
// Keys returns all keys matching the pattern (use "*" for all keys)
func (m *MemoryCacheBackend) Keys(ctx context.Context, pattern string) ([]string, error) {
if m.closed.Load() {
return nil, ErrBackendUnavailable
}
m.mu.RLock()
defer m.mu.RUnlock()
var keys []string
for key, item := range m.items {
if !item.isExpired() && matchPattern(pattern, key) {
keys = append(keys, key)
}
}
return keys, nil
}
// Size returns the number of items in the cache
func (m *MemoryCacheBackend) Size(ctx context.Context) (int64, error) {
if m.closed.Load() {
return 0, ErrBackendUnavailable
}
m.mu.RLock()
defer m.mu.RUnlock()
return m.currentSize, nil
}
// TTL returns the remaining time-to-live for a key
func (m *MemoryCacheBackend) TTL(ctx context.Context, key string) (time.Duration, error) {
if m.closed.Load() {
return 0, ErrBackendUnavailable
}
m.mu.RLock()
item, exists := m.items[key]
m.mu.RUnlock()
if !exists || item.isExpired() {
return 0, ErrCacheMiss
}
if item.expiresAt.IsZero() {
return 0, nil // No expiration
}
remaining := time.Until(item.expiresAt)
if remaining < 0 {
return 0, nil
}
return remaining, nil
}
// Expire updates the TTL for an existing key
func (m *MemoryCacheBackend) Expire(ctx context.Context, key string, ttl time.Duration) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
m.mu.Lock()
defer m.mu.Unlock()
item, exists := m.items[key]
if !exists || item.isExpired() {
return ErrCacheMiss
}
if ttl > 0 {
item.expiresAt = time.Now().Add(ttl)
} else {
item.expiresAt = time.Time{} // Remove expiration
}
return nil
}
// GetStats returns statistics about the cache backend
func (m *MemoryCacheBackend) GetStats(ctx context.Context) (*BackendStats, error) {
if m.closed.Load() {
return nil, ErrBackendUnavailable
}
m.mu.RLock()
lastError := m.lastError
lastErrorTime := m.lastErrorTime
m.mu.RUnlock()
avgGetLatency := time.Duration(0)
if getCount := m.getCount.Load(); getCount > 0 {
avgGetLatency = time.Duration(m.totalGetTime.Load() / getCount)
}
avgSetLatency := time.Duration(0)
if setCount := m.setCount.Load(); setCount > 0 {
avgSetLatency = time.Duration(m.totalSetTime.Load() / setCount)
}
return &BackendStats{
Type: TypeMemory,
Hits: m.hits.Load(),
Misses: m.misses.Load(),
Sets: m.sets.Load(),
Deletes: m.deletes.Load(),
Errors: m.errors.Load(),
Evictions: m.evictions.Load(),
CurrentSize: m.currentSize,
MaxSize: m.maxSize,
MemoryUsage: m.currentMemory,
AverageGetLatency: avgGetLatency,
AverageSetLatency: avgSetLatency,
LastError: lastError,
LastErrorTime: lastErrorTime,
Uptime: time.Since(m.startTime),
StartTime: m.startTime,
}, nil
}
// Ping checks if the backend is healthy
func (m *MemoryCacheBackend) Ping(ctx context.Context) error {
if m.closed.Load() {
return ErrBackendUnavailable
}
return nil
}
// Close closes the backend and releases resources
func (m *MemoryCacheBackend) Close() error {
if m.closed.Swap(true) {
return nil // Already closed
}
m.cleanupTicker.Stop()
close(m.cleanupDone)
m.mu.Lock()
m.items = nil
m.lruList = nil
m.mu.Unlock()
return nil
}
// IsHealthy returns true if the backend is healthy
func (m *MemoryCacheBackend) IsHealthy() bool {
return !m.closed.Load()
}
// Type returns the backend type
func (m *MemoryCacheBackend) Type() BackendType {
return TypeMemory
}
// Capabilities returns the backend capabilities
func (m *MemoryCacheBackend) Capabilities() *BackendCapabilities {
return &BackendCapabilities{
Distributed: false,
Persistent: false,
Eviction: true,
TTL: true,
MaxKeySize: 1024, // 1KB
MaxValueSize: 10485760, // 10MB
MaxKeys: m.maxSize,
SupportsExpire: true,
SupportsMultiGet: true,
SupportsTransaction: false,
SupportsCompression: false,
RequiresSerialize: false,
}
}
// Helper functions
// estimateValueSize estimates the size of a value in bytes
func estimateValueSize(value interface{}) int64 {
// This is a simplified estimation
// In production, you might want to use a more accurate method
switch v := value.(type) {
case string:
return int64(len(v))
case []byte:
return int64(len(v))
case int, int32, int64, uint, uint32, uint64:
return 8
case float32, float64:
return 8
case bool:
return 1
default:
// For complex types, use a default estimate
return 256
}
}
// matchPattern checks if a key matches a pattern (simplified glob matching)
func matchPattern(pattern, key string) bool {
if pattern == "*" {
return true
}
// Simplified pattern matching - in production, use a proper glob library
return key == pattern || (len(pattern) > 0 && pattern[0] == '*' &&
len(key) >= len(pattern)-1 && key[len(key)-len(pattern)+1:] == pattern[1:])
}
+501
View File
@@ -0,0 +1,501 @@
package backends
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMemoryBackend_BasicOperations tests basic CRUD operations
func TestMemoryBackend_BasicOperations(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("SetAndGet", func(t *testing.T) {
key := "test-key"
value := []byte("test-value")
ttl := 1 * time.Minute
err := backend.Set(ctx, key, value, ttl)
require.NoError(t, err)
retrieved, remainingTTL, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value, retrieved)
assert.Greater(t, remainingTTL, 50*time.Second)
assert.LessOrEqual(t, remainingTTL, ttl)
})
t.Run("GetNonExistent", func(t *testing.T) {
_, _, exists, err := backend.Get(ctx, "non-existent")
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("Delete", func(t *testing.T) {
key := "delete-key"
value := []byte("delete-value")
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
deleted, err := backend.Delete(ctx, key)
require.NoError(t, err)
assert.True(t, deleted)
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("DeleteNonExistent", func(t *testing.T) {
deleted, err := backend.Delete(ctx, "non-existent-delete")
require.NoError(t, err)
assert.False(t, deleted)
})
t.Run("Exists", func(t *testing.T) {
key := "exists-key"
value := []byte("exists-value")
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
err = backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
exists, err = backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
})
t.Run("Clear", func(t *testing.T) {
// Add multiple items
for i := 0; i < 10; i++ {
key := fmt.Sprintf("clear-key-%d", i)
value := []byte(fmt.Sprintf("clear-value-%d", i))
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
}
err := backend.Clear(ctx)
require.NoError(t, err)
stats := backend.GetStats()
size := stats["size"].(int64)
assert.Equal(t, int64(0), size)
})
}
// TestMemoryBackend_TTLExpiration tests TTL and expiration
func TestMemoryBackend_TTLExpiration(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.CleanupInterval = 50 * time.Millisecond
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("ShortTTL", func(t *testing.T) {
key := "short-ttl-key"
value := []byte("short-ttl-value")
shortTTL := 100 * time.Millisecond
err := backend.Set(ctx, key, value, shortTTL)
require.NoError(t, err)
// Verify exists immediately
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should be expired
_, _, exists, err = backend.Get(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("TTLDecrement", func(t *testing.T) {
key := "ttl-decrement-key"
value := []byte("ttl-decrement-value")
ttl := 2 * time.Second
err := backend.Set(ctx, key, value, ttl)
require.NoError(t, err)
// Check TTL immediately
_, ttl1, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Wait a bit
time.Sleep(500 * time.Millisecond)
// Check TTL again - should be less
_, ttl2, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Less(t, ttl2, ttl1, "TTL should decrease over time")
})
t.Run("CleanupExpiredItems", func(t *testing.T) {
// Set multiple items with short TTL
for i := 0; i < 5; i++ {
key := fmt.Sprintf("cleanup-key-%d", i)
value := []byte(fmt.Sprintf("cleanup-value-%d", i))
err := backend.Set(ctx, key, value, 50*time.Millisecond)
require.NoError(t, err)
}
// Wait for cleanup to run
time.Sleep(200 * time.Millisecond)
// All items should be cleaned up
for i := 0; i < 5; i++ {
key := fmt.Sprintf("cleanup-key-%d", i)
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists, "Expired items should be cleaned up")
}
})
}
// TestMemoryBackend_LRUEviction tests LRU eviction
func TestMemoryBackend_LRUEviction(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.MaxSize = 5
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Fill cache to max size
for i := 0; i < 5; i++ {
key := fmt.Sprintf("lru-key-%d", i)
value := []byte(fmt.Sprintf("lru-value-%d", i))
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
}
// Access first item to make it most recently used
_, _, exists, err := backend.Get(ctx, "lru-key-0")
require.NoError(t, err)
assert.True(t, exists)
// Add a new item - should evict lru-key-1 (least recently used)
err = backend.Set(ctx, "lru-key-new", []byte("new-value"), 1*time.Minute)
require.NoError(t, err)
// lru-key-0 should still exist (was accessed recently)
exists, err = backend.Exists(ctx, "lru-key-0")
require.NoError(t, err)
assert.True(t, exists, "Recently accessed item should not be evicted")
// lru-key-1 should be evicted
exists, err = backend.Exists(ctx, "lru-key-1")
require.NoError(t, err)
assert.False(t, exists, "Least recently used item should be evicted")
// Check eviction count
stats := backend.GetStats()
evictions := stats["evictions"].(int64)
assert.Greater(t, evictions, int64(0), "Should have evictions")
}
// TestMemoryBackend_MemoryLimit tests memory-based eviction
func TestMemoryBackend_MemoryLimit(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.MaxSize = 100
config.MaxMemoryBytes = 1024 // 1KB limit
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add items until memory limit is reached
largeValue := make([]byte, 512) // 512 bytes each
for i := 0; i < 5; i++ {
key := fmt.Sprintf("mem-key-%d", i)
err := backend.Set(ctx, key, largeValue, 1*time.Minute)
require.NoError(t, err)
}
stats := backend.GetStats()
memory := stats["memory"].(int64)
assert.LessOrEqual(t, memory, config.MaxMemoryBytes, "Memory should not exceed limit")
evictions := stats["evictions"].(int64)
assert.Greater(t, evictions, int64(0), "Should have memory-based evictions")
}
// TestMemoryBackend_ConcurrentAccess tests thread safety
func TestMemoryBackend_ConcurrentAccess(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
var wg sync.WaitGroup
goroutines := 20
iterations := 50
// Concurrent writes
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
key := fmt.Sprintf("concurrent-key-%d-%d", id, j)
value := []byte(fmt.Sprintf("concurrent-value-%d-%d", id, j))
err := backend.Set(ctx, key, value, 1*time.Minute)
assert.NoError(t, err)
// Read back
retrieved, _, exists, err := backend.Get(ctx, key)
assert.NoError(t, err)
if exists {
assert.Equal(t, value, retrieved)
}
// Random deletes
if j%5 == 0 {
backend.Delete(ctx, key)
}
}
}(i)
}
wg.Wait()
// Verify stats are consistent
stats := backend.GetStats()
hits := stats["hits"].(int64)
misses := stats["misses"].(int64)
assert.Greater(t, hits+misses, int64(0), "Should have cache operations")
}
// TestMemoryBackend_UpdateExisting tests updating existing keys
func TestMemoryBackend_UpdateExisting(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "update-key"
value1 := []byte("original-value")
value2 := []byte("updated-value")
// Set original
err = backend.Set(ctx, key, value1, 1*time.Minute)
require.NoError(t, err)
// Update
err = backend.Set(ctx, key, value2, 2*time.Minute)
require.NoError(t, err)
// Verify updated
retrieved, ttl, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value2, retrieved)
assert.Greater(t, ttl, 1*time.Minute, "TTL should be updated")
// Size should not increase (same key)
stats := backend.GetStats()
size := stats["size"].(int64)
assert.Equal(t, int64(1), size, "Size should be 1 for one key")
}
// TestMemoryBackend_Stats tests statistics tracking
func TestMemoryBackend_Stats(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Initial stats
stats := backend.GetStats()
assert.Equal(t, int64(0), stats["hits"].(int64))
assert.Equal(t, int64(0), stats["misses"].(int64))
// Add items and track hits/misses
backend.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
backend.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
// Hit
backend.Get(ctx, "key1")
// Miss
backend.Get(ctx, "non-existent")
stats = backend.GetStats()
assert.Equal(t, int64(1), stats["hits"].(int64))
assert.Equal(t, int64(1), stats["misses"].(int64))
hitRate := stats["hit_rate"].(float64)
assert.InDelta(t, 0.5, hitRate, 0.01)
}
// TestMemoryBackend_EmptyValues tests handling of empty values
func TestMemoryBackend_EmptyValues(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "empty-key"
emptyValue := []byte{}
err = backend.Set(ctx, key, emptyValue, 1*time.Minute)
require.NoError(t, err)
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, 0, len(retrieved))
}
// TestMemoryBackend_LargeValues tests handling of large values
func TestMemoryBackend_LargeValues(t *testing.T) {
t.Parallel()
config := DefaultConfig()
config.MaxMemoryBytes = 10 * 1024 * 1024 // 10MB
backend, err := NewMemoryBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "large-key"
largeValue := make([]byte, 1024*1024) // 1MB
err = backend.Set(ctx, key, largeValue, 1*time.Minute)
require.NoError(t, err)
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, len(largeValue), len(retrieved))
}
// TestMemoryBackend_Close tests proper cleanup on close
func TestMemoryBackend_Close(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
ctx := context.Background()
// Add some items
for i := 0; i < 10; i++ {
key := fmt.Sprintf("close-key-%d", i)
value := []byte(fmt.Sprintf("close-value-%d", i))
backend.Set(ctx, key, value, 1*time.Minute)
}
// Close
err = backend.Close()
require.NoError(t, err)
// Operations after close should fail
err = backend.Set(ctx, "after-close", []byte("value"), 1*time.Minute)
assert.Error(t, err)
assert.Equal(t, ErrBackendClosed, err)
_, _, _, err = backend.Get(ctx, "close-key-0")
assert.Error(t, err)
assert.Equal(t, ErrBackendClosed, err)
// Closing again should be safe
err = backend.Close()
assert.NoError(t, err)
}
// TestMemoryBackend_Ping tests ping operation
func TestMemoryBackend_Ping(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
err = backend.Ping(ctx)
assert.NoError(t, err)
// Close and ping should fail
backend.Close()
err = backend.Ping(ctx)
assert.Error(t, err)
}
// TestMemoryBackend_ValueIsolation tests that returned values are isolated
func TestMemoryBackend_ValueIsolation(t *testing.T) {
t.Parallel()
backend, err := NewMemoryBackend(DefaultConfig())
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "isolation-key"
originalValue := []byte("original-value")
err = backend.Set(ctx, key, originalValue, 1*time.Minute)
require.NoError(t, err)
// Get value and modify it
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Modify retrieved value
if len(retrieved) > 0 {
retrieved[0] = 'X'
}
// Get again - should be unchanged
retrieved2, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, originalValue, retrieved2, "Original value should not be modified")
}
+153
View File
@@ -0,0 +1,153 @@
package backends
import (
"context"
"time"
)
// MemoryBackend wraps MemoryCacheBackend to implement the CacheBackend interface
type MemoryBackend struct {
*MemoryCacheBackend
}
// NewMemoryBackend creates a new memory backend from a config
func NewMemoryBackend(config *Config) (*MemoryBackend, error) {
maxSize := int64(config.MaxSize)
if maxSize <= 0 {
maxSize = 1000
}
cacheBackend := NewMemoryCacheBackend(maxSize, config.MaxMemoryBytes, config.CleanupInterval)
return &MemoryBackend{
MemoryCacheBackend: cacheBackend,
}, nil
}
// Set stores a value in the cache with the specified TTL
func (m *MemoryBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
err := m.MemoryCacheBackend.Set(ctx, key, value, ttl)
if err == ErrBackendUnavailable {
return ErrBackendClosed
}
return err
}
// Get retrieves a value from the cache
func (m *MemoryBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
val, err := m.MemoryCacheBackend.Get(ctx, key)
if err != nil {
if err == ErrCacheMiss {
return nil, 0, false, nil
}
if err == ErrBackendUnavailable {
return nil, 0, false, ErrBackendClosed
}
return nil, 0, false, err
}
// Get the item directly to check TTL
m.MemoryCacheBackend.mu.RLock()
item, exists := m.MemoryCacheBackend.items[key]
m.MemoryCacheBackend.mu.RUnlock()
if !exists {
return nil, 0, false, nil
}
var ttl time.Duration
if !item.expiresAt.IsZero() {
ttl = time.Until(item.expiresAt)
if ttl < 0 {
ttl = 0
}
}
// Convert interface{} to []byte
var valueBytes []byte
if val != nil {
if bytes, ok := val.([]byte); ok {
valueBytes = bytes
} else {
// If it's not already []byte, we might need to handle other types
// For now, we'll just return an error
return nil, 0, false, ErrInvalidValue
}
}
return valueBytes, ttl, true, nil
}
// Delete removes a key from the cache
func (m *MemoryBackend) Delete(ctx context.Context, key string) (bool, error) {
// Check if key exists first
exists, err := m.MemoryCacheBackend.Exists(ctx, key)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
err = m.MemoryCacheBackend.Delete(ctx, key)
if err != nil {
return false, err
}
return true, nil
}
// Exists checks if a key exists in the cache
func (m *MemoryBackend) Exists(ctx context.Context, key string) (bool, error) {
return m.MemoryCacheBackend.Exists(ctx, key)
}
// Clear removes all keys from the cache
func (m *MemoryBackend) Clear(ctx context.Context) error {
return m.MemoryCacheBackend.Clear(ctx)
}
// GetStats returns cache statistics
func (m *MemoryBackend) GetStats() map[string]interface{} {
stats, err := m.MemoryCacheBackend.GetStats(context.Background())
if err != nil {
return map[string]interface{}{
"error": err.Error(),
}
}
// Convert BackendStats to map
hitRate := float64(0)
total := stats.Hits + stats.Misses
if total > 0 {
hitRate = float64(stats.Hits) / float64(total)
}
return map[string]interface{}{
"type": stats.Type,
"hits": stats.Hits,
"misses": stats.Misses,
"sets": stats.Sets,
"deletes": stats.Deletes,
"errors": stats.Errors,
"evictions": stats.Evictions,
"size": stats.CurrentSize,
"max_size": stats.MaxSize,
"memory": stats.MemoryUsage,
"hit_rate": hitRate,
"uptime": stats.Uptime,
"start_time": stats.StartTime,
}
}
// Close shuts down the cache backend and releases resources
func (m *MemoryBackend) Close() error {
return m.MemoryCacheBackend.Close()
}
// Ping checks if the backend is healthy and responsive
func (m *MemoryBackend) Ping(ctx context.Context) error {
return m.MemoryCacheBackend.Ping(ctx)
}
// Ensure MemoryBackend implements CacheBackend
var _ CacheBackend = (*MemoryBackend)(nil)
+277
View File
@@ -0,0 +1,277 @@
package backends
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
// RedisBackend implements a Redis-based cache backend
type RedisBackend struct {
client *redis.Client
config *Config
// Metrics
hits int64
misses int64
// Lifecycle
closed atomic.Bool
}
// NewRedisBackend creates a new Redis cache backend
func NewRedisBackend(config *Config) (*RedisBackend, error) {
if config == nil {
return nil, fmt.Errorf("config is required")
}
if config.RedisAddr == "" {
return nil, fmt.Errorf("redis address is required")
}
client := redis.NewClient(&redis.Options{
Addr: config.RedisAddr,
Password: config.RedisPassword,
DB: config.RedisDB,
PoolSize: config.PoolSize,
})
// Test connection
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
client.Close()
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
}
backend := &RedisBackend{
client: client,
config: config,
}
return backend, nil
}
// Set stores a value with TTL
func (r *RedisBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
if r.closed.Load() {
return ErrBackendClosed
}
prefixedKey := r.prefixKey(key)
return r.client.Set(ctx, prefixedKey, value, ttl).Err()
}
// Get retrieves a value
func (r *RedisBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
if r.closed.Load() {
return nil, 0, false, ErrBackendClosed
}
prefixedKey := r.prefixKey(key)
// Get value
value, err := r.client.Get(ctx, prefixedKey).Bytes()
if err == redis.Nil {
atomic.AddInt64(&r.misses, 1)
return nil, 0, false, nil
}
if err != nil {
atomic.AddInt64(&r.misses, 1)
return nil, 0, false, fmt.Errorf("failed to get key: %w", err)
}
// Get TTL
ttl, err := r.client.TTL(ctx, prefixedKey).Result()
if err != nil {
// Value exists but couldn't get TTL
atomic.AddInt64(&r.hits, 1)
return value, 0, true, nil
}
atomic.AddInt64(&r.hits, 1)
return value, ttl, true, nil
}
// Delete removes a key
func (r *RedisBackend) Delete(ctx context.Context, key string) (bool, error) {
if r.closed.Load() {
return false, ErrBackendClosed
}
prefixedKey := r.prefixKey(key)
result, err := r.client.Del(ctx, prefixedKey).Result()
if err != nil {
return false, fmt.Errorf("failed to delete key: %w", err)
}
return result > 0, nil
}
// Exists checks if a key exists
func (r *RedisBackend) Exists(ctx context.Context, key string) (bool, error) {
if r.closed.Load() {
return false, ErrBackendClosed
}
prefixedKey := r.prefixKey(key)
result, err := r.client.Exists(ctx, prefixedKey).Result()
if err != nil {
return false, fmt.Errorf("failed to check existence: %w", err)
}
return result > 0, nil
}
// Clear removes all keys with the prefix
func (r *RedisBackend) Clear(ctx context.Context) error {
if r.closed.Load() {
return ErrBackendClosed
}
// Use SCAN to find all keys with prefix
pattern := r.config.RedisPrefix + "*"
iter := r.client.Scan(ctx, 0, pattern, 0).Iterator()
var keys []string
for iter.Next(ctx) {
keys = append(keys, iter.Val())
}
if err := iter.Err(); err != nil {
return fmt.Errorf("failed to scan keys: %w", err)
}
if len(keys) == 0 {
return nil
}
// Delete in batches to avoid blocking Redis
batchSize := 100
for i := 0; i < len(keys); i += batchSize {
end := i + batchSize
if end > len(keys) {
end = len(keys)
}
if err := r.client.Del(ctx, keys[i:end]...).Err(); err != nil {
return fmt.Errorf("failed to delete keys: %w", err)
}
}
return nil
}
// GetStats returns statistics
func (r *RedisBackend) GetStats() map[string]interface{} {
hits := atomic.LoadInt64(&r.hits)
misses := atomic.LoadInt64(&r.misses)
total := hits + misses
hitRate := 0.0
if total > 0 {
hitRate = float64(hits) / float64(total)
}
stats := map[string]interface{}{
"type": "redis",
"hits": hits,
"misses": misses,
"hit_rate": hitRate,
}
// Try to get Redis info (non-critical)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
if info, err := r.client.Info(ctx, "memory").Result(); err == nil {
stats["redis_info"] = info
}
return stats
}
// Ping checks Redis health
func (r *RedisBackend) Ping(ctx context.Context) error {
if r.closed.Load() {
return ErrBackendClosed
}
return r.client.Ping(ctx).Err()
}
// Close shuts down the backend
func (r *RedisBackend) Close() error {
if !r.closed.CompareAndSwap(false, true) {
return nil // Already closed
}
return r.client.Close()
}
// prefixKey adds the configured prefix to a key
func (r *RedisBackend) prefixKey(key string) string {
if r.config.RedisPrefix == "" {
return key
}
return r.config.RedisPrefix + key
}
// Pipeline operations for batch operations (future enhancement)
// SetMany stores multiple key-value pairs in a pipeline
func (r *RedisBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
if r.closed.Load() {
return ErrBackendClosed
}
pipe := r.client.Pipeline()
for key, value := range items {
prefixedKey := r.prefixKey(key)
pipe.Set(ctx, prefixedKey, value, ttl)
}
_, err := pipe.Exec(ctx)
return err
}
// GetMany retrieves multiple values in a pipeline
func (r *RedisBackend) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
if r.closed.Load() {
return nil, ErrBackendClosed
}
if len(keys) == 0 {
return make(map[string][]byte), nil
}
pipe := r.client.Pipeline()
cmds := make(map[string]*redis.StringCmd, len(keys))
for _, key := range keys {
prefixedKey := r.prefixKey(key)
cmds[key] = pipe.Get(ctx, prefixedKey)
}
_, err := pipe.Exec(ctx)
if err != nil && err != redis.Nil {
return nil, err
}
results := make(map[string][]byte)
for key, cmd := range cmds {
value, err := cmd.Bytes()
if err == nil {
results[key] = value
atomic.AddInt64(&r.hits, 1)
} else if err != redis.Nil {
atomic.AddInt64(&r.misses, 1)
}
}
return results, nil
}
+545
View File
@@ -0,0 +1,545 @@
package backends
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestRedisBackend_BasicOperations tests basic Redis operations
func TestRedisBackend_BasicOperations(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("SetAndGet", func(t *testing.T) {
key := "redis-test-key"
value := []byte("redis-test-value")
ttl := 1 * time.Minute
err := backend.Set(ctx, key, value, ttl)
require.NoError(t, err)
retrieved, remainingTTL, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value, retrieved)
assert.Greater(t, remainingTTL, 50*time.Second)
})
t.Run("GetNonExistent", func(t *testing.T) {
_, _, exists, err := backend.Get(ctx, "non-existent-redis-key")
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("Delete", func(t *testing.T) {
key := "redis-delete-key"
value := []byte("redis-delete-value")
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
deleted, err := backend.Delete(ctx, key)
require.NoError(t, err)
assert.True(t, deleted)
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("Exists", func(t *testing.T) {
key := "redis-exists-key"
value := []byte("redis-exists-value")
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
err = backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
exists, err = backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
})
}
// TestRedisBackend_KeyPrefixing tests key namespace prefixing
func TestRedisBackend_KeyPrefixing(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
config.RedisPrefix = "test:prefix:"
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "my-key"
value := []byte("my-value")
err = backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
// Check that key is stored with prefix
keys := mr.CheckKeys()
require.Len(t, keys, 1)
assert.Equal(t, "test:prefix:my-key", keys[0])
// Get should work without prefix
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value, retrieved)
}
// TestRedisBackend_TTLExpiration tests TTL handling
func TestRedisBackend_TTLExpiration(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("ShortTTL", func(t *testing.T) {
key := "ttl-key"
value := []byte("ttl-value")
shortTTL := 100 * time.Millisecond
err := backend.Set(ctx, key, value, shortTTL)
require.NoError(t, err)
// Exists immediately
exists, err := backend.Exists(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Fast forward time in miniredis
mr.FastForward(150 * time.Millisecond)
// Should be expired
exists, err = backend.Exists(ctx, key)
require.NoError(t, err)
assert.False(t, exists)
})
t.Run("TTLRemaining", func(t *testing.T) {
key := "ttl-remaining-key"
value := []byte("ttl-remaining-value")
ttl := 10 * time.Second
err := backend.Set(ctx, key, value, ttl)
require.NoError(t, err)
// Get immediately
_, ttl1, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
// Fast forward 2 seconds
mr.FastForward(2 * time.Second)
// Check TTL is less
_, ttl2, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Less(t, ttl2, ttl1)
})
}
// TestRedisBackend_Clear tests clearing all keys
func TestRedisBackend_Clear(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
config.RedisPrefix = "clear-test:"
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Add multiple keys
for i := 0; i < 10; i++ {
key := fmt.Sprintf("clear-key-%d", i)
value := []byte(fmt.Sprintf("clear-value-%d", i))
err := backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
}
// Verify keys exist
keys := mr.CheckKeys()
assert.Len(t, keys, 10)
// Clear all
err = backend.Clear(ctx)
require.NoError(t, err)
// Verify all keys are gone
keys = mr.CheckKeys()
assert.Len(t, keys, 0)
}
// TestRedisBackend_ConnectionFailure tests behavior on connection failure
func TestRedisBackend_ConnectionFailure(t *testing.T) {
t.Parallel()
// Try to connect to non-existent Redis
config := DefaultRedisConfig("localhost:9999")
_, err := NewRedisBackend(config)
assert.Error(t, err, "Should fail to connect to non-existent Redis")
}
// TestRedisBackend_RedisErrors tests handling of Redis errors
func TestRedisBackend_RedisErrors(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Simulate Redis error
mr.SetError("simulated error")
// Operations should fail
err = backend.Set(ctx, "error-key", []byte("error-value"), 1*time.Minute)
assert.Error(t, err)
// Clear error
mr.ClearError()
// Operations should work again
err = backend.Set(ctx, "success-key", []byte("success-value"), 1*time.Minute)
assert.NoError(t, err)
}
// TestRedisBackend_ConcurrentAccess tests thread safety
func TestRedisBackend_ConcurrentAccess(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
var wg sync.WaitGroup
goroutines := 20
iterations := 50
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
key := fmt.Sprintf("concurrent-key-%d-%d", id, j)
value := []byte(fmt.Sprintf("concurrent-value-%d-%d", id, j))
err := backend.Set(ctx, key, value, 1*time.Minute)
assert.NoError(t, err)
retrieved, _, exists, err := backend.Get(ctx, key)
assert.NoError(t, err)
if exists {
assert.Equal(t, value, retrieved)
}
if j%5 == 0 {
backend.Delete(ctx, key)
}
}
}(i)
}
wg.Wait()
stats := backend.GetStats()
hits := stats["hits"].(int64)
misses := stats["misses"].(int64)
assert.Greater(t, hits+misses, int64(0))
}
// TestRedisBackend_Stats tests statistics tracking
func TestRedisBackend_Stats(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
// Initial stats
stats := backend.GetStats()
assert.Equal(t, int64(0), stats["hits"].(int64))
assert.Equal(t, int64(0), stats["misses"].(int64))
// Add and access items
backend.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
backend.Get(ctx, "key1") // Hit
backend.Get(ctx, "non-existent") // Miss
stats = backend.GetStats()
assert.Equal(t, int64(1), stats["hits"].(int64))
assert.Equal(t, int64(1), stats["misses"].(int64))
hitRate := stats["hit_rate"].(float64)
assert.InDelta(t, 0.5, hitRate, 0.01)
}
// TestRedisBackend_Ping tests health check
func TestRedisBackend_Ping(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
err = backend.Ping(ctx)
assert.NoError(t, err)
// Close and ping should fail
backend.Close()
err = backend.Ping(ctx)
assert.Error(t, err)
}
// TestRedisBackend_Close tests proper cleanup
func TestRedisBackend_Close(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
ctx := context.Background()
// Add items
for i := 0; i < 10; i++ {
key := fmt.Sprintf("close-key-%d", i)
value := []byte(fmt.Sprintf("close-value-%d", i))
backend.Set(ctx, key, value, 1*time.Minute)
}
// Close
err = backend.Close()
require.NoError(t, err)
// Operations should fail
err = backend.Set(ctx, "after-close", []byte("value"), 1*time.Minute)
assert.Error(t, err)
assert.Equal(t, ErrBackendClosed, err)
// Double close should be safe
err = backend.Close()
assert.NoError(t, err)
}
// TestRedisBackend_UpdateExisting tests updating existing keys
func TestRedisBackend_UpdateExisting(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "update-key"
value1 := []byte("original-value")
value2 := []byte("updated-value")
// Set original
err = backend.Set(ctx, key, value1, 1*time.Minute)
require.NoError(t, err)
// Update
err = backend.Set(ctx, key, value2, 2*time.Minute)
require.NoError(t, err)
// Verify updated
retrieved, ttl, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value2, retrieved)
assert.Greater(t, ttl, 1*time.Minute)
}
// TestRedisBackend_LargeValues tests handling of large values
func TestRedisBackend_LargeValues(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "large-key"
largeValue := make([]byte, 1024*1024) // 1MB
err = backend.Set(ctx, key, largeValue, 1*time.Minute)
require.NoError(t, err)
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, len(largeValue), len(retrieved))
}
// TestRedisBackend_EmptyValues tests handling of empty values
func TestRedisBackend_EmptyValues(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "empty-key"
emptyValue := []byte{}
err = backend.Set(ctx, key, emptyValue, 1*time.Minute)
require.NoError(t, err)
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, 0, len(retrieved))
}
// TestRedisBackend_PipelineOperations tests batch operations
func TestRedisBackend_PipelineOperations(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
t.Run("SetMany", func(t *testing.T) {
items := make(map[string][]byte)
for i := 0; i < 10; i++ {
key := fmt.Sprintf("batch-key-%d", i)
value := []byte(fmt.Sprintf("batch-value-%d", i))
items[key] = value
}
err := backend.SetMany(ctx, items, 1*time.Minute)
require.NoError(t, err)
// Verify all items were set
for key, expectedValue := range items {
retrieved, _, exists, err := backend.Get(ctx, key)
require.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, expectedValue, retrieved)
}
})
t.Run("GetMany", func(t *testing.T) {
// Set test data
testData := GenerateTestData(5)
for key, value := range testData {
backend.Set(ctx, key, value, 1*time.Minute)
}
// Get all keys
keys := make([]string, 0, len(testData))
for key := range testData {
keys = append(keys, key)
}
results, err := backend.GetMany(ctx, keys)
require.NoError(t, err)
assert.Len(t, results, len(testData))
for key, expectedValue := range testData {
retrievedValue, exists := results[key]
assert.True(t, exists)
assert.Equal(t, expectedValue, retrievedValue)
}
})
t.Run("GetManyWithNonExistent", func(t *testing.T) {
keys := []string{"exists-1", "non-existent", "exists-2"}
backend.Set(ctx, "exists-1", []byte("value-1"), 1*time.Minute)
backend.Set(ctx, "exists-2", []byte("value-2"), 1*time.Minute)
results, err := backend.GetMany(ctx, keys)
require.NoError(t, err)
assert.Len(t, results, 2) // Only existing keys
assert.Equal(t, []byte("value-1"), results["exists-1"])
assert.Equal(t, []byte("value-2"), results["exists-2"])
_, exists := results["non-existent"]
assert.False(t, exists)
})
}
// TestRedisBackend_NoPrefix tests operation without prefix
func TestRedisBackend_NoPrefix(t *testing.T) {
t.Parallel()
mr := NewMiniredisServer(t)
config := DefaultRedisConfig(mr.GetAddr())
config.RedisPrefix = "" // No prefix
backend, err := NewRedisBackend(config)
require.NoError(t, err)
defer backend.Close()
ctx := context.Background()
key := "no-prefix-key"
value := []byte("no-prefix-value")
err = backend.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
// Check key is stored without prefix
keys := mr.CheckKeys()
require.Len(t, keys, 1)
assert.Equal(t, key, keys[0])
}
+184
View File
@@ -0,0 +1,184 @@
package backends
import (
"context"
"fmt"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
// TestLogger implements a simple logger for tests
type TestLogger struct {
t *testing.T
}
func NewTestLogger(t *testing.T) *TestLogger {
return &TestLogger{t: t}
}
func (l *TestLogger) Debug(format string, args ...interface{}) {
l.t.Logf("[DEBUG] "+format, args...)
}
func (l *TestLogger) Info(format string, args ...interface{}) {
l.t.Logf("[INFO] "+format, args...)
}
func (l *TestLogger) Error(format string, args ...interface{}) {
l.t.Logf("[ERROR] "+format, args...)
}
func (l *TestLogger) Debugf(format string, args ...interface{}) {
l.Debug(format, args...)
}
func (l *TestLogger) Infof(format string, args ...interface{}) {
l.Info(format, args...)
}
func (l *TestLogger) Errorf(format string, args ...interface{}) {
l.Error(format, args...)
}
// MiniredisServer manages a miniredis instance for testing
type MiniredisServer struct {
server *miniredis.Miniredis
client *redis.Client
}
// NewMiniredisServer creates a new miniredis server for testing
func NewMiniredisServer(t *testing.T) *MiniredisServer {
t.Helper()
mr, err := miniredis.Run()
require.NoError(t, err, "failed to start miniredis")
client := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
// Verify connection
ctx := context.Background()
err = client.Ping(ctx).Err()
require.NoError(t, err, "failed to ping miniredis")
t.Cleanup(func() {
client.Close()
mr.Close()
})
return &MiniredisServer{
server: mr,
client: client,
}
}
// GetAddr returns the address of the miniredis server
func (m *MiniredisServer) GetAddr() string {
return m.server.Addr()
}
// GetClient returns the Redis client
func (m *MiniredisServer) GetClient() *redis.Client {
return m.client
}
// FastForward advances the miniredis server's time
func (m *MiniredisServer) FastForward(d time.Duration) {
m.server.FastForward(d)
}
// FlushAll removes all keys from the database
func (m *MiniredisServer) FlushAll() {
m.server.FlushAll()
}
// SetError simulates a Redis error
func (m *MiniredisServer) SetError(err string) {
m.server.SetError(err)
}
// ClearError clears any simulated errors
func (m *MiniredisServer) ClearError() {
m.server.SetError("")
}
// CheckKeys verifies that specific keys exist in Redis
func (m *MiniredisServer) CheckKeys() []string {
return m.server.Keys()
}
// TestConfig provides default test configuration
type TestConfig struct {
MaxSize int
DefaultTTL time.Duration
CleanupInterval time.Duration
EnableMetrics bool
}
// DefaultTestConfig returns a standard test configuration
func DefaultTestConfig() *TestConfig {
return &TestConfig{
MaxSize: 100,
DefaultTTL: 5 * time.Minute,
CleanupInterval: 1 * time.Second,
EnableMetrics: true,
}
}
// GenerateTestData creates test cache data
func GenerateTestData(count int) map[string][]byte {
data := make(map[string][]byte, count)
for i := 0; i < count; i++ {
key := fmt.Sprintf("test-key-%d", i)
value := []byte(fmt.Sprintf("test-value-%d", i))
data[key] = value
}
return data
}
// GenerateLargeValue creates a large test value
func GenerateLargeValue(sizeBytes int) []byte {
return make([]byte, sizeBytes)
}
// AssertCacheStats is a helper to verify cache statistics
func AssertCacheStats(t *testing.T, stats map[string]interface{}, expectedHits, expectedMisses int64) {
t.Helper()
hits, ok := stats["hits"].(int64)
require.True(t, ok, "hits should be int64")
require.Equal(t, expectedHits, hits, "unexpected hit count")
misses, ok := stats["misses"].(int64)
require.True(t, ok, "misses should be int64")
require.Equal(t, expectedMisses, misses, "unexpected miss count")
}
// WaitForCondition waits for a condition to be true or times out
func WaitForCondition(t *testing.T, timeout time.Duration, checkInterval time.Duration, condition func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if condition() {
return
}
time.Sleep(checkInterval)
}
t.Fatal("timeout waiting for condition")
}
// AssertEventuallyExpires verifies that a key eventually expires
func AssertEventuallyExpires(t *testing.T, backend CacheBackend, ctx context.Context, key string, maxWait time.Duration) {
t.Helper()
WaitForCondition(t, maxWait, 100*time.Millisecond, func() bool {
_, _, exists, err := backend.Get(ctx, key)
return err == nil && !exists
})
}
+329
View File
@@ -0,0 +1,329 @@
// Package resilience provides resilience patterns for cache backends.
package resilience
import (
"context"
"errors"
"sync"
"sync/atomic"
"time"
)
// Common errors
var (
// ErrCircuitOpen is returned when the circuit breaker is open
ErrCircuitOpen = errors.New("circuit breaker is open")
// ErrTooManyRequests is returned when too many requests are made in half-open state
ErrTooManyRequests = errors.New("too many requests in half-open state")
)
// State represents the state of the circuit breaker
type State int32
const (
// StateClosed allows all operations to pass through
StateClosed State = iota
// StateOpen blocks all operations
StateOpen
// StateHalfOpen allows a limited number of operations to test recovery
StateHalfOpen
)
// String returns the string representation of the state
func (s State) String() string {
switch s {
case StateClosed:
return "closed"
case StateOpen:
return "open"
case StateHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// CircuitBreakerConfig holds configuration for the circuit breaker
type CircuitBreakerConfig struct {
// MaxFailures is the number of consecutive failures before opening the circuit
MaxFailures int
// FailureThreshold is the failure rate threshold (0.0 to 1.0)
FailureThreshold float64
// Timeout is how long the circuit stays open before trying half-open
Timeout time.Duration
// HalfOpenMaxRequests is the number of requests allowed in half-open state
HalfOpenMaxRequests int
// ResetTimeout is how long to wait before resetting counters in closed state
ResetTimeout time.Duration
// OnStateChange is called when the circuit breaker changes state
OnStateChange func(from, to State)
}
// DefaultCircuitBreakerConfig returns default configuration
func DefaultCircuitBreakerConfig() *CircuitBreakerConfig {
return &CircuitBreakerConfig{
MaxFailures: 5,
FailureThreshold: 0.6,
Timeout: 30 * time.Second,
HalfOpenMaxRequests: 3,
ResetTimeout: 60 * time.Second,
}
}
// CircuitBreaker implements the circuit breaker pattern
type CircuitBreaker struct {
config *CircuitBreakerConfig
// State management
state atomic.Int32
lastStateChange time.Time
stateMu sync.RWMutex
// Failure tracking
consecutiveFailures atomic.Int32
totalRequests atomic.Int64
totalFailures atomic.Int64
halfOpenRequests atomic.Int32
// Timing
lastFailureTime time.Time
lastSuccessTime time.Time
nextRetryTime time.Time
timeMu sync.RWMutex
// Metrics
stateTransitions atomic.Int64
rejectedRequests atomic.Int64
}
// NewCircuitBreaker creates a new circuit breaker
func NewCircuitBreaker(config *CircuitBreakerConfig) *CircuitBreaker {
if config == nil {
config = DefaultCircuitBreakerConfig()
}
return &CircuitBreaker{
config: config,
lastStateChange: time.Now(),
}
}
// Execute runs a function through the circuit breaker
func (cb *CircuitBreaker) Execute(ctx context.Context, fn func() error) error {
if !cb.AllowRequest() {
cb.rejectedRequests.Add(1)
return ErrCircuitOpen
}
cb.totalRequests.Add(1)
err := fn()
if err != nil {
cb.RecordFailure()
} else {
cb.RecordSuccess()
}
return err
}
// AllowRequest checks if a request is allowed to proceed
func (cb *CircuitBreaker) AllowRequest() bool {
state := cb.GetState()
switch state {
case StateClosed:
return true
case StateOpen:
// Check if timeout has passed and we should try half-open
cb.timeMu.RLock()
shouldRetry := time.Now().After(cb.nextRetryTime)
cb.timeMu.RUnlock()
if shouldRetry {
cb.setState(StateHalfOpen)
return true
}
return false
case StateHalfOpen:
// Allow limited requests in half-open state
current := cb.halfOpenRequests.Add(1)
return current <= int32(cb.config.HalfOpenMaxRequests)
default:
return false
}
}
// RecordSuccess records a successful operation
func (cb *CircuitBreaker) RecordSuccess() {
cb.timeMu.Lock()
cb.lastSuccessTime = time.Now()
cb.timeMu.Unlock()
state := cb.GetState()
switch state {
case StateClosed:
// Reset consecutive failures
cb.consecutiveFailures.Store(0)
case StateHalfOpen:
// If we've had enough successful requests, close the circuit
successfulRequests := cb.halfOpenRequests.Load()
if successfulRequests >= int32(cb.config.HalfOpenMaxRequests) {
cb.setState(StateClosed)
cb.consecutiveFailures.Store(0)
cb.halfOpenRequests.Store(0)
}
}
}
// RecordFailure records a failed operation
func (cb *CircuitBreaker) RecordFailure() {
cb.totalFailures.Add(1)
failures := cb.consecutiveFailures.Add(1)
cb.timeMu.Lock()
cb.lastFailureTime = time.Now()
cb.timeMu.Unlock()
state := cb.GetState()
switch state {
case StateClosed:
// Check if we should open the circuit
if failures >= int32(cb.config.MaxFailures) {
cb.openCircuit()
} else if cb.config.FailureThreshold > 0 {
// Check failure rate
total := cb.totalRequests.Load()
failureCount := cb.totalFailures.Load()
if total > 10 && float64(failureCount)/float64(total) > cb.config.FailureThreshold {
cb.openCircuit()
}
}
case StateHalfOpen:
// Any failure in half-open state reopens the circuit
cb.openCircuit()
}
}
// openCircuit transitions to open state
func (cb *CircuitBreaker) openCircuit() {
cb.setState(StateOpen)
cb.halfOpenRequests.Store(0)
cb.timeMu.Lock()
cb.nextRetryTime = time.Now().Add(cb.config.Timeout)
cb.timeMu.Unlock()
}
// GetState returns the current state
func (cb *CircuitBreaker) GetState() State {
return State(cb.state.Load())
}
// setState changes the circuit breaker state
func (cb *CircuitBreaker) setState(newState State) {
oldState := State(cb.state.Swap(int32(newState)))
if oldState != newState {
cb.stateTransitions.Add(1)
cb.stateMu.Lock()
cb.lastStateChange = time.Now()
cb.stateMu.Unlock()
if cb.config.OnStateChange != nil {
cb.config.OnStateChange(oldState, newState)
}
}
}
// Reset resets the circuit breaker to closed state
func (cb *CircuitBreaker) Reset() {
cb.setState(StateClosed)
cb.consecutiveFailures.Store(0)
cb.totalRequests.Store(0)
cb.totalFailures.Store(0)
cb.halfOpenRequests.Store(0)
cb.rejectedRequests.Store(0)
cb.stateTransitions.Store(0)
now := time.Now()
cb.timeMu.Lock()
cb.lastFailureTime = now
cb.lastSuccessTime = now
cb.nextRetryTime = now
cb.timeMu.Unlock()
cb.stateMu.Lock()
cb.lastStateChange = now
cb.stateMu.Unlock()
}
// Stats returns circuit breaker statistics
func (cb *CircuitBreaker) Stats() CircuitBreakerStats {
cb.timeMu.RLock()
lastFailure := cb.lastFailureTime
lastSuccess := cb.lastSuccessTime
nextRetry := cb.nextRetryTime
cb.timeMu.RUnlock()
cb.stateMu.RLock()
lastChange := cb.lastStateChange
cb.stateMu.RUnlock()
totalReq := cb.totalRequests.Load()
totalFail := cb.totalFailures.Load()
successRate := float64(0)
if totalReq > 0 {
successRate = float64(totalReq-totalFail) / float64(totalReq)
}
return CircuitBreakerStats{
State: cb.GetState(),
ConsecutiveFailures: cb.consecutiveFailures.Load(),
TotalRequests: totalReq,
TotalFailures: totalFail,
SuccessRate: successRate,
RejectedRequests: cb.rejectedRequests.Load(),
StateTransitions: cb.stateTransitions.Load(),
LastFailureTime: lastFailure,
LastSuccessTime: lastSuccess,
LastStateChange: lastChange,
NextRetryTime: nextRetry,
}
}
// CircuitBreakerStats holds statistics for the circuit breaker
type CircuitBreakerStats struct {
State State
ConsecutiveFailures int32
TotalRequests int64
TotalFailures int64
SuccessRate float64
RejectedRequests int64
StateTransitions int64
LastFailureTime time.Time
LastSuccessTime time.Time
LastStateChange time.Time
NextRetryTime time.Time
}
// IsHealthy returns true if the circuit breaker is in a healthy state
func (cb *CircuitBreaker) IsHealthy() bool {
return cb.GetState() != StateOpen
}
+141
View File
@@ -0,0 +1,141 @@
// Package resilience provides resilience patterns for cache backends.
package resilience
import (
"context"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
)
// CircuitBreakerBackend wraps a cache backend with circuit breaker protection
type CircuitBreakerBackend struct {
backend backends.CacheBackend
cb *CircuitBreaker
}
// NewCircuitBreakerBackend creates a new circuit breaker wrapped backend
func NewCircuitBreakerBackend(b backends.CacheBackend, config *CircuitBreakerConfig) backends.CacheBackend {
if config == nil {
config = DefaultCircuitBreakerConfig()
}
return &CircuitBreakerBackend{
backend: b,
cb: NewCircuitBreaker(config),
}
}
// Set stores a value with circuit breaker protection
func (c *CircuitBreakerBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
if !c.cb.AllowRequest() {
return backends.ErrCircuitOpen
}
err := c.backend.Set(ctx, key, value, ttl)
if err == nil {
c.cb.RecordSuccess()
} else {
c.cb.RecordFailure()
}
return err
}
// Get retrieves a value with circuit breaker protection
func (c *CircuitBreakerBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
if !c.cb.AllowRequest() {
return nil, 0, false, backends.ErrCircuitOpen
}
value, ttl, exists, err := c.backend.Get(ctx, key)
if err == nil {
c.cb.RecordSuccess()
} else {
c.cb.RecordFailure()
}
return value, ttl, exists, err
}
// Delete removes a key with circuit breaker protection
func (c *CircuitBreakerBackend) Delete(ctx context.Context, key string) (bool, error) {
if !c.cb.AllowRequest() {
return false, backends.ErrCircuitOpen
}
deleted, err := c.backend.Delete(ctx, key)
if err == nil {
c.cb.RecordSuccess()
} else {
c.cb.RecordFailure()
}
return deleted, err
}
// Exists checks if a key exists with circuit breaker protection
func (c *CircuitBreakerBackend) Exists(ctx context.Context, key string) (bool, error) {
if !c.cb.AllowRequest() {
return false, backends.ErrCircuitOpen
}
exists, err := c.backend.Exists(ctx, key)
if err == nil {
c.cb.RecordSuccess()
} else {
c.cb.RecordFailure()
}
return exists, err
}
// Clear removes all keys with circuit breaker protection
func (c *CircuitBreakerBackend) Clear(ctx context.Context) error {
if !c.cb.AllowRequest() {
return backends.ErrCircuitOpen
}
err := c.backend.Clear(ctx)
if err == nil {
c.cb.RecordSuccess()
} else {
c.cb.RecordFailure()
}
return err
}
// GetStats returns statistics including circuit breaker state
func (c *CircuitBreakerBackend) GetStats() map[string]interface{} {
stats := c.backend.GetStats()
if stats == nil {
stats = make(map[string]interface{})
}
cbStats := c.cb.Stats()
stats["circuit_breaker"] = map[string]interface{}{
"state": cbStats.State.String(),
"consecutive_failures": cbStats.ConsecutiveFailures,
"total_requests": cbStats.TotalRequests,
"total_failures": cbStats.TotalFailures,
"success_rate": cbStats.SuccessRate,
}
return stats
}
// Ping checks backend health with circuit breaker protection
func (c *CircuitBreakerBackend) Ping(ctx context.Context) error {
if !c.cb.AllowRequest() {
return backends.ErrCircuitOpen
}
err := c.backend.Ping(ctx)
if err == nil {
c.cb.RecordSuccess()
} else {
c.cb.RecordFailure()
}
return err
}
// Close shuts down the backend
func (c *CircuitBreakerBackend) Close() error {
return c.backend.Close()
}
+553
View File
@@ -0,0 +1,553 @@
package resilience
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestCircuitBreaker_StateTransitions tests state machine transitions
func TestCircuitBreaker_StateTransitions(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 3,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 2,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
t.Run("Initial state is closed", func(t *testing.T) {
assert.Equal(t, StateClosed, cb.GetState())
})
t.Run("Closed to Open after max failures", func(t *testing.T) {
cb.Reset()
// Simulate failures
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
})
t.Run("Open to HalfOpen after timeout", func(t *testing.T) {
// Open the circuit
cb.Reset()
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
// Wait for timeout
time.Sleep(150 * time.Millisecond)
// Should allow request and transition to half-open
err := cb.Execute(ctx, func() error {
return nil
})
assert.NoError(t, err)
assert.Equal(t, StateHalfOpen, cb.GetState())
})
t.Run("HalfOpen to Closed after successful requests", func(t *testing.T) {
// Open circuit then wait for half-open
cb.Reset()
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
time.Sleep(150 * time.Millisecond)
// First request transitions to half-open and succeeds
err := cb.Execute(ctx, func() error {
return nil
})
assert.NoError(t, err)
// Should be in half-open after first request
state := cb.GetState()
assert.True(t, state == StateHalfOpen || state == StateClosed,
"After first successful request, should be half-open or potentially closed")
if state == StateHalfOpen {
// Need more successful requests to close
// The exact number depends on implementation but should be within HalfOpenMaxRequests
for i := 0; i < config.HalfOpenMaxRequests; i++ {
cb.Execute(ctx, func() error {
return nil
})
}
// After multiple successful requests, should eventually close
finalState := cb.GetState()
assert.True(t, finalState == StateClosed || finalState == StateHalfOpen,
"After successful requests, circuit should transition towards closed")
}
})
t.Run("HalfOpen to Open on failure", func(t *testing.T) {
// Open circuit then wait for half-open
cb.Reset()
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
time.Sleep(150 * time.Millisecond)
// First call transitions to half-open, second failure reopens
cb.Execute(ctx, func() error {
return errors.New("test error")
})
assert.Equal(t, StateOpen, cb.GetState())
})
}
// TestCircuitBreaker_OpenCircuitBlocks tests that open circuit blocks requests
func TestCircuitBreaker_OpenCircuitBlocks(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 1 * time.Second,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Trigger failures to open circuit
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
// Requests should be blocked
err := cb.Execute(ctx, func() error {
t.Fatal("Should not execute function when circuit is open")
return nil
})
assert.Error(t, err)
assert.Equal(t, ErrCircuitOpen, err)
}
// TestCircuitBreaker_HalfOpenMaxRequests tests max requests in half-open state
func TestCircuitBreaker_HalfOpenMaxRequests(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 3,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 2,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Open circuit then wait for half-open
for i := 0; i < 3; i++ {
cb.Execute(ctx, func() error {
return errors.New("test error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
time.Sleep(150 * time.Millisecond)
// After timeout, circuit should allow transition to half-open
// Execute HalfOpenMaxRequests successful requests
successCount := 0
for i := 0; i < config.HalfOpenMaxRequests; i++ {
err := cb.Execute(ctx, func() error {
successCount++
return nil
})
// Should allow up to HalfOpenMaxRequests
assert.NoError(t, err)
}
// Verify we executed the expected number
assert.Equal(t, config.HalfOpenMaxRequests, successCount)
// After successful requests, circuit behavior depends on implementation
// It could close (allowing more requests) or stay half-open (blocking)
// The important thing is that we allowed exactly HalfOpenMaxRequests
}
// TestCircuitBreaker_SuccessResetsFailures tests failure counter reset
func TestCircuitBreaker_SuccessResetsFailures(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 3,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Have some failures (but less than max)
cb.Execute(ctx, func() error {
return errors.New("error")
})
cb.Execute(ctx, func() error {
return errors.New("error")
})
assert.Equal(t, StateClosed, cb.GetState())
stats := cb.Stats()
assert.Equal(t, int32(2), stats.ConsecutiveFailures)
// One success should reset failures
cb.Execute(ctx, func() error {
return nil
})
assert.Equal(t, StateClosed, cb.GetState())
stats = cb.Stats()
assert.Equal(t, int32(0), stats.ConsecutiveFailures)
}
// TestCircuitBreaker_ConcurrentAccess tests thread safety
func TestCircuitBreaker_ConcurrentAccess(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 10,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 5,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
var wg sync.WaitGroup
goroutines := 20
iterations := 50
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
// Mix of successes and failures
cb.Execute(ctx, func() error {
if (id+j)%3 == 0 {
return errors.New("test error")
}
return nil
})
// Random state checks
_ = cb.GetState()
_ = cb.Stats()
}
}(i)
}
wg.Wait()
// Should complete without panics
stats := cb.Stats()
assert.NotNil(t, stats)
}
// TestCircuitBreaker_Stats tests statistics tracking
func TestCircuitBreaker_Stats(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 5,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 2,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Execute some requests
cb.Execute(ctx, func() error { return nil }) // Success
cb.Execute(ctx, func() error { return errors.New("error") }) // Failure
cb.Execute(ctx, func() error { return errors.New("error") }) // Failure
stats := cb.Stats()
assert.Equal(t, StateClosed, stats.State)
assert.Equal(t, int64(3), stats.TotalRequests)
assert.Equal(t, int64(2), stats.TotalFailures)
assert.Equal(t, int32(2), stats.ConsecutiveFailures)
}
// TestCircuitBreaker_Reset tests circuit reset
func TestCircuitBreaker_Reset(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Open the circuit
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error {
return errors.New("error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
// Reset
cb.Reset()
assert.Equal(t, StateClosed, cb.GetState())
stats := cb.Stats()
assert.Equal(t, int32(0), stats.ConsecutiveFailures)
assert.Equal(t, int64(0), stats.TotalRequests)
assert.Equal(t, int64(0), stats.TotalFailures)
}
// TestCircuitBreaker_StateChangeCallback tests state change notifications
func TestCircuitBreaker_StateChangeCallback(t *testing.T) {
t.Parallel()
var transitions []string
var mu sync.Mutex
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 50 * time.Millisecond,
HalfOpenMaxRequests: 1,
OnStateChange: func(from, to State) {
mu.Lock()
defer mu.Unlock()
transitions = append(transitions, from.String()+"->"+to.String())
},
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Trigger state transitions
// Closed -> Open
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error {
return errors.New("error")
})
}
// Should be open now
assert.Equal(t, StateOpen, cb.GetState())
// Wait for timeout to allow half-open transition
time.Sleep(100 * time.Millisecond)
// Open -> HalfOpen on first request after timeout
err := cb.Execute(ctx, func() error {
return nil
})
assert.NoError(t, err)
// Execute more successful requests to trigger HalfOpen -> Closed
for i := 0; i < config.HalfOpenMaxRequests-1; i++ {
cb.Execute(ctx, func() error {
return nil
})
}
mu.Lock()
defer mu.Unlock()
assert.Contains(t, transitions, "closed->open")
assert.Contains(t, transitions, "open->half-open")
}
// TestCircuitBreaker_IsHealthy tests health check
func TestCircuitBreaker_IsHealthy(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 2,
Timeout: 100 * time.Millisecond,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Initially healthy
assert.True(t, cb.IsHealthy())
// Open circuit
for i := 0; i < 2; i++ {
cb.Execute(ctx, func() error {
return errors.New("error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
assert.False(t, cb.IsHealthy(), "Should not be healthy when open")
// Wait for timeout and allow successful request
time.Sleep(150 * time.Millisecond)
cb.Execute(ctx, func() error {
return nil
})
// Should be healthy after recovery
assert.True(t, cb.IsHealthy(), "Should be healthy after recovery")
}
// TestCircuitBreaker_RapidFailures tests rapid consecutive failures
func TestCircuitBreaker_RapidFailures(t *testing.T) {
t.Parallel()
config := &CircuitBreakerConfig{
MaxFailures: 5,
Timeout: 200 * time.Millisecond,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Rapid failures
for i := 0; i < 10; i++ {
cb.Execute(ctx, func() error {
return errors.New("rapid error")
})
}
assert.Equal(t, StateOpen, cb.GetState())
stats := cb.Stats()
assert.GreaterOrEqual(t, stats.TotalFailures, int64(5))
}
// TestCircuitBreaker_TimeoutAccuracy tests timeout precision
func TestCircuitBreaker_TimeoutAccuracy(t *testing.T) {
t.Parallel()
timeout := 100 * time.Millisecond
config := &CircuitBreakerConfig{
MaxFailures: 1,
Timeout: timeout,
HalfOpenMaxRequests: 1,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
// Open circuit
cb.Execute(ctx, func() error {
return errors.New("error")
})
assert.Equal(t, StateOpen, cb.GetState())
// Wait just before timeout
time.Sleep(timeout - 20*time.Millisecond)
assert.False(t, cb.IsHealthy())
// Wait until after timeout
time.Sleep(40 * time.Millisecond)
// After timeout, AllowRequest should return true for transition to half-open
assert.True(t, cb.AllowRequest())
}
// TestCircuitBreaker_DefaultConfig tests default configuration
func TestCircuitBreaker_DefaultConfig(t *testing.T) {
t.Parallel()
cb := NewCircuitBreaker(nil) // Should use defaults
assert.NotNil(t, cb)
assert.Equal(t, StateClosed, cb.GetState())
// Verify defaults by triggering circuit breaker behavior
ctx := context.Background()
// Test that it takes 5 failures to open (default MaxFailures)
for i := 0; i < 4; i++ {
cb.Execute(ctx, func() error {
return errors.New("error")
})
}
assert.Equal(t, StateClosed, cb.GetState(), "Should still be closed after 4 failures")
// 5th failure should open it
cb.Execute(ctx, func() error {
return errors.New("error")
})
assert.Equal(t, StateOpen, cb.GetState(), "Should be open after 5 failures (default threshold)")
}
// TestCircuitBreaker_StateString tests state string representation
func TestCircuitBreaker_StateString(t *testing.T) {
t.Parallel()
assert.Equal(t, "closed", StateClosed.String())
assert.Equal(t, "open", StateOpen.String())
assert.Equal(t, "half-open", StateHalfOpen.String())
assert.Equal(t, "unknown", State(999).String())
}
// Benchmark circuit breaker performance
func BenchmarkCircuitBreaker_Execute(b *testing.B) {
config := &CircuitBreakerConfig{
MaxFailures: 100,
Timeout: 1 * time.Second,
HalfOpenMaxRequests: 10,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cb.Execute(ctx, func() error {
return nil
})
}
}
func BenchmarkCircuitBreaker_ExecuteWithFailures(b *testing.B) {
config := &CircuitBreakerConfig{
MaxFailures: 1000,
Timeout: 1 * time.Second,
HalfOpenMaxRequests: 10,
}
cb := NewCircuitBreaker(config)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
cb.Execute(ctx, func() error {
if i%10 == 0 {
return errors.New("error")
}
return nil
})
}
}
+375
View File
@@ -0,0 +1,375 @@
// Package resilience provides resilience patterns for cache backends.
package resilience
import (
"context"
"sync"
"sync/atomic"
"time"
)
// HealthStatus represents the health status of a backend
type HealthStatus int32
const (
// HealthUnknown indicates unknown health status
HealthUnknown HealthStatus = iota
// HealthHealthy indicates the backend is healthy
HealthHealthy
// HealthDegraded indicates the backend is degraded but operational
HealthDegraded
// HealthUnhealthy indicates the backend is unhealthy
HealthUnhealthy
)
// String returns the string representation of the health status
func (h HealthStatus) String() string {
switch h {
case HealthHealthy:
return "healthy"
case HealthDegraded:
return "degraded"
case HealthUnhealthy:
return "unhealthy"
default:
return "unknown"
}
}
// HealthCheckConfig holds configuration for the health checker
type HealthCheckConfig struct {
// CheckInterval is how often to check health
CheckInterval time.Duration
// Timeout is the timeout for each health check
Timeout time.Duration
// HealthyThreshold is the number of consecutive successes to become healthy
HealthyThreshold int
// UnhealthyThreshold is the number of consecutive failures to become unhealthy
UnhealthyThreshold int
// DegradedThreshold is the latency threshold in ms to mark as degraded
DegradedThreshold time.Duration
// OnStatusChange is called when health status changes
OnStatusChange func(from, to HealthStatus)
// CheckFunc is the function to check health
CheckFunc func(ctx context.Context) error
}
// DefaultHealthCheckConfig returns default configuration
func DefaultHealthCheckConfig() *HealthCheckConfig {
return &HealthCheckConfig{
CheckInterval: 30 * time.Second,
Timeout: 5 * time.Second,
HealthyThreshold: 3,
UnhealthyThreshold: 3,
DegradedThreshold: 100 * time.Millisecond,
}
}
// HealthChecker monitors the health of a backend
type HealthChecker struct {
config *HealthCheckConfig
// Status tracking
status atomic.Int32
consecutiveSuccesses atomic.Int32
consecutiveFailures atomic.Int32
// Timing
lastCheckTime time.Time
lastSuccessTime time.Time
lastFailureTime time.Time
averageLatency atomic.Int64
timeMu sync.RWMutex
// Metrics
totalChecks atomic.Int64
totalSuccesses atomic.Int64
totalFailures atomic.Int64
statusChanges atomic.Int64
// Lifecycle
ticker *time.Ticker
stopChan chan struct{}
stopped atomic.Bool
wg sync.WaitGroup
}
// NewHealthChecker creates a new health checker
func NewHealthChecker(config *HealthCheckConfig) *HealthChecker {
if config == nil {
config = DefaultHealthCheckConfig()
}
hc := &HealthChecker{
config: config,
stopChan: make(chan struct{}),
}
hc.status.Store(int32(HealthUnknown))
return hc
}
// Start begins health checking
func (hc *HealthChecker) Start() {
if hc.stopped.Load() {
return
}
hc.ticker = time.NewTicker(hc.config.CheckInterval)
hc.wg.Add(1)
go hc.checkLoop()
}
// Stop stops health checking
func (hc *HealthChecker) Stop() {
if hc.stopped.Swap(true) {
return // Already stopped
}
close(hc.stopChan)
if hc.ticker != nil {
hc.ticker.Stop()
}
hc.wg.Wait()
}
// checkLoop runs periodic health checks
func (hc *HealthChecker) checkLoop() {
defer hc.wg.Done()
// Initial check - log error but continue
if err := hc.Check(context.Background()); err != nil {
// Error is already tracked in Check() method, no need to log again
_ = err
}
for {
select {
case <-hc.stopChan:
return
case <-hc.ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), hc.config.Timeout)
if err := hc.Check(ctx); err != nil {
// Error is already tracked in Check() method, no need to log again
_ = err
}
cancel()
}
}
}
// Check performs a health check
func (hc *HealthChecker) Check(ctx context.Context) error {
if hc.config.CheckFunc == nil {
return nil
}
hc.totalChecks.Add(1)
start := time.Now()
// Create timeout context if not already set
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, hc.config.Timeout)
defer cancel()
}
// Perform health check
err := hc.config.CheckFunc(ctx)
latency := time.Since(start)
hc.timeMu.Lock()
hc.lastCheckTime = time.Now()
hc.timeMu.Unlock()
// Update average latency
hc.updateAverageLatency(latency)
if err != nil {
hc.recordFailure()
} else {
hc.recordSuccess(latency)
}
return err
}
// recordSuccess records a successful health check
func (hc *HealthChecker) recordSuccess(latency time.Duration) {
hc.totalSuccesses.Add(1)
successes := hc.consecutiveSuccesses.Add(1)
hc.consecutiveFailures.Store(0)
hc.timeMu.Lock()
hc.lastSuccessTime = time.Now()
hc.timeMu.Unlock()
currentStatus := hc.GetStatus()
newStatus := currentStatus
// Check if we should become healthy
if successes >= int32(hc.config.HealthyThreshold) {
if latency > hc.config.DegradedThreshold {
newStatus = HealthDegraded
} else {
newStatus = HealthHealthy
}
}
if newStatus != currentStatus {
hc.setStatus(newStatus)
}
}
// recordFailure records a failed health check
func (hc *HealthChecker) recordFailure() {
hc.totalFailures.Add(1)
failures := hc.consecutiveFailures.Add(1)
hc.consecutiveSuccesses.Store(0)
hc.timeMu.Lock()
hc.lastFailureTime = time.Now()
hc.timeMu.Unlock()
// Check if we should become unhealthy
if failures >= int32(hc.config.UnhealthyThreshold) {
hc.setStatus(HealthUnhealthy)
}
}
// updateAverageLatency updates the rolling average latency
func (hc *HealthChecker) updateAverageLatency(latency time.Duration) {
// Simple exponential moving average
currentAvg := time.Duration(hc.averageLatency.Load())
if currentAvg == 0 {
hc.averageLatency.Store(int64(latency))
} else {
// Weight: 0.2 for new value, 0.8 for old average
newAvg := (currentAvg*4 + latency) / 5
hc.averageLatency.Store(int64(newAvg))
}
}
// GetStatus returns the current health status
func (hc *HealthChecker) GetStatus() HealthStatus {
return HealthStatus(hc.status.Load())
}
// setStatus changes the health status
func (hc *HealthChecker) setStatus(newStatus HealthStatus) {
oldStatus := HealthStatus(hc.status.Swap(int32(newStatus)))
if oldStatus != newStatus {
hc.statusChanges.Add(1)
if hc.config.OnStatusChange != nil {
hc.config.OnStatusChange(oldStatus, newStatus)
}
}
}
// IsHealthy returns true if the backend is healthy or degraded
func (hc *HealthChecker) IsHealthy() bool {
status := hc.GetStatus()
return status == HealthHealthy || status == HealthDegraded
}
// LastCheckTime returns the time of the last health check
func (hc *HealthChecker) LastCheckTime() time.Time {
hc.timeMu.RLock()
defer hc.timeMu.RUnlock()
return hc.lastCheckTime
}
// HealthScore returns a health score between 0.0 (unhealthy) and 1.0 (healthy)
func (hc *HealthChecker) HealthScore() float64 {
status := hc.GetStatus()
switch status {
case HealthHealthy:
return 1.0
case HealthDegraded:
return 0.7
case HealthUnhealthy:
return 0.0
default:
return 0.5
}
}
// Stats returns health checker statistics
func (hc *HealthChecker) Stats() HealthCheckerStats {
hc.timeMu.RLock()
lastCheck := hc.lastCheckTime
lastSuccess := hc.lastSuccessTime
lastFailure := hc.lastFailureTime
hc.timeMu.RUnlock()
totalChecks := hc.totalChecks.Load()
totalSuccesses := hc.totalSuccesses.Load()
totalFailures := hc.totalFailures.Load()
successRate := float64(0)
if totalChecks > 0 {
successRate = float64(totalSuccesses) / float64(totalChecks)
}
return HealthCheckerStats{
Status: hc.GetStatus(),
ConsecutiveSuccesses: hc.consecutiveSuccesses.Load(),
ConsecutiveFailures: hc.consecutiveFailures.Load(),
TotalChecks: totalChecks,
TotalSuccesses: totalSuccesses,
TotalFailures: totalFailures,
SuccessRate: successRate,
AverageLatency: time.Duration(hc.averageLatency.Load()),
StatusChanges: hc.statusChanges.Load(),
LastCheckTime: lastCheck,
LastSuccessTime: lastSuccess,
LastFailureTime: lastFailure,
HealthScore: hc.HealthScore(),
}
}
// HealthCheckerStats holds statistics for the health checker
type HealthCheckerStats struct {
Status HealthStatus
ConsecutiveSuccesses int32
ConsecutiveFailures int32
TotalChecks int64
TotalSuccesses int64
TotalFailures int64
SuccessRate float64
AverageLatency time.Duration
StatusChanges int64
LastCheckTime time.Time
LastSuccessTime time.Time
LastFailureTime time.Time
HealthScore float64
}
// Reset resets the health checker statistics
func (hc *HealthChecker) Reset() {
hc.status.Store(int32(HealthUnknown))
hc.consecutiveSuccesses.Store(0)
hc.consecutiveFailures.Store(0)
hc.totalChecks.Store(0)
hc.totalSuccesses.Store(0)
hc.totalFailures.Store(0)
hc.statusChanges.Store(0)
hc.averageLatency.Store(0)
now := time.Now()
hc.timeMu.Lock()
hc.lastCheckTime = now
hc.lastSuccessTime = now
hc.lastFailureTime = now
hc.timeMu.Unlock()
}
+215
View File
@@ -0,0 +1,215 @@
// Package resilience provides resilience patterns for cache backends.
package resilience
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
)
// HealthCheckBackend wraps a cache backend with health checking
type HealthCheckBackend struct {
backend backends.CacheBackend
config *HealthCheckConfig
// Health tracking
status atomic.Int32
consecutiveFails atomic.Int32
consecutiveOK atomic.Int32
lastCheck time.Time
checkMutex sync.RWMutex
// Lifecycle
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewHealthCheckBackend creates a new health check wrapped backend
func NewHealthCheckBackend(b backends.CacheBackend, config *HealthCheckConfig) backends.CacheBackend {
if config == nil {
config = DefaultHealthCheckConfig()
}
ctx, cancel := context.WithCancel(context.Background())
hc := &HealthCheckBackend{
backend: b,
config: config,
ctx: ctx,
cancel: cancel,
}
// Set initial status to healthy (optimistic)
hc.status.Store(int32(HealthHealthy))
// Start health check routine
hc.wg.Add(1)
go hc.healthCheckLoop()
return hc
}
// Set stores a value and tracks health
func (h *HealthCheckBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
// Allow operations even if unhealthy (may recover)
err := h.backend.Set(ctx, key, value, ttl)
h.recordResult(err == nil)
return err
}
// Get retrieves a value and tracks health
func (h *HealthCheckBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
value, ttl, exists, err := h.backend.Get(ctx, key)
h.recordResult(err == nil)
return value, ttl, exists, err
}
// Delete removes a key and tracks health
func (h *HealthCheckBackend) Delete(ctx context.Context, key string) (bool, error) {
deleted, err := h.backend.Delete(ctx, key)
h.recordResult(err == nil)
return deleted, err
}
// Exists checks if a key exists and tracks health
func (h *HealthCheckBackend) Exists(ctx context.Context, key string) (bool, error) {
exists, err := h.backend.Exists(ctx, key)
h.recordResult(err == nil)
return exists, err
}
// Clear removes all keys and tracks health
func (h *HealthCheckBackend) Clear(ctx context.Context) error {
err := h.backend.Clear(ctx)
h.recordResult(err == nil)
return err
}
// GetStats returns statistics including health status
func (h *HealthCheckBackend) GetStats() map[string]interface{} {
stats := h.backend.GetStats()
if stats == nil {
stats = make(map[string]interface{})
}
h.checkMutex.RLock()
lastCheck := h.lastCheck
h.checkMutex.RUnlock()
status := HealthStatus(h.status.Load())
stats["health"] = map[string]interface{}{
"status": status.String(),
"consecutive_fails": h.consecutiveFails.Load(),
"consecutive_ok": h.consecutiveOK.Load(),
"last_check": lastCheck.Format(time.RFC3339),
"time_since_check": time.Since(lastCheck).Seconds(),
"check_interval_sec": h.config.CheckInterval.Seconds(),
}
return stats
}
// Ping checks backend health
func (h *HealthCheckBackend) Ping(ctx context.Context) error {
err := h.backend.Ping(ctx)
h.recordResult(err == nil)
return err
}
// Close shuts down the health checker and backend
func (h *HealthCheckBackend) Close() error {
// Stop health check routine
h.cancel()
// Wait for routine to finish
done := make(chan struct{})
go func() {
h.wg.Wait()
close(done)
}()
select {
case <-done:
// Finished normally
case <-time.After(2 * time.Second):
// Timeout
}
return h.backend.Close()
}
// IsHealthy returns true if the backend is healthy
func (h *HealthCheckBackend) IsHealthy() bool {
status := HealthStatus(h.status.Load())
return status == HealthHealthy || status == HealthDegraded
}
// recordResult records the result of an operation for health tracking
func (h *HealthCheckBackend) recordResult(success bool) {
if success {
fails := h.consecutiveFails.Swap(0)
oks := h.consecutiveOK.Add(1)
// Check if we should transition to healthy
if fails > 0 && oks >= int32(h.config.HealthyThreshold) {
oldStatus := HealthStatus(h.status.Swap(int32(HealthHealthy)))
if oldStatus != HealthHealthy && h.config.OnStatusChange != nil {
h.config.OnStatusChange(oldStatus, HealthHealthy)
}
}
} else {
oks := h.consecutiveOK.Swap(0)
fails := h.consecutiveFails.Add(1)
// Check if we should transition to unhealthy
if oks > 0 && fails >= int32(h.config.UnhealthyThreshold) {
oldStatus := HealthStatus(h.status.Swap(int32(HealthUnhealthy)))
if oldStatus != HealthUnhealthy && h.config.OnStatusChange != nil {
h.config.OnStatusChange(oldStatus, HealthUnhealthy)
}
} else if fails >= int32(h.config.UnhealthyThreshold)*2 {
// Severely degraded
h.status.Store(int32(HealthUnhealthy))
} else if fails >= int32(h.config.UnhealthyThreshold) {
// Degraded but still trying
h.status.Store(int32(HealthDegraded))
}
}
}
// healthCheckLoop runs periodic health checks
func (h *HealthCheckBackend) healthCheckLoop() {
defer h.wg.Done()
ticker := time.NewTicker(h.config.CheckInterval)
defer ticker.Stop()
// Do initial check
h.performHealthCheck()
for {
select {
case <-h.ctx.Done():
return
case <-ticker.C:
h.performHealthCheck()
}
}
}
// performHealthCheck performs a single health check
func (h *HealthCheckBackend) performHealthCheck() {
h.checkMutex.Lock()
h.lastCheck = time.Now()
h.checkMutex.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), h.config.Timeout)
defer cancel()
err := h.backend.Ping(ctx)
h.recordResult(err == nil)
}
+447
View File
@@ -0,0 +1,447 @@
package resilience
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// TestHealthChecker_StatusTransitions tests health status transitions
func TestHealthChecker_StatusTransitions(t *testing.T) {
t.Parallel()
var callCount atomic.Int32
var shouldFail atomic.Bool
checkFunc := func(ctx context.Context) error {
callCount.Add(1)
if shouldFail.Load() {
return errors.New("health check failed")
}
return nil
}
config := &HealthCheckConfig{
CheckInterval: 50 * time.Millisecond,
Timeout: 10 * time.Millisecond,
UnhealthyThreshold: 3,
HealthyThreshold: 2,
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
hc.Start()
defer hc.Stop()
// Initially unknown
assert.Equal(t, HealthUnknown, hc.GetStatus())
// Trigger failures
shouldFail.Store(true)
time.Sleep(200 * time.Millisecond)
// Should be unhealthy after threshold failures
status := hc.GetStatus()
assert.True(t, status == HealthUnhealthy || status == HealthDegraded)
// Recover
shouldFail.Store(false)
time.Sleep(150 * time.Millisecond)
// Should recover towards healthy
finalStatus := hc.GetStatus()
assert.True(t, finalStatus == HealthHealthy || finalStatus == HealthDegraded || finalStatus == HealthUnknown)
}
// TestHealthChecker_InitialState tests initial health status
func TestHealthChecker_InitialState(t *testing.T) {
t.Parallel()
checkFunc := func(ctx context.Context) error {
return nil
}
config := &HealthCheckConfig{
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
assert.Equal(t, HealthUnknown, hc.GetStatus())
assert.False(t, hc.IsHealthy())
}
// TestHealthChecker_ForceCheck tests manual health check trigger
func TestHealthChecker_ForceCheck(t *testing.T) {
t.Parallel()
var callCount atomic.Int32
checkFunc := func(ctx context.Context) error {
callCount.Add(1)
return nil
}
config := &HealthCheckConfig{
CheckInterval: 10 * time.Second, // Long interval
Timeout: 1 * time.Second,
UnhealthyThreshold: 3,
HealthyThreshold: 2,
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
initialCount := callCount.Load()
// Force check
hc.Check(context.Background())
// Should have been called
assert.Greater(t, callCount.Load(), initialCount)
}
// TestHealthChecker_StatusChangeCallback tests status change notifications
func TestHealthChecker_StatusChangeCallback(t *testing.T) {
t.Parallel()
var transitions []string
var mu sync.Mutex
var shouldFail atomic.Bool
checkFunc := func(ctx context.Context) error {
if shouldFail.Load() {
return errors.New("health check failed")
}
return nil
}
config := &HealthCheckConfig{
CheckInterval: 30 * time.Millisecond,
Timeout: 10 * time.Millisecond,
UnhealthyThreshold: 2,
HealthyThreshold: 2,
CheckFunc: checkFunc,
OnStatusChange: func(from, to HealthStatus) {
mu.Lock()
defer mu.Unlock()
transitions = append(transitions, from.String()+"->"+to.String())
},
}
hc := NewHealthChecker(config)
hc.Start()
defer hc.Stop()
// Trigger failures
shouldFail.Store(true)
time.Sleep(100 * time.Millisecond)
// Recover
shouldFail.Store(false)
time.Sleep(100 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
// Should have status transitions
assert.NotEmpty(t, transitions)
}
// TestHealthChecker_Stats tests statistics tracking
func TestHealthChecker_Stats(t *testing.T) {
t.Parallel()
var callCount atomic.Int32
checkFunc := func(ctx context.Context) error {
callCount.Add(1)
if callCount.Load()%2 == 0 {
return errors.New("failure")
}
return nil
}
config := &HealthCheckConfig{
CheckInterval: 20 * time.Millisecond,
Timeout: 10 * time.Millisecond,
UnhealthyThreshold: 5,
HealthyThreshold: 2,
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
hc.Start()
defer hc.Stop()
time.Sleep(150 * time.Millisecond)
stats := hc.Stats()
assert.Greater(t, stats.TotalChecks, int64(0))
assert.Greater(t, stats.TotalFailures, int64(0))
assert.Greater(t, stats.SuccessRate, 0.0)
assert.Less(t, stats.SuccessRate, 1.0)
}
// TestHealthChecker_Timeout tests check timeout handling
func TestHealthChecker_Timeout(t *testing.T) {
t.Parallel()
checkFunc := func(ctx context.Context) error {
// Simulate slow check
select {
case <-time.After(100 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
config := &HealthCheckConfig{
CheckInterval: 50 * time.Millisecond,
Timeout: 10 * time.Millisecond, // Short timeout
UnhealthyThreshold: 2,
HealthyThreshold: 2,
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
hc.Start()
defer hc.Stop()
time.Sleep(150 * time.Millisecond)
// Should be unhealthy due to timeouts
status := hc.GetStatus()
assert.NotEqual(t, HealthHealthy, status)
}
// TestHealthChecker_ConcurrentAccess tests thread safety
func TestHealthChecker_ConcurrentAccess(t *testing.T) {
t.Parallel()
checkFunc := func(ctx context.Context) error {
return nil
}
config := &HealthCheckConfig{
CheckInterval: 10 * time.Millisecond,
Timeout: 5 * time.Millisecond,
UnhealthyThreshold: 3,
HealthyThreshold: 2,
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
hc.Start()
defer hc.Stop()
var wg sync.WaitGroup
goroutines := 20
for i := 0; i < goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
_ = hc.GetStatus()
_ = hc.IsHealthy()
_ = hc.Stats()
hc.Check(context.Background())
}
}()
}
wg.Wait()
// Should complete without panics
}
// TestHealthChecker_StopAndStart tests lifecycle management
func TestHealthChecker_StopAndStart(t *testing.T) {
t.Parallel()
var callCount atomic.Int32
checkFunc := func(ctx context.Context) error {
callCount.Add(1)
return nil
}
config := &HealthCheckConfig{
CheckInterval: 20 * time.Millisecond,
Timeout: 10 * time.Millisecond,
UnhealthyThreshold: 3,
HealthyThreshold: 2,
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
// Start
hc.Start()
time.Sleep(100 * time.Millisecond)
count1 := callCount.Load()
assert.Greater(t, count1, int32(0))
// Stop
hc.Stop()
time.Sleep(100 * time.Millisecond)
count2 := callCount.Load()
// Should not have increased significantly after stop
assert.Less(t, count2-count1, int32(3))
}
// TestHealthChecker_DegradedState tests degraded status
func TestHealthChecker_DegradedState(t *testing.T) {
t.Parallel()
var callCount atomic.Int32
checkFunc := func(ctx context.Context) error {
count := callCount.Add(1)
// Fail once, then succeed
if count == 1 {
return errors.New("single failure")
}
return nil
}
config := &HealthCheckConfig{
CheckInterval: 30 * time.Millisecond,
Timeout: 10 * time.Millisecond,
UnhealthyThreshold: 3, // Need 3 failures for unhealthy
HealthyThreshold: 2, // Need 2 successes for healthy
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
hc.Start()
defer hc.Stop()
time.Sleep(100 * time.Millisecond)
// After initial checks, status should be set (might be healthy or degraded based on execution)
status := hc.GetStatus()
assert.True(t, status != HealthUnknown, "Status should not be unknown after checks")
}
// TestHealthChecker_DefaultConfig tests default configuration
func TestHealthChecker_DefaultConfig(t *testing.T) {
t.Parallel()
checkFunc := func(ctx context.Context) error {
return nil
}
config := &HealthCheckConfig{
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
assert.NotNil(t, hc)
assert.Equal(t, HealthUnknown, hc.GetStatus())
// Verify default config was applied (we can't access private fields, so just check it works)
assert.NotNil(t, hc)
}
// TestHealthChecker_StatusString tests status string representation
func TestHealthChecker_StatusString(t *testing.T) {
t.Parallel()
assert.Equal(t, "healthy", HealthHealthy.String())
assert.Equal(t, "unhealthy", HealthUnhealthy.String())
assert.Equal(t, "degraded", HealthDegraded.String())
assert.Equal(t, "unknown", HealthStatus(999).String())
}
// TestHealthChecker_RecoveryPattern tests typical failure and recovery
func TestHealthChecker_RecoveryPattern(t *testing.T) {
t.Parallel()
var checkNumber atomic.Int32
checkFunc := func(ctx context.Context) error {
n := checkNumber.Add(1)
// Fail checks 3-5, succeed others
if n >= 3 && n <= 5 {
return errors.New("temporary failure")
}
return nil
}
var statusLog []HealthStatus
var mu sync.Mutex
config := &HealthCheckConfig{
CheckInterval: 30 * time.Millisecond,
Timeout: 10 * time.Millisecond,
UnhealthyThreshold: 3,
HealthyThreshold: 2,
CheckFunc: checkFunc,
OnStatusChange: func(from, to HealthStatus) {
mu.Lock()
defer mu.Unlock()
statusLog = append(statusLog, to)
},
}
hc := NewHealthChecker(config)
hc.Start()
defer hc.Stop()
time.Sleep(300 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
// Should see transitions through unhealthy and back to healthy
assert.NotEmpty(t, statusLog)
// Final status should be healthy or degraded (recovered)
finalStatus := hc.GetStatus()
assert.True(t, finalStatus == HealthHealthy || finalStatus == HealthDegraded, "Should have recovered")
}
// Benchmark health checker performance
func BenchmarkHealthChecker_ForceCheck(b *testing.B) {
checkFunc := func(ctx context.Context) error {
return nil
}
config := &HealthCheckConfig{
CheckInterval: 10 * time.Minute,
Timeout: 1 * time.Second,
UnhealthyThreshold: 3,
HealthyThreshold: 2,
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
b.ResetTimer()
for i := 0; i < b.N; i++ {
hc.Check(context.Background())
}
}
func BenchmarkHealthChecker_Status(b *testing.B) {
checkFunc := func(ctx context.Context) error {
return nil
}
config := &HealthCheckConfig{
CheckFunc: checkFunc,
}
hc := NewHealthChecker(config)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = hc.GetStatus()
}
}
+4 -4
View File
@@ -39,25 +39,25 @@ func (p *Auth0Provider) BuildAuthParams(baseParams url.Values, scopes []string)
// Ensure offline_access scope is present for refresh tokens
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
if scope == ScopeOfflineAccess {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
scopes = append(scopes, ScopeOfflineAccess)
}
// Ensure openid scope is present
hasOpenID := false
for _, scope := range scopes {
if scope == "openid" {
if scope == ScopeOpenID {
hasOpenID = true
break
}
}
if !hasOpenID {
scopes = append(scopes, "openid")
scopes = append(scopes, ScopeOpenID)
}
return &AuthParams{
+5 -5
View File
@@ -40,7 +40,7 @@ func (p *AWSCognitoProvider) BuildAuthParams(baseParams url.Values, scopes []str
// Remove offline_access scope as Cognito doesn't use it (case-insensitive)
var filteredScopes []string
for _, scope := range scopes {
if strings.ToLower(scope) != "offline_access" {
if strings.ToLower(scope) != ScopeOfflineAccess {
filteredScopes = append(filteredScopes, scope)
}
}
@@ -48,18 +48,18 @@ func (p *AWSCognitoProvider) BuildAuthParams(baseParams url.Values, scopes []str
// Ensure openid scope is present
hasOpenID := false
for _, scope := range filteredScopes {
if scope == "openid" {
if scope == ScopeOpenID {
hasOpenID = true
break
}
}
if !hasOpenID {
filteredScopes = append(filteredScopes, "openid")
filteredScopes = append(filteredScopes, ScopeOpenID)
}
// Default Cognito scopes if none specified
if len(filteredScopes) == 1 && filteredScopes[0] == "openid" {
filteredScopes = append(filteredScopes, "email", "profile")
if len(filteredScopes) == 1 && filteredScopes[0] == ScopeOpenID {
filteredScopes = append(filteredScopes, ScopeEmail, ScopeProfile)
}
return &AuthParams{
+2 -2
View File
@@ -38,13 +38,13 @@ func (p *AzureProvider) BuildAuthParams(baseParams url.Values, scopes []string)
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
if scope == ScopeOfflineAccess {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
scopes = append(scopes, ScopeOfflineAccess)
}
return &AuthParams{
+3 -3
View File
@@ -102,17 +102,17 @@ func (p *BaseProvider) ValidateTokenExpiry(session Session, token string, tokenC
}
// BuildAuthParams constructs authorization parameters for the provider.
// It includes the "offline_access" scope by default for refresh token support.
// It includes the offline_access scope by default for refresh token support.
func (p *BaseProvider) BuildAuthParams(baseParams url.Values, scopes []string) (*AuthParams, error) {
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
if scope == ScopeOfflineAccess {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
scopes = append(scopes, ScopeOfflineAccess)
}
return &AuthParams{
+1 -1
View File
@@ -38,7 +38,7 @@ func (p *GitHubProvider) BuildAuthParams(baseParams url.Values, scopes []string)
// GitHub doesn't use offline_access scope, so remove it if present
var filteredScopes []string
for _, scope := range scopes {
if scope != "offline_access" {
if scope != ScopeOfflineAccess {
filteredScopes = append(filteredScopes, scope)
}
}
+5 -5
View File
@@ -39,7 +39,7 @@ func (p *GitLabProvider) BuildAuthParams(baseParams url.Values, scopes []string)
// Remove offline_access scope as GitLab doesn't use it
var filteredScopes []string
for _, scope := range scopes {
if scope != "offline_access" {
if scope != ScopeOfflineAccess {
filteredScopes = append(filteredScopes, scope)
}
}
@@ -47,18 +47,18 @@ func (p *GitLabProvider) BuildAuthParams(baseParams url.Values, scopes []string)
// Ensure openid scope is present for OIDC
hasOpenID := false
for _, scope := range filteredScopes {
if scope == "openid" {
if scope == ScopeOpenID {
hasOpenID = true
break
}
}
if !hasOpenID {
filteredScopes = append(filteredScopes, "openid")
filteredScopes = append(filteredScopes, ScopeOpenID)
}
// Default GitLab scopes if none specified
if len(filteredScopes) == 1 && filteredScopes[0] == "openid" {
filteredScopes = append(filteredScopes, "profile", "email")
if len(filteredScopes) == 1 && filteredScopes[0] == ScopeOpenID {
filteredScopes = append(filteredScopes, ScopeProfile, ScopeEmail)
}
return &AuthParams{
+2 -2
View File
@@ -36,10 +36,10 @@ func (p *GoogleProvider) BuildAuthParams(baseParams url.Values, scopes []string)
baseParams.Set("access_type", "offline")
baseParams.Set("prompt", "consent")
// Google does not use the "offline_access" scope, so we remove it if present.
// Google does not use the ScopeOfflineAccess scope, so we remove it if present.
var filteredScopes []string
for _, scope := range scopes {
if scope != "offline_access" {
if scope != ScopeOfflineAccess {
filteredScopes = append(filteredScopes, scope)
}
}
+8
View File
@@ -33,6 +33,14 @@ const (
ProviderTypeGitLab
)
// Standard OAuth2/OIDC scope constants
const (
ScopeOfflineAccess = "offline_access"
ScopeOpenID = "openid"
ScopeProfile = "profile"
ScopeEmail = "email"
)
// ProviderCapabilities defines the specific features and behaviors of an OIDC provider.
type ProviderCapabilities struct {
PreferredTokenValidation string
+4 -4
View File
@@ -39,25 +39,25 @@ func (p *KeycloakProvider) BuildAuthParams(baseParams url.Values, scopes []strin
// Ensure offline_access scope is present for refresh tokens
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
if scope == ScopeOfflineAccess {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
scopes = append(scopes, ScopeOfflineAccess)
}
// Ensure openid scope is present
hasOpenID := false
for _, scope := range scopes {
if scope == "openid" {
if scope == ScopeOpenID {
hasOpenID = true
break
}
}
if !hasOpenID {
scopes = append(scopes, "openid")
scopes = append(scopes, ScopeOpenID)
}
return &AuthParams{
+4 -4
View File
@@ -39,25 +39,25 @@ func (p *OktaProvider) BuildAuthParams(baseParams url.Values, scopes []string) (
// Ensure offline_access scope is present for refresh tokens
hasOfflineAccess := false
for _, scope := range scopes {
if scope == "offline_access" {
if scope == ScopeOfflineAccess {
hasOfflineAccess = true
break
}
}
if !hasOfflineAccess {
scopes = append(scopes, "offline_access")
scopes = append(scopes, ScopeOfflineAccess)
}
// Ensure openid scope is present
hasOpenID := false
for _, scope := range scopes {
if scope == "openid" {
if scope == ScopeOpenID {
hasOpenID = true
break
}
}
if !hasOpenID {
scopes = append(scopes, "openid")
scopes = append(scopes, ScopeOpenID)
}
return &AuthParams{
+1 -1
View File
@@ -61,7 +61,7 @@ func (v *ConfigValidator) ValidateScopes(scopes []string) error {
hasOpenIDScope := false
for _, scope := range scopes {
if strings.TrimSpace(scope) == "openid" {
if strings.TrimSpace(scope) == ScopeOpenID {
hasOpenIDScope = true
break
}