Files
2026-05-06 10:14:55 +03:00

286 lines
7.2 KiB
Go

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
}
// 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
}
}