package cache import ( "context" "sync" "sync/atomic" "time" ) // Interface defines the operations a cache must support. type Interface interface { // Set adds a value to the cache with the default TTL. Set(ctx context.Context, key string, value any) // SetWithTTL adds a value to the cache with a custom TTL. SetWithTTL(ctx context.Context, key string, value any, ttl time.Duration) // Get retrieves a value from the cache. Get(ctx context.Context, key string) (any, bool) // Delete removes a value from the cache. Delete(ctx context.Context, key string) // Clear removes all values from the cache. Clear(ctx context.Context) // Size returns the number of items in the cache. Size() int64 // Close stops all background tasks and releases resources. Close() error } // item represents a cached value with metadata. type item struct { value any expiration time.Time size int // Approximate size in bytes } // Config contains options for configuring a cache. type Config struct { // DefaultTTL is the default time-to-live for cache entries. DefaultTTL time.Duration // CleanupInterval is how often the cache runs cleanup. CleanupInterval time.Duration // MaxItems is the maximum number of items allowed in the cache. MaxItems int // OnEviction is called when an item is evicted from the cache. OnEviction func(key string, value any) } // DefaultConfig returns a default configuration for the cache. func DefaultConfig() Config { return Config{ DefaultTTL: 10 * time.Minute, CleanupInterval: 5 * time.Minute, MaxItems: 1000, OnEviction: nil, } } // Cache is a thread-safe in-memory cache with TTL and memory management. type Cache struct { data sync.Map config Config itemCount int64 // Use atomic operations to track item count stopChan chan struct{} closedChan chan struct{} } // New creates a new memory cache with the given configuration. func New(config Config) *Cache { c := &Cache{ config: config, stopChan: make(chan struct{}), closedChan: make(chan struct{}), } go c.cleanupLoop() return c } // NewDefault creates a new memory cache with default configuration. func NewDefault() *Cache { return New(DefaultConfig()) } // Set adds a value to the cache with the default TTL. func (c *Cache) Set(ctx context.Context, key string, value any) { c.SetWithTTL(ctx, key, value, c.config.DefaultTTL) } // SetWithTTL adds a value to the cache with a custom TTL. func (c *Cache) SetWithTTL(_ context.Context, key string, value any, ttl time.Duration) { // Estimate size of the item (very rough approximation). size := estimateSize(value) // Check if item already exists to avoid double counting. if _, exists := c.data.Load(key); exists { c.data.Delete(key) } else { // Only increment if this is a new key. atomic.AddInt64(&c.itemCount, 1) } c.data.Store(key, item{ value: value, expiration: time.Now().Add(ttl), size: size, }) // If we're over the max items, clean up old items. if c.config.MaxItems > 0 && atomic.LoadInt64(&c.itemCount) > int64(c.config.MaxItems) { c.cleanupOldest() } } // Get retrieves a value from the cache. func (c *Cache) Get(_ context.Context, key string) (any, bool) { value, ok := c.data.Load(key) if !ok { return nil, false } itm, ok := value.(item) if !ok { // If the value is not of type item, it means it was corrupted or not set correctly. c.data.Delete(key) return nil, false } if time.Now().After(itm.expiration) { c.data.Delete(key) atomic.AddInt64(&c.itemCount, -1) if c.config.OnEviction != nil { c.config.OnEviction(key, itm.value) } return nil, false } return itm.value, true } // Delete removes a value from the cache. func (c *Cache) Delete(_ context.Context, key string) { if value, loaded := c.data.LoadAndDelete(key); loaded { atomic.AddInt64(&c.itemCount, -1) if c.config.OnEviction != nil { if itm, ok := value.(item); ok { c.config.OnEviction(key, itm.value) } } } } // Clear removes all values from the cache. func (c *Cache) Clear(_ context.Context) { if c.config.OnEviction != nil { c.data.Range(func(key, value any) bool { itm, ok := value.(item) if !ok { return true } if keyStr, ok := key.(string); ok { c.config.OnEviction(keyStr, itm.value) } return true }) } c.data = sync.Map{} atomic.StoreInt64(&c.itemCount, 0) } // Size returns the number of items in the cache. func (c *Cache) Size() int64 { return atomic.LoadInt64(&c.itemCount) } // Close stops the cache cleanup goroutine. func (c *Cache) Close() error { select { case <-c.stopChan: // Already closed return nil default: close(c.stopChan) <-c.closedChan // Wait for cleanup goroutine to exit return nil } } // cleanupLoop periodically cleans up expired items. func (c *Cache) cleanupLoop() { ticker := time.NewTicker(c.config.CleanupInterval) defer func() { ticker.Stop() close(c.closedChan) }() for { select { case <-ticker.C: c.cleanup() case <-c.stopChan: return } } } // cleanup removes expired items. func (c *Cache) cleanup() { evicted := make(map[string]any) count := 0 c.data.Range(func(key, value any) bool { itm, ok := value.(item) if !ok { return true } if time.Now().After(itm.expiration) { c.data.Delete(key) count++ if c.config.OnEviction != nil { if keyStr, ok := key.(string); ok { evicted[keyStr] = itm.value } } } return true }) if count > 0 { atomic.AddInt64(&c.itemCount, -int64(count)) // Call eviction callbacks outside the loop to avoid blocking the range if c.config.OnEviction != nil { for k, v := range evicted { c.config.OnEviction(k, v) } } } } // cleanupOldest removes the oldest items if we're over the max items. func (c *Cache) cleanupOldest() { // Remove 20% of max items at once threshold := max(c.config.MaxItems/5, 1) currentCount := atomic.LoadInt64(&c.itemCount) // If we're not over the threshold, don't do anything if currentCount <= int64(c.config.MaxItems) { return } // Find the oldest items type keyExpPair struct { key string value any expiration time.Time } candidates := make([]keyExpPair, 0, threshold) c.data.Range(func(key, value any) bool { itm, ok := value.(item) if !ok { return true } if keyStr, ok := key.(string); ok && len(candidates) < threshold { candidates = append(candidates, keyExpPair{keyStr, itm.value, itm.expiration}) return true } // Find the newest item in candidates newestIdx := 0 for i := 1; i < len(candidates); i++ { if candidates[i].expiration.After(candidates[newestIdx].expiration) { newestIdx = i } } // Replace it if this item is older if itm.expiration.Before(candidates[newestIdx].expiration) { candidates[newestIdx] = keyExpPair{key.(string), itm.value, itm.expiration} } return true }) // Delete the oldest items deletedCount := 0 for _, candidate := range candidates { c.data.Delete(candidate.key) deletedCount++ if c.config.OnEviction != nil { c.config.OnEviction(candidate.key, candidate.value) } } // Update count if deletedCount > 0 { atomic.AddInt64(&c.itemCount, -int64(deletedCount)) } } // estimateSize attempts to estimate the memory footprint of a value. func estimateSize(value any) int { switch v := value.(type) { case string: return len(v) + 24 // base size + string overhead case []byte: return len(v) + 24 // base size + slice overhead case map[string]any: return len(v) * 64 // rough estimate default: return 64 // default conservative estimate } }