292 lines
8.4 KiB
Markdown
292 lines
8.4 KiB
Markdown
|
|
# 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:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import "time"
|
||
|
|
|
||
|
|
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 prevents duplicate computation when multiple goroutines request the same key
|
||
|
|
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()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Atomic Guarantees
|
||
|
|
|
||
|
|
The `GetOrCompute` method provides **atomic guarantees** to prevent cache stampedes and duplicate computations:
|
||
|
|
- When multiple goroutines request the same uncached key simultaneously, only one will execute the compute function
|
||
|
|
- Other goroutines will wait and receive the computed result
|
||
|
|
- This eliminates race conditions that could cause duplicate expensive operations like database queries or renders
|
||
|
|
|
||
|
|
## Usage
|
||
|
|
|
||
|
|
### Using the Default Cache
|
||
|
|
|
||
|
|
By default, htmgo continues to use a TTL-based cache for backward compatibility:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// No changes needed - works exactly as before
|
||
|
|
UserProfile := h.CachedPerKeyT(
|
||
|
|
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 `WithCacheStore` option:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"github.com/maddalax/htmgo/framework/h"
|
||
|
|
"github.com/maddalax/htmgo/framework/h/cache"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
// Create a memory-bounded LRU cache
|
||
|
|
lruCache = cache.NewLRUStore[any, string](10_000) // Max 10,000 items
|
||
|
|
|
||
|
|
// Use it with a cached component
|
||
|
|
UserProfile = h.CachedPerKeyT(
|
||
|
|
15*time.Minute,
|
||
|
|
func (userID int) (int, h.GetElementFunc) {
|
||
|
|
return userID, func () *h.Element {
|
||
|
|
return h.Div(h.Text("User profile"))
|
||
|
|
}
|
||
|
|
},
|
||
|
|
h.WithCacheStore(lruCache), // Pass the custom cache
|
||
|
|
)
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Changing the Default Cache Globally
|
||
|
|
|
||
|
|
You can override the default cache provider for your entire application:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"github.com/maddalax/htmgo/framework/h"
|
||
|
|
"github.com/maddalax/htmgo/framework/h/cache"
|
||
|
|
)
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
// All cached components will use LRU by default
|
||
|
|
h.DefaultCacheProvider = func () cache.Store[any, string] {
|
||
|
|
return cache.NewLRUStore[any, string](50_000)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## 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:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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]) GetOrCompute(key K, compute func() V, ttl time.Duration) V {
|
||
|
|
// Check if exists in cache
|
||
|
|
if val, ok := s.lru.Get(key); ok {
|
||
|
|
return val
|
||
|
|
}
|
||
|
|
|
||
|
|
// Not in cache, compute and store
|
||
|
|
// Note: This simple implementation doesn't provide true atomic guarantees
|
||
|
|
// For production use, you'd need additional synchronization
|
||
|
|
value := compute()
|
||
|
|
s.lru.Add(key, value)
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
```go
|
||
|
|
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) GetOrCompute(key any, compute func() string, ttl time.Duration) string {
|
||
|
|
keyStr := fmt.Sprintf("%s:%v", s.prefix, key)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
// Try to get from Redis
|
||
|
|
val, err := s.client.Get(ctx, keyStr).Result()
|
||
|
|
if err == nil {
|
||
|
|
return val
|
||
|
|
}
|
||
|
|
|
||
|
|
// Not in cache, compute new value
|
||
|
|
// For true atomic guarantees, use Redis SET with NX option
|
||
|
|
value := compute()
|
||
|
|
s.client.Set(ctx, keyStr, value, ttl)
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
// ... 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.
|
||
|
|
|
||
|
|
### Recommended Migration Path
|
||
|
|
|
||
|
|
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:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// 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.WithCacheStore(cache),
|
||
|
|
)
|
||
|
|
```
|
||
|
|
|
||
|
|
### Cache Key Validation
|
||
|
|
|
||
|
|
When using user input as cache keys, always validate and sanitize:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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
|
||
|
|
5. **Atomic Operations**: The `GetOrCompute` method prevents duplicate computations, significantly improving performance under high concurrency
|
||
|
|
|
||
|
|
### Concurrency Benefits
|
||
|
|
|
||
|
|
The atomic `GetOrCompute` method provides significant performance benefits:
|
||
|
|
- **Prevents Cache Stampedes**: When a popular cache entry expires, only one goroutine will recompute it
|
||
|
|
- **Reduces Load**: Expensive operations (database queries, API calls, complex renders) are never duplicated
|
||
|
|
- **Improves Response Times**: Waiting goroutines get results faster than computing themselves
|
||
|
|
|
||
|
|
## 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
|
||
|
|
6. **Implement atomic guarantees**: Ensure your `GetOrCompute` implementation prevents concurrent computation
|
||
|
|
7. **Test concurrent access**: Verify your cache handles simultaneous requests correctly
|
||
|
|
|
||
|
|
## Future Enhancements
|
||
|
|
|
||
|
|
- Built-in metrics and monitoring hooks
|
||
|
|
- Automatic size estimation for cached values
|
||
|
|
- Warming and preloading strategies
|
||
|
|
- Cache invalidation patterns
|