package traefikoidc import ( "fmt" "runtime" "runtime/debug" "strings" "sync" "testing" "time" ) // TestLRUCache_NoCircularReferences tests that ListNode structures don't create circular references // that prevent garbage collection func TestLRUCache_NoCircularReferences(t *testing.T) { tests := []struct { name string cacheSize int itemCount int description string }{ { name: "small_cache", cacheSize: 10, itemCount: 20, description: "Small cache with evictions should not have circular references", }, { name: "medium_cache", cacheSize: 100, itemCount: 200, description: "Medium cache with evictions should not have circular references", }, { name: "large_cache", cacheSize: 500, itemCount: 1000, description: "Large cache with evictions should not have circular references", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Force GC and get baseline memory runtime.GC() runtime.GC() debug.FreeOSMemory() var m1 runtime.MemStats runtime.ReadMemStats(&m1) // Create cache with LRU strategy config := DefaultUnifiedCacheConfig() config.MaxSize = tt.cacheSize config.Strategy = NewLRUStrategy(tt.cacheSize) config.EnableAutoCleanup = false // Disable to control cleanup timing cache := NewUnifiedCache(config) // Fill cache beyond capacity to trigger evictions for i := 0; i < tt.itemCount; i++ { key := generateCacheKey(i) value := generateCacheValue(i) cache.Set(key, value, time.Hour) // Periodic verification during population if i > 0 && i%50 == 0 { verifyLRUStructureIntegrity(t, cache, "during_population") } } // Verify cache is at expected size metrics := cache.GetMetrics() currentSize := metrics["item_count"].(int) if currentSize > tt.cacheSize { t.Errorf("Cache size exceeded limit: %d > %d", currentSize, tt.cacheSize) } // Access some items to modify LRU order for i := 0; i < tt.cacheSize/2; i++ { key := generateCacheKey(i) cache.Get(key) } // Verify structure integrity after access verifyLRUStructureIntegrity(t, cache, "after_access") // Add more items to cause more evictions for i := tt.itemCount; i < tt.itemCount+50; i++ { key := generateCacheKey(i) value := generateCacheValue(i) cache.Set(key, value, time.Hour) } // Verify structure integrity after more evictions verifyLRUStructureIntegrity(t, cache, "after_more_evictions") // Close cache to test cleanup cache.Close() // Force GC multiple times to detect circular references runtime.GC() runtime.GC() runtime.GC() debug.FreeOSMemory() time.Sleep(10 * time.Millisecond) // Allow GC to complete runtime.GC() debug.FreeOSMemory() // Measure final memory var m2 runtime.MemStats runtime.ReadMemStats(&m2) memoryGrowth := int64(m2.Alloc) - int64(m1.Alloc) // Memory should be mostly freed if no circular references maxExpectedMemory := int64(tt.cacheSize * 1024) // 1KB per item tolerance if memoryGrowth > maxExpectedMemory { t.Errorf("Potential circular references detected: %s\n"+ "Memory growth: %d bytes (max expected: %d)\n"+ "Initial alloc: %d, Final alloc: %d", tt.description, memoryGrowth, maxExpectedMemory, m1.Alloc, m2.Alloc) } t.Logf("Test %s: Items %d, Cache size %d, Memory growth: %d bytes", tt.name, tt.itemCount, currentSize, memoryGrowth) }) } } // TestLRUCache_ProperGarbageCollection tests that evicted items are properly garbage collected func TestLRUCache_ProperGarbageCollection(t *testing.T) { // Create cache with small size to force frequent evictions config := DefaultUnifiedCacheConfig() config.MaxSize = 50 config.Strategy = NewLRUStrategy(50) config.EnableAutoCleanup = false cache := NewUnifiedCache(config) // Record baseline memory runtime.GC() runtime.GC() debug.FreeOSMemory() var baseline runtime.MemStats runtime.ReadMemStats(&baseline) // Populate cache multiple times (each iteration should evict previous items) const iterations = 10 const itemsPerIteration = 100 for iter := 0; iter < iterations; iter++ { // Add items that should evict previous ones for i := 0; i < itemsPerIteration; i++ { key := generateCacheKeyWithIteration(iter, i) value := generateLargeCacheValue(i) // Use larger values to make memory impact visible cache.Set(key, value, time.Hour) } // Verify cache size is still bounded metrics := cache.GetMetrics() currentSize := metrics["item_count"].(int) if currentSize > config.MaxSize { t.Errorf("Cache size exceeded in iteration %d: %d > %d", iter, currentSize, config.MaxSize) } // Force GC and check memory growth isn't excessive if iter%3 == 2 { runtime.GC() runtime.GC() var current runtime.MemStats runtime.ReadMemStats(¤t) memoryGrowth := int64(current.Alloc) - int64(baseline.Alloc) maxExpectedMemory := int64(config.MaxSize * 2048) // 2KB per item in cache if memoryGrowth > maxExpectedMemory*2 { // 2x tolerance t.Errorf("Memory growth suggests poor garbage collection in iteration %d: %d bytes (max expected: %d)", iter, memoryGrowth, maxExpectedMemory) } t.Logf("Iteration %d: Cache size %d, Memory growth: %d bytes", iter, currentSize, memoryGrowth) } } // Final cleanup and verification cache.Close() runtime.GC() runtime.GC() runtime.GC() debug.FreeOSMemory() time.Sleep(50 * time.Millisecond) var final runtime.MemStats runtime.ReadMemStats(&final) finalGrowth := int64(final.Alloc) - int64(baseline.Alloc) // After cache closure and GC, memory should be mostly freed maxFinalMemory := int64(config.MaxSize * 512) // 512 bytes per item tolerance if finalGrowth > maxFinalMemory { t.Errorf("Poor garbage collection after cache closure: %d bytes growth (max expected: %d)", finalGrowth, maxFinalMemory) } t.Logf("Garbage collection test completed: %d iterations, Final memory growth: %d bytes", iterations, finalGrowth) } // TestLRUCache_ConcurrentAccessCircularReferences tests that concurrent access doesn't create // circular references or corruption func TestLRUCache_ConcurrentAccessCircularReferences(t *testing.T) { config := DefaultUnifiedCacheConfig() config.MaxSize = 100 config.Strategy = NewLRUStrategy(100) config.EnableAutoCleanup = false cache := NewUnifiedCache(config) defer cache.Close() // Record baseline runtime.GC() runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) const numGoroutines = 10 const operationsPerGoroutine = 100 var wg sync.WaitGroup // Run concurrent operations for g := 0; g < numGoroutines; g++ { wg.Add(1) go func(goroutineID int) { defer wg.Done() for op := 0; op < operationsPerGoroutine; op++ { switch op % 4 { case 0: // Set operation key := generateConcurrentCacheKey(goroutineID, op) value := generateCacheValue(op) cache.Set(key, value, time.Hour) case 1: // Get operation key := generateConcurrentCacheKey(goroutineID, op/2) cache.Get(key) case 2: // Delete operation key := generateConcurrentCacheKey(goroutineID, op/4) cache.Delete(key) case 3: // Cleanup operation cache.Cleanup() } // Occasional structure verification if op%50 == 49 { verifyLRUStructureIntegrity(t, cache, "concurrent_access") } } }(g) } wg.Wait() // Final structure verification verifyLRUStructureIntegrity(t, cache, "after_concurrent_access") // Force GC to detect any circular references runtime.GC() runtime.GC() runtime.GC() debug.FreeOSMemory() var m2 runtime.MemStats runtime.ReadMemStats(&m2) memoryGrowth := int64(m2.Alloc) - int64(m1.Alloc) // Memory growth should be reasonable maxExpectedMemory := int64(config.MaxSize * 1024) // 1KB per cached item if memoryGrowth > maxExpectedMemory*2 { t.Errorf("Excessive memory growth with concurrent access: %d bytes (max expected: %d)", memoryGrowth, maxExpectedMemory) } t.Logf("Concurrent access test: %d goroutines, %d ops each, Memory growth: %d bytes", numGoroutines, operationsPerGoroutine, memoryGrowth) } // TestLRUCache_SizeLimitsEnforced tests that cache size limits are strictly enforced // and don't allow unbounded growth func TestLRUCache_SizeLimitsEnforced(t *testing.T) { tests := []struct { name string maxSize int itemCount int }{ {"tiny_cache", 5, 20}, {"small_cache", 25, 100}, {"medium_cache", 100, 500}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := DefaultUnifiedCacheConfig() config.MaxSize = tt.maxSize config.Strategy = NewLRUStrategy(tt.maxSize) config.EnableAutoCleanup = false cache := NewUnifiedCache(config) defer cache.Close() // Record memory before population runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) // Add many more items than the limit for i := 0; i < tt.itemCount; i++ { key := generateCacheKey(i) value := generateCacheValue(i) cache.Set(key, value, time.Hour) // Verify size limits during population if i > tt.maxSize { metrics := cache.GetMetrics() currentSize := metrics["item_count"].(int) if currentSize > tt.maxSize { t.Errorf("Cache size limit exceeded during population at item %d: %d > %d", i, currentSize, tt.maxSize) } } } // Final size verification metrics := cache.GetMetrics() finalSize := metrics["item_count"].(int) if finalSize > tt.maxSize { t.Errorf("Final cache size exceeded limit: %d > %d", finalSize, tt.maxSize) } // Memory verification runtime.GC() var m2 runtime.MemStats runtime.ReadMemStats(&m2) memoryGrowth := int64(m2.Alloc) - int64(m1.Alloc) // Memory should be bounded by cache size, not total items added maxExpectedMemory := int64(tt.maxSize * 2048) // 2KB per item with overhead if memoryGrowth > maxExpectedMemory { t.Errorf("Memory not properly bounded: %d bytes (max expected: %d)\n"+ "Added %d items to cache limited to %d items", memoryGrowth, maxExpectedMemory, tt.itemCount, tt.maxSize) } t.Logf("Size limit test %s: Added %d items, Final size: %d, Memory: %d bytes", tt.name, tt.itemCount, finalSize, memoryGrowth) }) } } // TestLRUCache_EvictedItemsReleased tests that evicted items are properly released from memory func TestLRUCache_EvictedItemsReleased(t *testing.T) { config := DefaultUnifiedCacheConfig() config.MaxSize = 10 config.Strategy = NewLRUStrategy(10) config.EnableAutoCleanup = false cache := NewUnifiedCache(config) defer cache.Close() // Create items with identifiable content for leak detection const itemSize = 1024 * 10 // 10KB per item // Phase 1: Fill cache with large items for i := 0; i < config.MaxSize; i++ { key := generateCacheKey(i) value := strings.Repeat(fmt.Sprintf("item%d-", i), itemSize/10) cache.Set(key, value, time.Hour) } // Force GC and measure memory after first phase runtime.GC() runtime.GC() var m1 runtime.MemStats runtime.ReadMemStats(&m1) // Phase 2: Add more items to force eviction of all original items for i := config.MaxSize; i < config.MaxSize*3; i++ { key := generateCacheKey(i) value := strings.Repeat(fmt.Sprintf("item%d-", i), itemSize/10) cache.Set(key, value, time.Hour) } // Force GC and measure memory after evictions runtime.GC() runtime.GC() runtime.GC() debug.FreeOSMemory() var m2 runtime.MemStats runtime.ReadMemStats(&m2) memoryDiff := int64(m2.Alloc) - int64(m1.Alloc) // Memory should not grow significantly - evicted items should be GC'd // Allow for some overhead but not full retention of evicted items maxExpectedGrowth := int64(config.MaxSize * itemSize / 2) // 50% of one cache worth if memoryDiff > maxExpectedGrowth { t.Errorf("Evicted items may not be released: memory growth %d bytes (max expected: %d)", memoryDiff, maxExpectedGrowth) } // Verify cache still contains correct number of items metrics := cache.GetMetrics() currentSize := metrics["item_count"].(int) if currentSize != config.MaxSize { t.Errorf("Cache size incorrect after evictions: %d (expected: %d)", currentSize, config.MaxSize) } t.Logf("Evicted items test: Memory growth after evictions: %d bytes, Cache size: %d", memoryDiff, currentSize) } // Verification helper function func verifyLRUStructureIntegrity(t *testing.T, cache *UnifiedCache, phase string) { // Get the LRU strategy cache.mutex.RLock() strategy, ok := cache.strategy.(*LRUStrategy) cache.mutex.RUnlock() if !ok { return // Not an LRU strategy, skip verification } strategy.mutex.Lock() defer strategy.mutex.Unlock() // Verify doubly-linked list integrity if strategy.order == nil { t.Errorf("LRU order list is nil during %s", phase) return } // Count nodes by traversing forward forwardCount := 0 current := strategy.order.head.next visited := make(map[*ListNode]bool) for current != strategy.order.tail && forwardCount < strategy.maxSize*2 { if visited[current] { t.Errorf("Circular reference detected in forward traversal during %s", phase) return } visited[current] = true if current.prev == nil || current.next == nil { t.Errorf("Node with nil prev/next pointers during %s", phase) return } if current.next.prev != current { t.Errorf("Broken backward link during %s", phase) return } current = current.next forwardCount++ } // Verify map consistency mapSize := len(strategy.elements) if mapSize != forwardCount { t.Errorf("LRU map/list size mismatch during %s: map=%d, list=%d", phase, mapSize, forwardCount) } // Verify all map entries point to valid nodes for key, node := range strategy.elements { if node == nil { t.Errorf("Map contains nil node for key %s during %s", key, phase) continue } if node.Key != key { t.Errorf("Map key mismatch for key %s during %s", key, phase) } if !visited[node] { t.Errorf("Map node not in list for key %s during %s", key, phase) } } } // Helper functions for generating test data func generateCacheKey(id int) string { return fmt.Sprintf("cache_key_%d", id) } func generateCacheValue(id int) string { return fmt.Sprintf("cache_value_%d", id) } func generateLargeCacheValue(id int) string { return fmt.Sprintf("large_cache_value_%d_%s", id, strings.Repeat("data", 100)) } func generateCacheKeyWithIteration(iter, id int) string { return fmt.Sprintf("cache_key_iter_%d_id_%d", iter, id) } func generateConcurrentCacheKey(goroutineID, op int) string { return fmt.Sprintf("concurrent_key_g%d_op%d", goroutineID, op) } // BenchmarkLRUCache_MemoryEfficiency benchmarks memory efficiency of LRU cache operations func BenchmarkLRUCache_MemoryEfficiency(b *testing.B) { config := DefaultUnifiedCacheConfig() config.MaxSize = 1000 config.Strategy = NewLRUStrategy(1000) config.EnableAutoCleanup = false cache := NewUnifiedCache(config) defer cache.Close() // Record baseline memory runtime.GC() var baseline runtime.MemStats runtime.ReadMemStats(&baseline) b.ResetTimer() for i := 0; i < b.N; i++ { key := generateCacheKey(i) value := generateCacheValue(i) cache.Set(key, value, time.Hour) // Occasional reads to test LRU behavior if i%10 == 9 { cache.Get(generateCacheKey(i - 5)) } // Periodic memory check to detect leaks if i%10000 == 9999 { runtime.GC() var current runtime.MemStats runtime.ReadMemStats(¤t) memoryGrowth := current.Alloc - baseline.Alloc maxExpected := uint64(config.MaxSize * 1024) // 1KB per item if memoryGrowth > maxExpected*2 { b.Fatalf("Memory leak detected at iteration %d: %d bytes (max: %d)", i, memoryGrowth, maxExpected) } } } }