Files
traefikoidc/internal/cache/backends/hybrid_test.go
T
lukaszraczylo 6efb78b7a8 Smarter approach to the cookies (#103)
* Smarter approach to the cookies

  - Single maxCookieSize = 1400 constant with clear documentation
  - Combined cookie storage for ~40-45% size reduction
  - Backward compatible migration from legacy cookies

* Tuneup the code.
2025-12-12 18:35:06 +00:00

1491 lines
34 KiB
Go

//go:build !yaegi
package backends
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// mockBackend is a simple mock implementation of CacheBackend for testing
type mockBackend struct {
pingError error
data map[string]mockEntry
stats map[string]interface{}
mu sync.RWMutex
getCalls atomic.Int32
setCalls atomic.Int32
deleteCalls atomic.Int32
failSet bool
failGet bool
failDelete bool
failClear bool
failPing bool
}
type mockEntry struct {
expiresAt time.Time
value []byte
}
// mockBatchBackend extends mockBackend with batch operations
type mockBatchBackend struct {
*mockBackend
getManyError error
setManyError error
}
func newMockBackend() *mockBackend {
return &mockBackend{
data: make(map[string]mockEntry),
stats: map[string]interface{}{
"hits": int64(0),
"misses": int64(0),
},
}
}
func newMockBatchBackend() *mockBatchBackend {
return &mockBatchBackend{
mockBackend: newMockBackend(),
}
}
func (m *mockBatchBackend) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) {
if m.getManyError != nil {
return nil, m.getManyError
}
results := make(map[string][]byte)
for _, key := range keys {
value, _, exists, err := m.Get(ctx, key)
if err != nil {
return nil, err
}
if exists {
results[key] = value
}
}
return results, nil
}
func (m *mockBatchBackend) SetMany(ctx context.Context, items map[string][]byte, ttl time.Duration) error {
if m.setManyError != nil {
return m.setManyError
}
for key, value := range items {
if err := m.Set(ctx, key, value, ttl); err != nil {
return err
}
}
return nil
}
func (m *mockBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error {
m.setCalls.Add(1)
m.mu.Lock()
defer m.mu.Unlock()
if m.failSet {
return errors.New("mock set error")
}
expiresAt := time.Now().Add(ttl)
if ttl == 0 {
expiresAt = time.Now().Add(24 * time.Hour)
}
m.data[key] = mockEntry{
value: value,
expiresAt: expiresAt,
}
return nil
}
func (m *mockBackend) Get(ctx context.Context, key string) ([]byte, time.Duration, bool, error) {
m.getCalls.Add(1)
m.mu.RLock()
defer m.mu.RUnlock()
if m.failGet {
return nil, 0, false, errors.New("mock get error")
}
entry, exists := m.data[key]
if !exists {
return nil, 0, false, nil
}
// Check expiration
if time.Now().After(entry.expiresAt) {
return nil, 0, false, nil
}
ttl := time.Until(entry.expiresAt)
return entry.value, ttl, true, nil
}
func (m *mockBackend) Delete(ctx context.Context, key string) (bool, error) {
m.deleteCalls.Add(1)
m.mu.Lock()
defer m.mu.Unlock()
if m.failDelete {
return false, errors.New("mock delete error")
}
_, existed := m.data[key]
delete(m.data, key)
return existed, nil
}
func (m *mockBackend) Exists(ctx context.Context, key string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
entry, exists := m.data[key]
if !exists {
return false, nil
}
// Check expiration
if time.Now().After(entry.expiresAt) {
return false, nil
}
return true, nil
}
func (m *mockBackend) Clear(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.failClear {
return errors.New("mock clear error")
}
m.data = make(map[string]mockEntry)
return nil
}
func (m *mockBackend) GetStats() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
return m.stats
}
func (m *mockBackend) Close() error {
return nil
}
func (m *mockBackend) Ping(ctx context.Context) error {
if m.failPing {
if m.pingError != nil {
return m.pingError
}
return errors.New("mock ping error")
}
return nil
}
// Constructor Tests
func TestNewHybridBackend_Success(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
require.NotNil(t, hybrid)
// Verify default values
assert.NotNil(t, hybrid.logger)
assert.NotNil(t, hybrid.asyncWriteBuffer)
assert.NotNil(t, hybrid.syncWriteCacheTypes)
hybrid.Close()
}
func TestNewHybridBackend_NilConfig(t *testing.T) {
hybrid, err := NewHybridBackend(nil)
assert.Error(t, err)
assert.Nil(t, hybrid)
assert.Contains(t, err.Error(), "config is required")
}
func TestNewHybridBackend_NilPrimary(t *testing.T) {
config := &HybridConfig{
Primary: nil,
Secondary: newMockBackend(),
}
hybrid, err := NewHybridBackend(config)
assert.Error(t, err)
assert.Nil(t, hybrid)
assert.Contains(t, err.Error(), "primary")
}
func TestNewHybridBackend_NilSecondary(t *testing.T) {
config := &HybridConfig{
Primary: newMockBackend(),
Secondary: nil,
}
hybrid, err := NewHybridBackend(config)
assert.Error(t, err)
assert.Nil(t, hybrid)
assert.Contains(t, err.Error(), "secondary")
}
func TestNewHybridBackend_CustomLogger(t *testing.T) {
logger := &TestLogger{t: t}
config := &HybridConfig{
Primary: newMockBackend(),
Secondary: newMockBackend(),
Logger: logger,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
assert.Equal(t, logger, hybrid.logger)
hybrid.Close()
}
func TestNewHybridBackend_CustomAsyncBufferSize(t *testing.T) {
config := &HybridConfig{
Primary: newMockBackend(),
Secondary: newMockBackend(),
AsyncBufferSize: 50,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
assert.Equal(t, 50, cap(hybrid.asyncWriteBuffer))
hybrid.Close()
}
func TestNewHybridBackend_DefaultAsyncBufferSize(t *testing.T) {
config := &HybridConfig{
Primary: newMockBackend(),
Secondary: newMockBackend(),
// AsyncBufferSize not set or <= 0
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
assert.Equal(t, 1000, cap(hybrid.asyncWriteBuffer))
hybrid.Close()
}
func TestNewHybridBackend_CustomSyncWriteCacheTypes(t *testing.T) {
customTypes := map[string]bool{
"custom1": true,
"custom2": true,
}
config := &HybridConfig{
Primary: newMockBackend(),
Secondary: newMockBackend(),
SyncWriteCacheTypes: customTypes,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
assert.True(t, hybrid.syncWriteCacheTypes["custom1"])
assert.True(t, hybrid.syncWriteCacheTypes["custom2"])
hybrid.Close()
}
func TestNewHybridBackend_DefaultSyncWriteCacheTypes(t *testing.T) {
config := &HybridConfig{
Primary: newMockBackend(),
Secondary: newMockBackend(),
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
// Should have default critical types
assert.True(t, hybrid.syncWriteCacheTypes["blacklist"])
assert.True(t, hybrid.syncWriteCacheTypes["token"])
hybrid.Close()
}
// Basic Operations Tests
func TestHybridBackend_Set_BothSuccess(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
SyncWriteCacheTypes: map[string]bool{
"test": true, // Make writes synchronous for testing
},
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
key := "test:key1"
value := []byte("test-value")
ttl := 1 * time.Minute
err = hybrid.Set(ctx, key, value, ttl)
assert.NoError(t, err)
// Verify L1 write
assert.Equal(t, int32(1), primary.setCalls.Load())
assert.Equal(t, int64(1), hybrid.l1Writes.Load())
// Give time for sync write to complete
time.Sleep(10 * time.Millisecond)
// Verify L2 write (sync)
assert.Equal(t, int32(1), secondary.setCalls.Load())
assert.Equal(t, int64(1), hybrid.l2Writes.Load())
}
func TestHybridBackend_Set_L1Failure(t *testing.T) {
primary := newMockBackend()
primary.failSet = true
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
err = hybrid.Set(ctx, "key1", []byte("value"), 1*time.Minute)
// Should not return error even if L1 fails (continues to L2)
assert.NoError(t, err)
assert.Greater(t, hybrid.errors.Load(), int64(0))
}
func TestHybridBackend_Set_AsyncWrite(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
SyncWriteCacheTypes: map[string]bool{
// "general" is not in sync list, so async
},
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
key := "general:key1" // Will be async
value := []byte("test-value")
err = hybrid.Set(ctx, key, value, 1*time.Minute)
assert.NoError(t, err)
// L1 should be written immediately
assert.Equal(t, int32(1), primary.setCalls.Load())
// Wait for async worker to process
time.Sleep(100 * time.Millisecond)
// L2 should eventually be written
assert.Equal(t, int32(1), secondary.setCalls.Load())
}
func TestHybridBackend_Get_L1Hit(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
key := "test:key1"
value := []byte("test-value")
// Populate L1 directly
primary.Set(ctx, key, value, 1*time.Minute)
// Get should hit L1
retrieved, _, exists, err := hybrid.Get(ctx, key)
assert.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value, retrieved)
// L1 hit counter should increment
assert.Equal(t, int64(1), hybrid.l1Hits.Load())
// L2 should not be queried
assert.Equal(t, int32(0), secondary.getCalls.Load())
}
func TestHybridBackend_Get_L2Hit(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
key := "test:key1"
value := []byte("test-value")
// Populate L2 only
secondary.Set(ctx, key, value, 1*time.Minute)
// Get should miss L1, hit L2
retrieved, _, exists, err := hybrid.Get(ctx, key)
assert.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, value, retrieved)
// L2 hit counter should increment
assert.Equal(t, int64(1), hybrid.l2Hits.Load())
// L1 should be populated in background
time.Sleep(150 * time.Millisecond)
_, _, existsInL1, _ := primary.Get(ctx, key)
assert.True(t, existsInL1, "L1 should be populated from L2")
}
func TestHybridBackend_Get_Miss(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Get non-existent key
_, _, exists, err := hybrid.Get(ctx, "non-existent")
assert.NoError(t, err)
assert.False(t, exists)
// Miss counter should increment
assert.Equal(t, int64(1), hybrid.misses.Load())
}
func TestHybridBackend_Delete_BothCaches(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
key := "test:key1"
// Populate both caches
primary.Set(ctx, key, []byte("value"), 1*time.Minute)
secondary.Set(ctx, key, []byte("value"), 1*time.Minute)
// Delete
deleted, err := hybrid.Delete(ctx, key)
assert.NoError(t, err)
assert.True(t, deleted)
// Both should be deleted
assert.Equal(t, int32(1), primary.deleteCalls.Load())
assert.Equal(t, int32(1), secondary.deleteCalls.Load())
}
func TestHybridBackend_Exists_L1(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
key := "test:key1"
// Populate L1
primary.Set(ctx, key, []byte("value"), 1*time.Minute)
exists, err := hybrid.Exists(ctx, key)
assert.NoError(t, err)
assert.True(t, exists)
}
func TestHybridBackend_Exists_L2(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
key := "test:key1"
// Populate L2 only
secondary.Set(ctx, key, []byte("value"), 1*time.Minute)
exists, err := hybrid.Exists(ctx, key)
assert.NoError(t, err)
assert.True(t, exists)
}
func TestHybridBackend_Clear_BothCaches(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Populate both
primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
err = hybrid.Clear(ctx)
assert.NoError(t, err)
// Both should be cleared
exists1, _ := primary.Exists(ctx, "key1")
exists2, _ := secondary.Exists(ctx, "key2")
assert.False(t, exists1)
assert.False(t, exists2)
}
// Fallback Mode Tests
func TestHybridBackend_FallbackMode_OnL2Errors(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
secondary.failSet = true
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
SyncWriteCacheTypes: map[string]bool{"test": true},
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Multiple failures should trigger fallback mode
for i := 0; i < 3; i++ {
hybrid.Set(ctx, fmt.Sprintf("test:key%d", i), []byte("value"), 1*time.Minute)
time.Sleep(10 * time.Millisecond)
}
// Should eventually enter fallback mode
time.Sleep(50 * time.Millisecond)
assert.True(t, hybrid.fallbackMode.Load(), "Should enter fallback mode after L2 errors")
}
func TestHybridBackend_FallbackMode_SkipsL2(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
// Manually enable fallback mode
hybrid.fallbackMode.Store(true)
ctx := context.Background()
key := "test:key1"
value := []byte("test-value")
err = hybrid.Set(ctx, key, value, 1*time.Minute)
assert.NoError(t, err)
// L1 should be written
assert.Equal(t, int32(1), primary.setCalls.Load())
// L2 should be skipped
time.Sleep(50 * time.Millisecond)
assert.Equal(t, int32(0), secondary.setCalls.Load())
}
func TestHybridBackend_FallbackMode_Get(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
// Enable fallback mode
hybrid.fallbackMode.Store(true)
ctx := context.Background()
// Populate L2
secondary.Set(ctx, "key1", []byte("value"), 1*time.Minute)
// Get should only check L1 in fallback mode
_, _, exists, err := hybrid.Get(ctx, "key1")
assert.NoError(t, err)
assert.False(t, exists)
// Miss should be recorded
assert.Equal(t, int64(1), hybrid.misses.Load())
// L2 should not be queried
assert.Equal(t, int32(0), secondary.getCalls.Load())
}
// Health Monitoring Tests
func TestHybridBackend_Ping_BothHealthy(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
err = hybrid.Ping(ctx)
assert.NoError(t, err)
}
func TestHybridBackend_Ping_L1Failure(t *testing.T) {
primary := newMockBackend()
primary.failPing = true
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
err = hybrid.Ping(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "L1")
}
func TestHybridBackend_Ping_L2Failure(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
secondary.failPing = true
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
err = hybrid.Ping(ctx)
// Should not return error (L2 failure is tolerated)
assert.NoError(t, err)
// But should record error
lastErr := hybrid.lastL2Error.Load()
assert.NotNil(t, lastErr)
}
func TestHybridBackend_Ping_RecoverFromFallback(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
secondary.failPing = true
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// First ping fails L2, enters fallback
hybrid.Ping(ctx)
assert.True(t, hybrid.fallbackMode.Load())
// Fix L2
secondary.failPing = false
// Second ping succeeds, exits fallback
hybrid.Ping(ctx)
time.Sleep(10 * time.Millisecond)
assert.False(t, hybrid.fallbackMode.Load())
}
// GetStats Tests
func TestHybridBackend_GetStats(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Generate some activity
hybrid.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
hybrid.Get(ctx, "key1") // L1 hit
hybrid.Get(ctx, "key-miss") // miss
stats := hybrid.GetStats()
assert.NotNil(t, stats)
// Check required fields
assert.Equal(t, TypeHybrid, stats["type"])
assert.Contains(t, stats, "l1_hits")
assert.Contains(t, stats, "l2_hits")
assert.Contains(t, stats, "misses")
assert.Contains(t, stats, "total")
assert.Contains(t, stats, "l1_writes")
assert.Contains(t, stats, "l2_writes")
assert.Contains(t, stats, "errors")
assert.Contains(t, stats, "fallback_mode")
assert.Contains(t, stats, "l1_stats")
assert.Contains(t, stats, "l2_stats")
}
func TestHybridBackend_GetStats_HitRates(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
// Record some hits
hybrid.l1Hits.Store(10)
hybrid.l2Hits.Store(5)
hybrid.misses.Store(5)
stats := hybrid.GetStats()
// Should calculate hit rates
assert.Contains(t, stats, "l1_hit_rate")
assert.Contains(t, stats, "l2_hit_rate")
assert.Contains(t, stats, "overall_hit_rate")
// Check values
assert.InDelta(t, 0.5, stats["l1_hit_rate"], 0.01)
assert.InDelta(t, 0.25, stats["l2_hit_rate"], 0.01)
assert.InDelta(t, 0.75, stats["overall_hit_rate"], 0.01)
}
func TestHybridBackend_GetStats_LastL2Error(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
// Record an L2 error
errorTime := time.Now()
hybrid.lastL2Error.Store(errorTime)
stats := hybrid.GetStats()
assert.Contains(t, stats, "last_l2_error")
assert.Contains(t, stats, "seconds_since_l2_error")
}
// GetMany/SetMany Tests
func TestHybridBackend_GetMany_L1Hits(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Populate L1
primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
primary.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
results, err := hybrid.GetMany(ctx, []string{"key1", "key2"})
assert.NoError(t, err)
assert.Len(t, results, 2)
assert.Equal(t, []byte("value1"), results["key1"])
assert.Equal(t, []byte("value2"), results["key2"])
// Should be L1 hits
assert.Equal(t, int64(2), hybrid.l1Hits.Load())
}
func TestHybridBackend_GetMany_EmptyKeys(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
results, err := hybrid.GetMany(ctx, []string{})
assert.NoError(t, err)
assert.Empty(t, results)
}
func TestHybridBackend_GetMany_L2Fallback(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Populate L2 only
secondary.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
results, err := hybrid.GetMany(ctx, []string{"key1", "key2"})
assert.NoError(t, err)
assert.Len(t, results, 2)
assert.Equal(t, []byte("value1"), results["key1"])
assert.Equal(t, []byte("value2"), results["key2"])
// Should be L2 misses (L1 was empty)
assert.Equal(t, int64(0), hybrid.l1Hits.Load())
// Give async L1 population time to complete
time.Sleep(50 * time.Millisecond)
// Verify L1 was populated from L2 hits
val1, _, exists1, _ := primary.Get(ctx, "key1")
assert.True(t, exists1)
assert.Equal(t, []byte("value1"), val1)
}
func TestHybridBackend_GetMany_MixedL1L2(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// key1 in L1 only
primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
// key2 in L2 only
secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
// key3 in both (L1 should win)
primary.Set(ctx, "key3", []byte("value3-l1"), 1*time.Minute)
secondary.Set(ctx, "key3", []byte("value3-l2"), 1*time.Minute)
results, err := hybrid.GetMany(ctx, []string{"key1", "key2", "key3"})
assert.NoError(t, err)
assert.Len(t, results, 3)
assert.Equal(t, []byte("value1"), results["key1"])
assert.Equal(t, []byte("value2"), results["key2"])
assert.Equal(t, []byte("value3-l1"), results["key3"]) // L1 wins
// Should have 2 L1 hits (key1, key3)
assert.Equal(t, int64(2), hybrid.l1Hits.Load())
}
func TestHybridBackend_GetMany_FallbackMode(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Enable fallback mode
hybrid.fallbackMode.Store(true)
// Populate L1 and L2
primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
// In fallback mode, should only check L1
results, err := hybrid.GetMany(ctx, []string{"key1", "key2"})
assert.NoError(t, err)
// Should only find key1 (from L1)
assert.Len(t, results, 1)
assert.Equal(t, []byte("value1"), results["key1"])
assert.NotContains(t, results, "key2") // L2 not checked
assert.Equal(t, int64(1), hybrid.l1Hits.Load())
}
func TestHybridBackend_GetMany_L2Error(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
secondary.failGet = true // Force L2 errors
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// key1 in L1, key2 needs L2 (but will error)
primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
results, err := hybrid.GetMany(ctx, []string{"key1", "key2"})
// Should still succeed with L1 hits even when L2 errors
assert.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, []byte("value1"), results["key1"])
// Note: Individual Get errors may not immediately trigger fallback mode
// The circuit breaker needs multiple consecutive errors
}
func TestHybridBackend_GetMany_PartialL2Results(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Only key2 exists in L2
secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
// Request 3 keys, only one exists
results, err := hybrid.GetMany(ctx, []string{"key1", "key2", "key3"})
assert.NoError(t, err)
// Should only have key2
assert.Len(t, results, 1)
assert.Equal(t, []byte("value2"), results["key2"])
assert.NotContains(t, results, "key1")
assert.NotContains(t, results, "key3")
}
func TestHybridBackend_GetMany_WithBatchBackend(t *testing.T) {
primary := newMockBackend()
secondary := newMockBatchBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Populate L2 with batch backend
secondary.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
secondary.Set(ctx, "key2", []byte("value2"), 1*time.Minute)
secondary.Set(ctx, "key3", []byte("value3"), 1*time.Minute)
// GetMany should use batch operation
results, err := hybrid.GetMany(ctx, []string{"key1", "key2", "key3"})
assert.NoError(t, err)
assert.Len(t, results, 3)
assert.Equal(t, []byte("value1"), results["key1"])
assert.Equal(t, []byte("value2"), results["key2"])
assert.Equal(t, []byte("value3"), results["key3"])
// Verify L1 populated asynchronously
time.Sleep(50 * time.Millisecond)
val1, _, exists1, _ := primary.Get(ctx, "key1")
assert.True(t, exists1)
assert.Equal(t, []byte("value1"), val1)
}
func TestHybridBackend_GetMany_BatchBackendError(t *testing.T) {
primary := newMockBackend()
secondary := newMockBatchBackend()
secondary.getManyError = errors.New("batch operation failed")
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// key1 in L1
primary.Set(ctx, "key1", []byte("value1"), 1*time.Minute)
// GetMany should handle batch error gracefully
results, err := hybrid.GetMany(ctx, []string{"key1", "key2"})
// Should return L1 results even though L2 batch failed
assert.NoError(t, err)
assert.Len(t, results, 1)
assert.Equal(t, []byte("value1"), results["key1"])
// Batch error should trigger fallback mode
time.Sleep(50 * time.Millisecond)
assert.True(t, hybrid.fallbackMode.Load())
}
func TestHybridBackend_GetMany_MixedBatchResults(t *testing.T) {
primary := newMockBackend()
secondary := newMockBatchBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// key1 and key2 in L1
primary.Set(ctx, "key1", []byte("value1-l1"), 1*time.Minute)
primary.Set(ctx, "key2", []byte("value2-l1"), 1*time.Minute)
// key3 and key4 in L2 (batch backend)
secondary.Set(ctx, "key3", []byte("value3-l2"), 1*time.Minute)
secondary.Set(ctx, "key4", []byte("value4-l2"), 1*time.Minute)
// GetMany with mixed L1/L2 hits via batch
results, err := hybrid.GetMany(ctx, []string{"key1", "key2", "key3", "key4"})
assert.NoError(t, err)
assert.Len(t, results, 4)
// L1 results
assert.Equal(t, []byte("value1-l1"), results["key1"])
assert.Equal(t, []byte("value2-l1"), results["key2"])
// L2 batch results
assert.Equal(t, []byte("value3-l2"), results["key3"])
assert.Equal(t, []byte("value4-l2"), results["key4"])
// Should have 2 L1 hits
assert.Equal(t, int64(2), hybrid.l1Hits.Load())
}
func TestHybridBackend_SetMany_Success(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
SyncWriteCacheTypes: map[string]bool{"test": true},
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
items := map[string][]byte{
"test:key1": []byte("value1"),
"test:key2": []byte("value2"),
}
err = hybrid.SetMany(ctx, items, 1*time.Minute)
assert.NoError(t, err)
// L1 should have both
assert.Equal(t, int32(2), primary.setCalls.Load())
// L2 should have both (sync writes)
time.Sleep(50 * time.Millisecond)
assert.Equal(t, int32(2), secondary.setCalls.Load())
}
func TestHybridBackend_SetMany_EmptyItems(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
err = hybrid.SetMany(ctx, map[string][]byte{}, 1*time.Minute)
assert.NoError(t, err)
}
// Close Tests
func TestHybridBackend_Close(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
err = hybrid.Close()
assert.NoError(t, err)
// Context should be canceled
select {
case <-hybrid.ctx.Done():
// Good
default:
t.Error("Context should be canceled after Close")
}
}
// Helper Function Tests
func TestExtractCacheType(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
tests := []struct {
key string
expected string
}{
{"blacklist:token123", "blacklist"},
{"token:access123", "token"},
{"metadata:provider", "metadata"},
{"jwk:key1234567", "jwk"}, // Needs to be > 10 chars
{"session:sess1234", "session"},
{"introspect:tok123", "introspection"},
{"other:key", "general"},
{"short", "general"},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
result := hybrid.extractCacheType(tt.key)
assert.Equal(t, tt.expected, result)
})
}
}
func TestContains(t *testing.T) {
tests := []struct {
s string
substr string
expected bool
}{
{"blacklist", "black", true},
{"blacklist", "list", true},
{"blacklist", "xyz", false},
{"TOKEN", "token", true}, // case insensitive
{"short", "verylongstring", false},
{"", "any", false},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%s-%s", tt.s, tt.substr), func(t *testing.T) {
result := contains(tt.s, tt.substr)
assert.Equal(t, tt.expected, result)
})
}
}
func TestToLower(t *testing.T) {
tests := []struct {
input byte
expected byte
}{
{'A', 'a'},
{'Z', 'z'},
{'a', 'a'},
{'z', 'z'},
{'0', '0'},
{'!', '!'},
}
for _, tt := range tests {
t.Run(string(tt.input), func(t *testing.T) {
result := toLower(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
// Logger Tests
func TestDefaultLogger_Methods(t *testing.T) {
// Create a default logger using the test logger
testLogger := &TestLogger{t: t}
// These should not panic
testLogger.Debugf("debug %s", "message")
testLogger.Infof("info %s", "message")
testLogger.Warnf("warn %s", "message")
testLogger.Errorf("error %s", "message")
}
// Async Write Worker Tests
func TestHybridBackend_AsyncWriteWorker_ProcessesWrites(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
// No sync types - all writes are async
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Queue multiple async writes
for i := 0; i < 5; i++ {
key := fmt.Sprintf("async:key%d", i)
value := []byte(fmt.Sprintf("value%d", i))
err := hybrid.Set(ctx, key, value, 1*time.Minute)
require.NoError(t, err)
}
// Wait for async worker to process
time.Sleep(200 * time.Millisecond)
// All should be written to L2
assert.Equal(t, int32(5), secondary.setCalls.Load())
}
func TestHybridBackend_AsyncWriteWorker_BufferFull(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
AsyncBufferSize: 2, // Very small buffer
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
ctx := context.Background()
// Try to overflow buffer
for i := 0; i < 10; i++ {
key := fmt.Sprintf("async:key%d", i)
value := []byte(fmt.Sprintf("value%d", i))
hybrid.Set(ctx, key, value, 1*time.Minute)
}
// Some writes should be dropped (errors incremented)
time.Sleep(50 * time.Millisecond)
errors := hybrid.errors.Load()
// May have errors from buffer overflow
_ = errors
}
// RecordL2Error Tests
func TestHybridBackend_RecordL2Error_EntersFallbackMode(t *testing.T) {
primary := newMockBackend()
secondary := newMockBackend()
config := &HybridConfig{
Primary: primary,
Secondary: secondary,
}
hybrid, err := NewHybridBackend(config)
require.NoError(t, err)
defer hybrid.Close()
// Record error
hybrid.recordL2Error()
// Should have timestamp
lastErr := hybrid.lastL2Error.Load()
assert.NotNil(t, lastErr)
// Record another error immediately (within 1 second)
hybrid.recordL2Error()
// Should enter fallback mode
assert.True(t, hybrid.fallbackMode.Load())
}