feat: runtime manager
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
// Package operationlogstore implements the PostgreSQL-backed adapter for
|
||||
// `ports.OperationLogStore`.
|
||||
//
|
||||
// The package owns the on-disk shape of the `operation_log` table defined
|
||||
// in
|
||||
// `galaxy/rtmanager/internal/adapters/postgres/migrations/00001_init.sql`
|
||||
// and translates the schema-agnostic `ports.OperationLogStore` interface
|
||||
// declared in `internal/ports/operationlogstore.go` into concrete
|
||||
// go-jet/v2 statements driven by the pgx driver.
|
||||
//
|
||||
// Append uses `INSERT ... RETURNING id` to surface the bigserial id back
|
||||
// to callers; ListByGame is index-driven by `operation_log_game_started_idx`.
|
||||
package operationlogstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/rtmanager/internal/adapters/postgres/internal/sqlx"
|
||||
pgtable "galaxy/rtmanager/internal/adapters/postgres/jet/rtmanager/table"
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
"galaxy/rtmanager/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed operation-log store instance.
|
||||
type Config struct {
|
||||
// DB stores the connection pool the store uses for every query.
|
||||
DB *sql.DB
|
||||
|
||||
// OperationTimeout bounds one round trip.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Runtime Manager operation-log entries in PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed operation-log store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres operation log store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres operation log store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// operationLogSelectColumns is the canonical SELECT list for the
|
||||
// operation_log table, matching scanEntry's column order.
|
||||
var operationLogSelectColumns = pg.ColumnList{
|
||||
pgtable.OperationLog.ID,
|
||||
pgtable.OperationLog.GameID,
|
||||
pgtable.OperationLog.OpKind,
|
||||
pgtable.OperationLog.OpSource,
|
||||
pgtable.OperationLog.SourceRef,
|
||||
pgtable.OperationLog.ImageRef,
|
||||
pgtable.OperationLog.ContainerID,
|
||||
pgtable.OperationLog.Outcome,
|
||||
pgtable.OperationLog.ErrorCode,
|
||||
pgtable.OperationLog.ErrorMessage,
|
||||
pgtable.OperationLog.StartedAt,
|
||||
pgtable.OperationLog.FinishedAt,
|
||||
}
|
||||
|
||||
// Append inserts entry into the operation log and returns the generated
|
||||
// bigserial id. entry is validated through operation.OperationEntry.Validate
|
||||
// before the SQL is issued.
|
||||
func (store *Store) Append(ctx context.Context, entry operation.OperationEntry) (int64, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return 0, errors.New("append operation log entry: nil store")
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return 0, fmt.Errorf("append operation log entry: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "append operation log entry", store.operationTimeout)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.OperationLog.INSERT(
|
||||
pgtable.OperationLog.GameID,
|
||||
pgtable.OperationLog.OpKind,
|
||||
pgtable.OperationLog.OpSource,
|
||||
pgtable.OperationLog.SourceRef,
|
||||
pgtable.OperationLog.ImageRef,
|
||||
pgtable.OperationLog.ContainerID,
|
||||
pgtable.OperationLog.Outcome,
|
||||
pgtable.OperationLog.ErrorCode,
|
||||
pgtable.OperationLog.ErrorMessage,
|
||||
pgtable.OperationLog.StartedAt,
|
||||
pgtable.OperationLog.FinishedAt,
|
||||
).VALUES(
|
||||
entry.GameID,
|
||||
string(entry.OpKind),
|
||||
string(entry.OpSource),
|
||||
entry.SourceRef,
|
||||
entry.ImageRef,
|
||||
entry.ContainerID,
|
||||
string(entry.Outcome),
|
||||
entry.ErrorCode,
|
||||
entry.ErrorMessage,
|
||||
entry.StartedAt.UTC(),
|
||||
sqlx.NullableTimePtr(entry.FinishedAt),
|
||||
).RETURNING(pgtable.OperationLog.ID)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("append operation log entry: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListByGame returns the most recent entries for gameID, ordered by
|
||||
// started_at descending and capped by limit. The (game_id,
|
||||
// started_at DESC) index drives the read.
|
||||
func (store *Store) ListByGame(ctx context.Context, gameID string, limit int) ([]operation.OperationEntry, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list operation log entries by game: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return nil, fmt.Errorf("list operation log entries by game: game id must not be empty")
|
||||
}
|
||||
if limit <= 0 {
|
||||
return nil, fmt.Errorf("list operation log entries by game: limit must be positive, got %d", limit)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list operation log entries by game", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(operationLogSelectColumns).
|
||||
FROM(pgtable.OperationLog).
|
||||
WHERE(pgtable.OperationLog.GameID.EQ(pg.String(gameID))).
|
||||
ORDER_BY(pgtable.OperationLog.StartedAt.DESC(), pgtable.OperationLog.ID.DESC()).
|
||||
LIMIT(int64(limit))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operation log entries by game: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]operation.OperationEntry, 0)
|
||||
for rows.Next() {
|
||||
entry, err := scanEntry(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operation log entries by game: scan: %w", err)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list operation log entries by game: %w", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// rowScanner abstracts *sql.Row and *sql.Rows so scanEntry can be shared
|
||||
// across both single-row reads and iterated reads.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanEntry scans one operation_log row from rs.
|
||||
func scanEntry(rs rowScanner) (operation.OperationEntry, error) {
|
||||
var (
|
||||
id int64
|
||||
gameID string
|
||||
opKind string
|
||||
opSource string
|
||||
sourceRef string
|
||||
imageRef string
|
||||
containerID string
|
||||
outcome string
|
||||
errorCode string
|
||||
errorMessage string
|
||||
startedAt time.Time
|
||||
finishedAt sql.NullTime
|
||||
)
|
||||
if err := rs.Scan(
|
||||
&id,
|
||||
&gameID,
|
||||
&opKind,
|
||||
&opSource,
|
||||
&sourceRef,
|
||||
&imageRef,
|
||||
&containerID,
|
||||
&outcome,
|
||||
&errorCode,
|
||||
&errorMessage,
|
||||
&startedAt,
|
||||
&finishedAt,
|
||||
); err != nil {
|
||||
return operation.OperationEntry{}, err
|
||||
}
|
||||
return operation.OperationEntry{
|
||||
ID: id,
|
||||
GameID: gameID,
|
||||
OpKind: operation.OpKind(opKind),
|
||||
OpSource: operation.OpSource(opSource),
|
||||
SourceRef: sourceRef,
|
||||
ImageRef: imageRef,
|
||||
ContainerID: containerID,
|
||||
Outcome: operation.Outcome(outcome),
|
||||
ErrorCode: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
StartedAt: startedAt.UTC(),
|
||||
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.OperationLogStore interface at compile
|
||||
// time.
|
||||
var _ ports.OperationLogStore = (*Store)(nil)
|
||||
@@ -0,0 +1,207 @@
|
||||
package operationlogstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/rtmanager/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/rtmanager/internal/adapters/postgres/operationlogstore"
|
||||
"galaxy/rtmanager/internal/domain/operation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStore(t *testing.T) *operationlogstore.Store {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
store, err := operationlogstore.New(operationlogstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func successStartEntry(gameID string, startedAt time.Time, sourceRef string) operation.OperationEntry {
|
||||
finishedAt := startedAt.Add(time.Second)
|
||||
return operation.OperationEntry{
|
||||
GameID: gameID,
|
||||
OpKind: operation.OpKindStart,
|
||||
OpSource: operation.OpSourceLobbyStream,
|
||||
SourceRef: sourceRef,
|
||||
ImageRef: "galaxy/game:v1.2.3",
|
||||
ContainerID: "container-1",
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendReturnsPositiveIDs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
startedAt := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
id1, err := store.Append(ctx, successStartEntry("game-001", startedAt, "1700000000000-0"))
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id1, int64(0))
|
||||
|
||||
id2, err := store.Append(ctx, successStartEntry("game-001", startedAt.Add(time.Minute), "1700000000001-0"))
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id2, id1)
|
||||
}
|
||||
|
||||
func TestAppendValidatesEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*operation.OperationEntry)
|
||||
}{
|
||||
{"empty game id", func(e *operation.OperationEntry) { e.GameID = "" }},
|
||||
{"unknown op kind", func(e *operation.OperationEntry) { e.OpKind = "exotic" }},
|
||||
{"unknown op source", func(e *operation.OperationEntry) { e.OpSource = "exotic" }},
|
||||
{"unknown outcome", func(e *operation.OperationEntry) { e.Outcome = "exotic" }},
|
||||
{"zero started at", func(e *operation.OperationEntry) { e.StartedAt = time.Time{} }},
|
||||
{"failure without error code", func(e *operation.OperationEntry) {
|
||||
e.Outcome = operation.OutcomeFailure
|
||||
e.ErrorCode = ""
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entry := successStartEntry("game-001",
|
||||
time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC), "ref")
|
||||
tt.mutate(&entry)
|
||||
_, err := store.Append(ctx, entry)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByGameReturnsEntriesNewestFirst(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
base := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
for index := range 3 {
|
||||
_, err := store.Append(ctx, successStartEntry("game-001",
|
||||
base.Add(time.Duration(index)*time.Minute),
|
||||
"ref-game-001-"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// Foreign-game entry must not appear in the list.
|
||||
_, err := store.Append(ctx, successStartEntry("game-other", base, "ref-other"))
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := store.ListByGame(ctx, "game-001", 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 3)
|
||||
for index := range 2 {
|
||||
assert.True(t,
|
||||
!entries[index].StartedAt.Before(entries[index+1].StartedAt),
|
||||
"entries must be ordered started_at DESC; got %s before %s",
|
||||
entries[index].StartedAt, entries[index+1].StartedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListByGameRespectsLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
base := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
for index := range 5 {
|
||||
_, err := store.Append(ctx, successStartEntry("game-001",
|
||||
base.Add(time.Duration(index)*time.Minute), "ref"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
entries, err := store.ListByGame(ctx, "game-001", 2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
}
|
||||
|
||||
func TestListByGameReturnsEmptyForUnknownGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
entries, err := store.ListByGame(ctx, "game-missing", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries)
|
||||
}
|
||||
|
||||
func TestListByGameRejectsInvalidArgs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.ListByGame(ctx, "", 10)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = store.ListByGame(ctx, "game-001", 0)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = store.ListByGame(ctx, "game-001", -3)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAppendRoundTripsAllFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
startedAt := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
finishedAt := startedAt.Add(2 * time.Second)
|
||||
original := operation.OperationEntry{
|
||||
GameID: "game-001",
|
||||
OpKind: operation.OpKindStop,
|
||||
OpSource: operation.OpSourceGMRest,
|
||||
SourceRef: "request-7",
|
||||
ImageRef: "galaxy/game:v2.0.0",
|
||||
ContainerID: "container-X",
|
||||
Outcome: operation.OutcomeFailure,
|
||||
ErrorCode: "container_start_failed",
|
||||
ErrorMessage: "stop deadline exceeded",
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
}
|
||||
id, err := store.Append(ctx, original)
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := store.ListByGame(ctx, "game-001", 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
|
||||
got := entries[0]
|
||||
assert.Equal(t, id, got.ID)
|
||||
assert.Equal(t, original.GameID, got.GameID)
|
||||
assert.Equal(t, original.OpKind, got.OpKind)
|
||||
assert.Equal(t, original.OpSource, got.OpSource)
|
||||
assert.Equal(t, original.SourceRef, got.SourceRef)
|
||||
assert.Equal(t, original.ImageRef, got.ImageRef)
|
||||
assert.Equal(t, original.ContainerID, got.ContainerID)
|
||||
assert.Equal(t, original.Outcome, got.Outcome)
|
||||
assert.Equal(t, original.ErrorCode, got.ErrorCode)
|
||||
assert.Equal(t, original.ErrorMessage, got.ErrorMessage)
|
||||
assert.True(t, original.StartedAt.Equal(got.StartedAt))
|
||||
require.NotNil(t, got.FinishedAt)
|
||||
assert.True(t, original.FinishedAt.Equal(*got.FinishedAt))
|
||||
assert.Equal(t, time.UTC, got.StartedAt.Location())
|
||||
assert.Equal(t, time.UTC, got.FinishedAt.Location())
|
||||
}
|
||||
|
||||
func TestNewRejectsNilDB(t *testing.T) {
|
||||
_, err := operationlogstore.New(operationlogstore.Config{OperationTimeout: time.Second})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestNewRejectsNonPositiveTimeout(t *testing.T) {
|
||||
_, err := operationlogstore.New(operationlogstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
Reference in New Issue
Block a user