362f92e520
Closes out the producer-side of the diplomail surface. Paid-tier players can fan out one personal message to the rest of the active roster (gated on entitlement_snapshots.is_paid). Site admins gain a multi-game broadcast (POST /admin/mail/broadcast with `selected` / `all_running` scopes) and the bulk-purge endpoint that wipes diplomail rows tied to games finished more than N years ago. An admin listing (GET /admin/mail/messages) rounds out the observability surface. EntitlementReader and GameLookup are new narrow deps wired from `*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby service grows a one-off `ListFinishedGamesBefore` helper for the cleanup path (the cache evicts terminal-state games so the cache walk is not enough). Stage D will swap LangUndetermined for an actual body-language detector and add the translation cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
304 lines
7.7 KiB
Go
304 lines
7.7 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|