Add async job runner and GET /api/v1/jobs/{id}
CI / ci (pull_request) Successful in 1m29s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (2 high)
CI / ci (push) Successful in 1m27s

internal/jobs persists jobs in the jobs table, which is also the queue: a
bounded pool of workers claims queued jobs oldest first. Handlers are
registered per kind before Start with typed payloads and results, and can
publish partial results to the row while they run. Only typed results and
*jobs.Failure messages are stored; any other handler error is logged and
stored as "internal error", so dependency error text cannot surface a
secret. Start fails jobs a previous process left running with
"interrupted by restart" before any worker runs.

GET /api/v1/jobs/{id} returns {id, kind, status, result, error} inside the
protected /api/v1 group; an unknown id is a 404 not_found.

Migration 0003_jobs_status rebuilds jobs with status queued, running,
succeeded, failed, mapping legacy pending and done rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:05:23 -04:00
co-authored by Claude Opus 5.5
parent 3ba78d47bc
commit f37f240b26
16 changed files with 1290 additions and 22 deletions
+166
View File
@@ -0,0 +1,166 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: jobs.sql
package db
import (
"context"
"database/sql"
)
const claimNextJob = `-- name: ClaimNextJob :one
UPDATE jobs
SET status = 'running', started_at = ?1, updated_at = ?1
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at
`
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) {
row := q.db.QueryRowContext(ctx, claimNextJob, now)
var i Job
err := row.Scan(
&i.ID,
&i.Kind,
&i.Status,
&i.Payload,
&i.Result,
&i.Error,
&i.StartedAt,
&i.FinishedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const createJob = `-- name: CreateJob :exec
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES (?, ?, 'queued', ?, ?, ?)
`
type CreateJobParams struct {
ID string
Kind string
Payload string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error {
_, err := q.db.ExecContext(ctx, createJob,
arg.ID,
arg.Kind,
arg.Payload,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const failJob = `-- name: FailJob :execrows
UPDATE jobs
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'running'
`
type FailJobParams struct {
Error sql.NullString
Now sql.NullString
ID string
}
func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const failRunningJobs = `-- name: FailRunningJobs :execrows
UPDATE jobs
SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2
WHERE status = 'running'
`
type FailRunningJobsParams struct {
Error sql.NullString
Now sql.NullString
}
// Fails every job left running, at boot before any worker starts.
func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) {
result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getJob = `-- name: GetJob :one
SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs
WHERE id = ?
`
func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) {
row := q.db.QueryRowContext(ctx, getJob, id)
var i Job
err := row.Scan(
&i.ID,
&i.Kind,
&i.Status,
&i.Payload,
&i.Result,
&i.Error,
&i.StartedAt,
&i.FinishedAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const publishJobResult = `-- name: PublishJobResult :execrows
UPDATE jobs
SET result = ?, updated_at = ?
WHERE id = ? AND status = 'running'
`
type PublishJobResultParams struct {
Result sql.NullString
UpdatedAt string
ID string
}
// Stores a partial result of a running job.
func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) {
result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const succeedJob = `-- name: SucceedJob :execrows
UPDATE jobs
SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2
WHERE id = ?3 AND status = 'running'
`
type SucceedJobParams struct {
Result sql.NullString
Now sql.NullString
ID string
}
func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) {
result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}