* 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>
448 lines
11 KiB
Go
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)
|
|
}
|