package redisstate import ( "context" "errors" "fmt" "strings" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" "github.com/redis/go-redis/v9" ) // GameStore provides Redis-backed durable storage for game records. type GameStore struct { client *redis.Client keys Keyspace } // NewGameStore constructs one Redis-backed game store. It returns an // error when client is nil. func NewGameStore(client *redis.Client) (*GameStore, error) { if client == nil { return nil, errors.New("new game store: nil redis client") } return &GameStore{ client: client, keys: Keyspace{}, }, nil } // Save upserts record and rewrites the status secondary index when the // status changes. func (store *GameStore) Save(ctx context.Context, record game.Game) error { if store == nil || store.client == nil { return errors.New("save game: nil store") } if ctx == nil { return errors.New("save game: nil context") } if err := record.Validate(); err != nil { return fmt.Errorf("save game: %w", err) } payload, err := MarshalGame(record) if err != nil { return fmt.Errorf("save game: %w", err) } primaryKey := store.keys.Game(record.GameID) newIndexKey := store.keys.GamesByStatus(record.Status) member := record.GameID.String() createdAtScore := CreatedAtScore(record.CreatedAt) watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error { var previousStatus game.Status existingPayload, getErr := tx.Get(ctx, primaryKey).Bytes() switch { case errors.Is(getErr, redis.Nil): previousStatus = "" case getErr != nil: return fmt.Errorf("save game: %w", getErr) default: existing, err := UnmarshalGame(existingPayload) if err != nil { return fmt.Errorf("save game: %w", err) } previousStatus = existing.Status } _, err := tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, primaryKey, payload, GameRecordTTL) if previousStatus != "" && previousStatus != record.Status { pipe.ZRem(ctx, store.keys.GamesByStatus(previousStatus), member) } pipe.ZAdd(ctx, newIndexKey, redis.Z{ Score: createdAtScore, Member: member, }) if owner := strings.TrimSpace(record.OwnerUserID); owner != "" { pipe.SAdd(ctx, store.keys.GamesByOwner(owner), member) } return nil }) return err }, primaryKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("save game: %w", game.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // Get returns the record identified by gameID. func (store *GameStore) Get(ctx context.Context, gameID common.GameID) (game.Game, error) { if store == nil || store.client == nil { return game.Game{}, errors.New("get game: nil store") } if ctx == nil { return game.Game{}, errors.New("get game: nil context") } if err := gameID.Validate(); err != nil { return game.Game{}, fmt.Errorf("get game: %w", err) } payload, err := store.client.Get(ctx, store.keys.Game(gameID)).Bytes() switch { case errors.Is(err, redis.Nil): return game.Game{}, game.ErrNotFound case err != nil: return game.Game{}, fmt.Errorf("get game: %w", err) } record, err := UnmarshalGame(payload) if err != nil { return game.Game{}, fmt.Errorf("get game: %w", err) } return record, nil } // GetByStatus returns every record indexed under status. Stale index // entries (primary key removed out-of-band) are dropped silently. func (store *GameStore) GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) { if store == nil || store.client == nil { return nil, errors.New("get games by status: nil store") } if ctx == nil { return nil, errors.New("get games by status: nil context") } if !status.IsKnown() { return nil, fmt.Errorf("get games by status: status %q is unsupported", status) } members, err := store.client.ZRange(ctx, store.keys.GamesByStatus(status), 0, -1).Result() if err != nil { return nil, fmt.Errorf("get games by status: %w", err) } if len(members) == 0 { return nil, nil } primaryKeys := make([]string, len(members)) for index, member := range members { primaryKeys[index] = store.keys.Game(common.GameID(member)) } payloads, err := store.client.MGet(ctx, primaryKeys...).Result() if err != nil { return nil, fmt.Errorf("get games by status: %w", err) } records := make([]game.Game, 0, len(payloads)) for _, entry := range payloads { if entry == nil { continue } raw, ok := entry.(string) if !ok { return nil, fmt.Errorf("get games by status: unexpected payload type %T", entry) } record, err := UnmarshalGame([]byte(raw)) if err != nil { return nil, fmt.Errorf("get games by status: %w", err) } records = append(records, record) } return records, nil } // CountByStatus returns the number of game identifiers indexed under each // known status. The map carries one entry per game.AllStatuses, with zero // counts for empty buckets. The implementation issues one ZCARD per status // in a single Redis pipeline so the cost stays O(number of statuses). func (store *GameStore) CountByStatus(ctx context.Context) (map[game.Status]int, error) { if store == nil || store.client == nil { return nil, errors.New("count games by status: nil store") } if ctx == nil { return nil, errors.New("count games by status: nil context") } statuses := game.AllStatuses() pipeline := store.client.Pipeline() results := make([]*redis.IntCmd, len(statuses)) for index, status := range statuses { results[index] = pipeline.ZCard(ctx, store.keys.GamesByStatus(status)) } if _, err := pipeline.Exec(ctx); err != nil { return nil, fmt.Errorf("count games by status: %w", err) } counts := make(map[game.Status]int, len(statuses)) for index, status := range statuses { count, err := results[index].Result() if err != nil { return nil, fmt.Errorf("count games by status: %s: %w", status, err) } counts[status] = int(count) } return counts, nil } // GetByOwner returns every record whose OwnerUserID equals userID. // Stale index entries (primary key removed out-of-band) are dropped // silently. The slice order is adapter-defined. func (store *GameStore) GetByOwner(ctx context.Context, userID string) ([]game.Game, error) { if store == nil || store.client == nil { return nil, errors.New("get games by owner: nil store") } if ctx == nil { return nil, errors.New("get games by owner: nil context") } trimmed := strings.TrimSpace(userID) if trimmed == "" { return nil, fmt.Errorf("get games by owner: user id must not be empty") } members, err := store.client.SMembers(ctx, store.keys.GamesByOwner(trimmed)).Result() if err != nil { return nil, fmt.Errorf("get games by owner: %w", err) } if len(members) == 0 { return nil, nil } primaryKeys := make([]string, len(members)) for index, member := range members { primaryKeys[index] = store.keys.Game(common.GameID(member)) } payloads, err := store.client.MGet(ctx, primaryKeys...).Result() if err != nil { return nil, fmt.Errorf("get games by owner: %w", err) } records := make([]game.Game, 0, len(payloads)) for _, entry := range payloads { if entry == nil { continue } raw, ok := entry.(string) if !ok { return nil, fmt.Errorf("get games by owner: unexpected payload type %T", entry) } record, err := UnmarshalGame([]byte(raw)) if err != nil { return nil, fmt.Errorf("get games by owner: %w", err) } records = append(records, record) } return records, nil } // UpdateStatus applies one status transition in a compare-and-swap // fashion. func (store *GameStore) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error { if store == nil || store.client == nil { return errors.New("update game status: nil store") } if ctx == nil { return errors.New("update game status: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update game status: %w", err) } if err := game.Transition(input.ExpectedFrom, input.To, input.Trigger); err != nil { return err } primaryKey := store.keys.Game(input.GameID) member := input.GameID.String() at := input.At.UTC() watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error { payload, getErr := tx.Get(ctx, primaryKey).Bytes() switch { case errors.Is(getErr, redis.Nil): return game.ErrNotFound case getErr != nil: return fmt.Errorf("update game status: %w", getErr) } existing, err := UnmarshalGame(payload) if err != nil { return fmt.Errorf("update game status: %w", err) } if existing.Status != input.ExpectedFrom { return fmt.Errorf("update game status: %w", game.ErrConflict) } existing.Status = input.To existing.UpdatedAt = at if input.To == game.StatusRunning && existing.StartedAt == nil { startedAt := at existing.StartedAt = &startedAt } if input.To == game.StatusFinished && existing.FinishedAt == nil { finishedAt := at existing.FinishedAt = &finishedAt } encoded, err := MarshalGame(existing) if err != nil { return fmt.Errorf("update game status: %w", err) } _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, primaryKey, encoded, GameRecordTTL) pipe.ZRem(ctx, store.keys.GamesByStatus(input.ExpectedFrom), member) pipe.ZAdd(ctx, store.keys.GamesByStatus(input.To), redis.Z{ Score: CreatedAtScore(existing.CreatedAt), Member: member, }) return nil }) return err }, primaryKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("update game status: %w", game.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot // fields on the record identified by input.GameID. func (store *GameStore) UpdateRuntimeSnapshot(ctx context.Context, input ports.UpdateRuntimeSnapshotInput) error { if store == nil || store.client == nil { return errors.New("update runtime snapshot: nil store") } if ctx == nil { return errors.New("update runtime snapshot: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update runtime snapshot: %w", err) } primaryKey := store.keys.Game(input.GameID) at := input.At.UTC() watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error { payload, getErr := tx.Get(ctx, primaryKey).Bytes() switch { case errors.Is(getErr, redis.Nil): return game.ErrNotFound case getErr != nil: return fmt.Errorf("update runtime snapshot: %w", getErr) } existing, err := UnmarshalGame(payload) if err != nil { return fmt.Errorf("update runtime snapshot: %w", err) } existing.RuntimeSnapshot = input.Snapshot existing.UpdatedAt = at encoded, err := MarshalGame(existing) if err != nil { return fmt.Errorf("update runtime snapshot: %w", err) } _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, primaryKey, encoded, GameRecordTTL) return nil }) return err }, primaryKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("update runtime snapshot: %w", game.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // UpdateRuntimeBinding overwrites the runtime binding metadata on the // record identified by input.GameID. calls this method from // the runtimejobresult worker after a successful container start. func (store *GameStore) UpdateRuntimeBinding(ctx context.Context, input ports.UpdateRuntimeBindingInput) error { if store == nil || store.client == nil { return errors.New("update runtime binding: nil store") } if ctx == nil { return errors.New("update runtime binding: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update runtime binding: %w", err) } primaryKey := store.keys.Game(input.GameID) at := input.At.UTC() watchErr := store.client.Watch(ctx, func(tx *redis.Tx) error { payload, getErr := tx.Get(ctx, primaryKey).Bytes() switch { case errors.Is(getErr, redis.Nil): return game.ErrNotFound case getErr != nil: return fmt.Errorf("update runtime binding: %w", getErr) } existing, err := UnmarshalGame(payload) if err != nil { return fmt.Errorf("update runtime binding: %w", err) } binding := input.Binding existing.RuntimeBinding = &binding existing.UpdatedAt = at encoded, err := MarshalGame(existing) if err != nil { return fmt.Errorf("update runtime binding: %w", err) } _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error { pipe.Set(ctx, primaryKey, encoded, GameRecordTTL) return nil }) return err }, primaryKey) switch { case errors.Is(watchErr, redis.TxFailedErr): return fmt.Errorf("update runtime binding: %w", game.ErrConflict) case watchErr != nil: return watchErr default: return nil } } // Ensure GameStore satisfies the ports.GameStore interface at compile // time. var _ ports.GameStore = (*GameStore)(nil)