package lobby import ( "context" "fmt" "sync" "sync/atomic" "github.com/google/uuid" ) // Cache is the in-memory write-through projection of the active lobby // state: games (any non-finished/non-cancelled status), per-game // memberships, and the Race Name Directory canonical map. // // Reads (Get*) take RLocks; writes (Put*, Remove*) take Locks. The cache // mirrors the `internal/auth.Cache`, `internal/user.Cache`, and // `internal/admin.Cache` idioms — Postgres is the source of truth, the // cache is updated only after a successful commit. type Cache struct { mu sync.RWMutex games map[uuid.UUID]GameRecord memberships map[uuid.UUID]map[uuid.UUID]Membership // game_id -> membership_id -> Membership rnd map[CanonicalKey]RaceNameEntry // canonical -> latest entry (most recent write wins) ready atomic.Bool } // NewCache constructs an empty Cache. func NewCache() *Cache { return &Cache{ games: make(map[uuid.UUID]GameRecord), memberships: make(map[uuid.UUID]map[uuid.UUID]Membership), rnd: make(map[CanonicalKey]RaceNameEntry), } } // Warm fills the cache from store. Must be called once at process boot // before the HTTP listener accepts traffic. Subsequent calls re-warm. func (c *Cache) Warm(ctx context.Context, store *Store) error { if c == nil { return nil } games, err := store.ListAllGames(ctx) if err != nil { return fmt.Errorf("lobby cache warm: games: %w", err) } memberships, err := store.ListAllMemberships(ctx) if err != nil { return fmt.Errorf("lobby cache warm: memberships: %w", err) } raceNames, err := store.ListAllRaceNames(ctx) if err != nil { return fmt.Errorf("lobby cache warm: race names: %w", err) } c.mu.Lock() defer c.mu.Unlock() c.games = make(map[uuid.UUID]GameRecord, len(games)) for _, g := range games { if isCacheableStatus(g.Status) { c.games[g.GameID] = g } } c.memberships = make(map[uuid.UUID]map[uuid.UUID]Membership, len(c.games)) for _, m := range memberships { if _, ok := c.games[m.GameID]; !ok { continue } bucket := c.memberships[m.GameID] if bucket == nil { bucket = make(map[uuid.UUID]Membership) c.memberships[m.GameID] = bucket } bucket[m.MembershipID] = m } c.rnd = make(map[CanonicalKey]RaceNameEntry, len(raceNames)) for _, r := range raceNames { c.rnd[r.Canonical] = r } c.ready.Store(true) return nil } // Ready reports whether Warm completed at least once. func (c *Cache) Ready() bool { if c == nil { return false } return c.ready.Load() } // Sizes returns the cardinalities of the three subordinate projections. // Useful for the startup log line and tests. func (c *Cache) Sizes() (games int, memberships int, raceNames int) { if c == nil { return 0, 0, 0 } c.mu.RLock() defer c.mu.RUnlock() for _, b := range c.memberships { memberships += len(b) } return len(c.games), memberships, len(c.rnd) } // GetGame returns the cached game record together with a presence flag. // Misses always return the zero record and false. Note that a finished // or cancelled game is not in the cache; callers fall back to the store // when isCacheableStatus(...)==false at write time. func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) { if c == nil { return GameRecord{}, false } c.mu.RLock() defer c.mu.RUnlock() g, ok := c.games[gameID] return g, ok } // ListGames returns a snapshot copy of every cached game. Terminal- // state games (finished, cancelled) are evicted from the cache on // `PutGame`, so the result reflects the live roster of running / // paused / draft / starting / etc. games. The slice is freshly // allocated and safe for the caller to mutate. func (c *Cache) ListGames() []GameRecord { if c == nil { return nil } c.mu.RLock() defer c.mu.RUnlock() out := make([]GameRecord, 0, len(c.games)) for _, g := range c.games { out = append(out, g) } return out } // PutGame stores game in the cache when its status is cacheable; // terminal statuses (finished, cancelled) cause the entry to be evicted. func (c *Cache) PutGame(game GameRecord) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() if !isCacheableStatus(game.Status) { delete(c.games, game.GameID) delete(c.memberships, game.GameID) return } c.games[game.GameID] = game } // RemoveGame evicts the game and any cached memberships under it. func (c *Cache) RemoveGame(gameID uuid.UUID) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() delete(c.games, gameID) delete(c.memberships, gameID) } // PutMembership stores or updates a membership row. Removes from cache // when status is not active. func (c *Cache) PutMembership(m Membership) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() bucket := c.memberships[m.GameID] if m.Status != MembershipStatusActive { if bucket != nil { delete(bucket, m.MembershipID) if len(bucket) == 0 { delete(c.memberships, m.GameID) } } return } if bucket == nil { bucket = make(map[uuid.UUID]Membership) c.memberships[m.GameID] = bucket } bucket[m.MembershipID] = m } // MembershipsForGame returns a copy of the active memberships for // gameID. Empty when the game is not cached or has no active members. func (c *Cache) MembershipsForGame(gameID uuid.UUID) []Membership { if c == nil { return nil } c.mu.RLock() defer c.mu.RUnlock() bucket := c.memberships[gameID] if len(bucket) == 0 { return nil } out := make([]Membership, 0, len(bucket)) for _, m := range bucket { out = append(out, m) } return out } // PutRaceName stores or updates a race-name entry keyed by canonical. // The cache is best-effort — it serves uniqueness fast-paths but Postgres // is the authoritative reader on contention. func (c *Cache) PutRaceName(entry RaceNameEntry) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() c.rnd[entry.Canonical] = entry } // RemoveRaceName evicts the entry at canonical. func (c *Cache) RemoveRaceName(canonical CanonicalKey) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() delete(c.rnd, canonical) } // GetRaceName returns the cached entry plus a presence flag. func (c *Cache) GetRaceName(canonical CanonicalKey) (RaceNameEntry, bool) { if c == nil { return RaceNameEntry{}, false } c.mu.RLock() defer c.mu.RUnlock() e, ok := c.rnd[canonical] return e, ok } // EvictUserMemberships removes every cached membership belonging to // userID. Used by `OnUserBlocked` / `OnUserDeleted` after the cascade // commits so the cache reflects the new persisted state. func (c *Cache) EvictUserMemberships(userID uuid.UUID) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() for gameID, bucket := range c.memberships { for mid, m := range bucket { if m.UserID == userID { delete(bucket, mid) } } if len(bucket) == 0 { delete(c.memberships, gameID) } } } // EvictUserRaceNames removes every cached race-name owned by userID. func (c *Cache) EvictUserRaceNames(userID uuid.UUID) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() for k, e := range c.rnd { if e.OwnerUserID == userID { delete(c.rnd, k) } } } // EvictOwnerGames evicts every cached game whose owner is userID. Used // after the cascade cancels the user's owned games. func (c *Cache) EvictOwnerGames(userID uuid.UUID) { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() for gameID, g := range c.games { if g.OwnerUserID != nil && *g.OwnerUserID == userID { delete(c.games, gameID) delete(c.memberships, gameID) } } } // isCacheableStatus reports whether the cache should hold a game with // the supplied status. Terminal statuses (finished, cancelled) are // evicted; the in-memory cache only reflects active state. func isCacheableStatus(status string) bool { switch status { case GameStatusFinished, GameStatusCancelled: return false default: return true } }