runBatch recorded the batch's end with an uncancellable context and no deadline, before its handler returned, so a writer connection that stayed busy could hold the worker, and shutdown, open indefinitely. jobs.FinalWriteContext now gives every final write one bounded attempt context (FinalWriteTimeout, 5 seconds, not cancelled with ctx); the runner's record and finishBatch both use it. On timeout at shutdown the handler returns, the job and batch stay running, the next Start fails the job as interrupted and the next StartBatch fails the orphaned batch. TestBlockedBatchEndDoesNotHoldShutdown holds the writer while a batch reaches finishBatch and asserts the worker stops within the bound and the batch is reconciled after restart. TestBlockedFinalWriteDoesNotHoldShutdown no longer sets a runner field, so it compiles against earlier heads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
533 lines
18 KiB
Go
533 lines
18 KiB
Go
// Package jobs runs long work in the background. A job is a row in the jobs
|
|
// table: Enqueue inserts it queued, and a fixed pool of workers claims queued
|
|
// jobs oldest first, so the table itself is the queue and a job enqueued
|
|
// before a restart still runs after it.
|
|
//
|
|
// Each job kind has one handler, registered before Start. A handler takes a
|
|
// typed payload and returns a result implementing Public; it may publish
|
|
// partial results while it runs. The runner stores only what a result's
|
|
// PublicJSON returns and, for a failed job, only a failure code: either a
|
|
// *Failure code the kind declared at registration or one of the runner's own
|
|
// codes. Handler error text, which may quote a URL, token or response body
|
|
// from a dependency, is never stored, returned by the API or logged.
|
|
package jobs
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"sync"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
// Status is the state of a job.
|
|
type Status string
|
|
|
|
// Job states. A job moves queued → running → succeeded or failed.
|
|
const (
|
|
StatusQueued Status = "queued"
|
|
StatusRunning Status = "running"
|
|
StatusSucceeded Status = "succeeded"
|
|
StatusFailed Status = "failed"
|
|
)
|
|
|
|
// Failure codes the runner stores itself. A kind cannot declare them.
|
|
const (
|
|
// CodeInterrupted is the error of a job a previous process left running.
|
|
// It is fixed framework text, not a snake-case code, so no kind can
|
|
// declare it.
|
|
CodeInterrupted = "interrupted by restart"
|
|
// CodeInternal is the error of a job whose handler failed with anything
|
|
// but a *Failure with a code its kind declared, or whose payload or
|
|
// result could not be encoded. The underlying error is neither stored
|
|
// nor logged.
|
|
CodeInternal = "internal_error"
|
|
// CodeUnknownKind is the error of a job whose kind has no handler.
|
|
CodeUnknownKind = "unknown_kind"
|
|
)
|
|
|
|
// Retry backoff. A worker whose claim or final job write fails with a
|
|
// database error retries after retryMin, doubling up to retryMax, so a job
|
|
// queued when the error clears is claimed without waiting for a wake token,
|
|
// and a job the worker has finished is never left running.
|
|
const (
|
|
retryMin = 50 * time.Millisecond
|
|
retryMax = 5 * time.Second
|
|
)
|
|
|
|
// FinalWriteTimeout bounds each attempt at a job's final write, and at a
|
|
// handler's own last write, so a writer connection that stays busy cannot
|
|
// hold a stopping worker, and with it shutdown, open.
|
|
const FinalWriteTimeout = 5 * time.Second
|
|
|
|
// FinalWriteContext returns the context for one attempt at a final write
|
|
// of a job: it is not cancelled with ctx, so a job that finishes during
|
|
// shutdown is still recorded, but it ends after FinalWriteTimeout. A
|
|
// handler makes its own last write, the one recording what the job did,
|
|
// with it too, and returns the error when the attempt times out, so its
|
|
// worker can stop; the next Start fails the job as interrupted.
|
|
func FinalWriteContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.WithoutCancel(ctx), FinalWriteTimeout)
|
|
}
|
|
|
|
// codePattern is the form of every failure code: lower-case snake case.
|
|
var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
|
|
|
// ErrNotFound is returned by Get for an unknown job id.
|
|
var ErrNotFound = errors.New("jobs: job not found")
|
|
|
|
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
|
|
// registered handler.
|
|
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
|
|
|
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
|
// not decode into the kind's payload type.
|
|
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
|
|
|
// Failure is a handler error reporting a failure code the household may
|
|
// see. Code must be one the job's kind declared at registration; the runner
|
|
// stores it as the job's error. Any other handler error, including a Failure
|
|
// with an undeclared code, is stored as CodeInternal.
|
|
type Failure struct {
|
|
Code string
|
|
}
|
|
|
|
func (f *Failure) Error() string { return "jobs: failure " + f.Code }
|
|
|
|
// Public is a job result. PublicJSON returns the JSON the runner stores and
|
|
// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its
|
|
// data is public; nothing else of the value is ever encoded.
|
|
type Public interface {
|
|
PublicJSON() ([]byte, error)
|
|
}
|
|
|
|
// Handler runs one job with its payload. It may call publish any number of
|
|
// times with a partial result, which Get returns while the job runs; its
|
|
// return value is the final result. ctx is cancelled when the runner stops.
|
|
type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error)
|
|
|
|
// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest
|
|
// partial or final result, and is null until the handler publishes one.
|
|
// Error is set only on a failed job.
|
|
type Job struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Status Status `json:"status"`
|
|
Result json.RawMessage `json:"result"`
|
|
Error *string `json:"error"`
|
|
}
|
|
|
|
// runFunc is a registered Handler with its payload and results as JSON. It
|
|
// returns the failure code to store, or "" on success; the logged reason
|
|
// never quotes an error.
|
|
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
|
|
|
// handler is a registered kind: run runs a job, and check reports whether
|
|
// a payload decodes into the kind's payload type.
|
|
type handler struct {
|
|
run runFunc
|
|
check func(payload []byte) bool
|
|
}
|
|
|
|
// Runner owns the job handlers and the worker pool.
|
|
type Runner struct {
|
|
st *store.Store
|
|
logger *slog.Logger
|
|
workers int
|
|
// wake holds up to one token per worker; Enqueue adds one so an idle
|
|
// worker looks for the new job.
|
|
wake chan struct{}
|
|
// claim claims the oldest queued job, marking it running at now.
|
|
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
|
|
|
|
mu sync.Mutex
|
|
handlers map[string]handler
|
|
started bool
|
|
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// NewRunner returns a runner over st with workers concurrent workers.
|
|
func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) {
|
|
if st == nil {
|
|
return nil, errors.New("jobs: nil store")
|
|
}
|
|
if logger == nil {
|
|
return nil, errors.New("jobs: nil logger")
|
|
}
|
|
if workers < 1 {
|
|
return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers)
|
|
}
|
|
return &Runner{
|
|
st: st,
|
|
logger: logger,
|
|
workers: workers,
|
|
wake: make(chan struct{}, workers),
|
|
claim: db.New(st.Writer).ClaimNextJob,
|
|
handlers: make(map[string]handler),
|
|
}, nil
|
|
}
|
|
|
|
// Kind enqueues jobs of one registered kind.
|
|
type Kind[P any] struct {
|
|
r *Runner
|
|
name string
|
|
}
|
|
|
|
// Register makes h the handler for kind and returns the Kind that enqueues
|
|
// its jobs. failureCodes are the *Failure codes h may report. It must be
|
|
// called before Start, once per kind.
|
|
func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) {
|
|
if kind == "" {
|
|
return nil, errors.New("jobs: register: empty kind")
|
|
}
|
|
if h == nil {
|
|
return nil, fmt.Errorf("jobs: register %q: nil handler", kind)
|
|
}
|
|
declared := make(map[string]bool, len(failureCodes))
|
|
for _, code := range failureCodes {
|
|
if !codePattern.MatchString(code) {
|
|
return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code)
|
|
}
|
|
if code == CodeInternal || code == CodeUnknownKind {
|
|
return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code)
|
|
}
|
|
declared[code] = true
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.started {
|
|
return nil, fmt.Errorf("jobs: register %q: runner already started", kind)
|
|
}
|
|
if _, ok := r.handlers[kind]; ok {
|
|
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
|
|
}
|
|
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
|
p, err := decodePayload[P]([]byte(payload))
|
|
if err != nil {
|
|
return nil, CodeInternal, "payload does not decode"
|
|
}
|
|
result, err := h(ctx, p, func(partial R) error {
|
|
data, err := publicJSON(partial)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return publish(data)
|
|
})
|
|
if err != nil {
|
|
var failure *Failure
|
|
if !errors.As(err, &failure) {
|
|
return nil, CodeInternal, "handler error"
|
|
}
|
|
if !declared[failure.Code] {
|
|
return nil, CodeInternal, "undeclared failure code"
|
|
}
|
|
return nil, failure.Code, "handler failure"
|
|
}
|
|
data, err := publicJSON(result)
|
|
if err != nil {
|
|
return nil, CodeInternal, "result does not encode"
|
|
}
|
|
return data, "", ""
|
|
}
|
|
check := func(payload []byte) bool {
|
|
_, err := decodePayload[P](payload)
|
|
return err == nil
|
|
}
|
|
r.handlers[kind] = handler{run: run, check: check}
|
|
return &Kind[P]{r: r, name: kind}, nil
|
|
}
|
|
|
|
// decodePayload decodes payload into P, refusing unknown fields.
|
|
func decodePayload[P any](payload []byte) (P, error) {
|
|
var p P
|
|
dec := json.NewDecoder(bytes.NewReader(payload))
|
|
dec.DisallowUnknownFields()
|
|
err := dec.Decode(&p)
|
|
return p, err
|
|
}
|
|
|
|
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
|
|
// value. Its error never quotes PublicJSON's error.
|
|
func publicJSON(v Public) (json.RawMessage, error) {
|
|
data, err := v.PublicJSON()
|
|
if err != nil || !json.Valid(data) {
|
|
return nil, errors.New("jobs: result has no valid public JSON")
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Enqueue stores a queued job with payload and returns its id. A job
|
|
// enqueued before Start waits for it.
|
|
func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
|
}
|
|
return k.r.enqueue(ctx, k.name, data)
|
|
}
|
|
|
|
// EnqueueTx stores a queued job with payload in tx and returns its id, so
|
|
// the job exists exactly when what else tx writes does. Once tx has
|
|
// committed, call Wake so an idle worker runs it.
|
|
func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string, error) {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
|
}
|
|
return createJob(ctx, db.New(tx), k.name, data)
|
|
}
|
|
|
|
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
|
// call it after committing the transaction a job was enqueued in.
|
|
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
|
|
|
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
|
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
|
// ErrUnknownKind for a kind with no registered handler and
|
|
// ErrInvalidPayload for a payload that does not decode into the kind's
|
|
// payload type; neither stores a job.
|
|
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
|
r.mu.Lock()
|
|
h, ok := r.handlers[kind]
|
|
r.mu.Unlock()
|
|
if !ok {
|
|
return "", ErrUnknownKind
|
|
}
|
|
if !h.check(payload) {
|
|
return "", ErrInvalidPayload
|
|
}
|
|
return r.enqueue(ctx, kind, payload)
|
|
}
|
|
|
|
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
|
// worker.
|
|
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
|
id, err := createJob(ctx, db.New(r.st.Writer), kind, payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
r.wakeOne()
|
|
return id, nil
|
|
}
|
|
|
|
// createJob stores a queued job of kind with its encoded payload through q
|
|
// and returns its id.
|
|
func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte) (string, error) {
|
|
id := store.NewID()
|
|
now := store.Now().String()
|
|
if err := q.CreateJob(ctx, db.CreateJobParams{
|
|
ID: id,
|
|
Kind: kind,
|
|
Payload: string(payload),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}); err != nil {
|
|
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// wakeOne gives an idle worker a token to look for a queued job.
|
|
func (r *Runner) wakeOne() {
|
|
select {
|
|
case r.wake <- struct{}{}:
|
|
default: // every worker already has a token and will look again
|
|
}
|
|
}
|
|
|
|
// Start fails every job a previous process left running with
|
|
// CodeInterrupted, then starts the workers. They stop when ctx is cancelled;
|
|
// Wait waits for them. A job whose handler returns an error after ctx is
|
|
// cancelled is left running, so the next Start fails it as interrupted.
|
|
func (r *Runner) Start(ctx context.Context) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.started {
|
|
return errors.New("jobs: runner already started")
|
|
}
|
|
now := store.Now().String()
|
|
n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{
|
|
Error: sql.NullString{String: CodeInterrupted, Valid: true},
|
|
Now: sql.NullString{String: now, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("jobs: fail interrupted jobs: %w", err)
|
|
}
|
|
if n > 0 {
|
|
r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n)
|
|
}
|
|
r.started = true
|
|
for range r.workers {
|
|
r.wg.Add(1)
|
|
go r.work(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Wait blocks until every worker has stopped.
|
|
func (r *Runner) Wait() {
|
|
r.wg.Wait()
|
|
}
|
|
|
|
// Get returns the job with id, or ErrNotFound.
|
|
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
|
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Job{}, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return Job{}, fmt.Errorf("jobs: read job: %w", err)
|
|
}
|
|
job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)}
|
|
if row.Result.Valid {
|
|
job.Result = json.RawMessage(row.Result.String)
|
|
}
|
|
if row.Error.Valid {
|
|
job.Error = &row.Error.String
|
|
}
|
|
return job, nil
|
|
}
|
|
|
|
// work claims and runs queued jobs until ctx is cancelled, waiting for a
|
|
// wake token whenever none is queued. A claim that fails with a database
|
|
// error is retried with backoff rather than waiting for a token, so a job
|
|
// already queued is not stranded by a transient error.
|
|
func (r *Runner) work(ctx context.Context) {
|
|
defer r.wg.Done()
|
|
backoff := retryMin
|
|
for ctx.Err() == nil {
|
|
job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true})
|
|
switch {
|
|
case err == nil:
|
|
backoff = retryMin
|
|
r.run(ctx, job)
|
|
continue
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
backoff = retryMin
|
|
select {
|
|
case <-r.wake:
|
|
case <-ctx.Done():
|
|
}
|
|
continue
|
|
case ctx.Err() != nil:
|
|
return
|
|
}
|
|
r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff)
|
|
timer := time.NewTimer(backoff)
|
|
select {
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
}
|
|
backoff = min(2*backoff, retryMax)
|
|
}
|
|
}
|
|
|
|
// run runs a claimed job to completion. The final write is made even when
|
|
// ctx has been cancelled, so a handler that finishes during shutdown is
|
|
// recorded.
|
|
func (r *Runner) run(ctx context.Context, job db.Job) {
|
|
logger := r.logger.With("job_id", job.ID, "kind", job.Kind)
|
|
q := db.New(r.st.Writer)
|
|
|
|
h, ok := r.handlers[job.Kind] // read-only once started
|
|
if !ok {
|
|
logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind")
|
|
r.fail(ctx, logger, job.ID, CodeUnknownKind)
|
|
return
|
|
}
|
|
|
|
publish := func(partial json.RawMessage) error {
|
|
n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{
|
|
Result: sql.NullString{String: string(partial), Valid: true},
|
|
UpdatedAt: store.Now().String(),
|
|
ID: job.ID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("jobs: publish result: %w", err)
|
|
}
|
|
if n != 1 {
|
|
return fmt.Errorf("jobs: publish result: job %s is not running", job.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
result, code, reason := h.run(ctx, job.Payload, publish)
|
|
if code != "" {
|
|
if ctx.Err() != nil {
|
|
logger.WarnContext(context.WithoutCancel(ctx), "job interrupted by shutdown; it fails at the next start")
|
|
return
|
|
}
|
|
level := slog.LevelInfo
|
|
if code == CodeInternal {
|
|
level = slog.LevelError
|
|
}
|
|
logger.Log(ctx, level, "job failed", "code", code, "reason", reason)
|
|
r.fail(ctx, logger, job.ID, code)
|
|
return
|
|
}
|
|
|
|
r.record(ctx, logger, "record job success", func(write context.Context) (int64, error) {
|
|
return q.SucceedJob(write, db.SucceedJobParams{
|
|
Result: sql.NullString{String: string(result), Valid: true},
|
|
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
|
ID: job.ID,
|
|
})
|
|
})
|
|
}
|
|
|
|
// fail records the job as failed with code. The record errors it logs come
|
|
// from the database, never from a handler.
|
|
func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) {
|
|
r.record(ctx, logger, "record job failure", func(write context.Context) (int64, error) {
|
|
return db.New(r.st.Writer).FailJob(write, db.FailJobParams{
|
|
Error: sql.NullString{String: code, Valid: true},
|
|
Now: sql.NullString{String: store.Now().String(), Valid: true},
|
|
ID: id,
|
|
})
|
|
})
|
|
}
|
|
|
|
// record makes a job's final write, which must change the one running job.
|
|
// A database error is retried with backoff until the write lands, so a
|
|
// worker never moves on from a job it has finished while leaving it
|
|
// running. Each attempt is made with FinalWriteContext, so even when ctx
|
|
// has been cancelled, and is bounded by FinalWriteTimeout; once ctx is
|
|
// cancelled, a failed or timed-out attempt is not retried and the job
|
|
// stays running, so the next Start fails it as interrupted.
|
|
func (r *Runner) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
|
|
backoff := retryMin
|
|
for {
|
|
attempt, cancel := FinalWriteContext(ctx)
|
|
n, err := write(attempt)
|
|
cancel()
|
|
if err == nil {
|
|
if n != 1 {
|
|
logger.ErrorContext(ctx, what, "error", "job is not running")
|
|
}
|
|
return
|
|
}
|
|
if ctx.Err() != nil {
|
|
logger.ErrorContext(context.WithoutCancel(ctx), what+" failed during shutdown; it fails at the next start", "error", err)
|
|
return
|
|
}
|
|
logger.ErrorContext(ctx, what+" failed; retrying", "error", err, "retry_in", backoff)
|
|
timer := time.NewTimer(backoff)
|
|
select {
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
}
|
|
backoff = min(2*backoff, retryMax)
|
|
}
|
|
}
|