queue

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Jul 2, 2026 License: MIT Imports: 13 Imported by: 0

README

queue

A durable work queue with at-least-once delivery, safe for concurrent consumers across goroutines and replicas.

The Postgres implementation claims work with SELECT … FOR UPDATE SKIP LOCKED, so running N consumers over one table just works — each ready task goes to exactly one consumer at a time. A queue plugs straight into worker.Processor (it is both the Source and the Sink), and a Mux routes each task to a handler by Kind, so one queue and one worker pool can serve many task kinds.

Need to enqueue a task atomically with a domain write (the transactional outbox guarantee)? Schedule is a standalone insert — use the outbox package for transactional event emission. queue is for general deferred/background work.

Features

  • Durable, replica-safe claiming via FOR UPDATE SKIP LOCKED.
  • Lease + attempt tracking; a crashed consumer's task is reclaimed after the lease expires (at-least-once).
  • Optional Name for idempotent enqueue (dedup).
  • Delay to postpone a task.
  • Mux dispatch by Kind — many job types over one table.
  • Two-tier retry: fast in-process retry (queue.Retry) and durable store retry (Options.RetryDelay), plus dead-lettering for permanent failures.
  • InMem implementation with identical semantics for unit tests.

Install

import "github.com/assanoff/skit/queue"

Setup

log := logger.New(os.Stdout, logger.Config{Service: "worker"})

q := queue.NewPG(log, db, queue.Options{
    LeaseTimeout: 5 * time.Minute,  // a claimed task is reclaimable after this if not acked
    RetryDelay:   30 * time.Second, // wait before a retryable failure is retried
})

Create the table from a migration with queue.Schema() (the DDL for the fixed queue_tasks table and its index). In tests or local runs, call q.EnsureSchema(ctx) to run the same DDL directly.

Producing work

Schedule enqueues a task. Kind routes it to a handler; Payload is opaque bytes the handler decodes.

payload, _ := json.Marshal(WelcomeEmail{UserID: 42})

inserted, err := q.Schedule(ctx, queue.ScheduleParams{
    Kind:    "email.welcome",
    Payload: payload,
})
Idempotent enqueue (dedup by Name)

A non-empty Name is unique: scheduling the same Name twice is a no-op and returns inserted == false. An empty Name always inserts.

inserted, err := q.Schedule(ctx, queue.ScheduleParams{
    Name:    "import:2024-06",      // dedup key — enqueue this batch at most once
    Kind:    "widget.import",
    Payload: batch,
})
if err != nil {
    return err
}
if !inserted {
    // already queued earlier; nothing to do
}
Delayed work
q.Schedule(ctx, queue.ScheduleParams{
    Kind:    "reminder",
    Payload: b,
    Delay:   24 * time.Hour, // not claimable until now + Delay
})

Consuming work

A Mux maps each Task.Kind to a JobFunc and implements worker.Handler[Task]. Wire it into a worker.Processor (the queue is both the Source and Sink), run it as a worker.Loop, and supervise it in a worker.Group.

func sendWelcome(ctx context.Context, t queue.Task) error {
    var p WelcomeEmail
    if err := json.Unmarshal(t.Payload, &p); err != nil {
        return fmt.Errorf("%w: %v", ErrBadPayload, err) // terminal → dead-letter
    }
    return mailer.Send(ctx, p)
}

func run(ctx context.Context, log *logger.Logger, q *queue.PG) error {
    mux := queue.NewMux()
    if err := mux.Register("email.welcome", sendWelcome); err != nil {
        return err
    }
    if err := mux.Register("widget.import", importBatch); err != nil {
        return err
    }

    proc := worker.NewProcessor[queue.Task](log.Slog(), q, mux, q, worker.ProcessorConfig{
        Name:      "jobs",
        BatchSize: 100,
        // An unroutable kind or a bad payload is permanent — dead-letter it
        // instead of retrying forever.
        IsTerminal: func(err error) bool {
            return errors.Is(err, queue.ErrUnknownKind) || errors.Is(err, ErrBadPayload)
        },
    })

    group := worker.NewGroup(log.Slog(), 10*time.Second)
    group.Add(worker.NewLoop(log.Slog(), worker.LoopConfig{
        Name:               "jobs",
        Interval:           time.Second,
        ImmediateFirstTick: true,
    }, proc.Tick()))

    return group.Run(ctx)
}

