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), } } // GetOrCompute atomically gets an existing value or computes and stores a new value. func (s *TTLStore[K, V]) GetOrCompute(key K, compute func() V, ttl time.Duration) V { s.mutex.Lock() defer s.mutex.Unlock() // Check if exists and not expired if e, ok := s.cache[key]; ok && time.Now().Before(e.expiration) { return e.value } // Compute while holding lock value := compute() // Store the result s.cache[key] = &entry[V]{ value: value, expiration: time.Now().Add(ttl), } return value } // 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)) } }