Phase 4 — Performance: - F1: SSH DialPool with key-by-host pooling and 5min idle TTL - F2: In-memory entity lookup cache (TTL 60s, HTTP resolveEntityID) - F3: Trigram GIN indexes on entities.slug and entities.name (migration 031) - F4: Partial index on executions(classification_id) for auto-act (migration 032) - Added missing RunOutput and RunStreaming in actuator/ (E3 gap fill) Phase 6 — Infrastructure: - H1: Infisical image pinned to v0.99.1 - H2: execworker daemon — polls pending executions with per-execution advisory locks, recovers orphaned running executions, wired as docker-compose service - H3: splitSQL hardened with block comment and string-literal support, 6 new edge-case tests (11 total) - H4: Scheduler acquires pg_try_advisory_lock(0x01c05e6) at startup
281 lines
7.2 KiB
Go
281 lines
7.2 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/dtoro/oikos/migrations"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Pool wraps a pgx connection pool.
|
|
type Pool struct {
|
|
*pgxpool.Pool
|
|
}
|
|
|
|
// New creates a new connection pool.
|
|
func New(ctx context.Context, databaseURL string) (*Pool, error) {
|
|
cfg, err := pgxpool.ParseConfig(databaseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse database url: %w", err)
|
|
}
|
|
cfg.MaxConns = 15
|
|
|
|
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create pool: %w", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
return nil, fmt.Errorf("ping db: %w", err)
|
|
}
|
|
return &Pool{pool}, nil
|
|
}
|
|
|
|
// migrationLockKey is the advisory-lock key serializing migration runs —
|
|
// two concurrent `oikos migrate` invocations must not interleave DDL.
|
|
const migrationLockKey = 0x01c05e5
|
|
|
|
// Migrate runs all embedded forward migrations in order.
|
|
// Uses a schema_migrations table to track applied versions. The whole run
|
|
// happens on one connection holding a session advisory lock.
|
|
func (p *Pool) Migrate(ctx context.Context) error {
|
|
conn, err := p.Acquire(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("acquire migration conn: %w", err)
|
|
}
|
|
defer conn.Release()
|
|
|
|
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
|
return fmt.Errorf("acquire migration lock: %w", err)
|
|
}
|
|
defer conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", migrationLockKey)
|
|
|
|
// Create tracking table if not exists
|
|
_, err = conn.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("create schema_migrations: %w", err)
|
|
}
|
|
|
|
// List migration files
|
|
entries, err := fs.ReadDir(migrations.FS, ".")
|
|
if err != nil {
|
|
return fmt.Errorf("read migration fs: %w", err)
|
|
}
|
|
|
|
var files []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() && hasSuffix(e.Name(), ".up.sql") {
|
|
files = append(files, e.Name())
|
|
}
|
|
}
|
|
sort.Strings(files)
|
|
|
|
for _, fname := range files {
|
|
// Extract version number (001, 002, etc.)
|
|
var version int
|
|
if _, err := fmt.Sscanf(fname, "%03d", &version); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Check if already applied
|
|
var applied int
|
|
err := conn.QueryRow(ctx,
|
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = $1", version).Scan(&applied)
|
|
if err != nil {
|
|
return fmt.Errorf("check migration %d: %w", version, err)
|
|
}
|
|
if applied > 0 {
|
|
continue
|
|
}
|
|
|
|
// Read and execute migration — split into individual statements
|
|
// because TimescaleDB CAGGs and some DDL can't run inside a transaction,
|
|
// and pgx's multi-statement Exec wraps them implicitly.
|
|
content, err := migrations.FS.ReadFile(fname)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", fname, err)
|
|
}
|
|
|
|
stmts := splitSQL(string(content))
|
|
for i, stmt := range stmts {
|
|
stmt = strings.TrimSpace(stmt)
|
|
if stmt == "" {
|
|
continue
|
|
}
|
|
_, err := conn.Exec(ctx, stmt)
|
|
if err != nil {
|
|
return fmt.Errorf("exec migration %s stmt %d: %w", fname, i+1, err)
|
|
}
|
|
}
|
|
_, err = conn.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version)
|
|
if err != nil {
|
|
return fmt.Errorf("record migration %d: %w", version, err)
|
|
}
|
|
slog.Info("migration applied", "file", fname, "version", version, "statements", len(stmts))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SeedIngest ingests a YAML seed file into the database.
|
|
// Idempotent: if the file's content hash matches seed_versions, it's a no-op (A4).
|
|
func (p *Pool) SeedIngest(ctx context.Context, filename string, content []byte,
|
|
ingestFn func(ctx context.Context, tx pgx.Tx, data map[string]any) error) error {
|
|
|
|
hash := contentHash(content)
|
|
|
|
// Check if already applied with same hash
|
|
var existing string
|
|
err := p.QueryRow(ctx,
|
|
"SELECT content_hash FROM seed_versions WHERE file = $1", filename).Scan(&existing)
|
|
if err == nil && existing == hash {
|
|
return nil // no-op, same content
|
|
}
|
|
|
|
// Parse YAML
|
|
var data map[string]any
|
|
if err := yaml.Unmarshal(content, &data); err != nil {
|
|
return fmt.Errorf("parse %s: %w", filename, err)
|
|
}
|
|
|
|
// Apply in a single transaction
|
|
tx, err := p.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if err := ingestFn(ctx, tx, data); err != nil {
|
|
return fmt.Errorf("ingest %s: %w", filename, err)
|
|
}
|
|
|
|
// Record the seed version
|
|
_, err = tx.Exec(ctx,
|
|
`INSERT INTO seed_versions (file, content_hash) VALUES ($1, $2)
|
|
ON CONFLICT (file) DO UPDATE SET content_hash = $2, applied_at = now()`,
|
|
filename, hash)
|
|
if err != nil {
|
|
return fmt.Errorf("record seed version: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit seed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// contentHash returns a SHA-256 hex digest of the content.
|
|
func contentHash(content []byte) string {
|
|
h := sha256.Sum256(content)
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// hasSuffix reports whether the string ends with the given suffix.
|
|
func hasSuffix(s, suffix string) bool {
|
|
return strings.HasSuffix(s, suffix)
|
|
}
|
|
|
|
// splitSQL splits a SQL string into individual statements.
|
|
// Handles $$ ... $$ dollar-quoted blocks, $tag$ ... $tag$ tagged quotes,
|
|
// -- line comments, /* ... */ block comments, and '...' string literals
|
|
// so that semicolons inside any of these constructs are not treated as
|
|
// statement boundaries.
|
|
func splitSQL(sql string) []string {
|
|
var statements []string
|
|
var current strings.Builder
|
|
inDollarQuote := false
|
|
dollarTag := ""
|
|
|
|
i := 0
|
|
for i < len(sql) {
|
|
// Handle line comments (-- to end of line)
|
|
if !inDollarQuote && i+1 < len(sql) && sql[i] == '-' && sql[i+1] == '-' {
|
|
for i < len(sql) && sql[i] != '\n' {
|
|
current.WriteByte(sql[i])
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Handle block comments (/* ... */)
|
|
if !inDollarQuote && i+1 < len(sql) && sql[i] == '/' && sql[i+1] == '*' {
|
|
end := strings.Index(sql[i+2:], "*/")
|
|
if end >= 0 {
|
|
current.WriteString(sql[i : i+end+4])
|
|
i += end + 4
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Handle single-quoted string literals ('...')
|
|
if !inDollarQuote && sql[i] == '\'' {
|
|
j := i + 1
|
|
for j < len(sql) {
|
|
if sql[j] == '\'' {
|
|
if j+1 < len(sql) && sql[j+1] == '\'' {
|
|
j += 2 // skip doubled quote ''
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
j++
|
|
}
|
|
current.WriteString(sql[i : j+1])
|
|
i = j + 1
|
|
continue
|
|
}
|
|
|
|
// Check for dollar-quote start/end
|
|
if !inDollarQuote && sql[i] == '$' {
|
|
j := i + 1
|
|
for j < len(sql) && (sql[j] == '_' || (sql[j] >= 'a' && sql[j] <= 'z') || (sql[j] >= 'A' && sql[j] <= 'Z') || (sql[j] >= '0' && sql[j] <= '9')) {
|
|
j++
|
|
}
|
|
if j < len(sql) && sql[j] == '$' {
|
|
dollarTag = sql[i : j+1]
|
|
current.WriteString(dollarTag)
|
|
inDollarQuote = true
|
|
i = j + 1
|
|
continue
|
|
}
|
|
}
|
|
if inDollarQuote && strings.HasPrefix(sql[i:], dollarTag) {
|
|
current.WriteString(dollarTag)
|
|
i += len(dollarTag)
|
|
inDollarQuote = false
|
|
dollarTag = ""
|
|
continue
|
|
}
|
|
|
|
if !inDollarQuote && sql[i] == ';' {
|
|
statements = append(statements, current.String())
|
|
current.Reset()
|
|
i++
|
|
continue
|
|
}
|
|
|
|
current.WriteByte(sql[i])
|
|
i++
|
|
}
|
|
|
|
if strings.TrimSpace(current.String()) != "" {
|
|
statements = append(statements, current.String())
|
|
}
|
|
|
|
return statements
|
|
}
|