200 lines
5.3 KiB
Go
200 lines
5.3 KiB
Go
package nodepool
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
const (
|
|
// NodeTypeProxy identifies a proxy stream node.
|
|
NodeTypeProxy = "proxy"
|
|
// NodeTypeTranscode identifies a transcode stream node.
|
|
NodeTypeTranscode = "transcode"
|
|
)
|
|
|
|
// Node represents a stream node in the database.
|
|
type Node struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
URL string `json:"url"`
|
|
Enabled bool `json:"enabled"`
|
|
Healthy bool `json:"healthy"`
|
|
ActiveJobs int `json:"active_jobs"`
|
|
LastHealthCheck *time.Time `json:"last_health_check"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// CreateNodeInput holds the fields for creating a new node.
|
|
type CreateNodeInput struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// Validate checks required fields and allowed values.
|
|
func (i CreateNodeInput) Validate() error {
|
|
if i.Name == "" {
|
|
return errors.New("name is required")
|
|
}
|
|
if i.Type != NodeTypeProxy && i.Type != NodeTypeTranscode {
|
|
return fmt.Errorf("type must be %q or %q", NodeTypeProxy, NodeTypeTranscode)
|
|
}
|
|
if i.URL == "" {
|
|
return errors.New("url is required")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateNodeInput holds the fields for updating a node.
|
|
type UpdateNodeInput struct {
|
|
Name *string `json:"name,omitempty"`
|
|
URL *string `json:"url,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
}
|
|
|
|
// Repository provides CRUD operations for stream nodes.
|
|
type Repository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewRepository creates a new node repository.
|
|
func NewRepository(pool *pgxpool.Pool) *Repository {
|
|
return &Repository{pool: pool}
|
|
}
|
|
|
|
const nodeColumns = `id, name, type, url, enabled, healthy, active_jobs, last_health_check, created_at`
|
|
|
|
func scanNode(row pgx.Row) (*Node, error) {
|
|
var n Node
|
|
err := row.Scan(
|
|
&n.ID, &n.Name, &n.Type, &n.URL,
|
|
&n.Enabled, &n.Healthy, &n.ActiveJobs,
|
|
&n.LastHealthCheck, &n.CreatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &n, nil
|
|
}
|
|
|
|
func scanNodes(rows pgx.Rows) ([]*Node, error) {
|
|
var nodes []*Node
|
|
for rows.Next() {
|
|
var n Node
|
|
if err := rows.Scan(
|
|
&n.ID, &n.Name, &n.Type, &n.URL,
|
|
&n.Enabled, &n.Healthy, &n.ActiveJobs,
|
|
&n.LastHealthCheck, &n.CreatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
nodes = append(nodes, &n)
|
|
}
|
|
return nodes, rows.Err()
|
|
}
|
|
|
|
// List returns all nodes ordered by type then name.
|
|
func (r *Repository) List(ctx context.Context) ([]*Node, error) {
|
|
rows, err := r.pool.Query(ctx,
|
|
`SELECT `+nodeColumns+` FROM stream_nodes ORDER BY type, name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list nodes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanNodes(rows)
|
|
}
|
|
|
|
// ListEnabled returns all enabled nodes of a given type.
|
|
func (r *Repository) ListEnabled(ctx context.Context, nodeType string) ([]*Node, error) {
|
|
rows, err := r.pool.Query(ctx,
|
|
`SELECT `+nodeColumns+` FROM stream_nodes WHERE type = $1 AND enabled = true ORDER BY name`,
|
|
nodeType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list enabled nodes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
return scanNodes(rows)
|
|
}
|
|
|
|
// GetByID returns a single node by ID.
|
|
func (r *Repository) GetByID(ctx context.Context, id int) (*Node, error) {
|
|
row := r.pool.QueryRow(ctx,
|
|
`SELECT `+nodeColumns+` FROM stream_nodes WHERE id = $1`, id)
|
|
n, err := scanNode(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNodeNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get node: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// Create inserts a new node and returns it.
|
|
func (r *Repository) Create(ctx context.Context, input CreateNodeInput) (*Node, error) {
|
|
if err := input.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
row := r.pool.QueryRow(ctx,
|
|
`INSERT INTO stream_nodes (name, type, url) VALUES ($1, $2, $3)
|
|
RETURNING `+nodeColumns,
|
|
input.Name, input.Type, input.URL)
|
|
return scanNode(row)
|
|
}
|
|
|
|
// Update modifies a node's mutable fields.
|
|
func (r *Repository) Update(ctx context.Context, id int, input UpdateNodeInput) (*Node, error) {
|
|
row := r.pool.QueryRow(ctx,
|
|
`UPDATE stream_nodes SET
|
|
name = COALESCE($2, name),
|
|
url = COALESCE($3, url),
|
|
enabled = COALESCE($4, enabled)
|
|
WHERE id = $1
|
|
RETURNING `+nodeColumns,
|
|
id, input.Name, input.URL, input.Enabled)
|
|
n, err := scanNode(row)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrNodeNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("update node: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// Delete removes a node by ID.
|
|
func (r *Repository) Delete(ctx context.Context, id int) error {
|
|
tag, err := r.pool.Exec(ctx, `DELETE FROM stream_nodes WHERE id = $1`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete node: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNodeNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateHealth updates a node's health status and active job count.
|
|
func (r *Repository) UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs int) error {
|
|
tag, err := r.pool.Exec(ctx,
|
|
`UPDATE stream_nodes SET healthy = $2, active_jobs = $3, last_health_check = NOW()
|
|
WHERE id = $1`,
|
|
id, healthy, activeJobs)
|
|
if err != nil {
|
|
return fmt.Errorf("update node health: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNodeNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Sentinel errors.
|
|
var ErrNodeNotFound = errors.New("stream node not found")
|