Add async job runner and GET /api/v1/jobs/{id}
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:
@@ -0,0 +1,32 @@
|
||||
-- 0003_jobs_status: jobs.status becomes queued, running, succeeded, failed,
|
||||
-- the states the job runner (internal/jobs) reports. SQLite cannot change a
|
||||
-- CHECK in place, so the table is rebuilt: create the new table, copy every
|
||||
-- row, drop the old one and rename. Nothing references jobs and it has no
|
||||
-- indexes or triggers, so the rebuild touches no other schema object. Rows
|
||||
-- written under the 0001 values are mapped (pending -> queued,
|
||||
-- done -> succeeded) so the copy cannot fail on an existing database.
|
||||
|
||||
CREATE TABLE jobs_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
|
||||
payload TEXT NOT NULL, -- JSON
|
||||
result TEXT, -- JSON, written progressively
|
||||
error TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO jobs_new (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at)
|
||||
SELECT
|
||||
id,
|
||||
kind,
|
||||
CASE status WHEN 'pending' THEN 'queued' WHEN 'done' THEN 'succeeded' ELSE status END,
|
||||
payload, result, error, started_at, finished_at, created_at, updated_at
|
||||
FROM jobs;
|
||||
|
||||
DROP TABLE jobs;
|
||||
|
||||
ALTER TABLE jobs_new RENAME TO jobs;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- name: CreateJob :exec
|
||||
INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, ?, ?);
|
||||
|
||||
-- name: ClaimNextJob :one
|
||||
-- Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
-- when no job is queued.
|
||||
UPDATE jobs
|
||||
SET status = 'running', started_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
|
||||
RETURNING *;
|
||||
|
||||
-- name: PublishJobResult :execrows
|
||||
-- Stores a partial result of a running job.
|
||||
UPDATE jobs
|
||||
SET result = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'running';
|
||||
|
||||
-- name: SucceedJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'succeeded', result = sqlc.arg(result), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'running';
|
||||
|
||||
-- name: FailJob :execrows
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'running';
|
||||
|
||||
-- name: FailRunningJobs :execrows
|
||||
-- Fails every job left running, at boot before any worker starts.
|
||||
UPDATE jobs
|
||||
SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now)
|
||||
WHERE status = 'running';
|
||||
|
||||
-- name: GetJob :one
|
||||
SELECT * FROM jobs
|
||||
WHERE id = ?;
|
||||
Reference in New Issue
Block a user