308 lines
12 KiB
Go
308 lines
12 KiB
Go
// Package ports defines the stable interfaces that connect Game Master
|
|
// use cases to external state and external services.
|
|
package ports
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/gamemaster/internal/domain/runtime"
|
|
)
|
|
|
|
//go:generate go run go.uber.org/mock/mockgen -destination=../adapters/mocks/mock_runtimerecordstore.go -package=mocks galaxy/gamemaster/internal/ports RuntimeRecordStore
|
|
|
|
// RuntimeRecordStore stores runtime records and exposes the operations
|
|
// used by the service layer (Stages 13+) and the workers (Stages 15-18).
|
|
// Adapters must preserve domain semantics:
|
|
//
|
|
// - Get returns runtime.ErrNotFound when no record exists for gameID.
|
|
// - Insert installs a fresh record and returns runtime.ErrConflict
|
|
// when a row already exists.
|
|
// - UpdateStatus applies one transition through a compare-and-swap
|
|
// guard on the stored status and returns runtime.ErrConflict on a
|
|
// stale CAS.
|
|
// - UpdateScheduling mutates `next_generation_at`, `skip_next_tick`,
|
|
// and `current_turn` together; the destination status is unaffected.
|
|
// - UpdateImage rotates `current_image_ref` and
|
|
// `current_engine_version` under a compare-and-swap guard on the
|
|
// stored status and returns runtime.ErrConflict on a stale CAS.
|
|
// - UpdateEngineHealth rotates the `engine_health` column without
|
|
// touching status. The call applies from any status (including
|
|
// stopped and finished) so late-arriving health observations still
|
|
// bookkeep correctly. Returns runtime.ErrNotFound when no row
|
|
// matches.
|
|
// - Delete removes the record identified by gameID. The call is
|
|
// idempotent: it returns nil even when no row matches.
|
|
// - ListDueRunning returns every running record with
|
|
// `next_generation_at <= now`.
|
|
// - ListByStatus returns every record currently indexed under status.
|
|
// - List returns every record ordered by `created_at` descending. Used
|
|
// by the `internalListRuntimes` REST handler when no status filter
|
|
// is supplied.
|
|
type RuntimeRecordStore interface {
|
|
// Get returns the record identified by gameID. It returns
|
|
// runtime.ErrNotFound when no record exists.
|
|
Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error)
|
|
|
|
// Insert installs record into the store. It returns
|
|
// runtime.ErrConflict when a row already exists for record.GameID.
|
|
Insert(ctx context.Context, record runtime.RuntimeRecord) error
|
|
|
|
// UpdateStatus applies one status transition in a compare-and-swap
|
|
// fashion. The adapter must first call runtime.Transition to reject
|
|
// invalid pairs without touching the store, then verify that the
|
|
// stored status equals input.ExpectedFrom. Optional fields on the
|
|
// input (CurrentImageRef, CurrentEngineVersion, EngineHealthSummary)
|
|
// are persisted only when non-nil.
|
|
UpdateStatus(ctx context.Context, input UpdateStatusInput) error
|
|
|
|
// UpdateScheduling mutates the scheduling columns
|
|
// (`next_generation_at`, `skip_next_tick`, `current_turn`) of the
|
|
// record identified by input.GameID. The store does not validate
|
|
// the runtime status; callers issue UpdateScheduling alongside an
|
|
// UpdateStatus when the destination status changes.
|
|
UpdateScheduling(ctx context.Context, input UpdateSchedulingInput) error
|
|
|
|
// UpdateImage rotates `current_image_ref` and
|
|
// `current_engine_version` of the record identified by
|
|
// input.GameID under a compare-and-swap guard on the stored status.
|
|
// The destination status is unchanged. Used by the admin patch
|
|
// flow (Stage 17) where the runtime stays `running` while the
|
|
// engine container is recreated by Runtime Manager with a new
|
|
// image. Returns runtime.ErrNotFound when no row matches and
|
|
// runtime.ErrConflict when the stored status differs from
|
|
// input.ExpectedStatus.
|
|
UpdateImage(ctx context.Context, input UpdateImageInput) error
|
|
|
|
// UpdateEngineHealth rotates the `engine_health` column of the
|
|
// record identified by input.GameID without touching status. Used
|
|
// by the runtime:health_events consumer (Stage 18) when an
|
|
// observation should refresh the summary regardless of the current
|
|
// runtime status (including stopped and finished, so late-arriving
|
|
// events still bookkeep correctly). Returns runtime.ErrNotFound
|
|
// when no row matches.
|
|
UpdateEngineHealth(ctx context.Context, input UpdateEngineHealthInput) error
|
|
|
|
// Delete removes the record identified by gameID. The call is
|
|
// idempotent: it returns nil even when no row matches. Used by the
|
|
// register-runtime rollback path (Stage 13) when the engine
|
|
// /admin/init call or any later setup step fails after the row has
|
|
// been installed with status=starting.
|
|
Delete(ctx context.Context, gameID string) error
|
|
|
|
// ListDueRunning returns every record whose status is `running`
|
|
// and whose `next_generation_at <= now`. The order is
|
|
// adapter-defined; callers may reorder as needed.
|
|
ListDueRunning(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error)
|
|
|
|
// ListByStatus returns every record currently indexed under status.
|
|
// The order is adapter-defined; callers may reorder as needed.
|
|
ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error)
|
|
|
|
// List returns every record in the store, ordered by `created_at`
|
|
// descending. Used by the `internalListRuntimes` REST handler when no
|
|
// status filter is supplied.
|
|
List(ctx context.Context) ([]runtime.RuntimeRecord, error)
|
|
}
|
|
|
|
// UpdateStatusInput stores the arguments required to apply one status
|
|
// transition through a RuntimeRecordStore. The optional fields are
|
|
// pointers so the adapter can distinguish «leave alone» from «write
|
|
// the zero value».
|
|
type UpdateStatusInput struct {
|
|
// GameID identifies the record to mutate.
|
|
GameID string
|
|
|
|
// ExpectedFrom stores the status the caller believes the record
|
|
// currently has. A mismatch results in runtime.ErrConflict.
|
|
ExpectedFrom runtime.Status
|
|
|
|
// To stores the destination status.
|
|
To runtime.Status
|
|
|
|
// Now stores the wall-clock used to derive the lifecycle timestamps
|
|
// (started_at, stopped_at, finished_at, updated_at) according to
|
|
// To.
|
|
Now time.Time
|
|
|
|
// EngineHealthSummary is the new value of the `engine_health`
|
|
// column. Nil leaves the column unchanged.
|
|
EngineHealthSummary *string
|
|
|
|
// CurrentImageRef is the new value of the `current_image_ref`
|
|
// column. Nil leaves the column unchanged. Used by the patch flow
|
|
// (Stage 17) when the image reference rotates together with the
|
|
// status update.
|
|
CurrentImageRef *string
|
|
|
|
// CurrentEngineVersion is the new value of the
|
|
// `current_engine_version` column. Nil leaves the column unchanged.
|
|
// Used by the patch flow when the engine version rotates together
|
|
// with the status update.
|
|
CurrentEngineVersion *string
|
|
}
|
|
|
|
// Validate reports whether input contains a structurally valid status
|
|
// transition request. Adapters call Validate before touching the store.
|
|
func (input UpdateStatusInput) Validate() error {
|
|
if strings.TrimSpace(input.GameID) == "" {
|
|
return fmt.Errorf("update runtime status: game id must not be empty")
|
|
}
|
|
if !input.ExpectedFrom.IsKnown() {
|
|
return fmt.Errorf(
|
|
"update runtime status: expected from status %q is unsupported",
|
|
input.ExpectedFrom,
|
|
)
|
|
}
|
|
if !input.To.IsKnown() {
|
|
return fmt.Errorf(
|
|
"update runtime status: to status %q is unsupported",
|
|
input.To,
|
|
)
|
|
}
|
|
if err := runtime.Transition(input.ExpectedFrom, input.To); err != nil {
|
|
return fmt.Errorf("update runtime status: %w", err)
|
|
}
|
|
if input.Now.IsZero() {
|
|
return fmt.Errorf("update runtime status: now must not be zero")
|
|
}
|
|
if input.CurrentImageRef != nil && strings.TrimSpace(*input.CurrentImageRef) == "" {
|
|
return fmt.Errorf(
|
|
"update runtime status: current image ref must not be empty when set",
|
|
)
|
|
}
|
|
if input.CurrentEngineVersion != nil && strings.TrimSpace(*input.CurrentEngineVersion) == "" {
|
|
return fmt.Errorf(
|
|
"update runtime status: current engine version must not be empty when set",
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateSchedulingInput stores the arguments required to mutate the
|
|
// scheduling columns of one runtime record. The status enum is
|
|
// deliberately absent: scheduling and status updates are independent
|
|
// operations and the service layer composes them when both must change.
|
|
type UpdateSchedulingInput struct {
|
|
// GameID identifies the record to mutate.
|
|
GameID string
|
|
|
|
// NextGenerationAt is the new value of the column. Nil writes SQL
|
|
// NULL (used to clear the tick when the runtime leaves running).
|
|
NextGenerationAt *time.Time
|
|
|
|
// SkipNextTick is the new value of the column. The store overwrites
|
|
// the column unconditionally.
|
|
SkipNextTick bool
|
|
|
|
// CurrentTurn is the new value of the column. Must be non-negative.
|
|
CurrentTurn int
|
|
|
|
// Now stores the wall-clock used to refresh `updated_at`.
|
|
Now time.Time
|
|
}
|
|
|
|
// Validate reports whether input contains structurally valid scheduling
|
|
// arguments. Adapters call Validate before touching the store.
|
|
func (input UpdateSchedulingInput) Validate() error {
|
|
if strings.TrimSpace(input.GameID) == "" {
|
|
return fmt.Errorf("update runtime scheduling: game id must not be empty")
|
|
}
|
|
if input.CurrentTurn < 0 {
|
|
return fmt.Errorf("update runtime scheduling: current turn must not be negative")
|
|
}
|
|
if input.NextGenerationAt != nil && input.NextGenerationAt.IsZero() {
|
|
return fmt.Errorf(
|
|
"update runtime scheduling: next generation at must not be zero when set",
|
|
)
|
|
}
|
|
if input.Now.IsZero() {
|
|
return fmt.Errorf("update runtime scheduling: now must not be zero")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateImageInput stores the arguments required to rotate the engine
|
|
// image reference and version of one runtime record without changing
|
|
// its status. The store applies a compare-and-swap guard on
|
|
// `(game_id, status)` so callers can reject the update if the runtime
|
|
// has drifted out of the expected status.
|
|
type UpdateImageInput struct {
|
|
// GameID identifies the record to mutate.
|
|
GameID string
|
|
|
|
// ExpectedStatus stores the status the caller believes the record
|
|
// currently has. A mismatch results in runtime.ErrConflict.
|
|
ExpectedStatus runtime.Status
|
|
|
|
// CurrentImageRef stores the new value of the
|
|
// `current_image_ref` column. Must not be empty.
|
|
CurrentImageRef string
|
|
|
|
// CurrentEngineVersion stores the new value of the
|
|
// `current_engine_version` column. Must not be empty.
|
|
CurrentEngineVersion string
|
|
|
|
// Now stores the wall-clock used to refresh `updated_at`.
|
|
Now time.Time
|
|
}
|
|
|
|
// Validate reports whether input contains structurally valid image
|
|
// rotation arguments. Adapters call Validate before touching the store.
|
|
func (input UpdateImageInput) Validate() error {
|
|
if strings.TrimSpace(input.GameID) == "" {
|
|
return fmt.Errorf("update runtime image: game id must not be empty")
|
|
}
|
|
if !input.ExpectedStatus.IsKnown() {
|
|
return fmt.Errorf(
|
|
"update runtime image: expected status %q is unsupported",
|
|
input.ExpectedStatus,
|
|
)
|
|
}
|
|
if strings.TrimSpace(input.CurrentImageRef) == "" {
|
|
return fmt.Errorf("update runtime image: current image ref must not be empty")
|
|
}
|
|
if strings.TrimSpace(input.CurrentEngineVersion) == "" {
|
|
return fmt.Errorf("update runtime image: current engine version must not be empty")
|
|
}
|
|
if input.Now.IsZero() {
|
|
return fmt.Errorf("update runtime image: now must not be zero")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateEngineHealthInput stores the arguments required to rotate the
|
|
// `engine_health` column of one runtime record without touching its
|
|
// status. The store performs no compare-and-swap so callers can apply
|
|
// the update from any runtime status (including stopped and finished)
|
|
// to keep the summary current for late-arriving runtime:health_events.
|
|
type UpdateEngineHealthInput struct {
|
|
// GameID identifies the record to mutate.
|
|
GameID string
|
|
|
|
// EngineHealthSummary stores the new value of the `engine_health`
|
|
// column. The summary is a free-form short string drawn from the
|
|
// vocabulary documented in
|
|
// `gamemaster/README.md §Persistence Layout` and produced by the
|
|
// Stage 18 consumer.
|
|
EngineHealthSummary string
|
|
|
|
// Now stores the wall-clock used to refresh `updated_at`.
|
|
Now time.Time
|
|
}
|
|
|
|
// Validate reports whether input carries structurally valid arguments
|
|
// for an engine-health update. Adapters call Validate before touching
|
|
// the store.
|
|
func (input UpdateEngineHealthInput) Validate() error {
|
|
if strings.TrimSpace(input.GameID) == "" {
|
|
return fmt.Errorf("update runtime engine health: game id must not be empty")
|
|
}
|
|
if input.Now.IsZero() {
|
|
return fmt.Errorf("update runtime engine health: now must not be zero")
|
|
}
|
|
return nil
|
|
}
|