// Package healthsnapshotstore implements the PostgreSQL-backed adapter // for `ports.HealthSnapshotStore`. // // The package owns the on-disk shape of the `health_snapshots` table // defined in // `galaxy/rtmanager/internal/adapters/postgres/migrations/00001_init.sql` // and translates the schema-agnostic `ports.HealthSnapshotStore` interface // declared in `internal/ports/healthsnapshotstore.go` into concrete // go-jet/v2 statements driven by the pgx driver. // // The `details` jsonb column round-trips as a `json.RawMessage`. Empty // payloads are substituted with the SQL default `{}` on Upsert so the // CHECK constraints and downstream readers never observe a non-JSON // empty string. package healthsnapshotstore import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "galaxy/rtmanager/internal/adapters/postgres/internal/sqlx" pgtable "galaxy/rtmanager/internal/adapters/postgres/jet/rtmanager/table" "galaxy/rtmanager/internal/domain/health" "galaxy/rtmanager/internal/domain/runtime" "galaxy/rtmanager/internal/ports" pg "github.com/go-jet/jet/v2/postgres" ) // emptyDetails is the canonical jsonb payload installed when the caller // supplies an empty Details slice. It matches the SQL DEFAULT for the // column. const emptyDetails = "{}" // Config configures one PostgreSQL-backed health-snapshot 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 health snapshots in PostgreSQL. type Store struct { db *sql.DB operationTimeout time.Duration } // New constructs one PostgreSQL-backed health-snapshot store from cfg. func New(cfg Config) (*Store, error) { if cfg.DB == nil { return nil, errors.New("new postgres health snapshot store: db must not be nil") } if cfg.OperationTimeout <= 0 { return nil, errors.New("new postgres health snapshot store: operation timeout must be positive") } return &Store{ db: cfg.DB, operationTimeout: cfg.OperationTimeout, }, nil } // healthSnapshotSelectColumns is the canonical SELECT list for the // health_snapshots table, matching scanSnapshot's column order. var healthSnapshotSelectColumns = pg.ColumnList{ pgtable.HealthSnapshots.GameID, pgtable.HealthSnapshots.ContainerID, pgtable.HealthSnapshots.Status, pgtable.HealthSnapshots.Source, pgtable.HealthSnapshots.Details, pgtable.HealthSnapshots.ObservedAt, } // Upsert installs snapshot as the latest observation for snapshot.GameID. // snapshot is validated through health.HealthSnapshot.Validate before the // SQL is issued. func (store *Store) Upsert(ctx context.Context, snapshot health.HealthSnapshot) error { if store == nil || store.db == nil { return errors.New("upsert health snapshot: nil store") } if err := snapshot.Validate(); err != nil { return fmt.Errorf("upsert health snapshot: %w", err) } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "upsert health snapshot", store.operationTimeout) if err != nil { return err } defer cancel() details := emptyDetails if len(snapshot.Details) > 0 { details = string(snapshot.Details) } stmt := pgtable.HealthSnapshots.INSERT( pgtable.HealthSnapshots.GameID, pgtable.HealthSnapshots.ContainerID, pgtable.HealthSnapshots.Status, pgtable.HealthSnapshots.Source, pgtable.HealthSnapshots.Details, pgtable.HealthSnapshots.ObservedAt, ).VALUES( snapshot.GameID, snapshot.ContainerID, string(snapshot.Status), string(snapshot.Source), details, snapshot.ObservedAt.UTC(), ).ON_CONFLICT(pgtable.HealthSnapshots.GameID).DO_UPDATE( pg.SET( pgtable.HealthSnapshots.ContainerID.SET(pgtable.HealthSnapshots.EXCLUDED.ContainerID), pgtable.HealthSnapshots.Status.SET(pgtable.HealthSnapshots.EXCLUDED.Status), pgtable.HealthSnapshots.Source.SET(pgtable.HealthSnapshots.EXCLUDED.Source), pgtable.HealthSnapshots.Details.SET(pgtable.HealthSnapshots.EXCLUDED.Details), pgtable.HealthSnapshots.ObservedAt.SET(pgtable.HealthSnapshots.EXCLUDED.ObservedAt), ), ) query, args := stmt.Sql() if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil { return fmt.Errorf("upsert health snapshot: %w", err) } return nil } // Get returns the latest snapshot for gameID. It returns // runtime.ErrNotFound when no snapshot has been recorded yet. func (store *Store) Get(ctx context.Context, gameID string) (health.HealthSnapshot, error) { if store == nil || store.db == nil { return health.HealthSnapshot{}, errors.New("get health snapshot: nil store") } if strings.TrimSpace(gameID) == "" { return health.HealthSnapshot{}, fmt.Errorf("get health snapshot: game id must not be empty") } operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get health snapshot", store.operationTimeout) if err != nil { return health.HealthSnapshot{}, err } defer cancel() stmt := pg.SELECT(healthSnapshotSelectColumns). FROM(pgtable.HealthSnapshots). WHERE(pgtable.HealthSnapshots.GameID.EQ(pg.String(gameID))) query, args := stmt.Sql() row := store.db.QueryRowContext(operationCtx, query, args...) snapshot, err := scanSnapshot(row) if sqlx.IsNoRows(err) { return health.HealthSnapshot{}, runtime.ErrNotFound } if err != nil { return health.HealthSnapshot{}, fmt.Errorf("get health snapshot: %w", err) } return snapshot, nil } // rowScanner abstracts *sql.Row and *sql.Rows so scanSnapshot can be // shared across both single-row reads and iterated reads. type rowScanner interface { Scan(dest ...any) error } // scanSnapshot scans one health_snapshots row from rs. func scanSnapshot(rs rowScanner) (health.HealthSnapshot, error) { var ( gameID string containerID string status string source string details []byte observedAt time.Time ) if err := rs.Scan( &gameID, &containerID, &status, &source, &details, &observedAt, ); err != nil { return health.HealthSnapshot{}, err } return health.HealthSnapshot{ GameID: gameID, ContainerID: containerID, Status: health.SnapshotStatus(status), Source: health.SnapshotSource(source), Details: json.RawMessage(details), ObservedAt: observedAt.UTC(), }, nil } // Ensure Store satisfies the ports.HealthSnapshotStore interface at // compile time. var _ ports.HealthSnapshotStore = (*Store)(nil)