package user import ( "context" "sync" "sync/atomic" "github.com/google/uuid" ) // Cache is the in-memory write-through projection of the active rows in // `backend.entitlement_snapshots`. Reads (Get) are RLocked; writes (Add) // are Locked. // // The cache keys snapshots by user_id. Soft-delete does not evict // entries — read paths gate visibility through the // `accounts.deleted_at IS NULL` predicate, so a cached snapshot for a // soft-deleted user is harmless and is reaped on the next process // reboot. // // The caller is expected to commit the corresponding database write // *before* invoking Add so that the cache stays consistent under crash: // a Postgres commit failure leaves the cache untouched, matching the // previous DB state. This mirrors the post-commit write-through pattern // established in `backend/internal/auth.Cache`. type Cache struct { mu sync.RWMutex byID map[uuid.UUID]EntitlementSnapshot ready atomic.Bool } // NewCache constructs an empty Cache. The cache reports Ready() == false // until Warm completes successfully. func NewCache() *Cache { return &Cache{ byID: make(map[uuid.UUID]EntitlementSnapshot), } } // Warm replaces the cache contents with every row loaded from store. It // is intended to be called exactly once at process boot before the HTTP // listener accepts traffic; successful completion flips Ready to true. // Subsequent calls re-warm the cache (useful in tests). func (c *Cache) Warm(ctx context.Context, store *Store) error { if c == nil { return nil } snapshots, err := store.ListEntitlementSnapshots(ctx) if err != nil { return err } c.mu.Lock() defer c.mu.Unlock() c.byID = make(map[uuid.UUID]EntitlementSnapshot, len(snapshots)) for _, snap := range snapshots { c.byID[snap.UserID] = snap } c.ready.Store(true) return nil } // Ready reports whether Warm has completed at least once. The HTTP // readiness probe wires through this method together with the auth // cache so `/readyz` only flips to 200 after every cache is hydrated. func (c *Cache) Ready() bool { if c == nil { return false } return c.ready.Load() } // Size returns the number of cached entitlement snapshots. Useful for // the startup log line and tests. func (c *Cache) Size() int { if c == nil { return 0 } c.mu.RLock() defer c.mu.RUnlock() return len(c.byID) } // Get returns the snapshot for userID and a presence flag. Misses // always return the zero EntitlementSnapshot and false. func (c *Cache) Get(userID uuid.UUID) (EntitlementSnapshot, bool) { if c == nil { return EntitlementSnapshot{}, false } c.mu.RLock() defer c.mu.RUnlock() s, ok := c.byID[userID] return s, ok } // Add stores snap in the cache. It is safe to call on an existing // entry — the value is overwritten with the latest snapshot. func (c *Cache) Add(snap EntitlementSnapshot) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() c.byID[snap.UserID] = snap }