AzureAD\AdityaDevarshi
Phase 7: org-scoped in-memory TTL cache + read caching + invalidation + HTTP ETags
affd5ec
Raw
History Blame Contribute Delete
4.47 kB
// 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
}
}