1
0
mirror of https://github.com/redis/go-redis.git synced 2025-07-16 13:21:51 +03:00
Files
go-redis/client_side_cache_test.go
Nedyalko Dyakov 1f4537559a feat: implement client-side caching with Redis invalidation support
Add comprehensive client-side caching functionality that leverages the push notification
infrastructure for automatic cache invalidation.

Core Features:
- Local in-memory cache with configurable size and TTL
- Automatic Redis CLIENT TRACKING integration
- Real-time cache invalidation via push notifications
- LRU eviction policy for memory management
- Thread-safe operations with RWMutex
- Comprehensive statistics and monitoring

API Components:
- ClientSideCache: Main cache implementation
- ClientSideCacheOptions: Configuration options
- Client integration methods: EnableClientSideCache, DisableClientSideCache
- Convenience methods: CachedGet, CachedSet, CachedDel
- Statistics: GetStats with hits, misses, evictions, hit ratio

Implementation Details:
- Uses existing push notification system for invalidation
- Integrates with Redis CLIENT TRACKING (RESP3 required)
- Supports BCAST mode for prefix-based tracking
- Non-blocking invalidation processing
- Graceful fallback to Redis on cache misses
- Automatic cleanup on client close

Benefits:
- Significant performance improvements for read-heavy workloads
- Reduced Redis server load and network traffic
- Automatic cache coherence with real-time invalidation
- Transparent integration with existing Redis operations
- Zero configuration required (sensible defaults)

Test Coverage:
- Comprehensive unit tests for all cache operations
- Integration tests with real Redis instances
- Edge cases: expiration, eviction, invalidation
- Statistics verification and cache management
- Error handling and graceful degradation

Example Usage:
```go
// Enable client-side caching
client.EnableClientSideCache(&redis.ClientSideCacheOptions{
    MaxSize: 1000,
    DefaultTTL: 5 * time.Minute,
})

// Use cached operations
value, err := client.CachedGet(ctx, "key").Result()
err = client.CachedSet(ctx, "key", "value", time.Hour).Err()
```

Files Added:
- client_side_cache.go: Core implementation
- client_side_cache_test.go: Comprehensive tests
- examples/client-side-cache/: Working example with documentation

Integration:
- Leverages existing push notification infrastructure
- Updates shouldSkipNotification filtering (invalidate now processed)
- Maintains backward compatibility
- No breaking changes to existing APIs
2025-06-28 13:53:26 +03:00

484 lines
12 KiB
Go

