jobq

package module
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 5, 2026 License: MIT Imports: 14 Imported by: 0

README

jobq

A Postgres-backed background job queue for Go, built step by step to learn Go concurrency and distributed-systems design. Its only dependency is pgx.

  • At-least-once delivery, with handlers retried using exponential backoff and jitter, then discarded after MaxAttempts.
  • Safe under concurrency: many workers and processes share a queue, and each claim takes a different job (FOR UPDATE SKIP LOCKED).
  • Crash-safe: leases, heartbeats, and a rescuer recover jobs from workers that die; attempt-number fencing stops stalled workers from overwriting results.
  • Graceful shutdown, LISTEN/NOTIFY for millisecond pickup, leader election, periodic jobs, unique jobs, and transactional enqueue.
  • About 10,000 jobs/second on a single CPU core (see DESIGN.md).

Delivery guarantee: at-least-once. A job can run more than once, for example if a worker crashes after finishing a job but before recording it. Handlers must be idempotent.

Usage

pool, _ := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
store.Migrate(ctx, pool) // safe to call on every startup
client, _ := jobq.NewClient(pool)

// Enqueue, from anywhere in your app.
client.Enqueue(ctx, "send_email", EmailArgs{To: "user@example.com"}, nil)

// Or as part of your own transaction: the job exists only if it commits.
client.EnqueueTx(ctx, tx, "send_email", EmailArgs{To: user.Email}, nil)

// Run a worker.
worker := jobq.NewWorker(client, &jobq.WorkerConfig{Concurrency: 20})
jobq.Handle(worker, "send_email", func(ctx context.Context, job *jobq.Job, args EmailArgs) error {
	if args.To == "" {
		return jobq.Permanent(errors.New("no recipient")) // don't retry
	}
	return mailer.Send(ctx, args.To) // an error means: retry with backoff
})
worker.AddPeriodic(jobq.PeriodicJob{Name: "nightly-report", Every: 24 * time.Hour, Kind: "report", Args: ReportArgs{}})

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
worker.Run(ctx) // returns after a graceful shutdown

cmd/demo is a complete runnable example. Options per job are in EnqueueOpts (queue, priority, scheduled time, max attempts, unique key); worker settings are in WorkerConfig, and every zero value means a sensible default.

Operating it

jobqctl stats              # per queue: ready, scheduled, running, done, and lag
jobqctl list -state discarded
jobqctl show 42            # a job and its full error history
jobqctl retry 42           # run a finished job again
jobqctl cancel 43          # discard a job that hasn't started

The leader worker deletes completed jobs after 24 hours and discarded jobs after 7 days (configurable). Logs go through log/slog.

Metrics. jobq doesn't depend on a metrics library; export Worker.Stats() and Client.QueueStats() with whatever you use. With Prometheus:

lag := prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "jobq_queue_lag_seconds"}, []string{"queue"})
go func() {
	for range time.Tick(15 * time.Second) {
		stats, err := client.QueueStats(ctx)
		if err != nil {
			continue
		}
		for _, q := range stats {
			lag.WithLabelValues(q.Queue).Set(q.Lag.Seconds()) // alert when this keeps growing
		}
	}
}()

Development

Requires Go 1.25+ and Docker.

make db-up       # start Postgres
make check       # go vet + tests with the race detector (what CI runs)
make bench       # load test against a scratch database
make psql        # poke around the database

Each test gets its own throwaway database (see internal/testdb), so tests run in parallel. The chaos tests start real worker processes and kill -9 them; go test -short skips the slow ones.

Layout

client.go, enqueue.go      Client, Enqueue, EnqueueTx, unique jobs
worker.go                  Worker: fetch loop, backpressure, shutdown, logging
claim.go, complete.go      claiming jobs and recording outcomes (with fencing)
completer.go               batched completions
lease.go, leader.go        heartbeats, rescuer, leader election, cleaner
notify.go, periodic.go     LISTEN/NOTIFY, periodic jobs
handler.go, errors.go      typed handlers, Permanent errors
backoff.go, admin.go       retry backoff; inspection and admin API
store/                     embedded migrations
cmd/jobqctl, cmd/demo, cmd/jobqbench
DESIGN.md                  every decision, why it was made, and what the tests showed

