coda/internal/store/migrations.go
Rodrigo Verdiani 2eead05c34 feat: expirar jobs stuck com status timeout
Adiciona mecanismo de expiração periódica para jobs que ficam presos
nos status received ou processing além de um threshold configurável.

- models: novo status StatusTimeout
- store: migration v2 com índice em jobs(status); método ExpireStuck
  que faz UPDATE atômico para os status alvo anteriores ao threshold
- expirer: novo pacote com goroutine periódica que chama ExpireStuck
  e loga WARN quando algum job é expirado
- config: campos JobTimeoutMinutes (default 120) e
  ExpirerIntervalMinutes (default 10), lidos via env vars
  JOB_TIMEOUT_MINUTES e EXPIRER_INTERVAL_MINUTES
- main: instancia e inicia o Expirer no startup
- dashboard: card Timeout com contagem; badge neutral na tabela de jobs
2026-07-07 08:43:33 -03:00

86 lines
2.4 KiB
Go

package store
import (
"context"
"database/sql"
"fmt"
)
func migrate(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration tx: %w", err)
}
defer tx.Rollback()
var currentVersion int
err = tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&currentVersion)
if err != nil {
return fmt.Errorf("read current migration version: %w", err)
}
migrations := []struct {
version int
sql []string
}{
{
version: 1,
sql: []string{
`CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'received',
source TEXT NOT NULL DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
finished_at DATETIME,
duration_ms INTEGER NOT NULL DEFAULT 0,
error_msg TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at)`,
`CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL DEFAULT '',
level TEXT NOT NULL DEFAULT 'INFO',
message TEXT NOT NULL DEFAULT '',
timestamp DATETIME NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp)`,
`CREATE INDEX IF NOT EXISTS idx_logs_job_id ON logs(job_id)`,
`CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
)`,
},
},
{
version: 2,
sql: []string{
`CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status)`,
},
},
}
for _, m := range migrations {
if m.version <= currentVersion {
continue
}
for _, stmt := range m.sql {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("migration %d: %w", m.version, err)
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, m.version); err != nil {
return fmt.Errorf("record migration %d: %w", m.version, err)
}
}
return tx.Commit()
}