Documentation
¶
Overview ¶
Package queue is a durable work queue with at-least-once delivery, safe for concurrent consumers across processes.
The Postgres implementation (PG) claims work with SELECT … FOR UPDATE SKIP LOCKED, so running N consumers (goroutines or replicas) over one table just works — each ready task goes to exactly one consumer at a time. A Queue plugs straight into worker.Processor: its Claim/MarkDone/MarkFailed methods satisfy worker.Source[Task] and worker.Sink[Task], so the reliable claim -> handle -> ack/retry loop comes for free. Schedule enqueues work; an optional unique Name deduplicates retries of the same logical task. An InMem implementation with identical semantics is provided for unit tests and local runs without a database.
Wiring (Postgres + worker.Processor) ¶
Schedule produces work; a worker.Processor consumes it by leasing a batch (Claim), running a Handler, and recording the outcome (MarkDone / MarkFailed):
q := queue.NewPG(log, db, queue.Options{LeaseTimeout: 5 * time.Minute})
if err := q.EnsureSchema(ctx); err != nil { // safe at startup, even across replicas
return err
}
// Producer: enqueue a task. A Name makes the enqueue idempotent.
_, err := q.Schedule(ctx, queue.ScheduleParams{
Name: "send-welcome:42",
Kind: "email.welcome",
Payload: payload,
Delay: 0,
})
// Consumer: a Mux routes each task to a handler by Kind, so one queue and
// one worker pool can serve many task kinds. q is both the Source (Claim)
// and Sink (MarkDone/MarkFailed).
mux := queue.NewMux()
if err := mux.Register("email.welcome", sendWelcome); err != nil {
return err
}
proc := worker.NewProcessor[queue.Task](log, q, mux, q, worker.ProcessorConfig{
Name: "queue",
BatchSize: 100,
IsTerminal: func(err error) bool { return errors.Is(err, queue.ErrUnknownKind) },
})
group := worker.NewGroup(log, 10*time.Second)
group.Add(worker.NewPacedLoop(log, worker.LoopConfig{}, proc.PacedTick()))
if err := group.Run(ctx); err != nil {
return err
}
Dispatch and retry ¶
Mux maps a Task.Kind to a JobFunc and implements worker.Handler[Task] (Register returns an error — it never panics — on an empty kind, nil handler, or duplicate). Because Claim does not filter by Kind, the Mux is what makes the Kind column route work. Retry wraps a JobFunc with in-process retry (over the retry package) to absorb transient errors quickly; for durable backoff that survives crashes and frees the worker between attempts, rely instead on the store retry below (a non-terminal MarkFailed reschedules the task).
Schema ¶
The backing table is owned by this package, so you don't hand-write a migration for it: call PG.EnsureSchema at startup to provision it. The DDL is idempotent (CREATE ... IF NOT EXISTS) and runs under a transaction-scoped advisory lock, so several replicas can call it concurrently without racing. Schema returns the same DDL as a string if you'd rather embed it in your own migration tooling instead.
Semantics ¶
Claim leases up to limit ready tasks (run_at <= now and not currently leased, or with an expired lease), bumps each task's attempt count, and stamps a fresh LeaseID that MarkDone/MarkFailed must echo back. MarkDone acknowledges success and removes the task. MarkFailed with terminal=false releases the lease and reschedules the task to now+RetryDelay so a later Claim retries it without busy-looping; terminal=true parks it as a dead letter. When a lease no longer matches — because it expired and another consumer reclaimed the task — the mark is a no-op and returns ErrLeaseLost. PG.Cleanup reaps done/dead-lettered rows finished before a cutoff; run it periodically via worker.Loop.
Options ¶
- Options.LeaseTimeout: how long a claimed task stays leased before another consumer may reclaim it (default 5m).
- Options.RetryDelay: how long a retryable (non-terminal) failure waits before it is claimable again (default 30s). NewInMem takes the same Options as NewPG.
- ScheduleParams: Name (optional dedup key — empty means always insert), Kind (handler routing key, required), Payload (opaque body), Delay (postpones the earliest claim time).
The backing table is fixed (queue_tasks); all time math and lease-id generation happen in Go, keeping the SQL free of NOW()/gen_random_uuid().
Index ¶
- Variables
- func Schema() string
- type InMem
- func (q *InMem) Claim(_ context.Context, now time.Time, limit int) ([]Task, error)
- func (q *InMem) Len() int
- func (q *InMem) MarkDone(_ context.Context, t Task, _ time.Time) error
- func (q *InMem) MarkFailed(_ context.Context, t Task, errMsg string, terminal bool, now time.Time) error
- func (q *InMem) Schedule(_ context.Context, p ScheduleParams) (bool, error)
- type JobFunc
- type Mux
- type MuxOption
- type Options
- type PG
- func (q *PG) Claim(ctx context.Context, now time.Time, limit int) ([]Task, error)
- func (q *PG) Cleanup(ctx context.Context, before time.Time) (int64, error)
- func (q *PG) EnsureSchema(ctx context.Context) error
- func (q *PG) MarkDone(ctx context.Context, t Task, _ time.Time) error
- func (q *PG) MarkFailed(ctx context.Context, t Task, errMsg string, terminal bool, now time.Time) error
- func (q *PG) Schedule(ctx context.Context, p ScheduleParams) (bool, error)
- type Queue
- type ScheduleParams
- type Task
Constants ¶
This section is empty.
Variables ¶
var ErrLeaseLost = errors.New("queue: lease lost")
ErrLeaseLost is returned by MarkDone/MarkFailed when the task's lease no longer matches — another consumer reclaimed it after the lease expired. The mark is a no-op; the other consumer now owns the task.
var ErrUnknownKind = errors.New("queue: no handler registered for kind")
ErrUnknownKind is returned by Mux.Handle for a task whose Kind has no registered handler. Classify it as terminal in your Processor (or set a fallback) so an unroutable task dead-letters instead of being retried forever.
Functions ¶
Types ¶
type InMem ¶
type InMem struct {
// contains filtered or unexported fields
}
InMem is an in-memory Queue with the same lease/retry/dedup semantics as PG, for unit tests and local runs without a database. It is safe for concurrent use. It is not durable and does not coordinate across processes.
func NewInMem ¶
NewInMem builds an in-memory queue. It takes the same Options as NewPG; LeaseTimeout defaults to 5m and RetryDelay to 30s when <= 0.
func (*InMem) Len ¶
Len reports the number of tasks still in the queue (including dead letters). Test helper.
type JobFunc ¶ added in v0.4.0
JobFunc handles one claimed task, selected by a Mux on Task.Kind. It takes the whole Task (not just Payload) so a handler can see Name/Attempts/Kind. A nil return acks the task (MarkDone); a non-nil error routes it to MarkFailed.
func Retry ¶ added in v0.4.0
Retry wraps fn so transient failures are retried in-process per cfg before the task is marked failed. It is a thin convenience over retry.Do for JobFunc handlers (register the wrapped func on a Mux).
The retry runs inside a single claim, so the task's lease is held for the whole duration — keep cfg's budget and delays well under the queue's LeaseTimeout, or another consumer may reclaim the task while it is still retrying. For long, durable backoff that survives crashes and frees the worker between attempts, rely on the store's own retry (a non-terminal MarkFailed reschedules the task) instead of a long in-process budget.
type Mux ¶ added in v0.4.0
type Mux struct {
// contains filtered or unexported fields
}
Mux routes claimed tasks to handlers by Kind. It implements worker.Handler[Task], so one queue and a single worker pool can serve many task kinds: register a JobFunc per Kind and hand the Mux to a worker.Processor as its Handler. Claim does not filter by Kind, so the Mux is what makes the Kind column actually route work.
A Mux is safe for concurrent use. Register every kind at startup, before the processor runs.
func NewMux ¶ added in v0.4.0
NewMux builds an empty Mux. By default an unknown Kind returns ErrUnknownKind; override that with WithFallback.
type MuxOption ¶ added in v0.4.0
type MuxOption func(*Mux)
MuxOption customizes a Mux.
func WithFallback ¶ added in v0.4.0
WithFallback sets the handler invoked for a Kind with no registered handler. Without it, an unknown Kind yields ErrUnknownKind.
type Options ¶
type Options struct {
// LeaseTimeout is how long a claimed task stays leased before another
// consumer may reclaim it, guarding against a consumer that died mid-process
// (default 5m).
LeaseTimeout time.Duration
// RetryDelay is how long a retryable (non-terminal) failure waits before the
// task becomes claimable again — it reschedules run_at to now+RetryDelay
// instead of retrying immediately, so a persistently failing task does not
// busy-loop (default 30s). Permanent failures (terminal=true) are
// dead-lettered regardless. For fast in-process retries of transient errors,
// wrap the handler with Retry; this delay governs the durable store retry.
RetryDelay time.Duration
}
Options configures a queue (PG and InMem share it).
type PG ¶
type PG struct {
// contains filtered or unexported fields
}
PG is a Postgres-backed Queue. Every query is an inline const right next to the method that runs it — no string-templated query cache, no fmt.Sprintf — so the SQL reads as SQL. The backing table is fixed (queue_tasks), and all time math (lease cutoff) and id generation (lease_id) happen in Go, so the SQL stays free of NOW()/gen_random_uuid()/interval casts.
PG is safe for concurrent use by multiple goroutines and processes; Claim coordinates consumers via FOR UPDATE SKIP LOCKED so each ready task is handed to exactly one consumer at a time.
func NewPG ¶
NewPG builds a Postgres queue. It does not create the table; run a migration with Schema() or call EnsureSchema (handy in tests).
func (*PG) Claim ¶
Claim atomically leases up to limit ready tasks (run_at <= now, not currently leased or lease expired) under a fresh lease id, bumping their attempt count. A single CTE + UPDATE … RETURNING does selection and lease assignment in one round-trip; FOR UPDATE SKIP LOCKED lets consumers split the work. The lease cutoff is computed in Go (now - leaseTimeout) and the lease id is generated Go-side and shared by the whole batch (one per Claim).
func (*PG) Cleanup ¶
Cleanup removes dead-lettered/done tasks finished before the given time, returning how many rows were deleted. Run it periodically via worker.Loop.
func (*PG) EnsureSchema ¶
EnsureSchema creates the backing table and index if they do not exist. It is safe to call at startup, including from several replicas at once: the DDL runs under a transaction-scoped advisory lock, so concurrent boots serialize instead of racing on CREATE TABLE.
func (*PG) MarkDone ¶
MarkDone removes a successfully processed task. The lease_id predicate is the lease guard: a consumer that reclaimed the task after the lease expired holds a different lease_id, so the DELETE matches no row and we return ErrLeaseLost.
func (*PG) MarkFailed ¶
func (q *PG) MarkFailed(ctx context.Context, t Task, errMsg string, terminal bool, now time.Time) error
MarkFailed releases a failed task for retry (terminal=false: clears the lease and reschedules run_at to now+retryDelay so a later Claim retries it without busy-looping) or parks it as a dead letter (terminal=true: sets done_at so Claim skips it but Cleanup can reap it). Guarded by the same lease predicate as MarkDone.
type Queue ¶
type Queue interface {
// Schedule enqueues a task, returning inserted=false if Name already exists.
Schedule(ctx context.Context, p ScheduleParams) (inserted bool, err error)
// Claim leases up to limit ready tasks (run_at <= now and not currently
// leased), bumping their attempt count. Each returned Task carries a fresh
// LeaseID that MarkDone/MarkFailed must echo back.
Claim(ctx context.Context, now time.Time, limit int) ([]Task, error)
// MarkDone acknowledges successful processing and removes the task.
MarkDone(ctx context.Context, t Task, now time.Time) error
// MarkFailed records a failure. terminal=true parks the task as a dead letter;
// terminal=false releases the lease so a later Claim retries it.
MarkFailed(ctx context.Context, t Task, errMsg string, terminal bool, now time.Time) error
}
Queue is a durable work queue. Implementations are safe for concurrent use by multiple consumers. Claim/MarkDone/MarkFailed mirror worker.Source/Sink.
type ScheduleParams ¶
type ScheduleParams struct {
// Name is an optional dedup key. When set, scheduling the same Name twice is a
// no-op (Schedule reports inserted=false). When empty, the queue assigns a
// unique name so every call enqueues a distinct task.
Name string
// Kind routes the task to a handler; required.
Kind string
// Payload is the opaque task body.
Payload []byte
// Delay postpones the earliest claim time (RunAt = now + Delay). Zero means
// claimable immediately.
Delay time.Duration
}
ScheduleParams describes a task to enqueue.
type Task ¶
type Task struct {
ID int64
Name string
Kind string
Payload []byte
CreatedAt time.Time
RunAt time.Time
Attempts int
LeaseID string
LastError string
}
Task is a unit of queued work — a pure core type with no db tags (the PG store owns that mapping via taskRow). Payload is opaque to the queue; consumers route on Kind and decode Payload (typically JSON) themselves.