Roadmap

  • 0. Setup: repo, Postgres, migrations, CI
  • 1. Simplest loop: enqueue, fetch, run, complete
  • 2. Concurrency: executor pool, backpressure, typed handlers
  • 3. Failure handling: retries, backoff, discard
  • 4. Crash safety: leases, heartbeats, fencing, rescuer
  • 5. Graceful shutdown
  • 6. Multiple processes + LISTEN/NOTIFY
  • 7. Leader election, periodic jobs, unique jobs
  • 8. Transactional enqueue
  • 9. Logging, stats, admin CLI, cleanup
  • 10. Load test, profiling, write-up

Documentation

Overview

Package jobq is a Postgres-backed background job queue.

Delivery is at-least-once: every job runs to completion or is discarded after its final attempt, but after a crash a job may run more than once. Handlers must therefore be idempotent.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrJobNotFound = errors.New("jobq: job not found")
	ErrJobRunning  = errors.New("jobq: job is running")
	ErrJobFinished = errors.New("jobq: job has already finished")
)
View Source
var (
	ErrEmptyKind          = errors.New("jobq: kind is required")
	ErrEmptyArgs          = errors.New("jobq: args is required")
	ErrInvalidMaxAttempts = errors.New("jobq: invalid max attempts value")

	// ErrDuplicateJob means a job with the same UniqueKey is already waiting
	// or running, so no new job was enqueued.
	ErrDuplicateJob = errors.New("jobq: a job with this unique key is already queued or running")
)
View Source
var ErrNilPool = errors.New("jobq: NewClient called with nil")

Functions

func DefaultBackoff

func DefaultBackoff(attempt int) time.Duration

DefaultBackoff is how long to wait before retrying a job that has failed `attempt` times: 1s, 2s, 4s, 8s... doubling up to a cap of one hour.

The result is randomised between half and all of that ("jitter"), so that many jobs failing at the same moment, say because an email provider went down, don't all retry at the same moment and knock it over again.

func Handle

func Handle[T any](w *Worker, kind string, fn func(ctx context.Context, job *Job, args T) error) error

Handle registers a typed handler: the job's JSON args are decoded into T before fn is called, so handlers never deal with raw JSON.

jobq.Handle(w, "send_email", func(ctx context.Context, job *jobq.Job, args EmailArgs) error {
	return mailer.Send(ctx, args.To)
})

It's a function rather than a method because Go methods can't have their own type parameters.

func Permanent

func Permanent(err error) error

Permanent marks a handler error as not worth retrying: the job is discarded straight away instead of being retried until MaxAttempts runs out. Use it for failures that will never succeed, such as invalid args.

if args.To == "" {
	return jobq.Permanent(errors.New("no recipient"))
}

Types

type AttemptError

type AttemptError struct {
	Attempt int       `json:"attempt"`
	At      time.Time `json:"at"`
	Error   string    `json:"error"`
}

AttemptError is one failed attempt in a job's history.

type Client

type Client struct {
	// contains filtered or unexported fields
}

func NewClient

func NewClient(pool *pgxpool.Pool) (*Client, error)

func (*Client) CancelJob

func (c *Client) CancelJob(ctx context.Context, id int64) error

CancelJob discards a job that hasn't started yet. Running jobs can't be cancelled this way: stop them through your own handler logic.

func (*Client) DeleteFinishedJobs

func (c *Client) DeleteFinishedJobs(ctx context.Context, state string, olderThan time.Duration) (int64, error)

DeleteFinishedJobs deletes jobs in state "completed" or "discarded" that finished more than olderThan ago, in batches, and returns how many it deleted. Workers' leaders call it automatically (see WorkerConfig).

func (*Client) Enqueue

func (c *Client) Enqueue(ctx context.Context, kind string, args any, opts *EnqueueOpts) (int64, error)

Enqueue adds a job to the queue and returns its ID.

func (*Client) EnqueueTx

func (c *Client) EnqueueTx(ctx context.Context, tx pgx.Tx, kind string, args any, opts *EnqueueOpts) (int64, error)

EnqueueTx adds a job as part of the caller's transaction. The job exists only if tx commits, so it can't run for a write that was rolled back, and can't be lost for a write that was committed:

