Spaces:
Runtime error
Runtime error
AzureAD\AdityaDevarshi
Phase 7: org-scoped in-memory TTL cache + read caching + invalidation + HTTP ETags
affd5ec | // 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 | |
| } | |
| } | |