feat: backend service
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user