package performance import ( "github.com/maddalax/htmgo/framework/h" . "htmgo-site/pages/docs" "htmgo-site/ui" ) func PluggableCaches(ctx *h.RequestContext) *h.Page { return DocPage( ctx, h.Div( h.Class("flex flex-col gap-3"), Title("Creating Custom Cache Stores"), Text(` htmgo supports pluggable cache stores, allowing you to use any caching backend or implement custom caching strategies. This feature enables better control over memory usage, distributed caching support, and protection against memory exhaustion attacks. `), SubTitle("The Cache Store Interface"), Text(` All cache stores implement the following interface: `), ui.GoCodeSnippet(CacheStoreInterface), Text(` The interface is generic, supporting any comparable key type and any value type. `), Text(` Important: The GetOrCompute method provides atomic guarantees. When multiple goroutines request the same key simultaneously, only one will execute the compute function, preventing duplicate expensive operations like database queries or complex computations. `), SubTitle("Technical: The Race Condition Fix"), Text(` The previous implementation had a time-of-check to time-of-use (TOCTOU) race condition: `), Text(` With GetOrCompute, the entire check-compute-store operation happens atomically while holding the lock, eliminating the race window completely. `), Text(` The Close() method allows for cleanup of resources when the cache is no longer needed. `), SubTitle("Using Custom Cache Stores"), Text(` You can use custom cache stores in two ways: `), StepTitle("1. Per-Component Configuration"), ui.GoCodeSnippet(PerComponentExample), StepTitle("2. Global Default Configuration"), ui.GoCodeSnippet(GlobalConfigExample), SubTitle("Implementing a Custom Cache Store"), Text(` Here's a complete example of implementing a Redis-based cache store: `), ui.GoCodeSnippet(RedisCacheExample), SubTitle("Built-in Cache Stores"), Text(` htmgo provides two built-in cache implementations: `), StepTitle("TTL Store (Default)"), Text(` The default cache store that maintains backward compatibility with existing htmgo applications. It automatically removes expired entries based on TTL. `), ui.GoCodeSnippet(TTLStoreExample), StepTitle("LRU Store"), Text(` A memory-bounded cache that evicts least recently used items when the size limit is reached. This is useful for preventing memory exhaustion attacks. `), ui.GoCodeSnippet(LRUStoreExample), SubTitle("Migration Guide"), Text(` Good news! Existing htmgo applications require no changes to work with the new cache system. The default behavior remains exactly the same, with improved concurrency guarantees. The framework uses the atomic GetOrCompute method internally, preventing race conditions that could cause duplicate renders. `), Text(` If you want to take advantage of custom cache stores: `), StepTitle("Before (existing code):"), ui.GoCodeSnippet(MigrationBefore), StepTitle("After (with custom cache):"), ui.GoCodeSnippet(MigrationAfter), SubTitle("Best Practices"), Text(` 1. Resource Management: Always implement the Close() method if your cache uses external resources. `), Text(` 2. Thread Safety: The GetOrCompute method must be thread-safe and provide atomic guarantees. This means when multiple goroutines call GetOrCompute with the same key simultaneously, only one should execute the compute function. `), Text(` 3. Memory Bounds: Consider implementing size limits to prevent unbounded memory growth. `), Text(` 4. Error Handling: Cache operations should be resilient to failures and not crash the application. `), Text(` 5. Monitoring: Consider adding metrics to track cache hit rates and performance. `), Text(` 6. Atomic Operations: Always use GetOrCompute for cache retrieval to ensure proper concurrency handling and prevent cache stampedes. `), SubTitle("Common Use Cases"), StepTitle("Distributed Caching"), Text(` Use Redis or Memcached for sharing cache across multiple application instances: `), ui.GoCodeSnippet(DistributedCacheExample), StepTitle("Memory-Bounded Caching"), Text(` Prevent memory exhaustion by limiting cache size: `), ui.GoCodeSnippet(MemoryBoundedExample), StepTitle("Tiered Caching"), Text(` Implement a multi-level cache with fast local storage and slower distributed storage: `), ui.GoCodeSnippet(TieredCacheExample), Text(` Security Note: The pluggable cache system helps mitigate memory exhaustion attacks by allowing you to implement bounded caches. Always consider using size-limited caches in production environments where untrusted input could influence cache keys. `), Text(` Concurrency Note: The GetOrCompute method eliminates race conditions that could occur in the previous implementation. When multiple goroutines request the same uncached key via GetOrCompute method simultaneously, only one will execute the expensive render operation, while others wait for the result. This prevents "cache stampedes" where many goroutines simultaneously compute the same expensive value. `), NextStep( "mt-4", PrevBlock("Caching Per Key", DocPath("/performance/caching-per-key")), NextBlock("Server Sent Events", DocPath("/pushing-data/sse")), ), ), ) } const CacheStoreInterface = ` type Store[K comparable, V any] interface { // Set adds or updates an entry in the cache with the given TTL Set(key K, value V, ttl time.Duration) // GetOrCompute atomically gets an existing value or computes and stores a new value // This is the primary method for cache retrieval and prevents duplicate computation GetOrCompute(key K, compute func() V, ttl time.Duration) V // Delete removes an entry from the cache Delete(key K) // Purge removes all items from the cache Purge() // Close releases any resources used by the cache Close() } ` const PerComponentExample = ` // Create a custom cache store lruCache := cache.NewLRUStore[string, string](10000) // Max 10k items // Use it with a cached component var CachedUserProfile = h.CachedPerKeyT( 15*time.Minute, getUserProfile, h.WithStore(lruCache), // Pass the custom store ) ` const GlobalConfigExample = ` // Set a global default cache provider func init() { h.DefaultCacheProvider = func() cache.Store[any, string] { return cache.NewLRUStore[any, string](50000) } } // All cached components will now use LRU caching by default var CachedData = h.Cached(5*time.Minute, getData) // Uses LRU store ` const RedisCacheExample = ` package cache import ( "context" "encoding/json" "time" "github.com/redis/go-redis/v9" ) type RedisStore[K comparable, V any] struct { client *redis.Client prefix string ttl time.Duration } func NewRedisStore[K comparable, V any](client *redis.Client, prefix string, ttl time.Duration) *RedisStore[K, V] { return &RedisStore[K, V]{ client: client, prefix: prefix, ttl: ttl, } } func (r *RedisStore[K, V]) Set(key K, value V, ttl time.Duration) { ctx := context.Background() redisKey := fmt.Sprintf("%s:%v", r.prefix, key) // Serialize value data, err := json.Marshal(value) if err != nil { return } // Set in Redis with TTL r.client.Set(ctx, redisKey, data, ttl) } func (r *RedisStore[K, V]) GetOrCompute(key K, compute func() V, ttl time.Duration) V { ctx := context.Background() redisKey := fmt.Sprintf("%s:%v", r.prefix, key) // Try to get from Redis first data, err := r.client.Get(ctx, redisKey).Bytes() if err == nil { // Found in cache, deserialize var value V if err := json.Unmarshal(data, &value); err == nil { return value } } // Not in cache or error, compute new value value := compute() // Serialize and store if data, err := json.Marshal(value); err == nil { r.client.Set(ctx, redisKey, data, ttl) } return value } func (r *RedisStore[K, V]) Purge() { ctx := context.Background() // Delete all keys with our prefix iter := r.client.Scan(ctx, 0, r.prefix+"*", 0).Iterator() for iter.Next(ctx) { r.client.Del(ctx, iter.Val()) } } func (r *RedisStore[K, V]) Delete(key K) { ctx := context.Background() redisKey := fmt.Sprintf("%s:%v", r.prefix, key) r.client.Del(ctx, redisKey) } func (r *RedisStore[K, V]) Close() { r.client.Close() } // Usage redisClient := redis.NewClient(&redis.Options{ Addr: "localhost:6379", }) redisCache := NewRedisStore[string, string]( redisClient, "myapp:cache", 15*time.Minute, ) var CachedUserData = h.CachedPerKeyT( 15*time.Minute, getUserData, h.WithStore(redisCache), ) ` const TTLStoreExample = ` // Create a TTL-based cache (this is the default) ttlCache := cache.NewTTLStore[string, string]() // Use explicitly if needed var CachedData = h.Cached( 5*time.Minute, getData, h.WithStore(ttlCache), ) ` const LRUStoreExample = ` // Create an LRU cache with max 1000 items lruCache := cache.NewLRUStore[int, UserProfile](1000) // Use with per-key caching var CachedUserProfile = h.CachedPerKeyT( 30*time.Minute, func(userID int) (int, h.GetElementFunc) { return userID, func() *h.Element { return renderUserProfile(userID) } }, h.WithStore(lruCache), ) ` const MigrationBefore = ` // Existing code - continues to work without changes var CachedDashboard = h.Cached(10*time.Minute, func() *h.Element { return renderDashboard() }) var CachedUserData = h.CachedPerKeyT(15*time.Minute, func(userID string) (string, h.GetElementFunc) { return userID, func() *h.Element { return renderUserData(userID) } }) ` const MigrationAfter = ` // Enhanced with custom cache store memoryCache := cache.NewLRUStore[any, string](10000) var CachedDashboard = h.Cached(10*time.Minute, func() *h.Element { return renderDashboard() }, h.WithStore(memoryCache)) var CachedUserData = h.CachedPerKeyT(15*time.Minute, func(userID string) (string, h.GetElementFunc) { return userID, func() *h.Element { return renderUserData(userID) } }, h.WithStore(memoryCache)) ` const DistributedCacheExample = ` // Initialize Redis client redisClient := redis.NewClient(&redis.Options{ Addr: "redis-cluster:6379", Password: os.Getenv("REDIS_PASSWORD"), DB: 0, }) // Create distributed cache distributedCache := NewRedisStore[string, string]( redisClient, "webapp:cache", 30*time.Minute, ) // Set as global default h.DefaultCacheProvider = func() cache.Store[any, string] { return distributedCache } ` const MemoryBoundedExample = ` // Limit cache to 5000 items to prevent memory exhaustion boundedCache := cache.NewLRUStore[string, string](5000) // Use for user-generated content where keys might be unpredictable var CachedSearchResults = h.CachedPerKeyT( 5*time.Minute, func(query string) (string, h.GetElementFunc) { // Normalize and validate query to prevent cache poisoning normalized := normalizeSearchQuery(query) return normalized, func() *h.Element { return performSearch(normalized) } }, h.WithStore(boundedCache), ) ` const TieredCacheExample = ` type TieredCache[K comparable, V any] struct { l1 cache.Store[K, V] // Fast local cache l2 cache.Store[K, V] // Slower distributed cache } func NewTieredCache[K comparable, V any](local, distributed cache.Store[K, V]) *TieredCache[K, V] { return &TieredCache[K, V]{l1: local, l2: distributed} } func (t *TieredCache[K, V]) Get(key K) (V, bool) { // Check L1 first if val, ok := t.l1.Get(key); ok { return val, true } // Check L2 if val, ok := t.l2.Get(key); ok { // Populate L1 for next time t.l1.Set(key, val) return val, true } var zero V return zero, false } func (t *TieredCache[K, V]) Set(key K, value V) { t.l1.Set(key, value) t.l2.Set(key, value) } func (t *TieredCache[K, V]) Delete(key K) { t.l1.Delete(key) t.l2.Delete(key) } func (t *TieredCache[K, V]) Close() error { if err := t.l1.Close(); err != nil { return err } return t.l2.Close() } // Usage tieredCache := NewTieredCache( cache.NewLRUStore[string, string](1000), // L1: 1k items in memory NewRedisStore[string, string](redis, "", 1*time.Hour), // L2: Redis ) `