Claim does not filter by Kind, so the Mux is what makes the Kind column route work. To run a single kind you can register just one handler (or hand a worker.HandlerFunc[queue.Task] to the processor directly).

Unknown kinds

By default a task whose Kind has no registered handler yields queue.ErrUnknownKind (classify it terminal, as above, so it dead-letters). Override with a fallback to handle them yourself:

mux := queue.NewMux(queue.WithFallback(func(ctx context.Context, t queue.Task) error {
    log.Warn(ctx, "dropping task with unknown kind", "kind", t.Kind)
    return nil // ack and drop
}))
High throughput: adaptive pacing

Use a paced loop to drain a backlog promptly while idling cheaply when empty:

group.Add(worker.NewPacedLoop(log.Slog(), worker.LoopConfig{
    Name:            "jobs",
    Interval:        time.Second,      // idle wait
    MaxIdleInterval: 30 * time.Second, // back off when persistently idle
}, proc.PacedTick()))

Run several loops (in one process, or across replicas) over the same queue for more throughput — SKIP LOCKED keeps them from colliding.

Retry & failure handling

There are two complementary tiers.

In-process retry (fast, transient errors)

queue.Retry wraps a JobFunc so transient failures retry in-process before the task is marked failed — no DB round-trip, no poll wait. Keep the budget small; the task's lease is held for the whole retry.

mux.Register("email.welcome", queue.Retry(retry.Config{
    Backoff:    retry.Backoff{Base: 100 * time.Millisecond, MaxAttempts: 3},
    IsTerminal: func(err error) bool { return errors.Is(err, ErrBadPayload) },
}, sendWelcome))
Store retry (durable, survives crashes)

When a handler returns a non-terminal error, the queue reschedules the task to now + Options.RetryDelay and a later Claim retries it — durable, non-blocking, and replica-safe. Tune it with Options.RetryDelay.

Dead-lettering permanent failures

A failure classified terminal by the processor's IsTerminal is parked as a dead letter (done_at set): it is never claimed again but stays in the table for inspection until Cleanup reaps it. Return a terminal sentinel for failures that will never succeed (bad payload, unknown kind).

var ErrBadPayload = errors.New("bad payload") // classified terminal in ProcessorConfig

Crash recovery (at-least-once)

If a consumer dies after claiming a task but before acking, its lease expires after LeaseTimeout and the next Claim re-leases the task (its Attempts increment). Because delivery is at-least-once, handlers must be idempotent — e.g. upsert with ON CONFLICT DO NOTHING, or dedup on a business key.

Retention cleanup

Done and dead-lettered rows accumulate; reap them periodically with a worker.Loop:

group.Add(worker.NewLoop(log.Slog(), worker.LoopConfig{
    Name:     "queue-cleanup",
    Interval: time.Hour,
}, func(ctx context.Context) error {
    _, err := q.Cleanup(ctx, time.Now().Add(-24*time.Hour)) // delete rows finished > 24h ago
    return err
}))

Testing with InMem

NewInMem has the same lease/retry/dedup semantics as Postgres but needs no database — ideal for unit tests. It takes the same Options as NewPG.

q := queue.NewInMem(queue.Options{LeaseTimeout: time.Minute})

q.Schedule(ctx, queue.ScheduleParams{Kind: "x", Payload: b})
tasks, _ := q.Claim(ctx, time.Now(), 10)
// ... assert on tasks, then q.MarkDone / q.MarkFailed

InMem is safe for concurrent use but is not durable and does not coordinate across processes.

