236 lines
6.9 KiB
Go
236 lines
6.9 KiB
Go
// 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)
|