// Package flags loads + serves feature flag decisions. // // Why this exists: even with tier-gating and audit logs, you sometimes // just want to turn a feature off RIGHT NOW (e.g. the new geo-jump // scorer is throwing false positives during a real event). A row in // the feature_flags table flips it without a redeploy. // // Design notes: // // - Flag values are loaded into an in-memory map and refreshed in the // background every 30s. A check is a map lookup; the cost of a // gate vanishes from the hot path. // // - Default for unknown flags is enabled. New code wiring a gate // ships live; ops disables later if needed. // // - Percent rollout uses a stable hash of (flag_key, user_id) so the // same user sees a consistent decision across requests. Anonymous // callers (uuid.Nil) always get the "on" side of the percentage — // they're treated as the public path. package flags import ( "context" "crypto/sha256" "encoding/binary" "log/slog" "sync" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) type Flag struct { Key string Enabled bool PercentRollout int } // Store loads + serves feature flag decisions. Zero-value Store // allows everything (handy for tests). type Store struct { pool *pgxpool.Pool logger *slog.Logger mu sync.RWMutex flags map[string]Flag } // New returns a Store with no flags loaded yet. Call Refresh to do the // first read; the lifecycle helper Start spawns a periodic refresher // for free. func New(pool *pgxpool.Pool, logger *slog.Logger) *Store { if logger == nil { logger = slog.Default() } return &Store{ pool: pool, logger: logger, flags: map[string]Flag{}, } } // Start runs an initial load and then refreshes every 30 seconds. It // returns a stop function to be deferred from the caller. func (s *Store) Start(ctx context.Context) func() { if s == nil || s.pool == nil { return func() {} } _ = s.Refresh(ctx) tickerCtx, cancel := context.WithCancel(ctx) go func() { t := time.NewTicker(30 * time.Second) defer t.Stop() for { select { case <-tickerCtx.Done(): return case <-t.C: if err := s.Refresh(tickerCtx); err != nil { s.logger.Warn("feature flags refresh failed", "err", err) } } } }() return cancel } // Refresh re-reads the table. Errors leave the previous in-memory // snapshot intact so a transient DB blip doesn't black out the gate // for every request. func (s *Store) Refresh(ctx context.Context) error { if s == nil || s.pool == nil { return nil } rows, err := s.pool.Query(ctx, `SELECT key, enabled, percent_rollout FROM feature_flags`) if err != nil { return err } defer rows.Close() next := make(map[string]Flag, 16) for rows.Next() { var f Flag var pct int16 if err := rows.Scan(&f.Key, &f.Enabled, &pct); err != nil { return err } f.PercentRollout = int(pct) next[f.Key] = f } if err := rows.Err(); err != nil { return err } s.mu.Lock() s.flags = next s.mu.Unlock() return nil } // Enabled returns true when the gate identified by `key` is on for // `subject`. A subject is normally a userID; pass uuid.Nil for global // gates that have no user dimension (the call still respects on/off // state but skips the percent-rollout split). // // Unknown flag → enabled (safe default for new code). func (s *Store) Enabled(key string, subject uuid.UUID) bool { if s == nil { return true } s.mu.RLock() f, known := s.flags[key] s.mu.RUnlock() if !known { return true } if !f.Enabled { return false } if f.PercentRollout >= 100 { return true } if f.PercentRollout <= 0 { return false } if subject == uuid.Nil { return true } return percentBucket(key, subject) < f.PercentRollout } // Snapshot returns the current flag set — handy for an admin GET so // ops can see what's actually loaded without diving into the DB. func (s *Store) Snapshot() map[string]Flag { if s == nil { return nil } s.mu.RLock() defer s.mu.RUnlock() out := make(map[string]Flag, len(s.flags)) for k, v := range s.flags { out[k] = v } return out } // percentBucket maps (flag, subject) → [0, 100). Stable + uniform. func percentBucket(key string, subject uuid.UUID) int { h := sha256.New() h.Write([]byte(key)) h.Write([]byte{0}) h.Write(subject[:]) sum := h.Sum(nil) n := binary.BigEndian.Uint32(sum[:4]) return int(n % 100) }