The commit modernizes the caching implementation by introducing a pluggable store interface that allows different cache backends. Key changes: - Add Store interface for custom cache implementations - Create default TTL-based store for backwards compatibility - Add example LRU store for memory-bounded caching - Support cache store configuration via options pattern - Make cache cleanup logic implementation-specific - Add comprehensive tests and documentation The main goals were to: 1. Prevent unbounded memory growth through pluggable stores 2. Enable distributed caching support 3. Maintain backwards compatibility 4. Improve testability and maintainability Signed-off-by: franchb <hello@franchb.com>
353 lines
7.9 KiB
Go
353 lines
7.9 KiB
Go
package cache
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLRUStore_SetAndGet(t *testing.T) {
|
|
store := NewLRUStore[string, string](10)
|
|
defer store.Close()
|
|
|
|
// Test basic set and get
|
|
store.Set("key1", "value1", 1*time.Hour)
|
|
|
|
val, found := store.Get("key1")
|
|
if !found {
|
|
t.Error("Expected to find key1")
|
|
}
|
|
if val != "value1" {
|
|
t.Errorf("Expected value1, got %s", val)
|
|
}
|
|
|
|
// Test getting non-existent key
|
|
val, found = store.Get("nonexistent")
|
|
if found {
|
|
t.Error("Expected not to find nonexistent key")
|
|
}
|
|
if val != "" {
|
|
t.Errorf("Expected empty string for non-existent key, got %s", val)
|
|
}
|
|
}
|
|
|
|
func TestLRUStore_SizeLimit(t *testing.T) {
|
|
// Create store with capacity of 3
|
|
store := NewLRUStore[int, string](3)
|
|
defer store.Close()
|
|
|
|
// Add 3 items
|
|
store.Set(1, "one", 1*time.Hour)
|
|
store.Set(2, "two", 1*time.Hour)
|
|
store.Set(3, "three", 1*time.Hour)
|
|
|
|
// Verify all exist
|
|
for i := 1; i <= 3; i++ {
|
|
val, found := store.Get(i)
|
|
if !found {
|
|
t.Errorf("Expected to find key %d", i)
|
|
}
|
|
if val != []string{"one", "two", "three"}[i-1] {
|
|
t.Errorf("Unexpected value for key %d: %s", i, val)
|
|
}
|
|
}
|
|
|
|
// Add fourth item, should evict least recently used (key 1)
|
|
store.Set(4, "four", 1*time.Hour)
|
|
|
|
// Key 1 should be evicted
|
|
_, found := store.Get(1)
|
|
if found {
|
|
t.Error("Expected key 1 to be evicted")
|
|
}
|
|
|
|
// Keys 2, 3, 4 should still exist
|
|
for i := 2; i <= 4; i++ {
|
|
_, found := store.Get(i)
|
|
if !found {
|
|
t.Errorf("Expected to find key %d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLRUStore_LRUBehavior(t *testing.T) {
|
|
store := NewLRUStore[string, string](3)
|
|
defer store.Close()
|
|
|
|
// Add items in order
|
|
store.Set("a", "A", 1*time.Hour)
|
|
store.Set("b", "B", 1*time.Hour)
|
|
store.Set("c", "C", 1*time.Hour)
|
|
|
|
// Access "a" to make it recently used
|
|
store.Get("a")
|
|
|
|
// Add "d", should evict "b" (least recently used)
|
|
store.Set("d", "D", 1*time.Hour)
|
|
|
|
// Check what's in cache
|
|
_, foundA := store.Get("a")
|
|
_, foundB := store.Get("b")
|
|
_, foundC := store.Get("c")
|
|
_, foundD := store.Get("d")
|
|
|
|
if !foundA {
|
|
t.Error("Expected 'a' to still be in cache (was accessed)")
|
|
}
|
|
if foundB {
|
|
t.Error("Expected 'b' to be evicted (least recently used)")
|
|
}
|
|
if !foundC {
|
|
t.Error("Expected 'c' to still be in cache")
|
|
}
|
|
if !foundD {
|
|
t.Error("Expected 'd' to be in cache (just added)")
|
|
}
|
|
}
|
|
|
|
func TestLRUStore_UpdateMovesToFront(t *testing.T) {
|
|
store := NewLRUStore[string, string](3)
|
|
defer store.Close()
|
|
|
|
// Fill cache
|
|
store.Set("a", "A", 1*time.Hour)
|
|
store.Set("b", "B", 1*time.Hour)
|
|
store.Set("c", "C", 1*time.Hour)
|
|
|
|
// Update "a" with new value - should move to front
|
|
store.Set("a", "A_updated", 1*time.Hour)
|
|
|
|
// Add new item - should evict "b" not "a"
|
|
store.Set("d", "D", 1*time.Hour)
|
|
|
|
val, found := store.Get("a")
|
|
if !found {
|
|
t.Error("Expected 'a' to still be in cache after update")
|
|
}
|
|
if val != "A_updated" {
|
|
t.Errorf("Expected updated value, got %s", val)
|
|
}
|
|
|
|
_, found = store.Get("b")
|
|
if found {
|
|
t.Error("Expected 'b' to be evicted")
|
|
}
|
|
}
|
|
|
|
func TestLRUStore_Expiration(t *testing.T) {
|
|
store := NewLRUStore[string, string](10)
|
|
defer store.Close()
|
|
|
|
// Set with short TTL
|
|
store.Set("shortlived", "value", 100*time.Millisecond)
|
|
|
|
// Should exist immediately
|
|
val, found := store.Get("shortlived")
|
|
if !found {
|
|
t.Error("Expected to find shortlived key immediately after setting")
|
|
}
|
|
if val != "value" {
|
|
t.Errorf("Expected value, got %s", val)
|
|
}
|
|
|
|
// Wait for expiration
|
|
time.Sleep(150 * time.Millisecond)
|
|
|
|
// Should be expired now
|
|
val, found = store.Get("shortlived")
|
|
if found {
|
|
t.Error("Expected key to be expired")
|
|
}
|
|
if val != "" {
|
|
t.Errorf("Expected empty string for expired key, got %s", val)
|
|
}
|
|
}
|
|
|
|
func TestLRUStore_Delete(t *testing.T) {
|
|
store := NewLRUStore[string, string](10)
|
|
defer store.Close()
|
|
|
|
store.Set("key1", "value1", 1*time.Hour)
|
|
|
|
// Verify it exists
|
|
_, found := store.Get("key1")
|
|
if !found {
|
|
t.Error("Expected to find key1 before deletion")
|
|
}
|
|
|
|
// Delete it
|
|
store.Delete("key1")
|
|
|
|
// Verify it's gone
|
|
_, found = store.Get("key1")
|
|
if found {
|
|
t.Error("Expected key1 to be deleted")
|
|
}
|
|
|
|
// Delete non-existent key should not panic
|
|
store.Delete("nonexistent")
|
|
}
|
|
|
|
func TestLRUStore_Purge(t *testing.T) {
|
|
store := NewLRUStore[string, string](10)
|
|
defer store.Close()
|
|
|
|
// Add multiple items
|
|
store.Set("key1", "value1", 1*time.Hour)
|
|
store.Set("key2", "value2", 1*time.Hour)
|
|
store.Set("key3", "value3", 1*time.Hour)
|
|
|
|
// Verify they exist
|
|
for i := 1; i <= 3; i++ {
|
|
key := "key" + string(rune('0'+i))
|
|
_, found := store.Get(key)
|
|
if !found {
|
|
t.Errorf("Expected to find %s before purge", key)
|
|
}
|
|
}
|
|
|
|
// Purge all
|
|
store.Purge()
|
|
|
|
// Verify all are gone
|
|
for i := 1; i <= 3; i++ {
|
|
key := "key" + string(rune('0'+i))
|
|
_, found := store.Get(key)
|
|
if found {
|
|
t.Errorf("Expected %s to be purged", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLRUStore_ConcurrentAccess(t *testing.T) {
|
|
// Need capacity for all unique keys: 100 goroutines * 100 operations = 10,000
|
|
store := NewLRUStore[int, int](10000)
|
|
defer store.Close()
|
|
|
|
const numGoroutines = 100
|
|
const numOperations = 100
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(numGoroutines)
|
|
|
|
// Concurrent writes and reads
|
|
for i := 0; i < numGoroutines; i++ {
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
|
|
for j := 0; j < numOperations; j++ {
|
|
key := (id * numOperations) + j
|
|
store.Set(key, key*2, 1*time.Hour)
|
|
|
|
// Immediately read it back
|
|
val, found := store.Get(key)
|
|
if !found {
|
|
t.Errorf("Goroutine %d: Expected to find key %d", id, key)
|
|
}
|
|
if val != key*2 {
|
|
t.Errorf("Goroutine %d: Expected value %d, got %d", id, key*2, val)
|
|
}
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
func TestLRUStore_ExpiredEntriesCleanup(t *testing.T) {
|
|
store := NewLRUStore[string, string](100)
|
|
defer store.Close()
|
|
|
|
// Add many short-lived entries
|
|
for i := 0; i < 50; i++ {
|
|
key := "key" + string(rune('0'+i))
|
|
store.Set(key, "value", 100*time.Millisecond)
|
|
}
|
|
|
|
// Add some long-lived entries
|
|
for i := 50; i < 60; i++ {
|
|
key := "key" + string(rune('0'+i))
|
|
store.Set(key, "value", 1*time.Hour)
|
|
}
|
|
|
|
// Wait for short-lived entries to expire and cleanup to run
|
|
time.Sleep(1200 * time.Millisecond)
|
|
|
|
// Check that expired entries are gone
|
|
for i := 0; i < 50; i++ {
|
|
key := "key" + string(rune('0'+i))
|
|
_, found := store.Get(key)
|
|
if found {
|
|
t.Errorf("Expected expired key %s to be cleaned up", key)
|
|
}
|
|
}
|
|
|
|
// Long-lived entries should still exist
|
|
for i := 50; i < 60; i++ {
|
|
key := "key" + string(rune('0'+i))
|
|
_, found := store.Get(key)
|
|
if !found {
|
|
t.Errorf("Expected long-lived key %s to still exist", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLRUStore_InvalidSize(t *testing.T) {
|
|
// Test that creating store with invalid size panics
|
|
defer func() {
|
|
if r := recover(); r == nil {
|
|
t.Error("Expected panic for zero size")
|
|
}
|
|
}()
|
|
|
|
NewLRUStore[string, string](0)
|
|
}
|
|
|
|
func TestLRUStore_Close(t *testing.T) {
|
|
store := NewLRUStore[string, string](10)
|
|
|
|
// Close should not panic
|
|
store.Close()
|
|
|
|
// Multiple closes should not panic
|
|
store.Close()
|
|
store.Close()
|
|
}
|
|
|
|
func TestLRUStore_ComplexEvictionScenario(t *testing.T) {
|
|
store := NewLRUStore[string, string](4)
|
|
defer store.Close()
|
|
|
|
// Fill cache
|
|
store.Set("a", "A", 1*time.Hour)
|
|
store.Set("b", "B", 1*time.Hour)
|
|
store.Set("c", "C", 1*time.Hour)
|
|
store.Set("d", "D", 1*time.Hour)
|
|
|
|
// Access in specific order to control LRU order
|
|
store.Get("b") // b is most recently used
|
|
store.Get("d") // d is second most recently used
|
|
store.Get("a") // a is third most recently used
|
|
// c is least recently used
|
|
|
|
// Add two new items
|
|
store.Set("e", "E", 1*time.Hour) // Should evict c
|
|
store.Set("f", "F", 1*time.Hour) // Should evict the next LRU
|
|
|
|
// Check final state
|
|
expected := map[string]bool{
|
|
"a": true, // Most recently used before additions
|
|
"b": false, // Should be evicted as second LRU
|
|
"c": false, // First to be evicted
|
|
"d": true, // Second most recently used
|
|
"e": true, // Just added
|
|
"f": true, // Just added
|
|
}
|
|
|
|
for key, shouldExist := range expected {
|
|
_, found := store.Get(key)
|
|
if found != shouldExist {
|
|
t.Errorf("Key %s: expected existence=%v, got=%v", key, shouldExist, found)
|
|
}
|
|
}
|
|
}
|