htmgo/framework/h/cache_integration_test.go
Eliah Rusin 06f01b3d7c
Refactor caching system to use pluggable stores (#98)
* 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>

* Add custom cache stores docs and navigation

Signed-off-by: franchb <hello@franchb.com>

* Use GetOrCompute for atomic cache access

The commit introduces an atomic GetOrCompute method to the cache interface and refactors all cache implementations to use it. This prevents race conditions and duplicate computations when multiple goroutines request the same uncached key simultaneously.

The changes eliminate a time-of-check to time-of-use race condition in the original caching implementation, where separate Get/Set operations could lead to duplicate renders under high concurrency.

With GetOrCompute, the entire check-compute-store operation happens atomically while holding the lock, ensuring only one goroutine computes a value for any given key.

The API change is backwards compatible as the framework handles the GetOrCompute logic internally. Existing applications will automatically benefit from the

* rename to WithCacheStore

---------

Signed-off-by: franchb <hello@franchb.com>
Co-authored-by: maddalax <jm@madev.me>
2025-07-03 14:07:16 -05:00

448 lines
11 KiB
Go

package h
import (
"fmt"
"sync"
"testing"
"time"
"github.com/maddalax/htmgo/framework/h/cache"
)
func TestCached_WithDefaultStore(t *testing.T) {
callCount := 0
// Create a cached component
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Rendered %d times", callCount)))
})
// First render
html1 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Second render should use cache
html2 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCached_WithCustomStore(t *testing.T) {
// Use LRU store with small capacity
lruStore := cache.NewLRUStore[any, string](10)
defer lruStore.Close()
callCount := 0
// Create cached component with custom store
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Rendered %d times", callCount)))
}, WithCacheStore(lruStore))
// First render
html1 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Second render should use cache
html2 := Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKey_WithDefaultStore(t *testing.T) {
renderCounts := make(map[int]int)
// Create per-key cached component
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
renderCounts[userID]++
return Div(Text(fmt.Sprintf("User %d (rendered %d times)", userID, renderCounts[userID])))
}
})
// Render for different users
html1_user1 := Render(UserProfile(1))
html1_user2 := Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each user to be rendered once")
}
// Render again - should use cache
html2_user1 := Render(UserProfile(1))
html2_user2 := Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected renders to be cached")
}
if html1_user1 != html2_user1 || html1_user2 != html2_user2 {
t.Error("Expected same HTML from cache")
}
// Different users should have different content
if html1_user1 == html1_user2 {
t.Error("Expected different content for different users")
}
}
func TestCachedPerKey_WithLRUStore(t *testing.T) {
// Small LRU cache that can only hold 2 items
lruStore := cache.NewLRUStore[any, string](2)
defer lruStore.Close()
renderCounts := make(map[int]int)
// Create per-key cached component with LRU store
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
renderCounts[userID]++
return Div(Text(fmt.Sprintf("User %d", userID)))
}
}, WithCacheStore(lruStore))
// Render 2 users - fill cache to capacity
Render(UserProfile(1))
Render(UserProfile(2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each user to be rendered once")
}
// Render user 3 - should evict user 1 (least recently used)
Render(UserProfile(3))
if renderCounts[3] != 1 {
t.Error("Expected user 3 to be rendered once")
}
// Render user 1 again - should re-render (was evicted)
Render(UserProfile(1))
if renderCounts[1] != 2 {
t.Errorf("Expected user 1 to be re-rendered after eviction, got %d renders", renderCounts[1])
}
// Render user 2 again - should re-render (was evicted when user 1 was added back)
Render(UserProfile(2))
if renderCounts[2] != 2 {
t.Errorf("Expected user 2 to be re-rendered after eviction, got %d renders", renderCounts[2])
}
// At this point, cache contains users 1 and 2 (most recently used)
// Render user 1 again - should be cached
Render(UserProfile(1))
if renderCounts[1] != 2 {
t.Errorf("Expected user 1 to still be cached, got %d renders", renderCounts[1])
}
}
func TestCachedT_WithDefaultStore(t *testing.T) {
type Product struct {
ID int
Name string
Price float64
}
renderCount := 0
// Create cached component that takes typed data
ProductCard := CachedT(1*time.Hour, func(p Product) *Element {
renderCount++
return Div(
H3(Text(p.Name)),
P(Text(fmt.Sprintf("$%.2f", p.Price))),
)
})
product := Product{ID: 1, Name: "Widget", Price: 9.99}
// First render
html1 := Render(ProductCard(product))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Second render should use cache
html2 := Render(ProductCard(product))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKeyT_WithCustomStore(t *testing.T) {
type Article struct {
ID int
Title string
Content string
}
ttlStore := cache.NewTTLStore[any, string]()
defer ttlStore.Close()
renderCounts := make(map[int]int)
// Create per-key cached component with custom store
ArticleView := CachedPerKeyT(1*time.Hour, func(a Article) (int, GetElementFunc) {
return a.ID, func() *Element {
renderCounts[a.ID]++
return Div(
H1(Text(a.Title)),
P(Text(a.Content)),
)
}
}, WithCacheStore(ttlStore))
article1 := Article{ID: 1, Title: "First", Content: "Content 1"}
article2 := Article{ID: 2, Title: "Second", Content: "Content 2"}
// Render articles
Render(ArticleView(article1))
Render(ArticleView(article2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected each article to be rendered once")
}
// Render again - should use cache
Render(ArticleView(article1))
Render(ArticleView(article2))
if renderCounts[1] != 1 || renderCounts[2] != 1 {
t.Error("Expected renders to be cached")
}
}
func TestDefaultCacheProvider_Override(t *testing.T) {
// Save original provider
originalProvider := DefaultCacheProvider
defer func() {
DefaultCacheProvider = originalProvider
}()
// Track which cache is used
customCacheUsed := false
// Override default provider
DefaultCacheProvider = func() cache.Store[any, string] {
customCacheUsed = true
return cache.NewLRUStore[any, string](100)
}
// Create cached component without specifying store
CachedDiv := Cached(1*time.Hour, func() *Element {
return Div(Text("Content"))
})
// Render to trigger cache creation
Render(CachedDiv())
if !customCacheUsed {
t.Error("Expected custom default cache provider to be used")
}
}
func TestCachedPerKey_ConcurrentAccess(t *testing.T) {
lruStore := cache.NewLRUStore[any, string](1000)
defer lruStore.Close()
UserProfile := CachedPerKeyT(1*time.Hour, func(userID int) (int, GetElementFunc) {
return userID, func() *Element {
// Simulate some work
time.Sleep(10 * time.Millisecond)
return Div(Text(fmt.Sprintf("User %d", userID)))
}
}, WithCacheStore(lruStore))
const numGoroutines = 50
const numUsers = 20
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Many goroutines accessing overlapping user IDs
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numUsers; j++ {
userID := j % 10 // Reuse user IDs to test cache hits
html := Render(UserProfile(userID))
expectedContent := fmt.Sprintf("User %d", userID)
if !contains(html, expectedContent) {
t.Errorf("Goroutine %d: Expected content for user %d", id, userID)
}
}
}(i)
}
wg.Wait()
}
func TestCachedT2_MultipleParameters(t *testing.T) {
renderCount := 0
// Component that takes two parameters
CombinedView := CachedT2(1*time.Hour, func(title string, count int) *Element {
renderCount++
return Div(
H2(Text(title)),
P(Text(fmt.Sprintf("Count: %d", count))),
)
})
// First render
html1 := Render(CombinedView("Test", 42))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Second render with same params should use cache
html2 := Render(CombinedView("Test", 42))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
}
func TestCachedPerKeyT3_ComplexKey(t *testing.T) {
type CompositeKey struct {
UserID int
ProductID int
Timestamp int64
}
renderCount := 0
// Component with composite key
UserProductView := CachedPerKeyT3(1*time.Hour,
func(userID int, productID int, timestamp int64) (CompositeKey, GetElementFunc) {
key := CompositeKey{UserID: userID, ProductID: productID, Timestamp: timestamp}
return key, func() *Element {
renderCount++
return Div(Text(fmt.Sprintf("User %d viewed product %d at %d", userID, productID, timestamp)))
}
},
)
// Render with specific combination
ts := time.Now().Unix()
html1 := Render(UserProductView(1, 100, ts))
if renderCount != 1 {
t.Errorf("Expected 1 render, got %d", renderCount)
}
// Same combination should use cache
html2 := Render(UserProductView(1, 100, ts))
if renderCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", renderCount)
}
if html1 != html2 {
t.Error("Expected same HTML from cache")
}
// Different combination should render again
Render(UserProductView(1, 101, ts))
if renderCount != 2 {
t.Errorf("Expected 2 renders for different key, got %d", renderCount)
}
}
func TestCached_Expiration(t *testing.T) {
callCount := 0
// Create cached component with short TTL
CachedDiv := Cached(100*time.Millisecond, func() *Element {
callCount++
return Div(Text(fmt.Sprintf("Render %d", callCount)))
})
// First render
Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Immediate second render should use cache
Render(CachedDiv())
if callCount != 1 {
t.Errorf("Expected still 1 render (cached), got %d", callCount)
}
// Wait for expiration
time.Sleep(150 * time.Millisecond)
// Should render again after expiration
Render(CachedDiv())
if callCount != 2 {
t.Errorf("Expected 2 renders after expiration, got %d", callCount)
}
}
func TestCachedNode_ClearCache(t *testing.T) {
lruStore := cache.NewLRUStore[any, string](10)
defer lruStore.Close()
callCount := 0
CachedDiv := Cached(1*time.Hour, func() *Element {
callCount++
return Div(Text("Content"))
}, WithCacheStore(lruStore))
// Render and cache
element := CachedDiv()
Render(element)
if callCount != 1 {
t.Errorf("Expected 1 render, got %d", callCount)
}
// Clear cache
node := element.meta.(*CachedNode)
node.ClearCache()
// Should render again after cache clear
Render(element)
if callCount != 2 {
t.Errorf("Expected 2 renders after cache clear, got %d", callCount)
}
}
// Helper function
func contains(s, substr string) bool {
return len(s) >= len(substr) && s[0:len(substr)] == substr ||
len(s) > len(substr) && contains(s[1:], substr)
}