feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -0,0 +1,280 @@
// 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)
}
}