* 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>
318 lines
8.3 KiB
Go
318 lines
8.3 KiB
Go
package cache_test
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/maddalax/htmgo/framework/h"
|
|
"github.com/maddalax/htmgo/framework/h/cache"
|
|
)
|
|
|
|
// Example demonstrates basic caching with the default TTL store
|
|
func ExampleCached() {
|
|
renderCount := 0
|
|
|
|
// Create a cached component that expires after 5 minutes
|
|
CachedHeader := h.Cached(5*time.Minute, func() *h.Element {
|
|
renderCount++
|
|
return h.Header(
|
|
h.H1(h.Text("Welcome to our site")),
|
|
h.P(h.Text(fmt.Sprintf("Rendered %d times", renderCount))),
|
|
)
|
|
})
|
|
|
|
// First render - will execute the function
|
|
html1 := h.Render(CachedHeader())
|
|
fmt.Println("Render count:", renderCount)
|
|
|
|
// Second render - will use cached HTML
|
|
html2 := h.Render(CachedHeader())
|
|
fmt.Println("Render count:", renderCount)
|
|
fmt.Println("Same HTML:", html1 == html2)
|
|
|
|
// Output:
|
|
// Render count: 1
|
|
// Render count: 1
|
|
// Same HTML: true
|
|
}
|
|
|
|
// Example demonstrates per-key caching for user-specific content
|
|
func ExampleCachedPerKeyT() {
|
|
type User struct {
|
|
ID int
|
|
Name string
|
|
}
|
|
|
|
renderCounts := make(map[int]int)
|
|
|
|
// Create a per-user cached component
|
|
UserProfile := h.CachedPerKeyT(15*time.Minute, func(user User) (int, h.GetElementFunc) {
|
|
// Use user ID as the cache key
|
|
return user.ID, func() *h.Element {
|
|
renderCounts[user.ID]++
|
|
return h.Div(
|
|
h.Class("user-profile"),
|
|
h.H2(h.Text(user.Name)),
|
|
h.P(h.Text(fmt.Sprintf("User ID: %d", user.ID))),
|
|
)
|
|
}
|
|
})
|
|
|
|
alice := User{ID: 1, Name: "Alice"}
|
|
bob := User{ID: 2, Name: "Bob"}
|
|
|
|
// Render Alice's profile - will execute
|
|
h.Render(UserProfile(alice))
|
|
fmt.Printf("Alice render count: %d\n", renderCounts[1])
|
|
|
|
// Render Bob's profile - will execute
|
|
h.Render(UserProfile(bob))
|
|
fmt.Printf("Bob render count: %d\n", renderCounts[2])
|
|
|
|
// Render Alice's profile again - will use cache
|
|
h.Render(UserProfile(alice))
|
|
fmt.Printf("Alice render count after cache hit: %d\n", renderCounts[1])
|
|
|
|
// Output:
|
|
// Alice render count: 1
|
|
// Bob render count: 1
|
|
// Alice render count after cache hit: 1
|
|
}
|
|
|
|
// Example demonstrates using a memory-bounded LRU cache
|
|
func ExampleWithCacheStore_lru() {
|
|
// Create an LRU cache that holds maximum 1000 items
|
|
lruStore := cache.NewLRUStore[any, string](1000)
|
|
defer lruStore.Close()
|
|
|
|
renderCount := 0
|
|
|
|
// Use the LRU cache for a component
|
|
ProductCard := h.CachedPerKeyT(1*time.Hour,
|
|
func(productID int) (int, h.GetElementFunc) {
|
|
return productID, func() *h.Element {
|
|
renderCount++
|
|
// Simulate fetching product data
|
|
return h.Div(
|
|
h.H3(h.Text(fmt.Sprintf("Product #%d", productID))),
|
|
h.P(h.Text("$99.99")),
|
|
)
|
|
}
|
|
},
|
|
h.WithCacheStore(lruStore), // Use custom cache store
|
|
)
|
|
|
|
// Render many products
|
|
for i := 0; i < 1500; i++ {
|
|
h.Render(ProductCard(i))
|
|
}
|
|
|
|
// Due to LRU eviction, only 1000 items are cached
|
|
// Earlier items (0-499) were evicted
|
|
fmt.Printf("Total renders: %d\n", renderCount)
|
|
fmt.Printf("Expected renders: %d (due to LRU eviction)\n", 1500)
|
|
|
|
// Accessing an evicted item will cause a re-render
|
|
h.Render(ProductCard(0))
|
|
fmt.Printf("After accessing evicted item: %d\n", renderCount)
|
|
|
|
// Output:
|
|
// Total renders: 1500
|
|
// Expected renders: 1500 (due to LRU eviction)
|
|
// After accessing evicted item: 1501
|
|
}
|
|
|
|
// MockDistributedCache simulates a distributed cache like Redis
|
|
type MockDistributedCache struct {
|
|
data map[string]string
|
|
mutex sync.RWMutex
|
|
}
|
|
|
|
// DistributedCacheAdapter makes MockDistributedCache compatible with cache.Store interface
|
|
type DistributedCacheAdapter struct {
|
|
cache *MockDistributedCache
|
|
}
|
|
|
|
func (a *DistributedCacheAdapter) Set(key any, value string, ttl time.Duration) {
|
|
a.cache.mutex.Lock()
|
|
defer a.cache.mutex.Unlock()
|
|
// In a real implementation, you'd set TTL in Redis
|
|
keyStr := fmt.Sprintf("htmgo:%v", key)
|
|
a.cache.data[keyStr] = value
|
|
}
|
|
|
|
func (a *DistributedCacheAdapter) Delete(key any) {
|
|
a.cache.mutex.Lock()
|
|
defer a.cache.mutex.Unlock()
|
|
keyStr := fmt.Sprintf("htmgo:%v", key)
|
|
delete(a.cache.data, keyStr)
|
|
}
|
|
|
|
func (a *DistributedCacheAdapter) Purge() {
|
|
a.cache.mutex.Lock()
|
|
defer a.cache.mutex.Unlock()
|
|
a.cache.data = make(map[string]string)
|
|
}
|
|
|
|
func (a *DistributedCacheAdapter) Close() {
|
|
// Clean up connections in real implementation
|
|
}
|
|
|
|
func (a *DistributedCacheAdapter) GetOrCompute(key any, compute func() string, ttl time.Duration) string {
|
|
a.cache.mutex.Lock()
|
|
defer a.cache.mutex.Unlock()
|
|
|
|
keyStr := fmt.Sprintf("htmgo:%v", key)
|
|
|
|
// Check if exists
|
|
if val, ok := a.cache.data[keyStr]; ok {
|
|
return val
|
|
}
|
|
|
|
// Compute and store
|
|
value := compute()
|
|
a.cache.data[keyStr] = value
|
|
// In a real implementation, you'd also set TTL in Redis
|
|
|
|
return value
|
|
}
|
|
|
|
// Example demonstrates creating a custom cache adapter
|
|
func ExampleDistributedCacheAdapter() {
|
|
|
|
// Create the distributed cache
|
|
distCache := &MockDistributedCache{
|
|
data: make(map[string]string),
|
|
}
|
|
adapter := &DistributedCacheAdapter{cache: distCache}
|
|
|
|
// Use it with a cached component
|
|
SharedComponent := h.Cached(10*time.Minute, func() *h.Element {
|
|
return h.Div(h.Text("Shared across all servers"))
|
|
}, h.WithCacheStore(adapter))
|
|
|
|
html := h.Render(SharedComponent())
|
|
fmt.Printf("Cached in distributed store: %v\n", len(distCache.data) > 0)
|
|
fmt.Printf("HTML length: %d\n", len(html))
|
|
|
|
// Output:
|
|
// Cached in distributed store: true
|
|
// HTML length: 36
|
|
}
|
|
|
|
// Example demonstrates overriding the default cache provider globally
|
|
func ExampleDefaultCacheProvider() {
|
|
// Save the original provider to restore it later
|
|
originalProvider := h.DefaultCacheProvider
|
|
defer func() {
|
|
h.DefaultCacheProvider = originalProvider
|
|
}()
|
|
|
|
// Override the default to use LRU for all cached components
|
|
h.DefaultCacheProvider = func() cache.Store[any, string] {
|
|
// All cached components will use 10,000 item LRU cache by default
|
|
return cache.NewLRUStore[any, string](10_000)
|
|
}
|
|
|
|
// Now all cached components use LRU by default
|
|
renderCount := 0
|
|
AutoLRUComponent := h.Cached(1*time.Hour, func() *h.Element {
|
|
renderCount++
|
|
return h.Div(h.Text("Using LRU by default"))
|
|
})
|
|
|
|
h.Render(AutoLRUComponent())
|
|
fmt.Printf("Render count: %d\n", renderCount)
|
|
|
|
// Output:
|
|
// Render count: 1
|
|
}
|
|
|
|
// Example demonstrates caching with complex keys
|
|
func ExampleCachedPerKeyT3() {
|
|
type FilterOptions struct {
|
|
Category string
|
|
MinPrice float64
|
|
MaxPrice float64
|
|
}
|
|
|
|
renderCount := 0
|
|
|
|
// Cache filtered product lists with composite keys
|
|
FilteredProducts := h.CachedPerKeyT3(30*time.Minute,
|
|
func(category string, minPrice, maxPrice float64) (FilterOptions, h.GetElementFunc) {
|
|
// Create composite key from all parameters
|
|
key := FilterOptions{
|
|
Category: category,
|
|
MinPrice: minPrice,
|
|
MaxPrice: maxPrice,
|
|
}
|
|
return key, func() *h.Element {
|
|
renderCount++
|
|
// Simulate database query with filters
|
|
return h.Div(
|
|
h.H3(h.Text(fmt.Sprintf("Products in %s", category))),
|
|
h.P(h.Text(fmt.Sprintf("Price range: $%.2f - $%.2f", minPrice, maxPrice))),
|
|
h.Ul(
|
|
h.Li(h.Text("Product 1")),
|
|
h.Li(h.Text("Product 2")),
|
|
h.Li(h.Text("Product 3")),
|
|
),
|
|
)
|
|
}
|
|
},
|
|
)
|
|
|
|
// First query - will render
|
|
h.Render(FilteredProducts("Electronics", 100.0, 500.0))
|
|
fmt.Printf("Render count: %d\n", renderCount)
|
|
|
|
// Same query - will use cache
|
|
h.Render(FilteredProducts("Electronics", 100.0, 500.0))
|
|
fmt.Printf("Render count after cache hit: %d\n", renderCount)
|
|
|
|
// Different query - will render
|
|
h.Render(FilteredProducts("Electronics", 200.0, 600.0))
|
|
fmt.Printf("Render count after new query: %d\n", renderCount)
|
|
|
|
// Output:
|
|
// Render count: 1
|
|
// Render count after cache hit: 1
|
|
// Render count after new query: 2
|
|
}
|
|
|
|
// Example demonstrates cache expiration and refresh
|
|
func ExampleCached_expiration() {
|
|
renderCount := 0
|
|
now := time.Now()
|
|
|
|
// Cache with very short TTL for demonstration
|
|
TimeSensitive := h.Cached(100*time.Millisecond, func() *h.Element {
|
|
renderCount++
|
|
return h.Div(
|
|
h.Text(fmt.Sprintf("Generated at: %s (render #%d)",
|
|
now.Format("15:04:05"), renderCount)),
|
|
)
|
|
})
|
|
|
|
// First render
|
|
h.Render(TimeSensitive())
|
|
fmt.Printf("Render count: %d\n", renderCount)
|
|
|
|
// Immediate second render - uses cache
|
|
h.Render(TimeSensitive())
|
|
fmt.Printf("Render count (cached): %d\n", renderCount)
|
|
|
|
// Wait for expiration
|
|
time.Sleep(150 * time.Millisecond)
|
|
|
|
// Render after expiration - will re-execute
|
|
h.Render(TimeSensitive())
|
|
fmt.Printf("Render count (after expiration): %d\n", renderCount)
|
|
|
|
// Output:
|
|
// Render count: 1
|
|
// Render count (cached): 1
|
|
// Render count (after expiration): 2
|
|
}
|