Files
galaxy-game/lobby/internal/adapters/gameinmem/store.go
T
2026-04-28 20:39:18 +02:00

271 lines
7.6 KiB
Go

// Package gameinmem provides an in-memory ports.GameStore implementation
// for service-level tests. It mirrors the behavioural contract of the
// Redis-backed adapter in redisstate: it enforces game.Transition for
// status updates, the ExpectedFrom CAS check, and the
// StartedAt/FinishedAt side effects of the canonical status transitions.
//
// Production code never wires this adapter; it is test-only but exposed
// as a regular (non _test.go) package so other service test packages can
// import it.
package gameinmem
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/ports"
)
// Store is a concurrency-safe in-memory implementation of ports.GameStore.
// The zero value is not usable; call NewStore to construct.
type Store struct {
mu sync.Mutex
records map[common.GameID]game.Game
}
// NewStore constructs one empty Store ready for use.
func NewStore() *Store {
return &Store{records: make(map[common.GameID]game.Game)}
}
// Save upserts record. It honors the contract stated by
// ports.GameStore.Save: Save does not apply the domain transition gate but
// validates the record.
func (store *Store) Save(ctx context.Context, record game.Game) error {
if store == 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)
}
store.mu.Lock()
defer store.mu.Unlock()
store.records[record.GameID] = record
return nil
}
// Get returns the record identified by gameID. It returns game.ErrNotFound
// when no record exists.
func (store *Store) Get(ctx context.Context, gameID common.GameID) (game.Game, error) {
if store == 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)
}
store.mu.Lock()
defer store.mu.Unlock()
record, ok := store.records[gameID]
if !ok {
return game.Game{}, game.ErrNotFound
}
return record, nil
}
// CountByStatus returns the per-status game record count. Every status from
// game.AllStatuses is present in the result, with zero values for empty
// buckets, mirroring the Redis adapter contract.
func (store *Store) CountByStatus(ctx context.Context) (map[game.Status]int, error) {
if store == nil {
return nil, errors.New("count games by status: nil store")
}
if ctx == nil {
return nil, errors.New("count games by status: nil context")
}
store.mu.Lock()
defer store.mu.Unlock()
counts := make(map[game.Status]int, len(game.AllStatuses()))
for _, status := range game.AllStatuses() {
counts[status] = 0
}
for _, record := range store.records {
counts[record.Status]++
}
return counts, nil
}
// GetByStatus returns every record whose Status equals status. The slice is
// ordered by CreatedAt ascending to match the Redis adapter.
func (store *Store) GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) {
if store == 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)
}
store.mu.Lock()
defer store.mu.Unlock()
matching := make([]game.Game, 0, len(store.records))
for _, record := range store.records {
if record.Status == status {
matching = append(matching, record)
}
}
sort.Slice(matching, func(i, j int) bool {
return matching[i].CreatedAt.Before(matching[j].CreatedAt)
})
return matching, nil
}
// GetByOwner returns every record whose OwnerUserID equals userID. The
// slice is ordered by CreatedAt ascending to match the Redis adapter.
func (store *Store) GetByOwner(ctx context.Context, userID string) ([]game.Game, error) {
if store == 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")
}
store.mu.Lock()
defer store.mu.Unlock()
matching := make([]game.Game, 0, len(store.records))
for _, record := range store.records {
if record.OwnerUserID == trimmed {
matching = append(matching, record)
}
}
sort.Slice(matching, func(i, j int) bool {
return matching[i].CreatedAt.Before(matching[j].CreatedAt)
})
return matching, nil
}
// UpdateStatus applies one status transition in a compare-and-swap fashion.
// It returns an error from game.Transition for invalid triplets, returns
// game.ErrNotFound for a missing record, and game.ErrConflict when the
// current status differs from input.ExpectedFrom.
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
if store == 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
}
store.mu.Lock()
defer store.mu.Unlock()
record, ok := store.records[input.GameID]
if !ok {
return game.ErrNotFound
}
if record.Status != input.ExpectedFrom {
return fmt.Errorf("update game status: %w", game.ErrConflict)
}
at := input.At.UTC()
record.Status = input.To
record.UpdatedAt = at
if input.To == game.StatusRunning && record.StartedAt == nil {
startedAt := at
record.StartedAt = &startedAt
}
if input.To == game.StatusFinished && record.FinishedAt == nil {
finishedAt := at
record.FinishedAt = &finishedAt
}
store.records[input.GameID] = record
return nil
}
// UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot fields
// on the record identified by input.GameID. It does not change the status
// field.
func (store *Store) UpdateRuntimeSnapshot(ctx context.Context, input ports.UpdateRuntimeSnapshotInput) error {
if store == 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)
}
store.mu.Lock()
defer store.mu.Unlock()
record, ok := store.records[input.GameID]
if !ok {
return game.ErrNotFound
}
record.RuntimeSnapshot = input.Snapshot
record.UpdatedAt = input.At.UTC()
store.records[input.GameID] = record
return nil
}
// UpdateRuntimeBinding overwrites the runtime binding metadata on the
// record identified by input.GameID. It does not change the status
// field. uses this method from the runtimejobresult worker
// after a successful container start.
func (store *Store) UpdateRuntimeBinding(ctx context.Context, input ports.UpdateRuntimeBindingInput) error {
if store == 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)
}
store.mu.Lock()
defer store.mu.Unlock()
record, ok := store.records[input.GameID]
if !ok {
return game.ErrNotFound
}
binding := input.Binding
record.RuntimeBinding = &binding
record.UpdatedAt = input.At.UTC()
store.records[input.GameID] = record
return nil
}
// Ensure Store satisfies the ports.GameStore interface at compile time.
var _ ports.GameStore = (*Store)(nil)