package main import ( "context" "embed" "fmt" "log/slog" "sort" "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/*.sql var migrationFS embed.FS // migrate applies every migrations/*.sql not yet recorded, in filename order, each in its own // transaction. Applied names are the record — a file that changes after it ran is not re-applied. func migrate(ctx context.Context, pool *pgxpool.Pool) error { _, err := pool.Exec(ctx, `create table if not exists schema_migrations ( name text primary key, applied_at timestamptz not null default now() )`) if err != nil { return fmt.Errorf("create schema_migrations: %w", err) } applied := map[string]bool{} rows, err := pool.Query(ctx, `select name from schema_migrations`) if err != nil { return fmt.Errorf("read schema_migrations: %w", err) } defer rows.Close() for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return err } applied[name] = true } if err := rows.Err(); err != nil { return err } rows.Close() entries, err := migrationFS.ReadDir("migrations") if err != nil { return err } names := make([]string, 0, len(entries)) for _, e := range entries { names = append(names, e.Name()) } sort.Strings(names) for _, name := range names { if applied[name] { continue } sql, err := migrationFS.ReadFile("migrations/" + name) if err != nil { return err } tx, err := pool.Begin(ctx) if err != nil { return err } if _, err := tx.Exec(ctx, string(sql)); err != nil { tx.Rollback(ctx) return fmt.Errorf("migration %s: %w", name, err) } if _, err := tx.Exec(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil { tx.Rollback(ctx) return err } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("migration %s: %w", name, err) } slog.Info("migration applied", "ctx", "startup", "name", name) } return nil } // sweep runs the startup cleanup from docs/spec.md §4.6. An in-process conversion goroutine dies // with the process, so without this those rows say "converting" forever. func sweep(ctx context.Context, pool *pgxpool.Pool) error { tag, err := pool.Exec(ctx, `update submissions set status = 'failed', status_msg = 'interrupted by restart' where status in ('queued', 'downloading', 'converting')`) if err != nil { return err } if n := tag.RowsAffected(); n > 0 { slog.Warn("submissions interrupted by restart", "ctx", "startup", "count", n) } // ponytail: temp files of swept submissions are unlinked with the row in step 3, once the // pipeline exists and there is something to unlink. if _, err := pool.Exec(ctx, `delete from submissions where created_at < now() - interval '7 days'`); err != nil { return err } if _, err := pool.Exec(ctx, `delete from sessions where expires_at < now()`); err != nil { return err } var failed int err = pool.QueryRow(ctx, `select count(*) from submissions where status = 'failed'`).Scan(&failed) if err != nil { return err } if failed > 0 { // A failed submission is invisible to everyone but its submitter, so a broken pipeline // has no other way of announcing itself. slog.Warn("failed submissions present", "ctx", "startup", "count", failed) } return nil }