htmgo/framework/h/cache/example_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

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
}