feat: gamemaster
This commit is contained in:
@@ -0,0 +1,636 @@
|
||||
// Package runtimerecordstore implements the PostgreSQL-backed adapter
|
||||
// for `ports.RuntimeRecordStore`.
|
||||
//
|
||||
// The package owns the on-disk shape of the `runtime_records` table
|
||||
// defined in
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
|
||||
// and translates the schema-agnostic `ports.RuntimeRecordStore`
|
||||
// interface declared in `internal/ports/runtimerecordstore.go` into
|
||||
// concrete go-jet/v2 statements driven by the pgx driver.
|
||||
//
|
||||
// Lifecycle transitions (UpdateStatus) use compare-and-swap on
|
||||
// `(game_id, status)` rather than holding a SELECT ... FOR UPDATE lock
|
||||
// across the caller's logic, mirroring the pattern used by
|
||||
// `rtmanager/internal/adapters/postgres/runtimerecordstore`.
|
||||
package runtimerecordstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
|
||||
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed runtime-record store. The
|
||||
// store does not own the underlying *sql.DB lifecycle; the caller
|
||||
// (typically the service runtime) opens, instruments, migrates, and
|
||||
// closes the pool.
|
||||
type Config struct {
|
||||
// DB stores the connection pool the store uses for every query.
|
||||
DB *sql.DB
|
||||
|
||||
// OperationTimeout bounds one round trip. The store creates a
|
||||
// derived context for each operation so callers cannot starve the
|
||||
// pool with an unbounded ctx.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Game Master runtime records in PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed runtime-record store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres runtime record store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres runtime record store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runtimeSelectColumns is the canonical SELECT list for the
|
||||
// runtime_records table, matching scanRecord's column order.
|
||||
var runtimeSelectColumns = pg.ColumnList{
|
||||
pgtable.RuntimeRecords.GameID,
|
||||
pgtable.RuntimeRecords.Status,
|
||||
pgtable.RuntimeRecords.EngineEndpoint,
|
||||
pgtable.RuntimeRecords.CurrentImageRef,
|
||||
pgtable.RuntimeRecords.CurrentEngineVersion,
|
||||
pgtable.RuntimeRecords.TurnSchedule,
|
||||
pgtable.RuntimeRecords.CurrentTurn,
|
||||
pgtable.RuntimeRecords.NextGenerationAt,
|
||||
pgtable.RuntimeRecords.SkipNextTick,
|
||||
pgtable.RuntimeRecords.EngineHealth,
|
||||
pgtable.RuntimeRecords.CreatedAt,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
pgtable.RuntimeRecords.StartedAt,
|
||||
pgtable.RuntimeRecords.StoppedAt,
|
||||
pgtable.RuntimeRecords.FinishedAt,
|
||||
}
|
||||
|
||||
// Get returns the record identified by gameID. It returns
|
||||
// runtime.ErrNotFound when no record exists.
|
||||
func (store *Store) Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return runtime.RuntimeRecord{}, errors.New("get runtime record: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return runtime.RuntimeRecord{}, fmt.Errorf("get runtime record: game id must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get runtime record", store.operationTimeout)
|
||||
if err != nil {
|
||||
return runtime.RuntimeRecord{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(runtimeSelectColumns).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
record, err := scanRecord(row)
|
||||
if sqlx.IsNoRows(err) {
|
||||
return runtime.RuntimeRecord{}, runtime.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return runtime.RuntimeRecord{}, fmt.Errorf("get runtime record: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// Insert installs record into the store. Returns runtime.ErrConflict
|
||||
// when a row already exists for record.GameID.
|
||||
func (store *Store) Insert(ctx context.Context, record runtime.RuntimeRecord) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("insert runtime record: nil store")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("insert runtime record: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "insert runtime record", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.RuntimeRecords.INSERT(
|
||||
pgtable.RuntimeRecords.GameID,
|
||||
pgtable.RuntimeRecords.Status,
|
||||
pgtable.RuntimeRecords.EngineEndpoint,
|
||||
pgtable.RuntimeRecords.CurrentImageRef,
|
||||
pgtable.RuntimeRecords.CurrentEngineVersion,
|
||||
pgtable.RuntimeRecords.TurnSchedule,
|
||||
pgtable.RuntimeRecords.CurrentTurn,
|
||||
pgtable.RuntimeRecords.NextGenerationAt,
|
||||
pgtable.RuntimeRecords.SkipNextTick,
|
||||
pgtable.RuntimeRecords.EngineHealth,
|
||||
pgtable.RuntimeRecords.CreatedAt,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
pgtable.RuntimeRecords.StartedAt,
|
||||
pgtable.RuntimeRecords.StoppedAt,
|
||||
pgtable.RuntimeRecords.FinishedAt,
|
||||
).VALUES(
|
||||
record.GameID,
|
||||
string(record.Status),
|
||||
record.EngineEndpoint,
|
||||
record.CurrentImageRef,
|
||||
record.CurrentEngineVersion,
|
||||
record.TurnSchedule,
|
||||
int32(record.CurrentTurn),
|
||||
sqlx.NullableTimePtr(record.NextGenerationAt),
|
||||
record.SkipNextTick,
|
||||
record.EngineHealth,
|
||||
record.CreatedAt.UTC(),
|
||||
record.UpdatedAt.UTC(),
|
||||
sqlx.NullableTimePtr(record.StartedAt),
|
||||
sqlx.NullableTimePtr(record.StoppedAt),
|
||||
sqlx.NullableTimePtr(record.FinishedAt),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
if sqlx.IsUniqueViolation(err) {
|
||||
return fmt.Errorf("insert runtime record: %w", runtime.ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("insert runtime record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition with a compare-and-swap
|
||||
// guard on (game_id, status). The destination's lifecycle timestamps
|
||||
// (started_at, stopped_at, finished_at) and the optional fields
|
||||
// (engine_health, current_image_ref, current_engine_version) are
|
||||
// written only when applicable.
|
||||
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update runtime status: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime status", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
assignments := buildUpdateStatusAssignments(input, input.Now.UTC())
|
||||
|
||||
// The first positional argument to UPDATE is required by jet's
|
||||
// API but ignored when SET receives ColumnAssigment values
|
||||
// (jet then serialises SetClauseNew instead of clauseSet).
|
||||
stmt := pgtable.RuntimeRecords.UPDATE(pgtable.RuntimeRecords.Status).
|
||||
SET(assignments[0], assignments[1:]...).
|
||||
WHERE(pg.AND(
|
||||
pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)),
|
||||
pgtable.RuntimeRecords.Status.EQ(pg.String(string(input.ExpectedFrom))),
|
||||
))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime status: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime status: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return store.classifyMissingUpdate(operationCtx, input.GameID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildUpdateStatusAssignments returns the slice of column assignments
|
||||
// produced by one UpdateStatus call. Mandatory assignments (status,
|
||||
// updated_at) are always present; lifecycle timestamps and optional
|
||||
// fields appear only when relevant to the destination status or when
|
||||
// the corresponding pointer is non-nil.
|
||||
//
|
||||
// The slice element type is `any` so the result can be spread into
|
||||
// `UpdateStatement.SET(value any, values ...any)` without manual
|
||||
// boxing at the call site.
|
||||
func buildUpdateStatusAssignments(input ports.UpdateStatusInput, now time.Time) []any {
|
||||
nowExpr := pg.TimestampzT(now)
|
||||
assignments := []any{
|
||||
pgtable.RuntimeRecords.Status.SET(pg.String(string(input.To))),
|
||||
pgtable.RuntimeRecords.UpdatedAt.SET(nowExpr),
|
||||
}
|
||||
|
||||
if input.To == runtime.StatusRunning && input.ExpectedFrom == runtime.StatusStarting {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.StartedAt.SET(nowExpr))
|
||||
}
|
||||
if input.To == runtime.StatusStopped {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.StoppedAt.SET(nowExpr))
|
||||
}
|
||||
if input.To == runtime.StatusFinished {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.FinishedAt.SET(nowExpr))
|
||||
}
|
||||
if input.EngineHealthSummary != nil {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.EngineHealth.SET(pg.String(*input.EngineHealthSummary)))
|
||||
}
|
||||
if input.CurrentImageRef != nil {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.CurrentImageRef.SET(pg.String(*input.CurrentImageRef)))
|
||||
}
|
||||
if input.CurrentEngineVersion != nil {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.CurrentEngineVersion.SET(pg.String(*input.CurrentEngineVersion)))
|
||||
}
|
||||
|
||||
return assignments
|
||||
}
|
||||
|
||||
// classifyMissingUpdate distinguishes ErrNotFound from ErrConflict
|
||||
// after an UPDATE that affected zero rows. A row that is absent yields
|
||||
// ErrNotFound; a row whose status does not match the CAS predicate
|
||||
// yields ErrConflict.
|
||||
func (store *Store) classifyMissingUpdate(ctx context.Context, gameID string) error {
|
||||
probe := pg.SELECT(pgtable.RuntimeRecords.Status).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
|
||||
probeQuery, probeArgs := probe.Sql()
|
||||
|
||||
var current string
|
||||
row := store.db.QueryRowContext(ctx, probeQuery, probeArgs...)
|
||||
if err := row.Scan(¤t); err != nil {
|
||||
if sqlx.IsNoRows(err) {
|
||||
return runtime.ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("update runtime status: probe: %w", err)
|
||||
}
|
||||
return runtime.ErrConflict
|
||||
}
|
||||
|
||||
// UpdateImage rotates the `current_image_ref` and
|
||||
// `current_engine_version` columns of one runtime row under a
|
||||
// compare-and-swap guard on `(game_id, status)`. The destination
|
||||
// status is preserved; only `updated_at` and the two image columns
|
||||
// change. Returns runtime.ErrNotFound when no row matches and
|
||||
// runtime.ErrConflict when the stored status differs from
|
||||
// input.ExpectedStatus. Used by the admin patch flow (Stage 17) where
|
||||
// Runtime Manager recreates the engine container with a new image
|
||||
// while the runtime stays `running`.
|
||||
func (store *Store) UpdateImage(ctx context.Context, input ports.UpdateImageInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update runtime image: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime image", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
now := input.Now.UTC()
|
||||
stmt := pgtable.RuntimeRecords.UPDATE(
|
||||
pgtable.RuntimeRecords.CurrentImageRef,
|
||||
pgtable.RuntimeRecords.CurrentEngineVersion,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
).SET(
|
||||
pg.String(input.CurrentImageRef),
|
||||
pg.String(input.CurrentEngineVersion),
|
||||
pg.TimestampzT(now),
|
||||
).WHERE(pg.AND(
|
||||
pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)),
|
||||
pgtable.RuntimeRecords.Status.EQ(pg.String(string(input.ExpectedStatus))),
|
||||
))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime image: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime image: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return store.classifyMissingUpdate(operationCtx, input.GameID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateEngineHealth rotates the `engine_health` column of one runtime
|
||||
// row plus `updated_at`. The destination status is preserved and no
|
||||
// CAS guard is applied so late-arriving runtime:health_events still
|
||||
// refresh the summary regardless of the current runtime status. Used
|
||||
// by the Stage 18 health-events consumer. Returns runtime.ErrNotFound
|
||||
// when no row exists for input.GameID.
|
||||
func (store *Store) UpdateEngineHealth(ctx context.Context, input ports.UpdateEngineHealthInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update runtime engine health: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime engine health", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.RuntimeRecords.UPDATE(
|
||||
pgtable.RuntimeRecords.EngineHealth,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
).SET(
|
||||
pg.String(input.EngineHealthSummary),
|
||||
pg.TimestampzT(input.Now.UTC()),
|
||||
).WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime engine health: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime engine health: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return runtime.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateScheduling mutates the scheduling columns of one runtime row
|
||||
// (`next_generation_at`, `skip_next_tick`, `current_turn`) plus
|
||||
// `updated_at`. Returns runtime.ErrNotFound when no row exists.
|
||||
func (store *Store) UpdateScheduling(ctx context.Context, input ports.UpdateSchedulingInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update runtime scheduling: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime scheduling", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
var nextGenExpr pg.Expression
|
||||
if input.NextGenerationAt != nil {
|
||||
nextGenExpr = pg.TimestampzT(input.NextGenerationAt.UTC())
|
||||
} else {
|
||||
nextGenExpr = pg.NULL
|
||||
}
|
||||
|
||||
stmt := pgtable.RuntimeRecords.UPDATE(
|
||||
pgtable.RuntimeRecords.NextGenerationAt,
|
||||
pgtable.RuntimeRecords.SkipNextTick,
|
||||
pgtable.RuntimeRecords.CurrentTurn,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
).SET(
|
||||
nextGenExpr,
|
||||
pg.Bool(input.SkipNextTick),
|
||||
pg.Int32(int32(input.CurrentTurn)),
|
||||
pg.TimestampzT(input.Now.UTC()),
|
||||
).WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime scheduling: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime scheduling: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return runtime.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the record identified by gameID. The call is
|
||||
// idempotent: it returns nil even when no row matches (mirrors
|
||||
// PlayerMappingStore.DeleteByGame). Used by the register-runtime
|
||||
// rollback path (Stage 13) when engine /admin/init or any later setup
|
||||
// step fails after the row has been installed with status=starting.
|
||||
func (store *Store) Delete(ctx context.Context, gameID string) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("delete runtime record: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return fmt.Errorf("delete runtime record: game id must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete runtime record", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.RuntimeRecords.DELETE().
|
||||
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
return fmt.Errorf("delete runtime record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDueRunning returns every record whose status is `running` and
|
||||
// whose `next_generation_at <= now`. The order is
|
||||
// (next_generation_at ASC, game_id ASC), matching the
|
||||
// `runtime_records_status_next_gen_idx` direction.
|
||||
func (store *Store) ListDueRunning(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list due runtime records: nil store")
|
||||
}
|
||||
if now.IsZero() {
|
||||
return nil, fmt.Errorf("list due runtime records: now must not be zero")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list due runtime records", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
cutoff := pg.TimestampzT(now.UTC())
|
||||
stmt := pg.SELECT(runtimeSelectColumns).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pg.AND(
|
||||
pgtable.RuntimeRecords.Status.EQ(pg.String(string(runtime.StatusRunning))),
|
||||
pgtable.RuntimeRecords.NextGenerationAt.LT_EQ(cutoff),
|
||||
)).
|
||||
ORDER_BY(
|
||||
pgtable.RuntimeRecords.NextGenerationAt.ASC(),
|
||||
pgtable.RuntimeRecords.GameID.ASC(),
|
||||
)
|
||||
|
||||
return store.queryRecords(operationCtx, stmt, "list due runtime records")
|
||||
}
|
||||
|
||||
// List returns every record in the store, ordered by `created_at`
|
||||
// descending and by `game_id` ascending as a tie-breaker. Used by the
|
||||
// `internalListRuntimes` REST handler when no status filter is
|
||||
// supplied.
|
||||
func (store *Store) List(ctx context.Context) ([]runtime.RuntimeRecord, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list runtime records: nil store")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list runtime records", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(runtimeSelectColumns).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
ORDER_BY(
|
||||
pgtable.RuntimeRecords.CreatedAt.DESC(),
|
||||
pgtable.RuntimeRecords.GameID.ASC(),
|
||||
)
|
||||
|
||||
return store.queryRecords(operationCtx, stmt, "list runtime records")
|
||||
}
|
||||
|
||||
// ListByStatus returns every record currently indexed under status,
|
||||
// ordered by game_id ASC.
|
||||
func (store *Store) ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list runtime records by status: nil store")
|
||||
}
|
||||
if !status.IsKnown() {
|
||||
return nil, fmt.Errorf("list runtime records by status: status %q is unsupported", status)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list runtime records by status", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(runtimeSelectColumns).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pgtable.RuntimeRecords.Status.EQ(pg.String(string(status)))).
|
||||
ORDER_BY(pgtable.RuntimeRecords.GameID.ASC())
|
||||
|
||||
return store.queryRecords(operationCtx, stmt, "list runtime records by status")
|
||||
}
|
||||
|
||||
// queryRecords runs a SELECT statement and scans every returned row
|
||||
// into a runtime.RuntimeRecord slice. opName is used only to prefix
|
||||
// error messages.
|
||||
func (store *Store) queryRecords(ctx context.Context, stmt pg.SelectStatement, opName string) ([]runtime.RuntimeRecord, error) {
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", opName, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]runtime.RuntimeRecord, 0)
|
||||
for rows.Next() {
|
||||
record, err := scanRecord(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: scan: %w", opName, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", opName, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// rowScanner abstracts *sql.Row and *sql.Rows so scanRecord can be
|
||||
// shared across both single-row and iterated reads.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanRecord scans one runtime_records row from rs. Returns
|
||||
// sql.ErrNoRows verbatim so callers can distinguish "no row" from a
|
||||
// hard error.
|
||||
func scanRecord(rs rowScanner) (runtime.RuntimeRecord, error) {
|
||||
var (
|
||||
gameID string
|
||||
status string
|
||||
engineEndpoint string
|
||||
currentImageRef string
|
||||
currentEngineVersion string
|
||||
turnSchedule string
|
||||
currentTurn int32
|
||||
nextGenerationAt sql.NullTime
|
||||
skipNextTick bool
|
||||
engineHealth string
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
startedAt sql.NullTime
|
||||
stoppedAt sql.NullTime
|
||||
finishedAt sql.NullTime
|
||||
)
|
||||
if err := rs.Scan(
|
||||
&gameID,
|
||||
&status,
|
||||
&engineEndpoint,
|
||||
¤tImageRef,
|
||||
¤tEngineVersion,
|
||||
&turnSchedule,
|
||||
¤tTurn,
|
||||
&nextGenerationAt,
|
||||
&skipNextTick,
|
||||
&engineHealth,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
&startedAt,
|
||||
&stoppedAt,
|
||||
&finishedAt,
|
||||
); err != nil {
|
||||
return runtime.RuntimeRecord{}, err
|
||||
}
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.Status(status),
|
||||
EngineEndpoint: engineEndpoint,
|
||||
CurrentImageRef: currentImageRef,
|
||||
CurrentEngineVersion: currentEngineVersion,
|
||||
TurnSchedule: turnSchedule,
|
||||
CurrentTurn: int(currentTurn),
|
||||
NextGenerationAt: sqlx.TimePtrFromNullable(nextGenerationAt),
|
||||
SkipNextTick: skipNextTick,
|
||||
EngineHealth: engineHealth,
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
StartedAt: sqlx.TimePtrFromNullable(startedAt),
|
||||
StoppedAt: sqlx.TimePtrFromNullable(stoppedAt),
|
||||
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.RuntimeRecordStore interface at
|
||||
// compile time.
|
||||
var _ ports.RuntimeRecordStore = (*Store)(nil)
|
||||
@@ -0,0 +1,718 @@
|
||||
package runtimerecordstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/runtimerecordstore"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStore(t *testing.T) *runtimerecordstore.Store {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
store, err := runtimerecordstore.New(runtimerecordstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func startingRecord(gameID string, createdAt time.Time) runtime.RuntimeRecord {
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.StatusStarting,
|
||||
EngineEndpoint: "http://galaxy-game-" + gameID + ":8080",
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
CurrentEngineVersion: "v1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 0,
|
||||
EngineHealth: "",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func runningRecord(gameID string, createdAt time.Time, nextGen time.Time) runtime.RuntimeRecord {
|
||||
startedAt := createdAt.Add(time.Second)
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.StatusRunning,
|
||||
EngineEndpoint: "http://galaxy-game-" + gameID + ":8080",
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
CurrentEngineVersion: "v1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 1,
|
||||
NextGenerationAt: &nextGen,
|
||||
EngineHealth: "healthy",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: startedAt,
|
||||
StartedAt: &startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidConfig(t *testing.T) {
|
||||
_, err := runtimerecordstore.New(runtimerecordstore.Config{})
|
||||
require.Error(t, err)
|
||||
|
||||
store, err := runtimerecordstore.New(runtimerecordstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, store)
|
||||
}
|
||||
|
||||
func TestInsertGetRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := startingRecord("game-001", now)
|
||||
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.GameID, got.GameID)
|
||||
assert.Equal(t, runtime.StatusStarting, got.Status)
|
||||
assert.Equal(t, record.EngineEndpoint, got.EngineEndpoint)
|
||||
assert.Equal(t, record.CurrentImageRef, got.CurrentImageRef)
|
||||
assert.Equal(t, record.CurrentEngineVersion, got.CurrentEngineVersion)
|
||||
assert.Equal(t, record.TurnSchedule, got.TurnSchedule)
|
||||
assert.Equal(t, 0, got.CurrentTurn)
|
||||
assert.Nil(t, got.NextGenerationAt)
|
||||
assert.False(t, got.SkipNextTick)
|
||||
assert.Equal(t, "", got.EngineHealth)
|
||||
assert.True(t, got.CreatedAt.Equal(now), "created_at: want %v, got %v", now, got.CreatedAt)
|
||||
assert.Equal(t, time.UTC, got.CreatedAt.Location())
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
assert.Equal(t, time.UTC, got.UpdatedAt.Location())
|
||||
assert.Nil(t, got.StartedAt)
|
||||
assert.Nil(t, got.StoppedAt)
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestInsertRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := startingRecord("game-001", now)
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
err := store.Insert(ctx, record)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestInsertRejectsInvalidRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
bad := runtime.RuntimeRecord{} // empty
|
||||
err := store.Insert(ctx, bad)
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
}
|
||||
|
||||
func TestGetReturnsErrNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "missing")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateStatusStartingToRunningSetsStartedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
now := created.Add(2 * time.Second)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusStarting,
|
||||
To: runtime.StatusRunning,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(now))
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
assert.Nil(t, got.StoppedAt)
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestUpdateStatusToFinishedSetsFinishedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusGenerationInProgress,
|
||||
Now: created.Add(2 * time.Second),
|
||||
}))
|
||||
|
||||
finishAt := created.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusGenerationInProgress,
|
||||
To: runtime.StatusFinished,
|
||||
Now: finishAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusFinished, got.Status)
|
||||
require.NotNil(t, got.FinishedAt)
|
||||
assert.True(t, got.FinishedAt.Equal(finishAt))
|
||||
assert.True(t, got.UpdatedAt.Equal(finishAt))
|
||||
}
|
||||
|
||||
func TestUpdateStatusToStoppedSetsStoppedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
stopAt := created.Add(2 * time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: stopAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusStopped, got.Status)
|
||||
require.NotNil(t, got.StoppedAt)
|
||||
assert.True(t, got.StoppedAt.Equal(stopAt))
|
||||
require.NotNil(t, got.StartedAt, "started_at must remain set after stop")
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestUpdateStatusEngineUnreachableRecoveryKeepsStartedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
original := runningRecord("game-001", created, nextGen)
|
||||
require.NoError(t, store.Insert(ctx, original))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusEngineUnreachable,
|
||||
Now: created.Add(time.Minute),
|
||||
}))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusEngineUnreachable,
|
||||
To: runtime.StatusRunning,
|
||||
Now: created.Add(2 * time.Minute),
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(*original.StartedAt),
|
||||
"recovery transition must not overwrite started_at")
|
||||
}
|
||||
|
||||
func TestUpdateStatusOptionalFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
healthy := "engine_unreachable_summary"
|
||||
imageRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
engineVersion := "v1.2.4"
|
||||
now := created.Add(time.Minute)
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusGenerationInProgress,
|
||||
Now: now,
|
||||
EngineHealthSummary: &healthy,
|
||||
CurrentImageRef: &imageRef,
|
||||
CurrentEngineVersion: &engineVersion,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusGenerationInProgress, got.Status)
|
||||
assert.Equal(t, healthy, got.EngineHealth)
|
||||
assert.Equal(t, imageRef, got.CurrentImageRef)
|
||||
assert.Equal(t, engineVersion, got.CurrentEngineVersion)
|
||||
}
|
||||
|
||||
func TestUpdateStatusOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "ghost",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateStatusStaleCASReturnsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: created.Add(time.Second),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateStatusConcurrentCAS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
const concurrency = 8
|
||||
results := make([]error, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrency)
|
||||
for index := range concurrency {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results[index] = store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: created.Add(time.Duration(index+1) * time.Second),
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
wins, conflicts := 0, 0
|
||||
for _, err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
wins++
|
||||
case errors.Is(err, runtime.ErrConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, wins, "exactly one caller must win the CAS race")
|
||||
assert.Equal(t, concurrency-1, conflicts, "the rest must observe runtime.ErrConflict")
|
||||
}
|
||||
|
||||
func TestUpdateImageHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "game-001",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status, "patch must not change status")
|
||||
assert.Equal(t, "ghcr.io/galaxy/game:v1.2.4", got.CurrentImageRef)
|
||||
assert.Equal(t, "v1.2.4", got.CurrentEngineVersion)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
require.NotNil(t, got.NextGenerationAt, "next_generation_at must remain untouched")
|
||||
assert.True(t, got.NextGenerationAt.Equal(nextGen))
|
||||
assert.Equal(t, 1, got.CurrentTurn, "current_turn must remain untouched")
|
||||
}
|
||||
|
||||
func TestUpdateImageStaleStatusReturnsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "game-001",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: created.Add(time.Second),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateImageOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "ghost",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateImageRejectsInvalidInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
require.False(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(2 * time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "probe_failed",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status, "engine health update must not change status")
|
||||
assert.Equal(t, "probe_failed", got.EngineHealth)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
require.NotNil(t, got.NextGenerationAt, "next_generation_at must remain untouched")
|
||||
assert.True(t, got.NextGenerationAt.Equal(nextGen))
|
||||
assert.Equal(t, 1, got.CurrentTurn, "current_turn must remain untouched")
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthAcceptsEmptySummary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", got.EngineHealth)
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "ghost",
|
||||
EngineHealthSummary: "exited",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthRejectsInvalidInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "",
|
||||
EngineHealthSummary: "healthy",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
require.False(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthAppliesFromAnyStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
now := created.Add(time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "exited",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusStarting, got.Status, "no status mutation expected")
|
||||
assert.Equal(t, "exited", got.EngineHealth)
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
updated := nextGen.Add(time.Hour)
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "game-001",
|
||||
NextGenerationAt: &updated,
|
||||
SkipNextTick: true,
|
||||
CurrentTurn: 5,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.NextGenerationAt)
|
||||
assert.True(t, got.NextGenerationAt.Equal(updated))
|
||||
assert.True(t, got.SkipNextTick)
|
||||
assert.Equal(t, 5, got.CurrentTurn)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingClearsNextGen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "game-001",
|
||||
NextGenerationAt: nil,
|
||||
SkipNextTick: false,
|
||||
CurrentTurn: 0,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got.NextGenerationAt)
|
||||
assert.False(t, got.SkipNextTick)
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "ghost",
|
||||
CurrentTurn: 0,
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestListDueRunning(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
createdEarlier := time.Date(2026, time.April, 27, 10, 0, 0, 0, time.UTC)
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
due := created.Add(-time.Minute) // due before now
|
||||
future := created.Add(time.Hour) // not due yet
|
||||
|
||||
dueRecord := runningRecord("game-due", created, due)
|
||||
require.NoError(t, store.Insert(ctx, dueRecord))
|
||||
|
||||
futureRecord := runningRecord("game-future", created, future)
|
||||
require.NoError(t, store.Insert(ctx, futureRecord))
|
||||
|
||||
// A stopped record whose next_generation_at is in the past must
|
||||
// still be excluded by the running-status filter.
|
||||
stoppedRecord := startingRecord("game-stopped", createdEarlier)
|
||||
stoppedRecord.Status = runtime.StatusStopped
|
||||
startedAt := createdEarlier.Add(time.Second)
|
||||
stoppedAt := createdEarlier.Add(time.Minute)
|
||||
stoppedRecord.StartedAt = &startedAt
|
||||
stoppedRecord.StoppedAt = &stoppedAt
|
||||
stoppedRecord.UpdatedAt = stoppedAt
|
||||
stalePast := created.Add(-30 * time.Minute)
|
||||
stoppedRecord.NextGenerationAt = &stalePast
|
||||
require.NoError(t, store.Insert(ctx, stoppedRecord))
|
||||
|
||||
results, err := store.ListDueRunning(ctx, created)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, "game-due", results[0].GameID)
|
||||
}
|
||||
|
||||
func TestListByStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-r1", created, created.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-r2", created, created.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-s1", created)))
|
||||
|
||||
running, err := store.ListByStatus(ctx, runtime.StatusRunning)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, running, 2)
|
||||
assert.Equal(t, "game-r1", running[0].GameID)
|
||||
assert.Equal(t, "game-r2", running[1].GameID)
|
||||
|
||||
starting, err := store.ListByStatus(ctx, runtime.StatusStarting)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, starting, 1)
|
||||
assert.Equal(t, "game-s1", starting[0].GameID)
|
||||
|
||||
finished, err := store.ListByStatus(ctx, runtime.StatusFinished)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, finished)
|
||||
}
|
||||
|
||||
func TestListReturnsEveryRecordOrderedByCreatedAtDesc(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
earliest := time.Date(2026, time.April, 27, 10, 0, 0, 0, time.UTC)
|
||||
middle := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
latest := time.Date(2026, time.April, 27, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-earliest", earliest)))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-middle", middle, middle.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-latest", latest, latest.Add(time.Hour))))
|
||||
|
||||
records, err := store.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
assert.Equal(t, "game-latest", records[0].GameID)
|
||||
assert.Equal(t, "game-middle", records[1].GameID)
|
||||
assert.Equal(t, "game-earliest", records[2].GameID)
|
||||
}
|
||||
|
||||
func TestListReturnsEmptySliceWhenStoreIsEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
records, err := store.List(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestListByStatusUnknownRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.ListByStatus(ctx, runtime.Status("exotic"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListDueRunningRejectsZeroNow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.ListDueRunning(ctx, time.Time{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetRejectsEmptyGameID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", now)))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, "game-001"))
|
||||
|
||||
_, err := store.Get(ctx, "game-001")
|
||||
require.ErrorIs(t, err, runtime.ErrNotFound)
|
||||
|
||||
// Second call must be a no-op.
|
||||
require.NoError(t, store.Delete(ctx, "game-001"))
|
||||
}
|
||||
|
||||
func TestDeleteRejectsEmptyGameID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
require.Error(t, store.Delete(ctx, ""))
|
||||
}
|
||||
Reference in New Issue
Block a user