// Package starcache provides a short-TTL in-memory cache for pb.Star metadata // looked up via UserSocialService.GetFanIdentities. // // Why this exists: // Gateway controllers used to call ctrl.userServiceClient.GetFanIdentities // after every Register/Login/GetCurrentUser/GetMyProfile/AddIdentity/SwitchIdentity // just to fetch a single Star by ID. The full identity list is small (~few dozen // stars) and effectively static, so we cache it for 60s with singleflight to // collapse concurrent refreshes. // // Security: the cache only holds pb.Star structs (public star metadata). It // does NOT hold any user-specific fields (mobile, token, password, fan_profiles). // user_id is the cache key, but its only use is to look up the corresponding // star_id - it is not stored in any cache entry. Star ID is treated as public // data (it's what the registration UI itself displays). package starcache import ( "context" "sync" "time" "github.com/topfans/backend/pkg/logger" pb "github.com/topfans/backend/pkg/proto/user" "go.uber.org/zap" ) // CacheTTL is the per-star expiration window. Short enough that star metadata // changes (e.g. avatar update) propagate within a minute, long enough that the // chained GetFanIdentities calls in Register/Login/GetCurrentUser/etc. reuse // one RPC round-trip per minute of traffic. const CacheTTL = 60 * time.Second // Cache wraps a TTL map + singleflight for GetFanIdentities. // // Concurrency model: // - mu guards stars/expiry (RWMutex: many readers, one writer). // - sfMu guards the inflight map (separate lock so the slow path doesn't // block the hot read path). type Cache struct { cli pb.UserSocialService mu sync.RWMutex stars map[int64]*pb.Star expiry map[int64]time.Time sfMu sync.Mutex inflight map[int64]chan struct{} } // New constructs a Cache backed by the given UserSocialService client. func New(cli pb.UserSocialService) *Cache { return &Cache{ cli: cli, stars: make(map[int64]*pb.Star), expiry: make(map[int64]time.Time), inflight: make(map[int64]chan struct{}), } } // GetStar returns the cached pb.Star for starID, refreshing from the upstream // RPC on miss or expiry. Returns (nil, err) on upstream error; callers should // treat nil as "star info unavailable" and continue with the rest of the // response. // // starID == 0 is a no-op (returns nil, nil) so that callers don't need to // pre-check empty identities. func (c *Cache) GetStar(ctx context.Context, starID int64) (*pb.Star, error) { if starID == 0 { return nil, nil } // Hot path: read lock. c.mu.RLock() if s, ok := c.stars[starID]; ok { if exp, ok := c.expiry[starID]; ok && time.Now().Before(exp) { c.mu.RUnlock() return s, nil } } c.mu.RUnlock() // Singleflight: ensure only one refresh per starID at a time. c.sfMu.Lock() if ch, ok := c.inflight[starID]; ok { c.sfMu.Unlock() <-ch // Re-read cache after the leader populated it. c.mu.RLock() defer c.mu.RUnlock() return c.stars[starID], nil } ch := make(chan struct{}) c.inflight[starID] = ch c.sfMu.Unlock() defer func() { close(ch) c.sfMu.Lock() delete(c.inflight, starID) c.sfMu.Unlock() }() resp, err := c.cli.GetFanIdentities(ctx, &pb.GetFanIdentitiesRequest{}) if err != nil { logger.Logger.Warn("starcache: GetFanIdentities refresh failed", zap.Int64("star_id", starID), zap.Error(err), ) return nil, err } c.mu.Lock() now := time.Now() for _, s := range resp.Stars { c.stars[s.StarId] = s c.expiry[s.StarId] = now.Add(CacheTTL) } c.mu.Unlock() c.mu.RLock() defer c.mu.RUnlock() return c.stars[starID], nil } // Invalidate removes the cached entry for starID. Call this when star metadata // changes (e.g. admin updates a star's avatar) so the next GetStar refreshes. // // Safe to call with starID == 0 (no-op). func (c *Cache) Invalidate(starID int64) { if starID == 0 { return } c.mu.Lock() defer c.mu.Unlock() delete(c.stars, starID) delete(c.expiry, starID) } // Len returns the number of cached stars. Useful for tests and metrics. func (c *Cache) Len() int { c.mu.RLock() defer c.mu.RUnlock() return len(c.stars) }