Documentation
¶
Overview ¶
Package pgqueue is a small Postgres-backed durable job queue and pub/sub layer built on database/sql and lib/pq.
Jobs are rows in the pgqueue_jobs table (see Schema). Producers enqueue with Queue.Enqueue or, to make a job part of a business transaction, Queue.EnqueueTx — the wakeup NOTIFY rides the caller's transaction, so workers are only woken for jobs that actually committed. Workers claim batches with FOR UPDATE SKIP LOCKED, so any number of processes can consume the same queue without double-processing. Failed jobs retry with backoff until MaxAttempts, then remain in the table with status 'dead' for inspection. A rescue sweep re-queues jobs orphaned by a crashed worker (or dead-letters them once their attempts are used up), which makes delivery at-least-once: handlers must tolerate being invoked more than once for the same job. Terminal writes are fenced on the claimed attempt, so a stale run can never overwrite the outcome of the run that superseded it.
Publish and Subscriber wrap Postgres NOTIFY/LISTEN for fire-and-forget fan-out to every listening process. Notifications carry no durability: a subscriber that is disconnected when one fires never sees it.
Dependency policy: this package imports only the standard library and github.com/lib/pq, so it stays trivial to vendor or embed in any database/sql-based application.
Index ¶
- Constants
- Variables
- func DefaultBackoff(attempt int) time.Duration
- func IsPermanent(err error) bool
- func Permanent(err error) error
- func Publish(ctx context.Context, db DBTX, channel string, payload []byte) error
- type DBTX
- type EnqueueOption
- type Handler
- type Job
- type Logger
- type Middleware
- type Option
- type Queue
- func (q *Queue) Enqueue(ctx context.Context, jobType string, payload []byte, opts ...EnqueueOption) (int64, error)
- func (q *Queue) EnqueueTx(ctx context.Context, tx *sql.Tx, jobType string, payload []byte, ...) (int64, error)
- func (q *Queue) Prune(ctx context.Context, olderThan time.Duration) (int64, error)
- type Status
- type Subscriber
- type Worker
- type WorkerConfig
Examples ¶
Constants ¶
const Schema = `` /* 889-byte string literal not displayed */
Schema creates the pgqueue_jobs table and its indexes. Hosts that manage their own migrations should copy this SQL into a migration; if a release ever changes Schema, add a follow-up migration in the host application.
const SchemaDown = `DROP TABLE IF EXISTS pgqueue_jobs;`
SchemaDown drops everything Schema creates.
Variables ¶
var ErrAlreadyStarted = errors.New("pgqueue: already started")
ErrAlreadyStarted is returned by Worker.Start and Subscriber.Start when the receiver has already been started.
Functions ¶
func DefaultBackoff ¶
DefaultBackoff doubles from 2s (attempt 1) to a ceiling of 256s (attempt 8 and beyond), plus up to 25% jitter — so the longest possible delay is 320s.
func IsPermanent ¶
IsPermanent reports whether err is (or wraps) a Permanent error.
func Permanent ¶
Permanent wraps an error to tell the worker that retrying is pointless (e.g. an unparseable payload): the job goes straight to 'dead'.
func Publish ¶
Publish sends a fire-and-forget notification to every process currently LISTENing on channel. db may be a *sql.DB or, to defer delivery until commit, a *sql.Tx. Postgres caps NOTIFY payloads at ~8000 bytes.
Example ¶
package main
import (
"context"
"database/sql"
"log"
"log/slog"
"github.com/pascalallen/pgqueue"
)
func main() {
const dsn = "postgres://localhost/app?sslmode=disable"
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sub := pgqueue.NewSubscriber(dsn, slog.Default())
sub.Handle("orders_updated", func(payload []byte) {
// Runs on the subscriber goroutine: keep it fast or hand off.
log.Printf("order update: %s", payload)
})
go func() {
if err := sub.Start(ctx); err != nil {
log.Println("subscriber stopped:", err)
}
}()
// Every process currently LISTENing on the channel receives this; pass a
// *sql.Tx instead of db to defer delivery until commit.
if err := pgqueue.Publish(ctx, db, "orders_updated", []byte(`{"order_id":42}`)); err != nil {
log.Fatal(err)
}
}
Output:
Types ¶
type DBTX ¶
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
DBTX is the subset of database/sql operations this package needs. It is satisfied by both *sql.DB and *sql.Tx.
type EnqueueOption ¶
type EnqueueOption func(*enqueueSettings)
func WithMaxAttempts ¶
func WithMaxAttempts(n int) EnqueueOption
func WithRunAt ¶
func WithRunAt(t time.Time) EnqueueOption
WithRunAt schedules the job for a future time instead of immediately.
type Handler ¶
Handler executes a claimed job. A nil return completes the job; an error schedules a retry (or moves the job to 'dead' once MaxAttempts is reached, immediately if the error is wrapped with Permanent). Delivery is at-least-once: handlers must tolerate re-execution of the same job.
type Job ¶
type Job struct {
ID int64
Queue string
Type string
Payload json.RawMessage
Attempts int
MaxAttempts int
RunAt time.Time
CreatedAt time.Time
LastError string
}
Job is a claimed job as handed to a Handler. Attempts includes the attempt currently executing (it is 1 on the first delivery).
type Logger ¶
type Logger interface {
Debug(msg string, keyVals ...any)
Info(msg string, keyVals ...any)
Warn(msg string, keyVals ...any)
Error(msg string, keyVals ...any)
}
Logger is the minimal structured-logging port used by this package. It is deliberately a subset of common structured logger interfaces — *slog.Logger satisfies it as-is — so that host applications can pass their existing logger without an adapter.
type Middleware ¶ added in v1.1.0
Middleware wraps a Handler — for metrics, tracing, logging, or per-job setup — without the library taking on those dependencies itself.
type Option ¶
type Option func(*Queue)
func WithDefaultMaxAttempts ¶
WithDefaultMaxAttempts changes the per-queue default (5) applied to jobs enqueued without WithMaxAttempts.
func WithLogger ¶
func WithQueueName ¶
WithQueueName routes jobs to a queue other than "default".
type Queue ¶
type Queue struct {
// contains filtered or unexported fields
}
Queue enqueues jobs onto a single named queue.
func (*Queue) Enqueue ¶
func (q *Queue) Enqueue(ctx context.Context, jobType string, payload []byte, opts ...EnqueueOption) (int64, error)
Enqueue inserts a job and notifies workers. payload must be valid JSON (nil is stored as {}).
Example ¶
package main
import (
"context"
"database/sql"
"log"
"log/slog"
"time"
"github.com/pascalallen/pgqueue"
)
func main() {
db, err := sql.Open("postgres", "postgres://localhost/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// pgqueue.Schema creates the pgqueue_jobs table; copy it into your own
// migrations. Executing it directly is fine for a quick start.
if _, err := db.ExecContext(ctx, pgqueue.Schema); err != nil {
log.Fatal(err)
}
q := pgqueue.New(db, pgqueue.WithLogger(slog.Default()))
id, err := q.Enqueue(ctx, "send_welcome_email", []byte(`{"user_id":"01J5..."}`),
pgqueue.WithMaxAttempts(3),
pgqueue.WithRunAt(time.Now().Add(time.Minute)),
)
if err != nil {
log.Fatal(err)
}
log.Println("enqueued job", id)
}
Output:
func (*Queue) EnqueueTx ¶
func (q *Queue) EnqueueTx(ctx context.Context, tx *sql.Tx, jobType string, payload []byte, opts ...EnqueueOption) (int64, error)
EnqueueTx is Enqueue inside the caller's transaction: the job row and its wakeup NOTIFY are only visible/delivered if the transaction commits.
Example ¶
package main
import (
"context"
"database/sql"
"log"
"github.com/pascalallen/pgqueue"
)
func main() {
db, err := sql.Open("postgres", "postgres://localhost/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
q := pgqueue.New(db)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Fatal(err)
}
defer tx.Rollback()
// ... business writes on tx ...
// The job row and its wakeup NOTIFY become visible only if tx commits.
if _, err := q.EnqueueTx(ctx, tx, "send_welcome_email", []byte(`{"user_id":"01J5..."}`)); err != nil {
log.Fatal(err)
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
}
}
Output:
type Subscriber ¶
type Subscriber struct {
// contains filtered or unexported fields
}
Subscriber delivers NOTIFY payloads to registered callbacks over a single dedicated LISTEN connection that reconnects automatically.
func NewSubscriber ¶
func NewSubscriber(dsn string, logger Logger) *Subscriber
NewSubscriber takes a lib/pq connection string for the dedicated LISTEN connection.
func (*Subscriber) Handle ¶
func (s *Subscriber) Handle(channel string, fn func(payload []byte))
Handle registers a callback for one channel. It panics if called after Start. Callbacks run sequentially on the subscriber's goroutine — keep them fast or hand off internally.
type Worker ¶
type Worker struct {
// contains filtered or unexported fields
}
Worker claims and executes jobs from one queue. Register handlers, then run Start in a goroutine; cancel its context to begin a graceful drain and use Stop to wait for the drain to finish.
Example ¶
package main
import (
"context"
"database/sql"
"encoding/json"
"log"
"log/slog"
"time"
"github.com/pascalallen/pgqueue"
)
func main() {
const dsn = "postgres://localhost/app?sslmode=disable"
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal(err)
}
w := pgqueue.NewWorker(db, pgqueue.WorkerConfig{
Concurrency: 4,
ListenDSN: dsn, // dedicated LISTEN connection; empty means poll-only
Logger: slog.Default(),
})
w.Register("send_welcome_email", func(ctx context.Context, job pgqueue.Job) error {
var p struct {
UserID string `json:"user_id"`
}
if err := json.Unmarshal(job.Payload, &p); err != nil {
// Retrying an unparseable payload is pointless: dead-letter now.
return pgqueue.Permanent(err)
}
// A plain error schedules a retry with backoff; nil completes the job.
return sendWelcomeEmail(ctx, p.UserID)
})
ctx, cancel := context.WithCancel(context.Background())
go func() {
if err := w.Start(ctx); err != nil {
log.Println("worker stopped:", err)
}
}()
// ... on shutdown: stop claiming, then wait for in-flight jobs to drain.
cancel()
stopCtx, stopCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer stopCancel()
if err := w.Stop(stopCtx); err != nil {
log.Println("worker did not drain in time:", err)
}
}
func sendWelcomeEmail(context.Context, string) error { return nil }
Output:
func (*Worker) Register ¶
Register maps a job type to its handler. It panics if called after Start: the handler map is read without locking by running jobs.
type WorkerConfig ¶
type WorkerConfig struct {
// QueueName selects which queue to consume. Default "default".
QueueName string
// Concurrency is the number of jobs executed simultaneously. Default 2.
Concurrency int
// PollInterval is the safety-net poll cadence; LISTEN provides prompt
// wakeups when ListenDSN is set. Default 5s.
PollInterval time.Duration
// ListenDSN is a lib/pq connection string for the dedicated LISTEN
// connection that wakes the worker as soon as a job is enqueued. Empty
// means poll-only.
ListenDSN string
// Backoff maps the just-failed attempt number (1-based) to the delay
// before the next attempt. Default DefaultBackoff.
Backoff func(attempt int) time.Duration
// RescueAfter re-queues jobs stuck in 'running' longer than this —
// orphans left by a crashed process. It must comfortably exceed the
// longest handler runtime (JobTimeout, when set). Default 5m.
RescueAfter time.Duration
// JobTimeout bounds each handler invocation: the handler's context is
// canceled once it elapses and the attempt fails with the context error.
// Zero (the default) leaves handlers unbounded, so a hung handler blocks
// drain forever — set it unless every handler already bounds itself.
JobTimeout time.Duration
// Retain, when set, makes the worker prune this queue's completed and
// dead jobs whose last update is older than Retain (see Queue.Prune)
// during its maintenance sweep. Zero (the default) never prunes.
Retain time.Duration
// Middleware wraps every registered handler; the first entry is the
// outermost. Applied at Register time, so it must be set before then.
Middleware []Middleware
Logger Logger
}