// Package membership implements the in-process membership cache that // authorises every hot-path call (commandexecute, orderput, reportget) // owned by Game Master. // // The cache is a per-game TTL projection of Lobby's // `/api/v1/internal/games/{game_id}/memberships` view. Lobby invokes the // invalidation hook (`POST /api/v1/internal/games/{game_id}/memberships/invalidate`) // post-commit on every roster mutation; the TTL is the safety net for any // missed invalidation. Cache rules and trade-offs are documented in // `gamemaster/README.md §Hot Path → Membership cache and invalidation` and // `gamemaster/docs/stage16-membership-cache-and-invalidation.md`. package membership import ( "container/list" "context" "errors" "fmt" "log/slog" "sync" "time" "galaxy/gamemaster/internal/logging" "galaxy/gamemaster/internal/ports" "galaxy/gamemaster/internal/telemetry" ) // Result labels used with `telemetry.Runtime.RecordMembershipCacheResult`. const ( resultHit = "hit" resultMiss = "miss" resultInvalidate = "invalidate" ) // Dependencies groups the collaborators required by Cache. type Dependencies struct { // Lobby loads the per-game membership projection on cache miss. Lobby ports.LobbyClient // Telemetry records `gamemaster.membership_cache.hits` outcomes. Telemetry *telemetry.Runtime // Logger records structured cache events. Defaults to // `slog.Default()` when nil. Logger *slog.Logger // Clock supplies the wall-clock used for entry freshness. Defaults // to `time.Now` when nil. Clock func() time.Time // TTL bounds the freshness of one cached entry; expired entries are // re-fetched from Lobby. Must be positive. TTL time.Duration // MaxGames bounds the cache size in number of games. The // least-recently-used entry is evicted when an insert overflows the // bound. Must be positive. MaxGames int } // Cache stores the per-game membership projection used by hot-path // services. The zero value is not usable; construct with NewCache. type Cache struct { lobby ports.LobbyClient telemetry *telemetry.Runtime logger *slog.Logger clock func() time.Time ttl time.Duration maxGames int mu sync.Mutex entries map[string]*list.Element // gameID → element holding *cacheEntry lru *list.List // *cacheEntry, MRU at front inflight map[string]*flight // gameID → in-flight Lobby fetch } // cacheEntry stores one per-game membership projection. type cacheEntry struct { gameID string members map[string]string // user_id → status ("active"|"removed"|"blocked") loadedAt time.Time } // flight coordinates concurrent misses on the same gameID so only one // Lobby fetch is issued. Joiners wait on `done`; the leader populates // `members` (or `err`) before closing the channel. type flight struct { done chan struct{} members map[string]string err error } // NewCache constructs a Cache from deps. Returns a Go-level error when a // required dependency is missing or a numeric bound is non-positive. func NewCache(deps Dependencies) (*Cache, error) { switch { case deps.Lobby == nil: return nil, errors.New("new membership cache: nil lobby client") case deps.Telemetry == nil: return nil, errors.New("new membership cache: nil telemetry runtime") case deps.TTL <= 0: return nil, fmt.Errorf("new membership cache: ttl must be positive, got %s", deps.TTL) case deps.MaxGames <= 0: return nil, fmt.Errorf("new membership cache: max games must be positive, got %d", deps.MaxGames) } logger := deps.Logger if logger == nil { logger = slog.Default() } logger = logger.With("component", "gamemaster.membership_cache") clock := deps.Clock if clock == nil { clock = time.Now } return &Cache{ lobby: deps.Lobby, telemetry: deps.Telemetry, logger: logger, clock: clock, ttl: deps.TTL, maxGames: deps.MaxGames, entries: make(map[string]*list.Element), lru: list.New(), inflight: make(map[string]*flight), }, nil } // Resolve returns the membership status of userID inside gameID. The // returned status is the raw Lobby vocabulary (`"active"`, `"removed"`, // `"blocked"`) and is empty when the user is not present in the roster at // all; callers must compare against `"active"` to authorise a hot-path // call. // // Resolve fetches from Lobby on cache miss, on TTL expiry, or after an // Invalidate. Concurrent misses on the same gameID share a single Lobby // call. A failed Lobby fetch surfaces as ErrLobbyUnavailable and is not // cached. func (cache *Cache) Resolve(ctx context.Context, gameID, userID string) (string, error) { if cache == nil { return "", errors.New("membership cache: nil receiver") } if ctx == nil { return "", errors.New("membership cache: nil context") } if entry, ok := cache.lookupFresh(gameID); ok { cache.telemetry.RecordMembershipCacheResult(ctx, resultHit) return entry.members[userID], nil } members, err := cache.fetch(ctx, gameID) cache.telemetry.RecordMembershipCacheResult(ctx, resultMiss) if err != nil { logArgs := []any{ "game_id", gameID, "err", err.Error(), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) cache.logger.WarnContext(ctx, "lobby fetch failed", logArgs...) return "", err } return members[userID], nil } // Invalidate purges the cache entry for gameID, if any. Subsequent // Resolve calls fetch from Lobby. Safe to call from the invalidation // hook handler (Stage 19) at any time. func (cache *Cache) Invalidate(gameID string) { if cache == nil { return } cache.mu.Lock() if element, ok := cache.entries[gameID]; ok { cache.lru.Remove(element) delete(cache.entries, gameID) } cache.mu.Unlock() cache.telemetry.RecordMembershipCacheResult(context.Background(), resultInvalidate) } // lookupFresh returns the cached entry for gameID when it exists and is // still fresh. The MRU position is updated under the lock. func (cache *Cache) lookupFresh(gameID string) (*cacheEntry, bool) { cache.mu.Lock() defer cache.mu.Unlock() element, ok := cache.entries[gameID] if !ok { return nil, false } entry := element.Value.(*cacheEntry) if cache.clock().Sub(entry.loadedAt) >= cache.ttl { return nil, false } cache.lru.MoveToFront(element) return entry, true } // fetch loads the membership projection from Lobby, deduplicating // concurrent misses on the same gameID through the inflight map. The // successful result is cached; failures are not. func (cache *Cache) fetch(ctx context.Context, gameID string) (map[string]string, error) { cache.mu.Lock() if existing, ok := cache.inflight[gameID]; ok { cache.mu.Unlock() select { case <-existing.done: if existing.err != nil { return nil, existing.err } return existing.members, nil case <-ctx.Done(): return nil, ctx.Err() } } current := &flight{done: make(chan struct{})} cache.inflight[gameID] = current cache.mu.Unlock() members, err := cache.loadFromLobby(ctx, gameID) cache.mu.Lock() delete(cache.inflight, gameID) if err == nil { cache.installLocked(gameID, members) } cache.mu.Unlock() if err != nil { current.err = err } else { current.members = members } close(current.done) if err != nil { return nil, err } return members, nil } // loadFromLobby calls the LobbyClient and projects the raw response to // the user_id → status map the cache stores. func (cache *Cache) loadFromLobby(ctx context.Context, gameID string) (map[string]string, error) { records, err := cache.lobby.GetMemberships(ctx, gameID) if err != nil { return nil, fmt.Errorf("%w: %w", ErrLobbyUnavailable, err) } members := make(map[string]string, len(records)) for _, record := range records { members[record.UserID] = record.Status } return members, nil } // installLocked stores members under gameID, evicting the least-recently // -used entry if the cache is at capacity. Caller must hold cache.mu. func (cache *Cache) installLocked(gameID string, members map[string]string) { now := cache.clock() if element, ok := cache.entries[gameID]; ok { entry := element.Value.(*cacheEntry) entry.members = members entry.loadedAt = now cache.lru.MoveToFront(element) return } entry := &cacheEntry{gameID: gameID, members: members, loadedAt: now} cache.entries[gameID] = cache.lru.PushFront(entry) for cache.lru.Len() > cache.maxGames { oldest := cache.lru.Back() if oldest == nil { break } evicted := oldest.Value.(*cacheEntry) cache.lru.Remove(oldest) delete(cache.entries, evicted.gameID) } }