tx, _ := pool.Begin(ctx)
defer tx.Rollback(ctx)
userID := createUser(ctx, tx, form)
client.EnqueueTx(ctx, tx, "send_welcome_email", WelcomeArgs{UserID: userID}, nil)
tx.Commit(ctx) // user and job are saved together, or not at all

Workers are notified when the transaction commits, not before.

func (*Client) GetJob

func (c *Client) GetJob(ctx context.Context, id int64) (JobInfo, error)

GetJob returns one job, or ErrJobNotFound.

func (*Client) ListJobs

func (c *Client) ListJobs(ctx context.Context, f ListFilter) ([]JobInfo, error)

ListJobs returns matching jobs, newest first.

func (*Client) QueueStats

func (c *Client) QueueStats(ctx context.Context) ([]QueueStat, error)

QueueStats returns counts per queue.

func (*Client) RetryJob

func (c *Client) RetryJob(ctx context.Context, id int64) error

RetryJob runs a finished (completed or discarded) job again from attempt 1, or makes a waiting job due now. It returns ErrJobRunning for a running job, and ErrDuplicateJob if its UniqueKey is now taken by another live job.

type EnqueueOpts

type EnqueueOpts struct {
	Queue       string    // zero value: "default"
	Priority    int16     // zero value: 0
	RunAt       time.Time // zero value: "run now"
	MaxAttempts int       // zero value: 20 (defaultMaxAttempts)

	// UniqueKey, if set, prevents duplicates: while a job with this key is
	// available or running, enqueuing another returns ErrDuplicateJob. Once
	// it completes or is discarded, the key can be used again.
	// Zero value: no uniqueness check.
	UniqueKey string
}

type HandlerFunc

type HandlerFunc func(ctx context.Context, job *Job) error

HandlerFunc runs one job. Returning an error marks the attempt as failed.

type Job

type Job struct {
	ID          int64
	Queue       string
	Kind        string
	Args        json.RawMessage
	Attempt     int
	MaxAttempts int
}

type JobInfo

type JobInfo struct {
	ID          int64
	Queue       string
	Kind        string
	State       string // available, running, completed or discarded
	Args        json.RawMessage
	Priority    int16
	Attempt     int
	MaxAttempts int
	RunAt       time.Time
	LockedBy    string // the worker running it, if any
	CreatedAt   time.Time
	FinalizedAt *time.Time
	Errors      []AttemptError
}

JobInfo is everything stored about a job, for inspection and admin tools.

type ListFilter

type ListFilter struct {
	Queue string
	State string
	Kind  string
	Limit int // zero value: 50
}

ListFilter selects jobs for ListJobs. Empty fields match everything.

type PeriodicJob

type PeriodicJob struct {
	// Name identifies the schedule, e.g. "nightly-report". Every worker that
	// defines the same Name shares one schedule.
	Name string

	// Every is the interval. Slots are aligned to multiples of Every since
	// the Unix epoch (UTC): Every: time.Hour runs on the hour, and
	// 24*time.Hour at midnight UTC.
	Every time.Duration

	Kind string
	Args any
	Opts *EnqueueOpts
}

PeriodicJob is a job enqueued automatically once per interval.

type QueueStat

type QueueStat struct {
	Queue     string
	Ready     int64 // available and due now
	Scheduled int64 // available, due later (including retries in backoff)
	Running   int64
	Completed int64
	Discarded int64
	Lag       time.Duration
}

QueueStat summarises one queue. Lag is how long the oldest runnable job has been waiting: the number to alert on, since a growing lag means workers aren't keeping up.

type Worker

type Worker struct {
	// contains filtered or unexported fields
}

Worker claims jobs from one queue and runs them, up to Concurrency at once.

func NewWorker

func NewWorker(client *Client, workerConfig *WorkerConfig) *Worker

func (*Worker) AddPeriodic

func (w *Worker) AddPeriodic(p PeriodicJob) error

AddPeriodic schedules a job to be enqueued once per interval. Call it before Run. Any number of workers may define the same periodic job: each time slot is enqueued exactly once, however many of them are running.

func (*Worker) IsLeader

func (w *Worker) IsLeader() bool