package redis
import (
"context"
"testing"
"time"
)
func TestClientSideCache(t *testing.T) {
t.Run("NewClientSideCache", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3, // Required for push notifications
})
defer client.Close()
// Test with default options
cache, err := NewClientSideCache(client, nil)
if err != nil {
t.Fatalf("Failed to create client-side cache: %v", err)
}
defer cache.Close()
if cache == nil {
t.Error("NewClientSideCache should return a non-nil cache")
}
if cache.client != client {
t.Error("Cache should reference the provided client")
}
if cache.maxSize != 10000 {
t.Errorf("Expected default maxSize 10000, got %d", cache.maxSize)
}
if cache.defaultTTL != 5*time.Minute {
t.Errorf("Expected default TTL 5 minutes, got %v", cache.defaultTTL)
}
})
t.Run("NewClientSideCacheWithOptions", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
opts := &ClientSideCacheOptions{
MaxSize: 5000,
DefaultTTL: 10 * time.Minute,
EnableTracking: true,
NoLoop: false,
TrackingPrefix: []string{"user:", "session:"},
InvalidationBufferSize: 500,
}
cache, err := NewClientSideCache(client, opts)
if err != nil {
t.Fatalf("Failed to create client-side cache with options: %v", err)
}
defer cache.Close()
if cache.maxSize != 5000 {
t.Errorf("Expected maxSize 5000, got %d", cache.maxSize)
}
if cache.defaultTTL != 10*time.Minute {
t.Errorf("Expected TTL 10 minutes, got %v", cache.defaultTTL)
}
})
t.Run("CacheOperations", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
cache, err := NewClientSideCache(client, &ClientSideCacheOptions{
MaxSize: 100,
DefaultTTL: 1 * time.Minute,
EnableTracking: false, // Disable tracking for unit tests
})
if err != nil {
t.Fatalf("Failed to create client-side cache: %v", err)
}
defer cache.Close()
ctx := context.Background()
// Test cache miss and Redis fallback
key := "test:cache:key1"
value := "test_value"
// First get should be a cache miss
_, misses, _, _, _ := cache.GetStats()
initialMisses := misses
cmd := cache.Get(ctx, key)
if cmd.Err() == nil {
// If Redis is available and key exists, verify it's cached
hits, misses, _, _, size := cache.GetStats()
if misses <= initialMisses {
t.Error("Expected cache miss on first get")
}
if size > 0 && cmd.Err() == nil {
// Second get should be a cache hit
cmd2 := cache.Get(ctx, key)
if cmd2.Err() != nil {
t.Errorf("Second get failed: %v", cmd2.Err())
}
hits2, _, _, _, _ := cache.GetStats()
if hits2 <= hits {
t.Error("Expected cache hit on second get")
}
}
}
// Test Set operation
setCmd := cache.Set(ctx, key, value, time.Hour)
if setCmd.Err() == nil {
// Verify value is cached locally
if cachedValue, found := cache.getFromCache(key); found {
if cachedValue != value {
t.Errorf("Expected cached value %s, got %v", value, cachedValue)
}
}
}
// Test Del operation
delCmd := cache.Del(ctx, key)
if delCmd.Err() == nil {
// Verify value is removed from cache
if _, found := cache.getFromCache(key); found {
t.Error("Key should be removed from cache after Del")
}
}
})
t.Run("CacheEviction", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
cache, err := NewClientSideCache(client, &ClientSideCacheOptions{
MaxSize: 2, // Small cache for testing eviction
DefaultTTL: 1 * time.Hour,
EnableTracking: false,
})
if err != nil {
t.Fatalf("Failed to create client-side cache: %v", err)
}
defer cache.Close()
// Fill cache beyond capacity
cache.setInCache("key1", "value1", time.Hour)
cache.setInCache("key2", "value2", time.Hour)
cache.setInCache("key3", "value3", time.Hour) // Should trigger eviction
// Check that cache size doesn't exceed maxSize
_, _, _, _, size := cache.GetStats()
if size > 2 {
t.Errorf("Cache size %d exceeds maxSize 2", size)
}
// Check that eviction occurred
_, _, evictions, _, _ := cache.GetStats()
if evictions == 0 {
t.Error("Expected at least one eviction")
}
})
t.Run("CacheExpiration", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
cache, err := NewClientSideCache(client, &ClientSideCacheOptions{
MaxSize: 100,
DefaultTTL: 1 * time.Hour,
EnableTracking: false,
})
if err != nil {
t.Fatalf("Failed to create client-side cache: %v", err)
}
defer cache.Close()
// Set entry with short TTL
cache.setInCache("expiring_key", "value", 1*time.Millisecond)
// Wait for expiration
time.Sleep(10 * time.Millisecond)
// Try to get expired entry
if value, found := cache.getFromCache("expiring_key"); found {
t.Errorf("Expected expired entry to be removed, but found: %v", value)
}
})
t.Run("InvalidationHandler", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
cache, err := NewClientSideCache(client, &ClientSideCacheOptions{
MaxSize: 100,
DefaultTTL: 1 * time.Hour,
EnableTracking: false,
})
if err != nil {
t.Fatalf("Failed to create client-side cache: %v", err)
}
defer cache.Close()
// Create invalidation handler
handler := &clientSideCacheInvalidationHandler{cache: cache}
// Add some entries to cache
cache.setInCache("key1", "value1", time.Hour)
cache.setInCache("key2", "value2", time.Hour)
// Test invalidation notification
ctx := context.Background()
notification := []interface{}{"invalidate", []interface{}{"key1", "key2"}}
handled := handler.HandlePushNotification(ctx, notification)
if !handled {
t.Error("Handler should return true for valid invalidation notification")
}
// Give some time for async processing
time.Sleep(10 * time.Millisecond)
// Verify keys are removed from cache
if _, found := cache.getFromCache("key1"); found {
t.Error("key1 should be invalidated")
}
if _, found := cache.getFromCache("key2"); found {
t.Error("key2 should be invalidated")
}
})
t.Run("CacheStats", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
cache, err := NewClientSideCache(client, &ClientSideCacheOptions{
MaxSize: 100,
DefaultTTL: 1 * time.Hour,
EnableTracking: false,
})
if err != nil {
t.Fatalf("Failed to create client-side cache: %v", err)
}
defer cache.Close()
// Initial stats
hits, misses, evictions, hitRatio, size := cache.GetStats()
if hits != 0 || misses != 0 || evictions != 0 || hitRatio != 0 || size != 0 {
t.Error("Initial stats should be zero")
}
// Generate some cache activity
cache.getFromCache("nonexistent") // miss
cache.setInCache("key1", "value1", time.Hour)
cache.getFromCache("key1") // hit
hits, misses, evictions, hitRatio, size = cache.GetStats()
if hits != 1 {
t.Errorf("Expected 1 hit, got %d", hits)
}
if misses != 1 {
t.Errorf("Expected 1 miss, got %d", misses)
}
if size != 1 {
t.Errorf("Expected cache size 1, got %d", size)
}
if hitRatio != 0.5 {
t.Errorf("Expected hit ratio 0.5, got %f", hitRatio)
}
})
t.Run("CacheClear", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
cache, err := NewClientSideCache(client, &ClientSideCacheOptions{
MaxSize: 100,
DefaultTTL: 1 * time.Hour,
EnableTracking: false,
})
if err != nil {
t.Fatalf("Failed to create client-side cache: %v", err)
}
defer cache.Close()
// Add entries
cache.setInCache("key1", "value1", time.Hour)
cache.setInCache("key2", "value2", time.Hour)
// Verify entries exist
_, _, _, _, size := cache.GetStats()
if size != 2 {
t.Errorf("Expected cache size 2, got %d", size)
}
// Clear cache
cache.Clear()
// Verify cache is empty
_, _, _, _, size = cache.GetStats()
if size != 0 {
t.Errorf("Expected cache size 0 after clear, got %d", size)
}
})
}
func TestClientSideCacheIntegration(t *testing.T) {
t.Run("EnableDisableClientSideCache", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
// Initially no cache
if cache := client.GetClientSideCache(); cache != nil {
t.Error("Client should not have cache initially")
}
// Enable cache
err := client.EnableClientSideCache(nil)
if err != nil {
t.Fatalf("Failed to enable client-side cache: %v", err)
}
// Verify cache is enabled
if cache := client.GetClientSideCache(); cache == nil {
t.Error("Client should have cache after enabling")
}
// Try to enable again (should fail)
err = client.EnableClientSideCache(nil)
if err == nil {
t.Error("Enabling cache twice should return error")
}
// Disable cache
err = client.DisableClientSideCache()
if err != nil {
t.Errorf("Failed to disable client-side cache: %v", err)
}
// Verify cache is disabled
if cache := client.GetClientSideCache(); cache != nil {
t.Error("Client should not have cache after disabling")
}
// Disable again (should not error)
err = client.DisableClientSideCache()
if err != nil {
t.Errorf("Disabling cache twice should not error: %v", err)
}
})
t.Run("CachedOperations", func(t *testing.T) {
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
ctx := context.Background()
// Test operations without cache (should fallback to regular operations)
cmd1 := client.CachedGet(ctx, "test:key")
cmd2 := client.CachedSet(ctx, "test:key", "value", time.Hour)
cmd3 := client.CachedDel(ctx, "test:key")
// These should work the same as regular operations
if cmd1 == nil || cmd2 == nil || cmd3 == nil {
t.Error("Cached operations should return valid commands even without cache")
}
// Enable cache
err := client.EnableClientSideCache(&ClientSideCacheOptions{
MaxSize: 100,
DefaultTTL: 1 * time.Hour,
EnableTracking: false, // Disable for unit tests
})
if err != nil {
t.Fatalf("Failed to enable client-side cache: %v", err)
}
defer client.DisableClientSideCache()
// Test operations with cache
cmd4 := client.CachedGet(ctx, "test:key2")
cmd5 := client.CachedSet(ctx, "test:key2", "value2", time.Hour)
cmd6 := client.CachedDel(ctx, "test:key2")
if cmd4 == nil || cmd5 == nil || cmd6 == nil {
t.Error("Cached operations should return valid commands with cache enabled")
}
// Verify cache is being used
cache := client.GetClientSideCache()
if cache == nil {
t.Error("Cache should be available")
}
})
t.Run("CacheWithRealRedis", func(t *testing.T) {
// This test requires a real Redis instance
client := NewClient(&Options{
Addr: "localhost:6379",
Protocol: 3,
})
defer client.Close()
// Test connection
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
t.Skip("Redis not available, skipping integration test")
}
// Enable cache
err := client.EnableClientSideCache(&ClientSideCacheOptions{
MaxSize: 100,
DefaultTTL: 1 * time.Minute,
EnableTracking: true, // Enable tracking for real Redis
NoLoop: true,
})
if err != nil {
t.Fatalf("Failed to enable client-side cache: %v", err)
}
defer client.DisableClientSideCache()
// Test key
key := "test:client_side_cache:integration"
value := "test_value_123"
// Clean up
client.Del(ctx, key)
// Set value
err = client.CachedSet(ctx, key, value, time.Hour).Err()
if err != nil {
t.Fatalf("Failed to set value: %v", err)
}
// Get value (should be cached)
result, err := client.CachedGet(ctx, key).Result()
if err != nil {
t.Fatalf("Failed to get value: %v", err)
}
if result != value {
t.Errorf("Expected value %s, got %s", value, result)
}
// Verify cache stats
cache := client.GetClientSideCache()
hits, misses, _, hitRatio, size := cache.GetStats()
if size == 0 {
t.Error("Cache should contain entries")
}
if hits+misses == 0 {
t.Error("Cache should have some activity")
}
t.Logf("Cache stats: hits=%d, misses=%d, hitRatio=%.2f, size=%d", hits, misses, hitRatio, size)
// Clean up
client.Del(ctx, key)
})
}