package session import ( "container/list" "context" "errors" "fmt" "sync" "time" "go.uber.org/zap" ) // DefaultMaxEntries is the LRU bound applied when MemoryCacheOptions // does not supply a positive MaxEntries. Holds well below the per-process // memory budget for the documented MVP scale (≤10K active accounts, // ≤100K device sessions). const DefaultMaxEntries = 50_000 // DefaultTTL is the safety-net freshness window applied when // MemoryCacheOptions does not supply a positive TTL. Push events drive // invalidation in the steady state; the TTL guards against missed // events (cursor aged out, gateway restart) by forcing a fresh backend // lookup at most once per window. const DefaultTTL = 10 * time.Minute // MemoryCache is the canonical Cache implementation. Hot-path Lookup // reads serve from a process-local LRU + TTL map; misses delegate to // BackendLookup and seed the cache. session_invalidation push events // flip cached records to a revoked status without a backend // roundtrip, after which Lookup returns the revoked record straight // from memory and gateway rejects the request. // // MemoryCache is safe for concurrent use. type MemoryCache struct { mu sync.Mutex entries map[string]*list.Element byUser map[string]map[string]struct{} order *list.List max int ttl time.Duration backend BackendLookup now func() time.Time logger *zap.Logger } // memoryEntry is the value stored inside the LRU list. The key // duplication keeps Element.Value self-describing for eviction. type memoryEntry struct { key string record Record expiresAt time.Time } // MemoryCacheOptions tunes the cache. type MemoryCacheOptions struct { // MaxEntries bounds the number of cached records. Zero or // negative values default to DefaultMaxEntries. MaxEntries int // TTL bounds how long a cached entry serves the hot path before // a fresh backend lookup. Zero or negative values default to // DefaultTTL. TTL time.Duration // Now overrides time.Now for tests. Now func() time.Time // Logger is named "session.cache". A nil value uses zap.NewNop. Logger *zap.Logger } // NewMemoryCache constructs a MemoryCache. backend must not be nil. func NewMemoryCache(backend BackendLookup, opts MemoryCacheOptions) (*MemoryCache, error) { if backend == nil { return nil, errors.New("session.NewMemoryCache: backend lookup must not be nil") } max := opts.MaxEntries if max <= 0 { max = DefaultMaxEntries } ttl := opts.TTL if ttl <= 0 { ttl = DefaultTTL } now := opts.Now if now == nil { now = time.Now } logger := opts.Logger if logger == nil { logger = zap.NewNop() } return &MemoryCache{ entries: make(map[string]*list.Element, max), byUser: make(map[string]map[string]struct{}), order: list.New(), max: max, ttl: ttl, backend: backend, now: now, logger: logger.Named("session.cache"), }, nil } // Lookup serves deviceSessionID from the cache. A miss (or an entry // past its TTL) triggers a backend lookup and seeds the cache before // returning. Concurrent Lookups for the same key are not coalesced — // that level of optimisation is not needed at the documented MVP // scale. func (c *MemoryCache) Lookup(ctx context.Context, deviceSessionID string) (Record, error) { if c == nil { return Record{}, errors.New("session memory cache: nil cache") } if deviceSessionID == "" { return Record{}, ErrNotFound } now := c.now() c.mu.Lock() if elem, ok := c.entries[deviceSessionID]; ok { entry := elem.Value.(*memoryEntry) if entry.expiresAt.After(now) { c.order.MoveToFront(elem) rec := entry.record c.mu.Unlock() return rec, nil } // Expired — evict and fall through to backend. c.evictLocked(elem) } c.mu.Unlock() rec, err := c.backend.LookupSession(ctx, deviceSessionID) if err != nil { return Record{}, fmt.Errorf("session memory cache: %w", err) } c.mu.Lock() c.insertLocked(deviceSessionID, rec, now.Add(c.ttl)) c.mu.Unlock() return rec, nil } // MarkRevoked flips the cached record for deviceSessionID to a // revoked status. Calling on a missing entry is a no-op. func (c *MemoryCache) MarkRevoked(deviceSessionID string) { if c == nil || deviceSessionID == "" { return } c.mu.Lock() defer c.mu.Unlock() elem, ok := c.entries[deviceSessionID] if !ok { return } entry := elem.Value.(*memoryEntry) entry.record.Status = StatusRevoked } // MarkAllRevokedForUser flips every cached record whose UserID is // userID to revoked. The user index is updated in O(n) over the // user's session set, not the whole cache. func (c *MemoryCache) MarkAllRevokedForUser(userID string) { if c == nil || userID == "" { return } c.mu.Lock() defer c.mu.Unlock() set, ok := c.byUser[userID] if !ok { return } for id := range set { if elem, ok := c.entries[id]; ok { elem.Value.(*memoryEntry).record.Status = StatusRevoked } } } // Len returns the current number of cached entries. Useful for // metrics and tests. func (c *MemoryCache) Len() int { if c == nil { return 0 } c.mu.Lock() defer c.mu.Unlock() return c.order.Len() } // insertLocked stores rec under deviceSessionID. The caller holds c.mu. func (c *MemoryCache) insertLocked(deviceSessionID string, rec Record, expiresAt time.Time) { if existing, ok := c.entries[deviceSessionID]; ok { existing.Value.(*memoryEntry).record = rec existing.Value.(*memoryEntry).expiresAt = expiresAt c.order.MoveToFront(existing) c.indexUserLocked(deviceSessionID, rec.UserID) return } elem := c.order.PushFront(&memoryEntry{ key: deviceSessionID, record: rec, expiresAt: expiresAt, }) c.entries[deviceSessionID] = elem c.indexUserLocked(deviceSessionID, rec.UserID) if c.order.Len() > c.max { oldest := c.order.Back() if oldest != nil { c.evictLocked(oldest) } } } // evictLocked removes elem from every internal index. The caller holds c.mu. func (c *MemoryCache) evictLocked(elem *list.Element) { entry := elem.Value.(*memoryEntry) delete(c.entries, entry.key) if set := c.byUser[entry.record.UserID]; set != nil { delete(set, entry.key) if len(set) == 0 { delete(c.byUser, entry.record.UserID) } } c.order.Remove(elem) } // indexUserLocked associates deviceSessionID with userID in byUser. // The caller holds c.mu. func (c *MemoryCache) indexUserLocked(deviceSessionID, userID string) { if userID == "" { return } set, ok := c.byUser[userID] if !ok { set = make(map[string]struct{}) c.byUser[userID] = set } set[deviceSessionID] = struct{}{} } var _ Cache = (*MemoryCache)(nil)