htmgo/framework/h/cache/lru_store_test.go
Eliah Rusin 04997d7315
Refactor caching system to use pluggable stores
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>
2025-06-26 21:38:38 +03:00

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)
}
}
}