htmgo/framework/h/cache
Eliah Rusin 04997d7315
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>
2025-06-26 21:38:38 +03:00
..
example_test.go Refactor caching system to use pluggable stores 2025-06-26 21:38:38 +03:00
interface.go Refactor caching system to use pluggable stores 2025-06-26 21:38:38 +03:00
lru_store_example.go Refactor caching system to use pluggable stores 2025-06-26 21:38:38 +03:00
lru_store_test.go Refactor caching system to use pluggable stores 2025-06-26 21:38:38 +03:00
README.md Refactor caching system to use pluggable stores 2025-06-26 21:38:38 +03:00
ttl_store.go Refactor caching system to use pluggable stores 2025-06-26 21:38:38 +03:00
ttl_store_test.go Refactor caching system to use pluggable stores 2025-06-26 21:38:38 +03:00

Pluggable Cache System for htmgo

Overview

The htmgo framework now supports a pluggable cache system that allows developers to provide their own caching implementations. This addresses potential memory exhaustion vulnerabilities in the previous TTL-only caching approach and provides greater flexibility for production deployments.

Motivation

The previous caching mechanism relied exclusively on Time-To-Live (TTL) expiration, which could lead to:

  • Unbounded memory growth: High-cardinality cache keys could consume all available memory
  • DDoS vulnerability: Attackers could exploit this by generating many unique cache keys
  • Limited flexibility: No support for size-bounded caches or distributed caching solutions

Architecture

The new system introduces a generic Store[K comparable, V any] interface:

type Store[K comparable, V any] interface {
Set(key K, value V, ttl time.Duration)
Get(key K) (V, bool)
Delete(key K)
Purge()
Close()
}

Usage

Using the Default Cache

By default, htmgo continues to use a TTL-based cache for backward compatibility:

// No changes needed - works exactly as before
UserProfile := h.CachedPerKey(
15*time.Minute,
func (userID int) (int, h.GetElementFunc) {
return userID, func () *h.Element {
return h.Div(h.Text("User profile"))
}
},
)

Using a Custom Cache

You can provide your own cache implementation using the WithStore option:

import (
"github.com/maddalax/htmgo/framework/h"
"github.com/maddalax/htmgo/framework/h/cache"
)

// Create a memory-bounded LRU cache
lruCache := cache.NewLRUStore[any, string](10000) // Max 10,000 items

// Use it with a cached component
UserProfile := h.CachedPerKey(
15*time.Minute,
func (userID int) (int, h.GetElementFunc) {
return userID, func () *h.Element {
return h.Div(h.Text("User profile"))
}
},
h.WithStore(lruCache), // Pass the custom cache
)

Changing the Default Cache Globally

You can override the default cache provider for your entire application:

func init() {
// All cached components will use LRU by default
h.DefaultCacheProvider = func () cache.Store[any, string] {
return cache.NewLRUStore[any, string](50000)
}
}

Example Implementations

Built-in Stores

  1. TTLStore (default): Time-based expiration with periodic cleanup
  2. LRUStore (example): Least Recently Used eviction with size limits

Integrating Third-Party Libraries

Here's an example of integrating the high-performance go-freelru library:

import (
"time"
"github.com/elastic/go-freelru"
"github.com/maddalax/htmgo/framework/h/cache"
)

type FreeLRUAdapter[K comparable, V any] struct {
lru *freelru.LRU[K, V]
}

func NewFreeLRUAdapter[K comparable, V any](size uint32) cache.Store[K, V] {
lru, err := freelru.New[K, V](size, nil)
if err != nil {
panic(err)
}
return &FreeLRUAdapter[K, V]{lru: lru}
}

func (s *FreeLRUAdapter[K, V]) Set(key K, value V, ttl time.Duration) {
// Note: go-freelru doesn't support per-item TTL
s.lru.Add(key, value)
}

func (s *FreeLRUAdapter[K, V]) Get(key K) (V, bool) {
return s.lru.Get(key)
}

func (s *FreeLRUAdapter[K, V]) Delete(key K) {
s.lru.Remove(key)
}

func (s *FreeLRUAdapter[K, V]) Purge() {
s.lru.Clear()
}

func (s *FreeLRUAdapter[K, V]) Close() {
// No-op for this implementation
}

Redis-based Distributed Cache

type RedisStore struct {
client *redis.Client
prefix string
}

func (s *RedisStore) Set(key any, value string, ttl time.Duration) {
keyStr := fmt.Sprintf("%s:%v", s.prefix, key)
s.client.Set(context.Background(), keyStr, value, ttl)
}

func (s *RedisStore) Get(key any) (string, bool) {
keyStr := fmt.Sprintf("%s:%v", s.prefix, key)
val, err := s.client.Get(context.Background(), keyStr).Result()
if err == redis.Nil {
return "", false
}
return val, err == nil
}

// ... implement other methods

Migration Guide

For Existing Applications

The changes are backward compatible. Existing applications will continue to work without modifications. The function signatures now accept optional CacheOption parameters, but these can be omitted.

  1. Assess your caching needs: Determine if you need memory bounds or distributed caching
  2. Choose an implementation: Use the built-in LRUStore or integrate a third-party library
  3. Update critical components: Start with high-traffic or high-cardinality cached components
  4. Monitor memory usage: Ensure your cache size limits are appropriate

Security Considerations

Memory-Bounded Caches

For public-facing applications, we strongly recommend using a memory-bounded cache to prevent DoS attacks:

// Limit cache to reasonable size based on your server's memory
cache := cache.NewLRUStore[any, string](100_000)

// Use for all user-specific caching
UserContent := h.CachedPerKey(
5*time.Minute,
getUserContent,
h.WithStore(cache),
)

Cache Key Validation

When using user input as cache keys, always validate and sanitize:

func cacheKeyForUser(userInput string) string {
// Limit length and remove special characters
key := strings.TrimSpace(userInput)
if len(key) > 100 {
key = key[:100]
}
return regexp.MustCompile(`[^a-zA-Z0-9_-]`).ReplaceAllString(key, "")
}

Performance Considerations

  1. TTLStore: Best for small caches with predictable key patterns
  2. LRUStore: Good general-purpose choice with memory bounds
  3. Third-party stores: Consider go-freelru or theine-go for high-performance needs
  4. Distributed stores: Use Redis/Memcached for multi-instance deployments

Best Practices

  1. Set appropriate cache sizes: Balance memory usage with hit rates
  2. Use consistent TTLs: Align with your data update patterns
  3. Monitor cache metrics: Track hit rates, evictions, and memory usage
  4. Handle cache failures gracefully: Caches should enhance, not break functionality
  5. Close caches properly: Call Close() during graceful shutdown

Future Enhancements

  • Built-in metrics and monitoring hooks
  • Automatic size estimation for cached values
  • Warming and preloading strategies
  • Cache invalidation patterns