feat: game lobby service
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user