package main import ( "context" "database/sql" "embed" "fmt" "log/slog" "sort" ) //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, db *sql.DB) error { _, err := db.ExecContext(ctx, `create table if not exists schema_migrations ( name text primary key, applied_at timestamp not null default (datetime('now')) )`) if err != nil { return fmt.Errorf("create schema_migrations: %w", err) } applied := map[string]bool{} rows, err := db.QueryContext(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 := db.BeginTx(ctx, nil) if err != nil { return err } if _, err := tx.ExecContext(ctx, string(sql)); err != nil { tx.Rollback() return fmt.Errorf("migration %s: %w", name, err) } if _, err := tx.ExecContext(ctx, `insert into schema_migrations (name) values ($1)`, name); err != nil { tx.Rollback() return err } if err := tx.Commit(); 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, db *sql.DB) error { res, err := db.ExecContext(ctx, `update submissions set status = 'failed', status_msg = 'interrupted by restart' where status in ('queued', 'downloading', 'converting')`) if err != nil { return err } if n := affected(res); 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 := db.ExecContext(ctx, `delete from submissions where created_at < datetime('now', '-7 days')`); err != nil { return err } if _, err := db.ExecContext(ctx, `delete from sessions where expires_at < datetime('now')`); err != nil { return err } var failed int err = db.QueryRowContext(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 }