// Package cache provides a small, dependency-free caching abstraction used by // the API layer to memoize org-scoped reads (settings, products, customers, // resolved rates, analytics). // // The Cache interface is intentionally minimal so an in-memory implementation // can later be swapped for a Redis-backed one without touching call sites. // // KEY FORMAT: callers MUST prefix every key with the org id segment, e.g. // "settings:5", "products:5:all=false", "rate:5:p=1:c=2:d=2026-06-09". This // guarantees that DeletePrefix("products:5") invalidates only org 5's data and // that one org can never read another org's cached bytes. package cache import ( "container/list" "strings" "sync" "time" ) // Cache is the storage abstraction used by the API layer. // // Implementations MUST be safe for concurrent use by multiple goroutines. type Cache interface { // Get returns the value for key and true if present and not expired. Get(key string) ([]byte, bool) // Set stores val under key with the given ttl. A ttl <= 0 means the entry // never expires (until evicted by size pressure or Delete). Set(key string, val []byte, ttl time.Duration) // Delete removes a single key. Delete(key string) // DeletePrefix removes every key that begins with prefix. DeletePrefix(prefix string) } // entry is a single cached value plus its expiry and a pointer to its node in // the insertion-order list (used for eviction). type entry struct { val []byte expiresAt time.Time // zero means "no expiry" elem *list.Element } // memory is a concurrency-safe, bounded, TTL-aware in-memory Cache. // // Eviction policy: when the number of live entries would exceed maxEntries, the // OLDEST-INSERTED entry is evicted (FIFO). This is simple, predictable, and // adequate for the API's memoization use case where most entries share a short // TTL and explicit invalidation handles correctness. (It is not strict LRU; // reads do not refresh insertion order.) // // TTL is enforced lazily on Get; expired entries are dropped when first read // after expiry, and are also naturally pushed out by FIFO eviction. type memory struct { mu sync.RWMutex items map[string]*entry order *list.List // *list.Element.Value holds the string key, front = oldest maxEntries int } // NewMemory returns an in-memory Cache bounded to maxEntries live entries. // A maxEntries <= 0 disables the size bound (unbounded growth). func NewMemory(maxEntries int) Cache { return &memory{ items: make(map[string]*entry), order: list.New(), maxEntries: maxEntries, } } func (m *memory) Get(key string) ([]byte, bool) { // Fast path under a read lock. m.mu.RLock() e, ok := m.items[key] if !ok { m.mu.RUnlock() return nil, false } expired := !e.expiresAt.IsZero() && time.Now().After(e.expiresAt) if !expired { val := e.val m.mu.RUnlock() return val, true } m.mu.RUnlock() // Expired: drop it under a write lock (re-check in case of races). m.mu.Lock() if e, ok := m.items[key]; ok { if !e.expiresAt.IsZero() && time.Now().After(e.expiresAt) { m.removeLocked(key, e) } } m.mu.Unlock() return nil, false } func (m *memory) Set(key string, val []byte, ttl time.Duration) { var exp time.Time if ttl > 0 { exp = time.Now().Add(ttl) } m.mu.Lock() defer m.mu.Unlock() if e, ok := m.items[key]; ok { // Overwrite in place; keep insertion position. e.val = val e.expiresAt = exp return } elem := m.order.PushBack(key) m.items[key] = &entry{val: val, expiresAt: exp, elem: elem} // Enforce size bound by evicting oldest-inserted entries (FIFO). if m.maxEntries > 0 { for m.order.Len() > m.maxEntries { front := m.order.Front() if front == nil { break } oldKey, _ := front.Value.(string) if oe, ok := m.items[oldKey]; ok { m.removeLocked(oldKey, oe) } else { m.order.Remove(front) } } } } func (m *memory) Delete(key string) { m.mu.Lock() defer m.mu.Unlock() if e, ok := m.items[key]; ok { m.removeLocked(key, e) } } func (m *memory) DeletePrefix(prefix string) { m.mu.Lock() defer m.mu.Unlock() for k, e := range m.items { if strings.HasPrefix(k, prefix) { m.removeLocked(k, e) } } } // removeLocked deletes key/e from both the map and the order list. // Caller must hold m.mu for writing. func (m *memory) removeLocked(key string, e *entry) { delete(m.items, key) if e.elem != nil { m.order.Remove(e.elem) e.elem = nil } }