IsLeader reports whether this worker currently holds leadership, and so runs the database-wide maintenance (the rescuer, and cleanup of old jobs).

func (*Worker) Register

func (w *Worker) Register(kind string, handler HandlerFunc) error

Register adds the handler for a kind of job. Call it before Run.

func (*Worker) Run

func (w *Worker) Run(ctx context.Context) error

Run claims and runs jobs until ctx is cancelled, then shuts down gracefully: it stops claiming, lets running jobs finish (for up to ShutdownTimeout), records their outcomes, and returns.

It returns an error only if some jobs were still running after the shutdown timeout and a further grace period. Those jobs' leases then expire and another worker retries them.

func (*Worker) Stats

func (w *Worker) Stats() WorkerStats

Stats returns the worker's counters. Safe to call at any time.

type WorkerConfig

type WorkerConfig struct {
	Queue        string        // zero value: "default"
	ID           string        // zero value: "<hostname>-<pid>"
	PollInterval time.Duration // zero value: 1s
	Concurrency  int           // zero value: 10 jobs at once

	// Backoff returns how long to wait before retrying a job that has failed
	// `attempt` times. Zero value: DefaultBackoff.
	Backoff func(attempt int) time.Duration

	// LeaseDuration is how long a claimed job stays reserved without a
	// heartbeat. The worker extends it every LeaseDuration/3 while the job
	// runs; if the worker dies, the job is rescued once it expires.
	// Zero value: 30s.
	LeaseDuration time.Duration

	// RescueInterval is how often to look for jobs with expired leases.
	// Zero value: LeaseDuration/2.
	RescueInterval time.Duration

	// ShutdownTimeout is how long Run waits, once its context is cancelled,
	// for running jobs to finish on their own. After that, their contexts are
	// cancelled too. Zero value: 30s.
	ShutdownTimeout time.Duration

	// ElectionInterval is how often a worker tries to become leader, and how
	// often the leader checks it still is. One worker per database leads and
	// runs the rescuer and cleaner. Zero value: 5s.
	ElectionInterval time.Duration

	// Logger receives structured logs: failed and discarded jobs, rescued
	// jobs, leadership changes, connection problems. Zero value: slog.Default().
	Logger *slog.Logger

	// The leader deletes finished jobs once they're older than these.
	// Zero values: 24h for completed, 7 days for discarded (kept longer, as
	// they're the ones worth investigating). A negative value keeps them
	// forever.
	CompletedRetention time.Duration
	DiscardedRetention time.Duration

	// CleanupInterval is how often the leader deletes old jobs. Zero value: 5m.
	CleanupInterval time.Duration
}

WorkerConfig configures a Worker. Every zero value means "use the default".

type WorkerStats

type WorkerStats struct {
	Running   int   // jobs executing right now
	Completed int64 // jobs that succeeded
	Retried   int64 // failed attempts that will be retried
	Discarded int64 // jobs given up on
	Panics    int64 // handler panics (also counted as retried or discarded)
	IsLeader  bool
}

WorkerStats are counters since the worker was created, for metrics. See the README for exporting them to Prometheus.

Directories

Path Synopsis
cmd
demo command
Command demo is a small, complete jobq app: it migrates the database, registers a typed handler and a periodic job, enqueues some work, and runs a worker until you press Ctrl-C (or the process gets SIGTERM).
Command demo is a small, complete jobq app: it migrates the database, registers a typed handler and a periodic job, enqueues some work, and runs a worker until you press Ctrl-C (or the process gets SIGTERM).
jobqbench command
Command jobqbench measures jobq's throughput: it inserts N no-op jobs, runs W workers against them, and reports jobs per second.
Command jobqbench measures jobq's throughput: it inserts N no-op jobs, runs W workers against them, and reports jobs per second.
jobqctl command
Command jobqctl inspects and manages a jobq database.
Command jobqctl inspects and manages a jobq database.
internal
testdb
Package testdb gives each test its own throwaway Postgres database, so tests can run in parallel without seeing each other's jobs.
Package testdb gives each test its own throwaway Postgres database, so tests can run in parallel without seeing each other's jobs.
Package store owns the database schema and every SQL query jobq runs.
Package store owns the database schema and every SQL query jobq runs.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL