htmgo/framework/h/cache/ttl_store.go
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

128 lines
2.5 KiB
Go

package cache
import (
"flag"
"log/slog"
"sync"
"time"
)
// TTLStore is a time-to-live based cache implementation that mimics
// the original htmgo caching behavior. It stores values with expiration
// times and periodically cleans up expired entries.
type TTLStore[K comparable, V any] struct {
cache map[K]*entry[V]
mutex sync.RWMutex
closeOnce sync.Once
closeChan chan struct{}
}
type entry[V any] struct {
value V
expiration time.Time
}
// NewTTLStore creates a new TTL-based cache store.
func NewTTLStore[K comparable, V any]() Store[K, V] {
s := &TTLStore[K, V]{
cache: make(map[K]*entry[V]),
closeChan: make(chan struct{}),
}
s.startCleaner()
return s
}
// Set adds or updates an entry in the cache with the given TTL.
func (s *TTLStore[K, V]) Set(key K, value V, ttl time.Duration) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.cache[key] = &entry[V]{
value: value,
expiration: time.Now().Add(ttl),
}
}
// Get retrieves an entry from the cache.
func (s *TTLStore[K, V]) Get(key K) (V, bool) {
s.mutex.RLock()
defer s.mutex.RUnlock()
var zero V
e, ok := s.cache[key]
if !ok {
return zero, false
}
// Check if expired
if time.Now().After(e.expiration) {
return zero, false
}
return e.value, true
}
// Delete removes an entry from the cache.
func (s *TTLStore[K, V]) Delete(key K) {
s.mutex.Lock()
defer s.mutex.Unlock()
delete(s.cache, key)
}
// Purge removes all items from the cache.
func (s *TTLStore[K, V]) Purge() {
s.mutex.Lock()
defer s.mutex.Unlock()
s.cache = make(map[K]*entry[V])
}
// Close stops the background cleaner goroutine.
func (s *TTLStore[K, V]) Close() {
s.closeOnce.Do(func() {
close(s.closeChan)
})
}
// startCleaner starts a background goroutine that periodically removes expired entries.
func (s *TTLStore[K, V]) startCleaner() {
isTests := flag.Lookup("test.v") != nil
go func() {
ticker := time.NewTicker(time.Minute)
if isTests {
ticker = time.NewTicker(time.Second)
}
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.clearExpired()
case <-s.closeChan:
return
}
}
}()
}
// clearExpired removes all expired entries from the cache.
func (s *TTLStore[K, V]) clearExpired() {
s.mutex.Lock()
defer s.mutex.Unlock()
now := time.Now()
deletedCount := 0
for key, e := range s.cache {
if now.After(e.expiration) {
delete(s.cache, key)
deletedCount++
}
}
if deletedCount > 0 {
slog.Debug("Deleted expired cache entries", slog.Int("count", deletedCount))
}
}