Options reference

Option Default Meaning
LeaseTimeout 5m How long a claimed task stays leased before another consumer may reclaim it (crash recovery).
RetryDelay 30s How long a retryable (non-terminal) failure waits before it is claimable again.

ScheduleParams: Name (optional dedup key — empty means always insert), Kind (handler routing key, required), Payload (opaque bytes), Delay (postpones the earliest claim time).

Semantics summary

  • Claim leases up to limit ready tasks (run_at <= now, unleased or lease-expired), bumps each task's Attempts, and stamps a fresh LeaseID.
  • MarkDone acks success and removes the task.
  • MarkFailed (terminal=false) reschedules to now + RetryDelay; (terminal=true) parks a dead letter.
  • A mark with a stale lease (the task was reclaimed) is a no-op returning ErrLeaseLost.
  • All time math and lease-id generation happen in Go, so the SQL stays free of NOW()/gen_random_uuid() and the clock is injectable in tests.

See also: worker (Processor/Loop/Group), retry (in-process retry policy), outbox (transactional event emission).

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

Constants

This section is empty.

Variables

View Source
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.

View Source
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

func Schema

func Schema() string

Schema returns the DDL that creates the queue_tasks table and its index. Call PG.EnsureSchema at startup to provision it (advisory-lock guarded, replica- safe); Schema is exposed for embedding the DDL in your own migration tooling.

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

func NewInMem(opts Options) *InMem

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) Claim

func (q *InMem) Claim(_ context.Context, now time.Time, limit int) ([]Task, error)

Claim implements Queue.

func (*InMem) Len

func (q *InMem) Len() int

Len reports the number of tasks still in the queue (including dead letters). Test helper.

func (*InMem) MarkDone

func (q *InMem) MarkDone(_ context.Context, t Task, _ time.Time) error

MarkDone implements Queue.

func (*InMem) MarkFailed

func (q *InMem) MarkFailed(_ context.Context, t Task, errMsg string, terminal bool, now time.Time) error

MarkFailed implements Queue.

func (*InMem) Schedule

func (q *InMem) Schedule(_ context.Context, p ScheduleParams) (bool, error)

Schedule implements Queue.

type JobFunc added in v0.4.0

type JobFunc func(ctx context.Context, t Task) error

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

func Retry(cfg retry.Config, fn JobFunc) JobFunc

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

func NewMux(opts ...MuxOption) *Mux

NewMux builds an empty Mux. By default an unknown Kind returns ErrUnknownKind; override that with WithFallback.

func (*Mux) Handle added in v0.4.0

func (m *Mux) Handle(ctx context.Context, t Task) error

Handle implements worker.Handler[Task]: it dispatches t to the handler registered for t.Kind, or the fallback (default: ErrUnknownKind).

func (*Mux) Register added in v0.4.0

func (m *Mux) Register(kind string, fn JobFunc) error

Register binds fn to kind. It returns an error — never panics — on an empty kind, a nil handler, or a duplicate kind, so wiring mistakes surface at startup where the caller can handle them.

type MuxOption added in v0.4.0

type MuxOption func(*Mux)

MuxOption customizes a Mux.

func WithFallback added in v0.4.0

func WithFallback(fn JobFunc) MuxOption

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

func NewPG(log *logger.Logger, db *sqlx.DB, opts Options) *PG

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

func (q *PG) Claim(ctx context.Context, now time.Time, limit int) ([]Task, error)

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

func (q *PG) Cleanup(ctx context.Context, before time.Time) (int64, error)

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

func (q *PG) EnsureSchema(ctx context.Context) error

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

func (q *PG) MarkDone(ctx context.Context, t Task, _ time.Time) error

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.

func (*PG) Schedule

func (q *PG) Schedule(ctx context.Context, p ScheduleParams) (bool, error)

Schedule enqueues a task. An empty Name is replaced with a unique value so the call always inserts.

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.

Jump to

Keyboard shortcuts

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