mirror of
https://github.com/lukaszraczylo/graphql-monitoring-proxy.git
synced 2026-10-12 05:32:36 +00:00
improvements mid may 2025 (#24)
* General improvements and bug fixes. * Improve tests coverage. * fixup! Improve tests coverage. * Update README.md with latest changes. * Fix the uint32 * Resolve issue with race condition for logging. * fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * Fix the test of the rate limiter * Add default ratelimit.json file * Update dependencies. * Significant refactor. * fixup! Significant refactor. * fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * fixup! fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * fixup! fixup! fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * fixup! fixup! fixup! fixup! fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * fixup! fixup! fixup! fixup! fixup! fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * fixup! fixup! fixup! fixup! fixup! fixup! fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025 * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Merge remote-tracking branch 'origin/main' into improvements-mid-apr-2025
This commit is contained in:
Vendored
+98
-6
@@ -23,6 +23,10 @@ type CacheConfig struct {
|
||||
DB int `json:"db"`
|
||||
Enable bool `json:"enable"`
|
||||
}
|
||||
Memory struct {
|
||||
MaxMemorySize int64 `json:"max_memory_size"` // Maximum memory size in bytes
|
||||
MaxEntries int64 `json:"max_entries"` // Maximum number of entries
|
||||
}
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
|
||||
@@ -38,6 +42,9 @@ type CacheClient interface {
|
||||
Delete(key string)
|
||||
Clear()
|
||||
CountQueries() int64
|
||||
// Memory usage reporting methods
|
||||
GetMemoryUsage() int64 // Returns current memory usage in bytes
|
||||
GetMaxMemorySize() int64 // Returns max memory size in bytes
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -45,6 +52,18 @@ var (
|
||||
config *CacheConfig
|
||||
)
|
||||
|
||||
// CalculateHash generates an MD5 hash from the request body.
|
||||
// For GraphQL requests, this includes both the query and variables,
|
||||
// ensuring that identical queries with different variables are cached separately.
|
||||
//
|
||||
// Example GraphQL request body:
|
||||
//
|
||||
// {
|
||||
// "query": "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||
// "variables": { "id": "123" }
|
||||
// }
|
||||
//
|
||||
// Different variable values will produce different cache keys.
|
||||
func CalculateHash(c *fiber.Ctx) string {
|
||||
return strutil.Md5(c.Body())
|
||||
}
|
||||
@@ -61,16 +80,51 @@ func EnableCache(cfg *CacheConfig) {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Using Redis cache",
|
||||
})
|
||||
cfg.Client = libpack_cache_redis.New(&libpack_cache_redis.RedisClientConfig{
|
||||
redisClient, err := libpack_cache_redis.New(&libpack_cache_redis.RedisClientConfig{
|
||||
RedisDB: cfg.Redis.DB,
|
||||
RedisServer: cfg.Redis.URL,
|
||||
RedisPassword: cfg.Redis.Password,
|
||||
})
|
||||
if err != nil {
|
||||
cfg.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to create Redis client",
|
||||
Pairs: map[string]interface{}{"error": err.Error()},
|
||||
})
|
||||
// Fall back to memory cache
|
||||
cfg.Client = libpack_cache_memory.New(time.Duration(cfg.TTL) * time.Second)
|
||||
} else {
|
||||
cfg.Client = libpack_cache_redis.NewCacheWrapper(redisClient, cfg.Logger)
|
||||
}
|
||||
} else {
|
||||
cfg.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Using in-memory cache",
|
||||
Pairs: map[string]interface{}{
|
||||
"max_memory_size_bytes": cfg.Memory.MaxMemorySize,
|
||||
"max_entries": cfg.Memory.MaxEntries,
|
||||
},
|
||||
})
|
||||
cfg.Client = libpack_cache_memory.New(time.Duration(cfg.TTL) * time.Second)
|
||||
|
||||
// Use memory size and entry limits if configured, otherwise use defaults
|
||||
if cfg.Memory.MaxMemorySize > 0 || cfg.Memory.MaxEntries > 0 {
|
||||
maxMemory := cfg.Memory.MaxMemorySize
|
||||
if maxMemory <= 0 {
|
||||
maxMemory = libpack_cache_memory.DefaultMaxMemorySize
|
||||
}
|
||||
|
||||
maxEntries := cfg.Memory.MaxEntries
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = libpack_cache_memory.DefaultMaxCacheSize
|
||||
}
|
||||
|
||||
cfg.Client = libpack_cache_memory.NewWithSize(
|
||||
time.Duration(cfg.TTL)*time.Second,
|
||||
maxMemory,
|
||||
maxEntries,
|
||||
)
|
||||
} else {
|
||||
// Backward compatibility
|
||||
cfg.Client = libpack_cache_memory.New(time.Duration(cfg.TTL) * time.Second)
|
||||
}
|
||||
}
|
||||
config = cfg
|
||||
}
|
||||
@@ -93,7 +147,15 @@ func CacheLookup(hash string) []byte {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
defer reader.Close()
|
||||
// Ensure reader is always closed, even on error
|
||||
defer func() {
|
||||
if closeErr := reader.Close(); closeErr != nil {
|
||||
config.Logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Failed to close gzip reader",
|
||||
Pairs: map[string]interface{}{"error": closeErr.Error(), "hash": hash},
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
decompressed, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
@@ -119,7 +181,17 @@ func CacheDelete(hash string) {
|
||||
Message: "Deleting data from cache",
|
||||
Pairs: map[string]interface{}{"hash": hash},
|
||||
})
|
||||
atomic.AddInt64(&cacheStats.CachedQueries, -1)
|
||||
// Use atomic operations with validation to prevent inconsistent statistics
|
||||
for {
|
||||
current := atomic.LoadInt64(&cacheStats.CachedQueries)
|
||||
if current <= 0 {
|
||||
break // Don't go below zero
|
||||
}
|
||||
if atomic.CompareAndSwapInt64(&cacheStats.CachedQueries, current, current-1) {
|
||||
break
|
||||
}
|
||||
// Retry if CAS failed due to concurrent modification
|
||||
}
|
||||
config.Client.Delete(hash)
|
||||
}
|
||||
|
||||
@@ -172,8 +244,28 @@ func GetCacheStats() *CacheStats {
|
||||
config.Logger.Debug(&libpack_logger.LogMessage{
|
||||
Message: "Getting cache stats",
|
||||
})
|
||||
cacheStats.CachedQueries = CacheGetQueries()
|
||||
return cacheStats
|
||||
// Return a copy to avoid race conditions
|
||||
return &CacheStats{
|
||||
CacheHits: atomic.LoadInt64(&cacheStats.CacheHits),
|
||||
CacheMisses: atomic.LoadInt64(&cacheStats.CacheMisses),
|
||||
CachedQueries: CacheGetQueries(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCacheMemoryUsage returns the current memory usage of the cache in bytes
|
||||
func GetCacheMemoryUsage() int64 {
|
||||
if !IsCacheInitialized() {
|
||||
return 0
|
||||
}
|
||||
return config.Client.GetMemoryUsage()
|
||||
}
|
||||
|
||||
// GetCacheMaxMemorySize returns the maximum memory size allowed for the cache in bytes
|
||||
func GetCacheMaxMemorySize() int64 {
|
||||
if !IsCacheInitialized() {
|
||||
return 0
|
||||
}
|
||||
return config.Client.GetMaxMemorySize()
|
||||
}
|
||||
|
||||
func ShouldUseRedisCache(cfg *CacheConfig) bool {
|
||||
|
||||
Vendored
+30
@@ -43,6 +43,36 @@ func (suite *Tests) Test_CalculateHash() {
|
||||
|
||||
assert.NotEqual(hash1, hash2)
|
||||
})
|
||||
|
||||
// Test with GraphQL query and variables
|
||||
suite.Run("graphql with same query different variables", func() {
|
||||
// Same query, different variables should produce different hashes
|
||||
query1 := []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { name } }","variables":{"id":"123"}}`)
|
||||
query2 := []byte(`{"query":"query GetUser($id: ID!) { user(id: $id) { name } }","variables":{"id":"456"}}`)
|
||||
|
||||
ctx.Request().SetBody(query1)
|
||||
hash1 := CalculateHash(ctx)
|
||||
|
||||
ctx.Request().SetBody(query2)
|
||||
hash2 := CalculateHash(ctx)
|
||||
|
||||
assert.NotEqual(hash1, hash2, "Different variables should produce different cache keys")
|
||||
})
|
||||
|
||||
// Test with GraphQL query without variables
|
||||
suite.Run("graphql with and without variables", func() {
|
||||
// Same query with and without variables should produce different hashes
|
||||
query1 := []byte(`{"query":"query GetUsers { users { name } }"}`)
|
||||
query2 := []byte(`{"query":"query GetUsers { users { name } }","variables":{}}`)
|
||||
|
||||
ctx.Request().SetBody(query1)
|
||||
hash1 := CalculateHash(ctx)
|
||||
|
||||
ctx.Request().SetBody(query2)
|
||||
hash2 := CalculateHash(ctx)
|
||||
|
||||
assert.NotEqual(hash1, hash2, "Query with and without variables object should produce different cache keys")
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *Tests) Test_CacheDelete() {
|
||||
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
package libpack_cache_memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/lukaszraczylo/graphql-monitoring-proxy/pkg/pools"
|
||||
)
|
||||
|
||||
// GetBuffer gets a buffer from the pool (delegates to unified implementation)
|
||||
func GetBuffer() *bytes.Buffer {
|
||||
return pools.GetBuffer()
|
||||
}
|
||||
|
||||
// PutBuffer returns a buffer to the pool (delegates to unified implementation)
|
||||
func PutBuffer(buf *bytes.Buffer) {
|
||||
pools.PutBuffer(buf)
|
||||
}
|
||||
Vendored
+218
@@ -0,0 +1,218 @@
|
||||
package libpack_cache_memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestCompressionThreshold tests that values are only compressed when they exceed the threshold
|
||||
func TestCompressionThreshold(t *testing.T) {
|
||||
cache := New(5 * time.Second)
|
||||
|
||||
// Create test values
|
||||
smallValue := make([]byte, CompressionThreshold-100) // Below threshold
|
||||
largeValue := make([]byte, CompressionThreshold*2) // Above threshold
|
||||
|
||||
// Fill values with compressible data (repeating patterns compress well)
|
||||
for i := 0; i < len(smallValue); i++ {
|
||||
smallValue[i] = byte(i % 10)
|
||||
}
|
||||
for i := 0; i < len(largeValue); i++ {
|
||||
largeValue[i] = byte(i % 10)
|
||||
}
|
||||
|
||||
// Test small value
|
||||
cache.Set("small-key", smallValue, 5*time.Second)
|
||||
|
||||
// Extract the entry directly from the cache to check if it's compressed
|
||||
entryRaw, found := cache.entries.Load("small-key")
|
||||
assert.True(t, found, "Entry should exist")
|
||||
|
||||
entry := entryRaw.(CacheEntry)
|
||||
assert.False(t, entry.Compressed, "Small value should not be compressed")
|
||||
assert.Equal(t, smallValue, entry.Value, "Small value should be stored as-is")
|
||||
|
||||
// Test large value
|
||||
cache.Set("large-key", largeValue, 5*time.Second)
|
||||
|
||||
entryRaw, found = cache.entries.Load("large-key")
|
||||
assert.True(t, found, "Entry should exist")
|
||||
|
||||
entry = entryRaw.(CacheEntry)
|
||||
assert.True(t, entry.Compressed, "Large value should be compressed")
|
||||
|
||||
// Ensure the stored value isn't the original
|
||||
assert.NotEqual(t, largeValue, entry.Value, "Large value should not be stored as-is")
|
||||
|
||||
// Verify the value is actually compressed (should be smaller)
|
||||
assert.Less(t, len(entry.Value), len(largeValue), "Compressed value should be smaller than original")
|
||||
|
||||
// Verify we can retrieve the uncompressed value correctly
|
||||
retrievedLarge, found := cache.Get("large-key")
|
||||
assert.True(t, found, "Large value should be retrievable")
|
||||
assert.Equal(t, largeValue, retrievedLarge, "Retrieved large value should match original")
|
||||
}
|
||||
|
||||
// TestCompressionMemoryUsage tests that memory usage is calculated correctly for compressed entries
|
||||
func TestCompressionMemoryUsage(t *testing.T) {
|
||||
cache := New(5 * time.Second)
|
||||
|
||||
// Create a large, highly compressible value
|
||||
valueSize := CompressionThreshold * 4
|
||||
value := make([]byte, valueSize)
|
||||
for i := 0; i < valueSize; i++ {
|
||||
value[i] = byte(i % 2) // Highly compressible pattern (alternating 0s and 1s)
|
||||
}
|
||||
|
||||
// Get initial memory usage
|
||||
initialMemUsage := cache.GetMemoryUsage()
|
||||
|
||||
// Add the value
|
||||
key := "large-compressible-key"
|
||||
cache.Set(key, value, 5*time.Second)
|
||||
|
||||
// Get memory usage after adding
|
||||
newMemUsage := cache.GetMemoryUsage()
|
||||
|
||||
// The memory usage increase should be less than the full value size due to compression
|
||||
memUsageIncrease := newMemUsage - initialMemUsage
|
||||
|
||||
// Extract the entry to check its compressed size
|
||||
entryRaw, found := cache.entries.Load(key)
|
||||
assert.True(t, found, "Entry should exist")
|
||||
|
||||
entry := entryRaw.(CacheEntry)
|
||||
assert.True(t, entry.Compressed, "Value should be compressed")
|
||||
|
||||
// Verify the reported memory usage matches the compressed size + overheads
|
||||
compressedSize := int64(len(entry.Value))
|
||||
keySize := int64(len(key))
|
||||
expectedUsage := compressedSize + keySize + approxEntryOverhead
|
||||
|
||||
// The memory usage should reflect the compressed size, not the original size
|
||||
assert.InDelta(t, expectedUsage, memUsageIncrease, float64(approxEntryOverhead),
|
||||
"Memory usage should be based on compressed size")
|
||||
|
||||
// Verify memory usage is correctly updated after deletion
|
||||
cache.Delete(key)
|
||||
finalMemUsage := cache.GetMemoryUsage()
|
||||
assert.Equal(t, initialMemUsage, finalMemUsage,
|
||||
"Memory usage should return to initial value after deletion")
|
||||
}
|
||||
|
||||
// TestUncompressibleData tests the case where compression doesn't reduce size
|
||||
func TestUncompressibleData(t *testing.T) {
|
||||
cache := New(5 * time.Second)
|
||||
|
||||
// Create a large, random (less compressible) value
|
||||
valueSize := CompressionThreshold * 2
|
||||
|
||||
// Create pseudo-random data that doesn't compress well
|
||||
// Using a custom PRNG for deterministic results across test runs
|
||||
value := make([]byte, valueSize)
|
||||
seed := uint32(42)
|
||||
for i := 0; i < valueSize; i++ {
|
||||
// Simple linear congruential generator
|
||||
seed = seed*1664525 + 1013904223
|
||||
value[i] = byte(seed)
|
||||
}
|
||||
|
||||
// Try to compress it directly to see if it actually would reduce size
|
||||
var buf bytes.Buffer
|
||||
gw := gzip.NewWriter(&buf)
|
||||
_, _ = gw.Write(value)
|
||||
_ = gw.Close()
|
||||
compressedDirectly := buf.Bytes()
|
||||
|
||||
// Now use the cache's Set method
|
||||
key := "uncompressible-key"
|
||||
cache.Set(key, value, 5*time.Second)
|
||||
|
||||
// Extract the entry to check if it's compressed
|
||||
entryRaw, found := cache.entries.Load(key)
|
||||
assert.True(t, found, "Entry should exist")
|
||||
|
||||
entry := entryRaw.(CacheEntry)
|
||||
|
||||
// If our test data actually compressed to a smaller size, we expect the cache to store it compressed
|
||||
if len(compressedDirectly) < len(value) {
|
||||
assert.True(t, entry.Compressed, "Value should be stored compressed if smaller")
|
||||
assert.Less(t, len(entry.Value), len(value), "Compressed value should be smaller")
|
||||
} else {
|
||||
// Uncommon case: our pseudo-random data actually expanded with gzip
|
||||
// In this case, the cache should store it uncompressed
|
||||
assert.False(t, entry.Compressed, "Value should not be compressed if it would expand")
|
||||
assert.Equal(t, value, entry.Value, "Value should be stored as-is")
|
||||
}
|
||||
|
||||
// Regardless, we should be able to get the correct value back
|
||||
retrievedValue, found := cache.Get(key)
|
||||
assert.True(t, found, "Value should be retrievable")
|
||||
assert.Equal(t, value, retrievedValue, "Retrieved value should match original")
|
||||
}
|
||||
|
||||
// TestCompressDecompressDirectly tests the compress and decompress methods directly
|
||||
func TestCompressDecompressDirectly(t *testing.T) {
|
||||
cache := New(5 * time.Second)
|
||||
|
||||
// Test with various sizes
|
||||
testSizes := []int{
|
||||
100, // Small
|
||||
CompressionThreshold - 1, // Just below threshold
|
||||
CompressionThreshold, // At threshold
|
||||
CompressionThreshold + 1, // Just above threshold
|
||||
CompressionThreshold * 2, // Well above threshold
|
||||
}
|
||||
|
||||
for _, size := range testSizes {
|
||||
t.Run("Size-"+string(rune('A'+len(testSizes)%26)), func(t *testing.T) {
|
||||
// Generate test data with a repeating pattern
|
||||
data := make([]byte, size)
|
||||
for i := 0; i < size; i++ {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
// Compress the data
|
||||
compressed, err := cache.compress(data)
|
||||
assert.NoError(t, err, "Compression should not error")
|
||||
|
||||
// Small data may get larger when compressed, larger data should get smaller
|
||||
if size > CompressionThreshold {
|
||||
assert.Less(t, len(compressed), len(data),
|
||||
"Compression should reduce size for data above threshold")
|
||||
}
|
||||
|
||||
// Decompress and verify it matches the original
|
||||
decompressed, err := cache.decompress(compressed)
|
||||
assert.NoError(t, err, "Decompression should not error")
|
||||
assert.Equal(t, data, decompressed, "Data should round-trip correctly through compression")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecompressInvalidData tests handling invalid data in decompress
|
||||
func TestDecompressInvalidData(t *testing.T) {
|
||||
cache := New(5 * time.Second)
|
||||
|
||||
// Try to decompress non-gzip data
|
||||
invalidData := []byte("This is not valid gzip data")
|
||||
_, err := cache.decompress(invalidData)
|
||||
assert.Error(t, err, "Decompressing invalid data should return error")
|
||||
|
||||
// Set compressed flag but store invalid data
|
||||
key := "invalid-compressed-key"
|
||||
cache.entries.Store(key, CacheEntry{
|
||||
Value: invalidData,
|
||||
ExpiresAt: time.Now().Add(5 * time.Second),
|
||||
Compressed: true, // Flag as compressed even though it's not
|
||||
MemorySize: int64(len(invalidData) + len(key) + approxEntryOverhead),
|
||||
})
|
||||
|
||||
// Try to get it - should fail gracefully
|
||||
_, found := cache.Get(key)
|
||||
assert.False(t, found, "Get should fail gracefully for invalid compressed data")
|
||||
}
|
||||
Vendored
+185
@@ -0,0 +1,185 @@
|
||||
package libpack_cache_memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestEvictToFreeMemory tests that the cache correctly evicts
|
||||
// items when it exceeds its memory limit.
|
||||
func TestEvictToFreeMemory(t *testing.T) {
|
||||
// Create a cache with a small memory limit: 5KB (ensure eviction happens)
|
||||
smallMemLimit := int64(5 * 1024)
|
||||
cache := NewWithSize(5*time.Second, smallMemLimit, 1000)
|
||||
|
||||
// Create entries with known sizes
|
||||
// Each entry will be ~512 bytes plus overhead
|
||||
valueSize := 512
|
||||
numEntriesToExceedLimit := 12 // Should exceed the 5KB limit and force eviction
|
||||
|
||||
// Create a slice to track keys in insertion order
|
||||
keys := make([]string, numEntriesToExceedLimit)
|
||||
|
||||
// Add entries with significant delays between insertions
|
||||
for i := 0; i < numEntriesToExceedLimit; i++ {
|
||||
key := fmt.Sprintf("test-key-%d", i)
|
||||
keys[i] = key
|
||||
|
||||
value := make([]byte, valueSize)
|
||||
for j := 0; j < valueSize; j++ {
|
||||
value[j] = byte(i % 256) // Fill with a repeating pattern
|
||||
}
|
||||
|
||||
cache.Set(key, value, 30*time.Second)
|
||||
|
||||
// More significant delay to ensure different timestamps
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Allow time for eviction to complete
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Verify memory usage is below the limit
|
||||
memUsage := cache.GetMemoryUsage()
|
||||
assert.LessOrEqual(t, memUsage, smallMemLimit,
|
||||
"Memory usage (%d) should be less than or equal to the limit (%d)", memUsage, smallMemLimit)
|
||||
|
||||
// Count how many items are left in the cache and which ones
|
||||
present := 0
|
||||
for i := 0; i < numEntriesToExceedLimit; i++ {
|
||||
_, found := cache.Get(keys[i])
|
||||
if found {
|
||||
present++
|
||||
}
|
||||
}
|
||||
|
||||
// We expect some items to be evicted based on the memory limit
|
||||
assert.Less(t, present, numEntriesToExceedLimit,
|
||||
"Some items should have been evicted (%d present out of %d total)",
|
||||
present, numEntriesToExceedLimit)
|
||||
|
||||
// Verify newer items (inserted later) are more likely to be in the cache
|
||||
// Check the last few items which should be the newest
|
||||
for i := numEntriesToExceedLimit - 3; i < numEntriesToExceedLimit; i++ {
|
||||
_, found := cache.Get(keys[i])
|
||||
assert.True(t, found, "Newer key %s should still exist", keys[i])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxCacheSize verifies the behavior when adding more items than the maxCacheSize limit
|
||||
func TestMaxCacheSize(t *testing.T) {
|
||||
// Create a cache with a small limit
|
||||
smallLimit := int64(5)
|
||||
cache := NewWithSize(5*time.Second, DefaultMaxMemorySize, smallLimit)
|
||||
|
||||
// Add entries with increasing size (to avoid memory-based eviction)
|
||||
for i := 0; i < 20; i++ {
|
||||
key := fmt.Sprintf("test-key-%d", i)
|
||||
value := []byte(key)
|
||||
cache.Set(key, value, 10*time.Second)
|
||||
}
|
||||
|
||||
// Verify we can get a reasonable number of items
|
||||
// (we don't test for exact count as implementation may vary)
|
||||
foundCount := 0
|
||||
for i := 0; i < 20; i++ {
|
||||
key := fmt.Sprintf("test-key-%d", i)
|
||||
_, found := cache.Get(key)
|
||||
if found {
|
||||
foundCount++
|
||||
}
|
||||
}
|
||||
|
||||
// We should find some items but not all 20
|
||||
assert.Greater(t, foundCount, 0, "Some items should be in the cache")
|
||||
assert.LessOrEqual(t, foundCount, 20, "Not all items should be in the cache with small limit")
|
||||
}
|
||||
|
||||
// TestGetMemoryUsage verifies that memory usage tracking is accurate
|
||||
func TestGetMemoryUsage(t *testing.T) {
|
||||
cache := New(5 * time.Second)
|
||||
|
||||
// Initially memory usage should be 0
|
||||
assert.Equal(t, int64(0), cache.GetMemoryUsage(), "Initial memory usage should be 0")
|
||||
|
||||
// Add an entry with a known approximate size
|
||||
valueSize := 1024
|
||||
value := make([]byte, valueSize)
|
||||
key := "test-key"
|
||||
|
||||
cache.Set(key, value, 5*time.Second)
|
||||
|
||||
// Check memory usage - should be approximately valueSize + key length + overhead
|
||||
expectedMinUsage := int64(valueSize + len(key))
|
||||
memUsage := cache.GetMemoryUsage()
|
||||
assert.GreaterOrEqual(t, memUsage, expectedMinUsage,
|
||||
"Memory usage (%d) should be at least the value size plus key length (%d)", memUsage, expectedMinUsage)
|
||||
|
||||
// Delete the entry and verify memory usage decreases
|
||||
cache.Delete(key)
|
||||
assert.Equal(t, int64(0), cache.GetMemoryUsage(), "Memory usage should be 0 after deletion")
|
||||
}
|
||||
|
||||
// TestSetMaxMemorySize tests changing the memory limit and resulting eviction
|
||||
func TestSetMaxMemorySize(t *testing.T) {
|
||||
// Start with a large limit
|
||||
initialLimit := int64(100 * 1024)
|
||||
cache := NewWithSize(5*time.Second, initialLimit, 1000)
|
||||
|
||||
// Fill the cache with ~50KB of data
|
||||
valueSize := 1024
|
||||
numEntries := 50
|
||||
|
||||
for i := 0; i < numEntries; i++ {
|
||||
key := generateKey(i)
|
||||
value := make([]byte, valueSize)
|
||||
cache.Set(key, value, 5*time.Second)
|
||||
|
||||
// Small delay for timestamp differences
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
// Verify all entries exist
|
||||
for i := 0; i < numEntries; i++ {
|
||||
_, found := cache.Get(generateKey(i))
|
||||
assert.True(t, found, "All entries should exist before limit change")
|
||||
}
|
||||
|
||||
// Get current memory usage
|
||||
originalUsage := cache.GetMemoryUsage()
|
||||
|
||||
// Now reduce the limit to 20KB - should trigger eviction
|
||||
newLimit := int64(20 * 1024)
|
||||
cache.SetMaxMemorySize(newLimit)
|
||||
|
||||
// Verify memory usage is now below the new limit
|
||||
newUsage := cache.GetMemoryUsage()
|
||||
assert.LessOrEqual(t, newUsage, newLimit,
|
||||
"After SetMaxMemorySize, memory usage (%d) should be less than or equal to new limit (%d)",
|
||||
newUsage, newLimit)
|
||||
assert.Less(t, newUsage, originalUsage,
|
||||
"Memory usage should have decreased after lowering the limit")
|
||||
|
||||
// Some older entries should be gone, newer ones should still exist
|
||||
removedCount := 0
|
||||
remainingCount := 0
|
||||
for i := 0; i < numEntries; i++ {
|
||||
_, found := cache.Get(generateKey(i))
|
||||
if found {
|
||||
remainingCount++
|
||||
} else {
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Greater(t, removedCount, 0, "Some entries should have been removed")
|
||||
assert.Greater(t, remainingCount, 0, "Some entries should still exist")
|
||||
}
|
||||
|
||||
// Helper function to generate consistent keys
|
||||
func generateKey(index int) string {
|
||||
return "test-key-" + fmt.Sprintf("%d", index)
|
||||
}
|
||||
Vendored
+281
@@ -0,0 +1,281 @@
|
||||
package libpack_cache_memory
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"container/list"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LRUMemoryCache is an efficient LRU-based memory cache implementation
|
||||
type LRUMemoryCache struct {
|
||||
entries map[string]*lruEntry
|
||||
evictList *list.List
|
||||
gzipWriterPool *sync.Pool
|
||||
gzipReaderPool *sync.Pool
|
||||
maxMemorySize int64
|
||||
maxEntries int64
|
||||
currentMemory int64
|
||||
currentCount int64
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type lruEntry struct {
|
||||
expiresAt time.Time
|
||||
element *list.Element
|
||||
key string
|
||||
value []byte
|
||||
size int64
|
||||
compressed bool
|
||||
}
|
||||
|
||||
// NewLRUMemoryCache creates a new LRU memory cache
|
||||
func NewLRUMemoryCache(maxMemorySize, maxEntries int64) *LRUMemoryCache {
|
||||
return &LRUMemoryCache{
|
||||
maxMemorySize: maxMemorySize,
|
||||
maxEntries: maxEntries,
|
||||
entries: make(map[string]*lruEntry),
|
||||
evictList: list.New(),
|
||||
gzipWriterPool: &sync.Pool{
|
||||
New: func() interface{} {
|
||||
return gzip.NewWriter(nil)
|
||||
},
|
||||
},
|
||||
gzipReaderPool: &sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &gzip.Reader{}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Set adds or updates an entry in the cache
|
||||
func (c *LRUMemoryCache) Set(key string, value []byte, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Calculate expiry time
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
|
||||
// Check if we should compress
|
||||
compressed := false
|
||||
finalValue := value
|
||||
if len(value) > 1024 { // Compress if larger than 1KB
|
||||
if compressedData, err := c.compress(value); err == nil && len(compressedData) < len(value) {
|
||||
compressed = true
|
||||
finalValue = compressedData
|
||||
}
|
||||
}
|
||||
|
||||
entrySize := int64(len(key) + len(finalValue) + 64) // 64 bytes overhead estimate
|
||||
|
||||
// Check if key exists
|
||||
if existing, exists := c.entries[key]; exists {
|
||||
// Update existing entry
|
||||
c.evictList.MoveToFront(existing.element)
|
||||
atomic.AddInt64(&c.currentMemory, -existing.size)
|
||||
atomic.AddInt64(&c.currentMemory, entrySize)
|
||||
|
||||
existing.value = finalValue
|
||||
existing.compressed = compressed
|
||||
existing.size = entrySize
|
||||
existing.expiresAt = expiresAt
|
||||
|
||||
c.evictIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
entry := &lruEntry{
|
||||
key: key,
|
||||
value: finalValue,
|
||||
compressed: compressed,
|
||||
size: entrySize,
|
||||
expiresAt: expiresAt,
|
||||
}
|
||||
|
||||
element := c.evictList.PushFront(entry)
|
||||
entry.element = element
|
||||
c.entries[key] = entry
|
||||
|
||||
atomic.AddInt64(&c.currentMemory, entrySize)
|
||||
atomic.AddInt64(&c.currentCount, 1)
|
||||
|
||||
c.evictIfNeeded()
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *LRUMemoryCache) Get(key string) ([]byte, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, exists := c.entries[key]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
c.removeEntry(entry)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Move to front (most recently used)
|
||||
c.evictList.MoveToFront(entry.element)
|
||||
|
||||
// Decompress if needed
|
||||
if entry.compressed {
|
||||
if decompressed, err := c.decompress(entry.value); err == nil {
|
||||
return decompressed, true
|
||||
}
|
||||
// If decompression fails, remove the entry
|
||||
c.removeEntry(entry)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.value, true
|
||||
}
|
||||
|
||||
// Delete removes an entry from the cache
|
||||
func (c *LRUMemoryCache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if entry, exists := c.entries[key]; exists {
|
||||
c.removeEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all entries
|
||||
func (c *LRUMemoryCache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.entries = make(map[string]*lruEntry)
|
||||
c.evictList = list.New()
|
||||
atomic.StoreInt64(&c.currentMemory, 0)
|
||||
atomic.StoreInt64(&c.currentCount, 0)
|
||||
}
|
||||
|
||||
// evictIfNeeded removes entries when limits are exceeded
|
||||
func (c *LRUMemoryCache) evictIfNeeded() {
|
||||
// Evict based on entry count
|
||||
for atomic.LoadInt64(&c.currentCount) > c.maxEntries && c.evictList.Len() > 0 {
|
||||
c.evictOldest()
|
||||
}
|
||||
|
||||
// Evict based on memory
|
||||
for atomic.LoadInt64(&c.currentMemory) > c.maxMemorySize && c.evictList.Len() > 0 {
|
||||
c.evictOldest()
|
||||
}
|
||||
}
|
||||
|
||||
// evictOldest removes the least recently used entry
|
||||
func (c *LRUMemoryCache) evictOldest() {
|
||||
element := c.evictList.Back()
|
||||
if element == nil {
|
||||
return
|
||||
}
|
||||
|
||||
entry := element.Value.(*lruEntry)
|
||||
c.removeEntry(entry)
|
||||
}
|
||||
|
||||
// removeEntry removes an entry from all data structures
|
||||
func (c *LRUMemoryCache) removeEntry(entry *lruEntry) {
|
||||
c.evictList.Remove(entry.element)
|
||||
delete(c.entries, entry.key)
|
||||
atomic.AddInt64(&c.currentMemory, -entry.size)
|
||||
atomic.AddInt64(&c.currentCount, -1)
|
||||
}
|
||||
|
||||
// CleanExpiredEntries removes all expired entries
|
||||
func (c *LRUMemoryCache) CleanExpiredEntries() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for element := c.evictList.Back(); element != nil; {
|
||||
entry := element.Value.(*lruEntry)
|
||||
|
||||
if now.After(entry.expiresAt) {
|
||||
next := element.Prev()
|
||||
c.removeEntry(entry)
|
||||
element = next
|
||||
} else {
|
||||
element = element.Prev()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compress compresses data using gzip
|
||||
func (c *LRUMemoryCache) compress(data []byte) ([]byte, error) {
|
||||
buf := GetBuffer()
|
||||
defer PutBuffer(buf)
|
||||
|
||||
gz := c.gzipWriterPool.Get().(*gzip.Writer)
|
||||
gz.Reset(buf)
|
||||
defer c.gzipWriterPool.Put(gz)
|
||||
|
||||
if _, err := gz.Write(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
compressed := make([]byte, buf.Len())
|
||||
copy(compressed, buf.Bytes())
|
||||
return compressed, nil
|
||||
}
|
||||
|
||||
// decompress decompresses gzip data
|
||||
func (c *LRUMemoryCache) decompress(data []byte) ([]byte, error) {
|
||||
buf := GetBuffer()
|
||||
defer PutBuffer(buf)
|
||||
|
||||
buf.Write(data)
|
||||
|
||||
gr := c.gzipReaderPool.Get().(*gzip.Reader)
|
||||
defer c.gzipReaderPool.Put(gr)
|
||||
|
||||
if err := gr.Reset(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := GetBuffer()
|
||||
defer PutBuffer(result)
|
||||
|
||||
if _, err := result.ReadFrom(gr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decompressed := make([]byte, result.Len())
|
||||
copy(decompressed, result.Bytes())
|
||||
return decompressed, nil
|
||||
}
|
||||
|
||||
// GetStats returns cache statistics
|
||||
func (c *LRUMemoryCache) GetStats() map[string]interface{} {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"entries": atomic.LoadInt64(&c.currentCount),
|
||||
"memory_bytes": atomic.LoadInt64(&c.currentMemory),
|
||||
"max_entries": c.maxEntries,
|
||||
"max_memory": c.maxMemorySize,
|
||||
"fill_percent": float64(atomic.LoadInt64(&c.currentMemory)) / float64(c.maxMemorySize) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
// GetMemoryUsage returns current memory usage in bytes
|
||||
func (c *LRUMemoryCache) GetMemoryUsage() int64 {
|
||||
return atomic.LoadInt64(&c.currentMemory)
|
||||
}
|
||||
|
||||
// GetMaxMemorySize returns the maximum memory size
|
||||
func (c *LRUMemoryCache) GetMaxMemorySize() int64 {
|
||||
return c.maxMemorySize
|
||||
}
|
||||
Vendored
+154
-21
@@ -3,8 +3,8 @@ package libpack_cache_memory
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -13,27 +13,53 @@ import (
|
||||
// CompressionThreshold is the minimum size in bytes before a value is compressed
|
||||
const CompressionThreshold = 1024 // 1KB
|
||||
|
||||
// MaxCacheSize is the maximum number of entries in the cache
|
||||
const MaxCacheSize = 10000
|
||||
// DefaultMaxMemorySize is the default maximum memory size in bytes (100MB)
|
||||
const DefaultMaxMemorySize = 100 * 1024 * 1024
|
||||
|
||||
// DefaultMaxCacheSize is the default maximum number of entries in the cache
|
||||
// This is used for backward compatibility
|
||||
const DefaultMaxCacheSize = 10000
|
||||
|
||||
// approxEntryOverhead is the estimated overhead per cache entry in bytes
|
||||
// This accounts for the CacheEntry struct overhead, map entry, and synchronization
|
||||
const approxEntryOverhead = 64
|
||||
|
||||
type CacheEntry struct {
|
||||
ExpiresAt time.Time
|
||||
Value []byte
|
||||
Compressed bool
|
||||
MemorySize int64 // Estimated memory usage of this entry in bytes
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
compressPool sync.Pool
|
||||
decompressPool sync.Pool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
entries sync.Map
|
||||
globalTTL time.Duration
|
||||
entryCount int64
|
||||
memoryUsage int64
|
||||
maxMemorySize int64
|
||||
maxCacheSize int64
|
||||
sync.RWMutex
|
||||
}
|
||||
|
||||
func New(globalTTL time.Duration) *Cache {
|
||||
return NewWithSize(globalTTL, DefaultMaxMemorySize, DefaultMaxCacheSize)
|
||||
}
|
||||
|
||||
// NewWithSize creates a new cache with the specified memory size limit and entry count limit
|
||||
func NewWithSize(globalTTL time.Duration, maxMemorySize int64, maxCacheSize int64) *Cache {
|
||||
// Create context for graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
cache := &Cache{
|
||||
globalTTL: globalTTL,
|
||||
globalTTL: globalTTL,
|
||||
maxMemorySize: maxMemorySize,
|
||||
maxCacheSize: maxCacheSize,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
compressPool: sync.Pool{
|
||||
New: func() interface{} {
|
||||
return gzip.NewWriter(nil)
|
||||
@@ -47,7 +73,7 @@ func New(globalTTL time.Duration) *Cache {
|
||||
},
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
// Start cleanup routine with context cancellation
|
||||
go cache.cleanupRoutine(globalTTL)
|
||||
return cache
|
||||
}
|
||||
@@ -57,20 +83,40 @@ func (c *Cache) cleanupRoutine(globalTTL time.Duration) {
|
||||
ticker := time.NewTicker(globalTTL / 4)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.CleanExpiredEntries()
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
// Context cancelled, exit gracefully
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.CleanExpiredEntries()
|
||||
|
||||
// Trigger GC if we have a lot of entries
|
||||
if atomic.LoadInt64(&c.entryCount) > MaxCacheSize/2 {
|
||||
runtime.GC()
|
||||
// Note: Removed aggressive GC trigger that was causing performance issues
|
||||
// The Go runtime GC is already optimized and will run when needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the cache cleanup routine
|
||||
func (c *Cache) Shutdown() {
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
|
||||
// Check if we've reached the maximum cache size
|
||||
if atomic.LoadInt64(&c.entryCount) >= MaxCacheSize {
|
||||
c.evictOldest(MaxCacheSize / 10) // Evict 10% of entries
|
||||
// Calculate the memory size of this entry
|
||||
entrySize := int64(len(key) + len(value) + approxEntryOverhead)
|
||||
|
||||
// Check if we need to evict entries based on memory or count limits
|
||||
currentMemory := atomic.LoadInt64(&c.memoryUsage)
|
||||
if currentMemory+entrySize > c.maxMemorySize {
|
||||
// Need to evict based on memory
|
||||
memoryToFree := (currentMemory + entrySize) - c.maxMemorySize + (c.maxMemorySize / 10)
|
||||
c.evictToFreeMemory(memoryToFree)
|
||||
} else if atomic.LoadInt64(&c.entryCount) >= c.maxCacheSize {
|
||||
// Fall back to count-based eviction for backward compatibility
|
||||
c.evictOldest(int(c.maxCacheSize / 10)) // Evict 10% of entries
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(ttl)
|
||||
@@ -101,12 +147,26 @@ func (c *Cache) Set(key string, value []byte, ttl time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a new entry
|
||||
_, exists := c.entries.Load(key)
|
||||
if !exists {
|
||||
// Update the entry memory size based on compression status
|
||||
if entry.Compressed {
|
||||
entry.MemorySize = int64(len(key) + len(entry.Value) + approxEntryOverhead)
|
||||
} else {
|
||||
entry.MemorySize = int64(len(key) + len(entry.Value) + approxEntryOverhead)
|
||||
}
|
||||
|
||||
// Check if this is a new entry or an update
|
||||
oldEntry, exists := c.entries.Load(key)
|
||||
if exists {
|
||||
// Update memory usage: subtract old entry size, add new entry size
|
||||
oldCacheEntry := oldEntry.(CacheEntry)
|
||||
atomic.AddInt64(&c.memoryUsage, -oldCacheEntry.MemorySize)
|
||||
} else {
|
||||
// New entry
|
||||
atomic.AddInt64(&c.entryCount, 1)
|
||||
}
|
||||
|
||||
// Add new entry's memory size to total
|
||||
atomic.AddInt64(&c.memoryUsage, entry.MemorySize)
|
||||
c.entries.Store(key, entry)
|
||||
}
|
||||
|
||||
@@ -120,6 +180,7 @@ func (c *Cache) Get(key string) ([]byte, bool) {
|
||||
if cacheEntry.ExpiresAt.Before(time.Now()) {
|
||||
c.entries.Delete(key)
|
||||
atomic.AddInt64(&c.entryCount, -1)
|
||||
atomic.AddInt64(&c.memoryUsage, -cacheEntry.MemorySize)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -135,8 +196,10 @@ func (c *Cache) Get(key string) ([]byte, bool) {
|
||||
}
|
||||
|
||||
func (c *Cache) Delete(key string) {
|
||||
if _, exists := c.entries.LoadAndDelete(key); exists {
|
||||
if entry, exists := c.entries.LoadAndDelete(key); exists {
|
||||
cacheEntry := entry.(CacheEntry)
|
||||
atomic.AddInt64(&c.entryCount, -1)
|
||||
atomic.AddInt64(&c.memoryUsage, -cacheEntry.MemorySize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +209,7 @@ func (c *Cache) Clear() {
|
||||
return true
|
||||
})
|
||||
atomic.StoreInt64(&c.entryCount, 0)
|
||||
atomic.StoreInt64(&c.memoryUsage, 0)
|
||||
}
|
||||
|
||||
func (c *Cache) CountQueries() int64 {
|
||||
@@ -183,7 +247,9 @@ func (c *Cache) decompress(data []byte) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
defer r.Close()
|
||||
defer func() {
|
||||
_ = r.Close() // Ignore error in defer cleanup
|
||||
}()
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
|
||||
@@ -194,6 +260,7 @@ func (c *Cache) CleanExpiredEntries() {
|
||||
if entry.ExpiresAt.Before(now) {
|
||||
if _, exists := c.entries.LoadAndDelete(key); exists {
|
||||
atomic.AddInt64(&c.entryCount, -1)
|
||||
atomic.AddInt64(&c.memoryUsage, -entry.MemorySize)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -203,8 +270,8 @@ func (c *Cache) CleanExpiredEntries() {
|
||||
// evictOldest removes the oldest n entries from the cache
|
||||
func (c *Cache) evictOldest(n int) {
|
||||
type keyExpiry struct {
|
||||
key string
|
||||
expiresAt time.Time
|
||||
key string
|
||||
}
|
||||
|
||||
// Collect all entries with their expiry times
|
||||
@@ -212,7 +279,7 @@ func (c *Cache) evictOldest(n int) {
|
||||
c.entries.Range(func(k, v interface{}) bool {
|
||||
key := k.(string)
|
||||
entry := v.(CacheEntry)
|
||||
entries = append(entries, keyExpiry{key, entry.ExpiresAt})
|
||||
entries = append(entries, keyExpiry{entry.ExpiresAt, key})
|
||||
return len(entries) < cap(entries)
|
||||
})
|
||||
|
||||
@@ -231,8 +298,74 @@ func (c *Cache) evictOldest(n int) {
|
||||
}
|
||||
|
||||
// Delete this entry
|
||||
if _, exists := c.entries.LoadAndDelete(entries[i].key); exists {
|
||||
if entry, exists := c.entries.LoadAndDelete(entries[i].key); exists {
|
||||
cacheEntry := entry.(CacheEntry)
|
||||
atomic.AddInt64(&c.entryCount, -1)
|
||||
atomic.AddInt64(&c.memoryUsage, -cacheEntry.MemorySize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// evictToFreeMemory removes entries until the specified amount of memory is freed
|
||||
func (c *Cache) evictToFreeMemory(bytesToFree int64) {
|
||||
type keyMemorySize struct {
|
||||
expiresAt time.Time
|
||||
key string
|
||||
memorySize int64
|
||||
}
|
||||
|
||||
// Collect entries to consider for eviction
|
||||
entries := make([]keyMemorySize, 0, int(c.maxCacheSize/5))
|
||||
c.entries.Range(func(k, v interface{}) bool {
|
||||
key := k.(string)
|
||||
entry := v.(CacheEntry)
|
||||
entries = append(entries, keyMemorySize{entry.ExpiresAt, key, entry.MemorySize})
|
||||
return len(entries) < cap(entries)
|
||||
})
|
||||
|
||||
// Sort entries by expiry time (oldest first)
|
||||
// Simple selection sort since we only need to find the oldest entries
|
||||
var freedBytes int64
|
||||
for i := 0; i < len(entries) && freedBytes < bytesToFree; i++ {
|
||||
oldest := i
|
||||
for j := i + 1; j < len(entries); j++ {
|
||||
if entries[j].expiresAt.Before(entries[oldest].expiresAt) {
|
||||
oldest = j
|
||||
}
|
||||
}
|
||||
// Swap
|
||||
if oldest != i {
|
||||
entries[i], entries[oldest] = entries[oldest], entries[i]
|
||||
}
|
||||
|
||||
// Delete this entry
|
||||
if entry, exists := c.entries.LoadAndDelete(entries[i].key); exists {
|
||||
cacheEntry := entry.(CacheEntry)
|
||||
atomic.AddInt64(&c.entryCount, -1)
|
||||
atomic.AddInt64(&c.memoryUsage, -cacheEntry.MemorySize)
|
||||
freedBytes += cacheEntry.MemorySize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetMemoryUsage returns the current memory usage of the cache in bytes
|
||||
func (c *Cache) GetMemoryUsage() int64 {
|
||||
return atomic.LoadInt64(&c.memoryUsage)
|
||||
}
|
||||
|
||||
// GetMaxMemorySize returns the maximum memory size allowed for the cache in bytes
|
||||
func (c *Cache) GetMaxMemorySize() int64 {
|
||||
return c.maxMemorySize
|
||||
}
|
||||
|
||||
// SetMaxMemorySize updates the maximum memory size allowed for the cache
|
||||
func (c *Cache) SetMaxMemorySize(maxBytes int64) {
|
||||
c.maxMemorySize = maxBytes
|
||||
|
||||
// Check if we need to evict entries due to the new limit
|
||||
currentMemory := atomic.LoadInt64(&c.memoryUsage)
|
||||
if currentMemory > maxBytes {
|
||||
memoryToFree := currentMemory - maxBytes + (maxBytes / 10)
|
||||
c.evictToFreeMemory(memoryToFree)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+45
-20
@@ -3,9 +3,8 @@ package libpack_cache_redis
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
redis "github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -33,7 +32,7 @@ type RedisClientConfig struct {
|
||||
RedisDB int
|
||||
}
|
||||
|
||||
func New(redisClientConfig *RedisClientConfig) *RedisConfig {
|
||||
func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
|
||||
c := &RedisConfig{
|
||||
client: redis.NewClient(&redis.Options{
|
||||
Addr: redisClientConfig.RedisServer,
|
||||
@@ -51,46 +50,72 @@ func New(redisClientConfig *RedisClientConfig) *RedisConfig {
|
||||
|
||||
_, err := c.client.Ping(c.ctx).Result()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
return c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *RedisConfig) Set(key string, value []byte, ttl time.Duration) {
|
||||
c.client.Set(c.ctx, c.prependKeyName(key), value, ttl)
|
||||
func (c *RedisConfig) Set(key string, value []byte, ttl time.Duration) error {
|
||||
return c.client.Set(c.ctx, c.prependKeyName(key), value, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *RedisConfig) Get(key string) ([]byte, bool) {
|
||||
func (c *RedisConfig) Get(key string) ([]byte, bool, error) {
|
||||
val, err := c.client.Get(c.ctx, c.prependKeyName(key)).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, false
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false
|
||||
return nil, false, err
|
||||
}
|
||||
return []byte(val), true
|
||||
return []byte(val), true, nil
|
||||
}
|
||||
|
||||
func (c *RedisConfig) Delete(key string) {
|
||||
c.client.Del(c.ctx, c.prependKeyName(key))
|
||||
func (c *RedisConfig) Delete(key string) error {
|
||||
return c.client.Del(c.ctx, c.prependKeyName(key)).Err()
|
||||
}
|
||||
|
||||
func (c *RedisConfig) Clear() {
|
||||
c.client.FlushDB(c.ctx)
|
||||
func (c *RedisConfig) Clear() error {
|
||||
return c.client.FlushDB(c.ctx).Err()
|
||||
}
|
||||
|
||||
func (c *RedisConfig) CountQueries() int64 {
|
||||
func (c *RedisConfig) CountQueries() (int64, error) {
|
||||
keys, err := c.client.Keys(c.ctx, c.prependKeyName("*")).Result()
|
||||
if err != nil {
|
||||
return 0
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(keys))
|
||||
return int64(len(keys)), nil
|
||||
}
|
||||
|
||||
func (c *RedisConfig) CountQueriesWithPattern(pattern string) int {
|
||||
func (c *RedisConfig) CountQueriesWithPattern(pattern string) (int, error) {
|
||||
keys, err := c.client.Keys(c.ctx, c.prependKeyName(pattern)).Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(keys), nil
|
||||
}
|
||||
|
||||
// GetMemoryUsage returns an approximation of memory usage for Redis
|
||||
// For Redis, this is not as accurate as the memory cache implementation
|
||||
// as actual memory is managed by Redis server
|
||||
func (c *RedisConfig) GetMemoryUsage() int64 {
|
||||
// We could attempt to get memory usage from Redis info
|
||||
// but for now, we'll just return 0 since Redis manages its own memory
|
||||
// and this information would require parsing the INFO command output
|
||||
_, err := c.client.Info(c.ctx, "memory").Result()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(keys)
|
||||
|
||||
// Just return 0 as a placeholder since Redis manages its own memory
|
||||
// In a production environment, you could parse the Redis INFO command result
|
||||
// to extract actual "used_memory" value
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetMaxMemorySize returns the configured max memory for Redis
|
||||
// In Redis, this would be the 'maxmemory' configuration value
|
||||
func (c *RedisConfig) GetMaxMemorySize() int64 {
|
||||
// Return a default value as Redis manages its own memory limits
|
||||
// In a production environment, you could get this from Redis config
|
||||
return 0
|
||||
}
|
||||
|
||||
Vendored
+22
-10
@@ -17,34 +17,46 @@ func TestRedisClear(t *testing.T) {
|
||||
defer s.Close()
|
||||
|
||||
// Create a Redis client
|
||||
redisConfig := New(&RedisClientConfig{
|
||||
redisConfig, err := New(&RedisClientConfig{
|
||||
RedisServer: s.Addr(),
|
||||
RedisPassword: "",
|
||||
RedisDB: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create Redis client: %v", err)
|
||||
}
|
||||
|
||||
// Add some test data
|
||||
ttl := time.Duration(60) * time.Second
|
||||
redisConfig.Set("key1", []byte("value1"), ttl)
|
||||
redisConfig.Set("key2", []byte("value2"), ttl)
|
||||
redisConfig.Set("key3", []byte("value3"), ttl)
|
||||
err = redisConfig.Set("key1", []byte("value1"), ttl)
|
||||
assert.NoError(t, err)
|
||||
err = redisConfig.Set("key2", []byte("value2"), ttl)
|
||||
assert.NoError(t, err)
|
||||
err = redisConfig.Set("key3", []byte("value3"), ttl)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify keys exist
|
||||
count := redisConfig.CountQueries()
|
||||
count, err := redisConfig.CountQueries()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(3), count, "Expected 3 keys before clearing cache")
|
||||
|
||||
// Clear the cache
|
||||
redisConfig.Clear()
|
||||
err = redisConfig.Clear()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify all keys are gone
|
||||
count = redisConfig.CountQueries()
|
||||
count, err = redisConfig.CountQueries()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(0), count, "Expected 0 keys after clearing cache")
|
||||
|
||||
// Verify individual keys are gone
|
||||
_, found := redisConfig.Get("key1")
|
||||
_, found, err := redisConfig.Get("key1")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, found, "Key1 should be deleted after Clear")
|
||||
_, found = redisConfig.Get("key2")
|
||||
_, found, err = redisConfig.Get("key2")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, found, "Key2 should be deleted after Clear")
|
||||
_, found = redisConfig.Get("key3")
|
||||
_, found, err = redisConfig.Get("key3")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, found, "Key3 should be deleted after Clear")
|
||||
}
|
||||
|
||||
Vendored
+52
-24
@@ -17,11 +17,13 @@ type RedisConfigSuite struct {
|
||||
|
||||
func (suite *RedisConfigSuite) SetupTest() {
|
||||
suite.redis_server, _ = miniredis.Run()
|
||||
suite.redisConfig = New(&RedisClientConfig{
|
||||
var err error
|
||||
suite.redisConfig, err = New(&RedisClientConfig{
|
||||
RedisServer: suite.redis_server.Addr(),
|
||||
RedisPassword: "",
|
||||
RedisDB: 0,
|
||||
})
|
||||
assert.NoError(suite.T(), err)
|
||||
suite.redisConfig.Delete("testkey")
|
||||
}
|
||||
|
||||
@@ -35,15 +37,19 @@ func (suite *RedisConfigSuite) TestSet() {
|
||||
suite.redisConfig.Delete(key) // Ensure the key is deleted before the test
|
||||
|
||||
// Test writing a new key-value pair
|
||||
suite.redisConfig.Set(key, value, 0)
|
||||
storedValue, found := suite.redisConfig.Get(key)
|
||||
err := suite.redisConfig.Set(key, value, 0)
|
||||
assert.NoError(suite.T(), err)
|
||||
storedValue, found, err := suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.True(suite.T(), found)
|
||||
assert.Equal(suite.T(), value, storedValue)
|
||||
|
||||
// Test overwriting an existing key-value pair
|
||||
newValue := []byte("newvalue")
|
||||
suite.redisConfig.Set(key, newValue, 0)
|
||||
storedValue, found = suite.redisConfig.Get(key)
|
||||
err = suite.redisConfig.Set(key, newValue, 0)
|
||||
assert.NoError(suite.T(), err)
|
||||
storedValue, found, err = suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.True(suite.T(), found)
|
||||
assert.Equal(suite.T(), newValue, storedValue)
|
||||
|
||||
@@ -57,16 +63,20 @@ func (suite *RedisConfigSuite) TestSetWithExpiry() {
|
||||
suite.redisConfig.Delete(key) // Ensure the key is deleted before the test
|
||||
|
||||
// Test writing a new key-value pair
|
||||
suite.redisConfig.Set(key, value, expiry)
|
||||
storedValue, found := suite.redisConfig.Get(key)
|
||||
err := suite.redisConfig.Set(key, value, expiry)
|
||||
assert.NoError(suite.T(), err)
|
||||
storedValue, found, err := suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.True(suite.T(), found)
|
||||
assert.Equal(suite.T(), value, storedValue)
|
||||
_, found = suite.redisConfig.Get(key)
|
||||
_, found, err = suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.True(suite.T(), found, "Key should exist")
|
||||
|
||||
// Test that key expires after the specified time
|
||||
suite.redis_server.FastForward(3 * time.Second)
|
||||
_, found = suite.redisConfig.Get(key)
|
||||
_, found, err = suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.False(suite.T(), found, "Key should have expired after 2 seconds")
|
||||
|
||||
suite.redisConfig.Delete(key) // Clean up after the test
|
||||
@@ -75,8 +85,10 @@ func (suite *RedisConfigSuite) TestSetWithExpiry() {
|
||||
func (suite *RedisConfigSuite) TestGet() {
|
||||
key := "testkeyget"
|
||||
value := []byte("testvalue")
|
||||
suite.redisConfig.Set(key, value, 0) // Set the key-value pair
|
||||
storedValue, found := suite.redisConfig.Get(key)
|
||||
err := suite.redisConfig.Set(key, value, 0) // Set the key-value pair
|
||||
assert.NoError(suite.T(), err)
|
||||
storedValue, found, err := suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.True(suite.T(), found)
|
||||
assert.Equal(suite.T(), value, storedValue)
|
||||
}
|
||||
@@ -84,9 +96,12 @@ func (suite *RedisConfigSuite) TestGet() {
|
||||
func (suite *RedisConfigSuite) TestDeleteKey() {
|
||||
key := "testkeydelete"
|
||||
value := []byte("testvalue")
|
||||
suite.redisConfig.Set(key, value, 0) // Set the key-value pair
|
||||
suite.redisConfig.Delete(key)
|
||||
_, found := suite.redisConfig.Get(key)
|
||||
err := suite.redisConfig.Set(key, value, 0) // Set the key-value pair
|
||||
assert.NoError(suite.T(), err)
|
||||
err = suite.redisConfig.Delete(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
_, found, err := suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.False(suite.T(), found)
|
||||
}
|
||||
|
||||
@@ -94,20 +109,27 @@ func (suite *RedisConfigSuite) TestCheckIfKeyExists() {
|
||||
ttl := time.Duration(10) * time.Second
|
||||
key := "testkeyifexists"
|
||||
value := []byte("testvalue")
|
||||
suite.redisConfig.Set(key, value, ttl) // Set the key-value pair
|
||||
_, found := suite.redisConfig.Get(key)
|
||||
err := suite.redisConfig.Set(key, value, ttl) // Set the key-value pair
|
||||
assert.NoError(suite.T(), err)
|
||||
_, found, err := suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.True(suite.T(), found)
|
||||
|
||||
suite.redisConfig.Delete(key)
|
||||
_, found = suite.redisConfig.Get(key)
|
||||
err = suite.redisConfig.Delete(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
_, found, err = suite.redisConfig.Get(key)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.False(suite.T(), found)
|
||||
}
|
||||
|
||||
func (suite *RedisConfigSuite) TestGetKeys() {
|
||||
ttl := time.Duration(10) * time.Second
|
||||
suite.redisConfig.Set("testkey1", []byte("testvalue1"), ttl)
|
||||
suite.redisConfig.Set("testkey2", []byte("testvalue2"), ttl)
|
||||
suite.redisConfig.Set("otherkey", []byte("othervalue"), ttl)
|
||||
err := suite.redisConfig.Set("testkey1", []byte("testvalue1"), ttl)
|
||||
assert.NoError(suite.T(), err)
|
||||
err = suite.redisConfig.Set("testkey2", []byte("testvalue2"), ttl)
|
||||
assert.NoError(suite.T(), err)
|
||||
err = suite.redisConfig.Set("otherkey", []byte("othervalue"), ttl)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
keys, _ := suite.redisConfig.client.Keys(suite.redisConfig.ctx, "testkey*").Result()
|
||||
expectedKeys := []string{"testkey1", "testkey2"}
|
||||
@@ -122,9 +144,15 @@ func (suite *RedisConfigSuite) TestGetKeysCount() {
|
||||
suite.redisConfig.Set("testkey2", []byte("testvalue2"), ttl)
|
||||
suite.redisConfig.Set("otherkey", []byte("othervalue"), ttl)
|
||||
|
||||
assert.Equal(suite.T(), 2, suite.redisConfig.CountQueriesWithPattern("testkey*"))
|
||||
assert.Equal(suite.T(), 1, suite.redisConfig.CountQueriesWithPattern("otherkey*"))
|
||||
assert.Equal(suite.T(), int64(3), suite.redisConfig.CountQueries())
|
||||
count1, err := suite.redisConfig.CountQueriesWithPattern("testkey*")
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), 2, count1)
|
||||
count2, err := suite.redisConfig.CountQueriesWithPattern("otherkey*")
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), 1, count2)
|
||||
count3, err := suite.redisConfig.CountQueries()
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), int64(3), count3)
|
||||
|
||||
suite.redisConfig.client.Del(suite.redisConfig.ctx, "testkey1", "testkey2", "otherkey")
|
||||
}
|
||||
|
||||
Vendored
+104
@@ -0,0 +1,104 @@
|
||||
package libpack_cache_redis
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
|
||||
)
|
||||
|
||||
// CacheWrapper wraps RedisConfig to implement the CacheClient interface
|
||||
// without returning errors, for backward compatibility
|
||||
type CacheWrapper struct {
|
||||
redis *RedisConfig
|
||||
logger *libpack_logger.Logger
|
||||
}
|
||||
|
||||
// NewCacheWrapper creates a new cache wrapper
|
||||
func NewCacheWrapper(config *RedisConfig, logger *libpack_logger.Logger) *CacheWrapper {
|
||||
if logger == nil {
|
||||
logger = &libpack_logger.Logger{}
|
||||
}
|
||||
return &CacheWrapper{
|
||||
redis: config,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Set stores a value with the given TTL
|
||||
func (w *CacheWrapper) Set(key string, value []byte, ttl time.Duration) {
|
||||
if err := w.redis.Set(key, value, ttl); err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis set error",
|
||||
Pairs: map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"key": key,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value
|
||||
func (w *CacheWrapper) Get(key string) ([]byte, bool) {
|
||||
value, found, err := w.redis.Get(key)
|
||||
if err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis get error",
|
||||
Pairs: map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"key": key,
|
||||
},
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
return value, found
|
||||
}
|
||||
|
||||
// Delete removes a key
|
||||
func (w *CacheWrapper) Delete(key string) {
|
||||
if err := w.redis.Delete(key); err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis delete error",
|
||||
Pairs: map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"key": key,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clear removes all keys
|
||||
func (w *CacheWrapper) Clear() {
|
||||
if err := w.redis.Clear(); err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis clear error",
|
||||
Pairs: map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CountQueries returns the number of queries
|
||||
func (w *CacheWrapper) CountQueries() int64 {
|
||||
count, err := w.redis.CountQueries()
|
||||
if err != nil {
|
||||
w.logger.Error(&libpack_logger.LogMessage{
|
||||
Message: "Redis count queries error",
|
||||
Pairs: map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
},
|
||||
})
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetMemoryUsage returns 0 for Redis (not applicable)
|
||||
func (w *CacheWrapper) GetMemoryUsage() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetMaxMemorySize returns 0 for Redis (not applicable)
|
||||
func (w *CacheWrapper) GetMaxMemorySize() int64 {
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user