Skip to content

Task Queue / Worker Pool — Go Implementation

An in-process task queue with priority scheduling, delayed tasks, retries with exponential backoff, per-attempt timeouts, task dependencies, cancellation, dead letters and graceful shutdown.

📦 Core Implementation

Key Abstractions

Type Responsibility Pattern
Queue Submit, Start, Cancel, Wait, Shutdown, Stats, DeadLetters Facade
TaskSpec / TaskResult What callers submit and get back Value objects
Handler / HandlerFunc Processes one attempt of one task type Strategy (+ adapter, like http.HandlerFunc)
task Internal state: status, attempts, heap index, dependents, done channel
taskHeap Indexed binary heap with a pluggable less Heap
Permanent(err) Marks an error as non-retryable Error wrapping
ExponentialBackoff base·2^(n-1) capped, with full jitter

Task lifecycle

stateDiagram-v2
    [*] --> BLOCKED: has unfinished deps
    [*] --> SCHEDULED: RunAt in the future
    [*] --> READY
    BLOCKED --> READY: all deps SUCCEEDED
    BLOCKED --> CANCELLED: a dep FAILED/CANCELLED
    SCHEDULED --> READY: runAt reached
    READY --> RUNNING: worker acquires
    RUNNING --> SUCCEEDED
    RUNNING --> SCHEDULED: error, attempts left (backoff)
    RUNNING --> FAILED: attempts exhausted / Permanent / panic
    RUNNING --> CANCELLED: Cancel or Shutdown deadline
    READY --> CANCELLED: Cancel
    SCHEDULED --> CANCELLED: Cancel

All terminal transitions go through finalizeLocked. It runs exactly once per task, closes the done channel, appends to the dead-letter list on FAILED, and then unblocks or cascade-cancels the dependents.

Key design decisions

1. One mutex, handlers outside it. All of the state (ready heap, delayed heap, task index, counters) sits behind one sync.Mutex. The critical sections are a few O(log n) heap operations. The handler, the slow part, runs with no lock held. Sharding the lock wouldn't buy anything until well past 10⁵ tasks/s.

2. Workers pull, and sleep on a broadcast channel. acquire() either returns a task, or returns (q.changed, wait). The worker then selects on changed, a timer for the earliest delayed task, and baseCtx.Done(). Every state change calls notifyLocked(), which closes and replaces changed, waking every idle worker. This is sync.Cond.Broadcast that composes with select. There's no lost wake-up, because the channel is captured under the same lock as the emptiness check. There's no 100 ms polling either.

3. Delays live in a heap, not in time.AfterFunc. Delayed tasks and retry backoffs go into delayed (ordered by runAt, then seq). A timer callback that fires after shutdown, or that a cancel can't find, is a classic leak. Here everything waiting stays visible to Cancel and Shutdown.

4. Strict total order. The ready heap orders by (priority desc, seq asc). The comparator must not read time.Now(). The previous version did, which breaks the heap invariant because comparisons change over time.

5. Dependencies can't form cycles. DependsOn may only name tasks that are already submitted, so the graph is a DAG by construction. A task with unfinished dependencies waits in BLOCKED, outside both heaps. It doesn't sit at the top of the ready heap stalling everything behind it. Each dependency keeps a list of dependents, and on success it decrements their waitingOn.

6. Retries. attempts < MaxAttempts and the error isn't Permanent → re-enqueue with runAt = now + Backoff(attempt). The default backoff is exponential with full jitter (rand.N(d+1)), so a burst of failures doesn't retry in lockstep. Panics are recovered into Permanent errors, so one bad payload can't kill a worker.

7. Cancellation is cooperative. A queued task is removed immediately: it records heapIdx, and finalizeLocked calls heap.Remove. A running task has its attempt context cancelled. When the handler returns, finish sees cancelRequested and marks it CANCELLED without retrying. A handler that ignores ctx can't be stopped. Go has no goroutine kill, so that's a contract the Handler docs state.

8. Shutdown(ctx). First it sets closing, so Submit returns ErrClosed. Workers keep draining, delayed retries included, until outstanding == 0. If ctx expires first, hardStop() cancels baseCtx, the parent of every attempt context. In-flight handlers return, the workers exit, and every remaining task is finalized as CANCELLED. A small goroutine waits on the WaitGroup so Shutdown can select on it against ctx. It always exits, because hard stop makes the workers return.

9. Results via Wait(ctx, id), not a shared results channel. A buffered results channel that nobody reads fills up and blocks every worker. A per-task done channel that is closed exactly once can't block anyone.

Where to extend

Requirement Change
Per-type concurrency limits A map[type]chan struct{} semaphore checked in acquire. Skip tasks whose type is saturated, which needs a heap per type.
Starvation of low priority Aging: the effective priority rises with wait time. That has to be a periodic re-heap, never time.Now() inside Less.
Rate limiting A token bucket per type, consulted before handing out a task
Durable queue Replace the heaps with a DB table or Redis ZSET. Make acquire a lease (SELECT … FOR UPDATE SKIP LOCKED + lease_until). See HIGH_LEVEL_DESIGN.
Bounded memory for finished tasks Today tasks keeps every finished task for Wait and idempotency. Add a retention window, or move results to a store with a TTL.
Redrive from the dead-letter list Resubmit with a new ID (old dependents were already cancelled)

▶️ How to Run

cd golang-low-level-design/task-queue
go run task_queue.go
go test -race task_queue.go task_queue_test.go

🧩 Design Patterns

Pattern Where Why
Worker Pool Start / worker Bounded concurrency, fixed goroutine count
Strategy Handler per task type New task types without touching the queue
Adapter HandlerFunc Plain functions as handlers
Priority Queue taskHeap × 2 O(log n) dispatch by priority and by time
Exponential Backoff + Jitter ExponentialBackoff Avoid synchronized retry storms
Dead Letter DeadLetters() Park poison tasks for inspection instead of retrying forever

📄 Full Source

// Task Queue / Worker Pool - Low Level Design (Go)
// --------------------------------------------------
// An in-process task queue: priority scheduling, delayed tasks, retries with
// exponential backoff, per-attempt timeouts, dependencies (DAG), cancellation,
// a dead-letter list and graceful shutdown with a deadline.
//
// Key design decisions:
//   - One mutex guards all queue state (two heaps + task index). Handlers run
//     outside the lock, so the critical sections are a few heap operations.
//   - Workers pull. When idle they block on a "changed" channel that is
//     closed and replaced on every state change (a broadcast that, unlike
//     sync.Cond, composes with select/ctx/timers), or on a timer for the next
//     delayed task. No polling loop.
//   - Delayed tasks and retry backoff live in a second heap ordered by runAt,
//     not in time.AfterFunc callbacks, so shutdown can see and cancel them.
//   - Ready heap order: priority desc, then submission sequence: a strict,
//     time-independent total order (FIFO within a priority).
//   - Dependencies must already be submitted, which makes cycles impossible.
//     A task becomes ready when all of its dependencies succeed; if any fails
//     or is cancelled, the dependent is cancelled (cascading).
//   - Every task has a done channel closed exactly once on its terminal state;
//     Wait(ctx, id) selects on it. No global results channel that can fill up
//     and block workers.
//   - Shutdown(ctx): stop accepting, drain everything already submitted; if
//     ctx expires first, cancel the base context (in-flight handlers see
//     ctx.Done()) and mark everything left Cancelled.

package main

import (
    "container/heap"
    "context"
    "errors"
    "fmt"
    "math/rand/v2"
    "strings"
    "sync"
    "time"
)

// ============================================================
// PUBLIC TYPES
// ============================================================

type Priority int

const (
    PriorityLow Priority = iota
    PriorityNormal
    PriorityHigh
    PriorityCritical
)

type Status int

const (
    StatusBlocked   Status = iota // waiting on dependencies
    StatusScheduled               // waiting for runAt (delayed task or retry backoff)
    StatusReady                   // in the priority heap
    StatusRunning
    StatusSucceeded
    StatusFailed // exhausted attempts or permanent error; dead-lettered
    StatusCancelled
)

func (s Status) String() string {
    return [...]string{"BLOCKED", "SCHEDULED", "READY", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED"}[s]
}

func (s Status) Terminal() bool { return s >= StatusSucceeded }

var (
    ErrClosed            = errors.New("taskqueue: queue is shut down")
    ErrUnknownHandler    = errors.New("taskqueue: no handler for task type")
    ErrUnknownDependency = errors.New("taskqueue: unknown dependency")
    ErrNotFound          = errors.New("taskqueue: task not found")
    ErrAlreadyFinished   = errors.New("taskqueue: task already finished")
    ErrDependencyFailed  = errors.New("taskqueue: dependency did not succeed")
    ErrCancelled         = errors.New("taskqueue: task cancelled")
)

// Handler processes one task attempt. It must honour ctx: that is the only
// way to stop it on timeout, Cancel or Shutdown.
type Handler interface {
    Handle(ctx context.Context, payload any) (any, error)
}

// HandlerFunc adapts a function to Handler (like http.HandlerFunc).
type HandlerFunc func(ctx context.Context, payload any) (any, error)

func (f HandlerFunc) Handle(ctx context.Context, p any) (any, error) { return f(ctx, p) }

// Permanent marks an error as non-retryable (bad input, 4xx, ...).
func Permanent(err error) error { return &permanentError{err} }

type permanentError struct{ err error }

func (e *permanentError) Error() string { return "permanent: " + e.err.Error() }
func (e *permanentError) Unwrap() error { return e.err }

type TaskSpec struct {
    ID          string // idempotency key; generated if empty
    Type        string // selects the Handler
    Payload     any
    Priority    Priority
    MaxAttempts int           // default 3
    Timeout     time.Duration // per attempt; 0 = none
    RunAt       time.Time     // zero = as soon as possible
    DependsOn   []string      // IDs of already-submitted tasks
}

type TaskResult struct {
    ID       string
    Status   Status
    Value    any
    Err      error
    Attempts int
}

type Options struct {
    Workers  int                             // default 4
    Handlers map[string]Handler              // fixed at construction: read without locking
    Backoff  func(attempt int) time.Duration // delay before retry #attempt; default exp + full jitter
}

// ExponentialBackoff returns base*2^(attempt-1) capped at max, with "full
// jitter" (uniform in [0, d]) so retries from a burst of failures spread out.
func ExponentialBackoff(base, max time.Duration) func(int) time.Duration {
    return func(attempt int) time.Duration {
        d := base << (attempt - 1)
        if d > max || d <= 0 { // d <= 0 guards shift overflow
            d = max
        }
        return rand.N(d + 1)
    }
}

// ============================================================
// INTERNAL TASK + HEAPS
// ============================================================

type task struct {
    spec       TaskSpec
    seq        uint64
    status     Status
    attempts   int
    runAt      time.Time
    heapIdx    int
    waitingOn  int     // unfinished dependencies
    dependents []*task // tasks waiting on this one

    ctx             context.Context    // set while running
    cancel          context.CancelFunc // set while running
    cancelRequested bool

    value any
    err   error
    done  chan struct{} // closed on terminal status
}

type taskHeap struct {
    items []*task
    less  func(a, b *task) bool
}

func (h *taskHeap) Len() int           { return len(h.items) }
func (h *taskHeap) Less(i, j int) bool { return h.less(h.items[i], h.items[j]) }
func (h *taskHeap) Swap(i, j int) {
    h.items[i], h.items[j] = h.items[j], h.items[i]
    h.items[i].heapIdx = i
    h.items[j].heapIdx = j
}
func (h *taskHeap) Push(x any) {
    t := x.(*task)
    t.heapIdx = len(h.items)
    h.items = append(h.items, t)
}
func (h *taskHeap) Pop() any {
    n := len(h.items)
    t := h.items[n-1]
    h.items[n-1] = nil
    h.items = h.items[:n-1]
    t.heapIdx = -1
    return t
}
func (h *taskHeap) peek() *task { return h.items[0] }

func byPriority(a, b *task) bool {
    if a.spec.Priority != b.spec.Priority {
        return a.spec.Priority > b.spec.Priority
    }
    return a.seq < b.seq
}

func byRunAt(a, b *task) bool {
    if !a.runAt.Equal(b.runAt) {
        return a.runAt.Before(b.runAt)
    }
    return a.seq < b.seq
}

// ============================================================
// QUEUE
// ============================================================

type Queue struct {
    handlers map[string]Handler
    backoff  func(int) time.Duration
    workers  int

    baseCtx  context.Context // parent of every attempt's ctx
    hardStop context.CancelFunc

    mu          sync.Mutex
    tasks       map[string]*task
    ready       taskHeap
    delayed     taskHeap
    changed     chan struct{} // closed + replaced on every state change
    seq         uint64
    outstanding int // non-terminal tasks
    closing     bool
    started     bool
    deadLetters []TaskResult
    retries     int

    wg sync.WaitGroup
}

func New(opts Options) *Queue {
    if opts.Workers <= 0 {
        opts.Workers = 4
    }
    if opts.Backoff == nil {
        opts.Backoff = ExponentialBackoff(100*time.Millisecond, 10*time.Second)
    }
    ctx, cancel := context.WithCancel(context.Background())
    return &Queue{
        handlers: opts.Handlers,
        backoff:  opts.Backoff,
        workers:  opts.Workers,
        baseCtx:  ctx,
        hardStop: cancel,
        tasks:    make(map[string]*task),
        ready:    taskHeap{less: byPriority},
        delayed:  taskHeap{less: byRunAt},
        changed:  make(chan struct{}),
    }
}

// Start launches the workers. Tasks may be submitted before Start.
func (q *Queue) Start() {
    q.mu.Lock()
    defer q.mu.Unlock()
    if q.started {
        return
    }
    q.started = true
    for i := 0; i < q.workers; i++ {
        q.wg.Add(1)
        go q.worker()
    }
}

func (q *Queue) notifyLocked() {
    close(q.changed)
    q.changed = make(chan struct{})
}

// Submit enqueues a task. It is idempotent on ID: re-submitting an existing
// ID returns that ID without enqueuing a duplicate.
func (q *Queue) Submit(spec TaskSpec) (string, error) {
    if _, ok := q.handlers[spec.Type]; !ok {
        return "", fmt.Errorf("%w: %q", ErrUnknownHandler, spec.Type)
    }
    if spec.MaxAttempts <= 0 {
        spec.MaxAttempts = 3
    }
    q.mu.Lock()
    defer q.mu.Unlock()
    if q.closing {
        return "", ErrClosed
    }
    q.seq++
    if spec.ID == "" {
        spec.ID = fmt.Sprintf("task-%d", q.seq)
    }
    if _, dup := q.tasks[spec.ID]; dup {
        return spec.ID, nil
    }
    deps := make([]*task, 0, len(spec.DependsOn))
    for _, id := range spec.DependsOn {
        d, ok := q.tasks[id]
        if !ok {
            return "", fmt.Errorf("%w: %q", ErrUnknownDependency, id)
        }
        deps = append(deps, d)
    }
    t := &task{spec: spec, seq: q.seq, heapIdx: -1, runAt: spec.RunAt, done: make(chan struct{})}
    q.tasks[spec.ID] = t
    q.outstanding++
    for _, d := range deps {
        switch {
        case d.status == StatusSucceeded:
        case d.status.Terminal():
            q.finalizeLocked(t, StatusCancelled, nil, fmt.Errorf("%w: %s", ErrDependencyFailed, d.spec.ID))
            return t.spec.ID, nil
        default:
            t.waitingOn++
            d.dependents = append(d.dependents, t)
        }
    }
    if t.waitingOn > 0 {
        t.status = StatusBlocked
    } else {
        q.enqueueLocked(t)
    }
    return t.spec.ID, nil
}

// enqueueLocked puts a runnable task in the delayed or ready heap.
func (q *Queue) enqueueLocked(t *task) {
    if !t.runAt.IsZero() && time.Now().Before(t.runAt) {
        t.status = StatusScheduled
        heap.Push(&q.delayed, t)
    } else {
        t.status = StatusReady
        heap.Push(&q.ready, t)
    }
    q.notifyLocked()
}

// finalizeLocked moves t to a terminal status exactly once and resolves its
// dependents: unblock them on success, cancel them (recursively) otherwise.
func (q *Queue) finalizeLocked(t *task, st Status, v any, err error) {
    if t.status.Terminal() {
        return
    }
    switch t.status { // remove from wherever it is queued
    case StatusReady:
        heap.Remove(&q.ready, t.heapIdx)
    case StatusScheduled:
        heap.Remove(&q.delayed, t.heapIdx)
    }
    t.status, t.value, t.err = st, v, err
    q.outstanding--
    if st == StatusFailed {
        q.deadLetters = append(q.deadLetters, t.resultLocked())
    }
    close(t.done)
    for _, d := range t.dependents {
        if d.status.Terminal() {
            continue
        }
        if st == StatusSucceeded {
            if d.waitingOn--; d.waitingOn == 0 {
                q.enqueueLocked(d)
            }
        } else {
            q.finalizeLocked(d, StatusCancelled, nil, fmt.Errorf("%w: %s", ErrDependencyFailed, t.spec.ID))
        }
    }
    t.dependents = nil
    q.notifyLocked()
}

func (t *task) resultLocked() TaskResult {
    return TaskResult{ID: t.spec.ID, Status: t.status, Value: t.value, Err: t.err, Attempts: t.attempts}
}

// acquire returns the next task to run, or (nil, wake, wait) to sleep on, or
// (nil, nil, 0) when the worker should exit.
func (q *Queue) acquire() (*task, <-chan struct{}, time.Duration) {
    q.mu.Lock()
    defer q.mu.Unlock()
    if q.baseCtx.Err() != nil {
        return nil, nil, 0
    }
    now := time.Now()
    for q.delayed.Len() > 0 && !now.Before(q.delayed.peek().runAt) {
        t := heap.Pop(&q.delayed).(*task)
        t.status = StatusReady
        heap.Push(&q.ready, t)
    }
    if q.ready.Len() > 0 {
        t := heap.Pop(&q.ready).(*task)
        t.status = StatusRunning
        t.attempts++
        var ctx context.Context
        var cancel context.CancelFunc
        if t.spec.Timeout > 0 {
            ctx, cancel = context.WithTimeout(q.baseCtx, t.spec.Timeout)
        } else {
            ctx, cancel = context.WithCancel(q.baseCtx)
        }
        t.ctx, t.cancel = ctx, cancel
        return t, nil, 0
    }
    if q.closing && q.outstanding == 0 {
        return nil, nil, 0
    }
    var wait time.Duration
    if q.delayed.Len() > 0 {
        wait = q.delayed.peek().runAt.Sub(now)
    }
    return nil, q.changed, wait
}

func (q *Queue) worker() {
    defer q.wg.Done()
    for {
        t, wake, wait := q.acquire()
        if t != nil {
            q.run(t)
            continue
        }
        if wake == nil {
            return
        }
        var timer *time.Timer
        var timerC <-chan time.Time
        if wait > 0 {
            timer = time.NewTimer(wait)
            timerC = timer.C
        }
        select {
        case <-wake:
        case <-timerC:
        case <-q.baseCtx.Done():
        }
        if timer != nil {
            timer.Stop()
        }
    }
}

// run executes one attempt outside the lock, then records the outcome.
func (q *Queue) run(t *task) {
    v, err := safeHandle(t.ctx, q.handlers[t.spec.Type], t.spec.Payload)
    t.cancel()
    q.finish(t, v, err)
}

// safeHandle turns a handler panic into an error so one bad task cannot
// kill a worker goroutine (and with it, silently, the pool's capacity).
func safeHandle(ctx context.Context, h Handler, p any) (v any, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = Permanent(fmt.Errorf("handler panic: %v", r))
        }
    }()
    return h.Handle(ctx, p)
}

func (q *Queue) finish(t *task, v any, err error) {
    q.mu.Lock()
    defer q.mu.Unlock()
    t.ctx, t.cancel = nil, nil
    var perm *permanentError
    switch {
    case err == nil:
        q.finalizeLocked(t, StatusSucceeded, v, nil)
    case t.cancelRequested || q.baseCtx.Err() != nil:
        q.finalizeLocked(t, StatusCancelled, nil, fmt.Errorf("%w: %v", ErrCancelled, err))
    case t.attempts < t.spec.MaxAttempts && !errors.As(err, &perm):
        q.retries++
        t.err = err
        t.runAt = time.Now().Add(q.backoff(t.attempts))
        q.enqueueLocked(t)
    default:
        q.finalizeLocked(t, StatusFailed, nil, err)
    }
}

// Cancel stops a task. A queued or blocked task is removed immediately; a
// running task has its context cancelled and is marked Cancelled when the
// handler returns. Dependents are cancelled too.
func (q *Queue) Cancel(id string) error {
    q.mu.Lock()
    defer q.mu.Unlock()
    t, ok := q.tasks[id]
    switch {
    case !ok:
        return ErrNotFound
    case t.status.Terminal():
        return ErrAlreadyFinished
    case t.status == StatusRunning:
        t.cancelRequested = true
        t.cancel()
    default:
        q.finalizeLocked(t, StatusCancelled, nil, ErrCancelled)
    }
    return nil
}

// Wait blocks until the task reaches a terminal status or ctx is done.
func (q *Queue) Wait(ctx context.Context, id string) (TaskResult, error) {
    q.mu.Lock()
    t, ok := q.tasks[id]
    q.mu.Unlock()
    if !ok {
        return TaskResult{}, ErrNotFound
    }
    select {
    case <-t.done:
        q.mu.Lock()
        defer q.mu.Unlock()
        return t.resultLocked(), nil
    case <-ctx.Done():
        return TaskResult{}, ctx.Err()
    }
}

// Shutdown stops accepting tasks and waits for everything already submitted
// (including delayed tasks and pending retries) to finish. If ctx expires
// first, in-flight attempts are cancelled via their context, the remaining
// tasks are marked Cancelled, and ctx.Err() is returned. A handler that
// ignores ctx will still delay the return: Go cannot kill a goroutine.
func (q *Queue) Shutdown(ctx context.Context) error {
    q.mu.Lock()
    q.closing = true
    if !q.started { // nobody would drain: start workers so Shutdown terminates
        q.mu.Unlock()
        q.Start()
        q.mu.Lock()
    }
    q.notifyLocked()
    q.mu.Unlock()

    drained := make(chan struct{})
    go func() { q.wg.Wait(); close(drained) }()
    select {
    case <-drained:
        return nil
    case <-ctx.Done():
    }
    q.hardStop()
    <-drained // workers exit once their current handler returns
    q.mu.Lock()
    defer q.mu.Unlock()
    for _, t := range q.tasks {
        q.finalizeLocked(t, StatusCancelled, nil, fmt.Errorf("%w: shutdown", ErrCancelled))
    }
    return ctx.Err()
}

type Stats struct {
    ByStatus    map[Status]int
    Retries     int
    DeadLetters int
}

func (q *Queue) Stats() Stats {
    q.mu.Lock()
    defer q.mu.Unlock()
    st := Stats{ByStatus: make(map[Status]int), Retries: q.retries, DeadLetters: len(q.deadLetters)}
    for _, t := range q.tasks {
        st.ByStatus[t.status]++
    }
    return st
}

// DeadLetters returns tasks that failed permanently, oldest first.
func (q *Queue) DeadLetters() []TaskResult {
    q.mu.Lock()
    defer q.mu.Unlock()
    return append([]TaskResult(nil), q.deadLetters...)
}

// ============================================================
// DEMO
// ============================================================

func main() {
    var mu sync.Mutex
    var order []string
    record := func(s string) { mu.Lock(); order = append(order, s); mu.Unlock() }
    flakyCalls := 0

    handlers := map[string]Handler{
        "echo": HandlerFunc(func(ctx context.Context, p any) (any, error) {
            record(p.(string))
            return p, nil
        }),
        "flaky": HandlerFunc(func(ctx context.Context, p any) (any, error) {
            mu.Lock()
            flakyCalls++
            n := flakyCalls
            mu.Unlock()
            if n < 3 {
                return nil, fmt.Errorf("transient error #%d", n)
            }
            return "ok on attempt 3", nil
        }),
        "broken": HandlerFunc(func(ctx context.Context, p any) (any, error) {
            return nil, errors.New("downstream 503")
        }),
        "invalid": HandlerFunc(func(ctx context.Context, p any) (any, error) {
            return nil, Permanent(errors.New("payload failed validation"))
        }),
        "sleep": HandlerFunc(func(ctx context.Context, p any) (any, error) {
            select {
            case <-time.After(p.(time.Duration)):
                return "slept", nil
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }),
    }
    fixedBackoff := func(attempt int) time.Duration { return time.Duration(attempt) * 5 * time.Millisecond }
    bg := context.Background()
    show := func(q *Queue, id string) {
        r, _ := q.Wait(bg, id)
        fmt.Printf("  %-10s %-9s attempts=%d value=%v err=%v\n", r.ID, r.Status, r.Attempts, r.Value, r.Err)
    }

    fmt.Println("--- Priority + FIFO (1 worker, submitted before Start) ---")
    q := New(Options{Workers: 1, Handlers: handlers, Backoff: fixedBackoff})
    q.Submit(TaskSpec{ID: "low", Type: "echo", Payload: "low", Priority: PriorityLow})
    q.Submit(TaskSpec{ID: "high-1", Type: "echo", Payload: "high-1", Priority: PriorityHigh})
    q.Submit(TaskSpec{ID: "normal", Type: "echo", Payload: "normal", Priority: PriorityNormal})
    q.Submit(TaskSpec{ID: "high-2", Type: "echo", Payload: "high-2", Priority: PriorityHigh})
    q.Submit(TaskSpec{ID: "high-1", Type: "echo", Payload: "dup"}) // idempotent: ignored
    q.Start()
    q.Shutdown(bg)
    fmt.Println("  execution order:", strings.Join(order, " -> "))

    fmt.Println("--- Retries, permanent errors, dead letters ---")
    q = New(Options{Workers: 4, Handlers: handlers, Backoff: fixedBackoff})
    q.Start()
    q.Submit(TaskSpec{ID: "flaky", Type: "flaky", MaxAttempts: 5})
    q.Submit(TaskSpec{ID: "broken", Type: "broken", MaxAttempts: 3})
    q.Submit(TaskSpec{ID: "invalid", Type: "invalid", MaxAttempts: 5})
    for _, id := range []string{"flaky", "broken", "invalid"} {
        show(q, id)
    }
    fmt.Println("  dead letters:", len(q.DeadLetters()))

    fmt.Println("--- Dependencies (DAG) ---")
    q.Submit(TaskSpec{ID: "extract", Type: "echo", Payload: "extract"})
    q.Submit(TaskSpec{ID: "transform", Type: "echo", Payload: "transform", DependsOn: []string{"extract"}})
    q.Submit(TaskSpec{ID: "load", Type: "echo", Payload: "load", DependsOn: []string{"transform"}})
    q.Submit(TaskSpec{ID: "report", Type: "echo", Payload: "report", DependsOn: []string{"broken"}})
    for _, id := range []string{"extract", "transform", "load", "report"} {
        show(q, id)
    }

    fmt.Println("--- Delayed task, timeout, cancel ---")
    q.Submit(TaskSpec{ID: "delayed", Type: "echo", Payload: "delayed", RunAt: time.Now().Add(30 * time.Millisecond)})
    q.Submit(TaskSpec{ID: "timeout", Type: "sleep", Payload: time.Second, Timeout: 20 * time.Millisecond, MaxAttempts: 1})
    q.Submit(TaskSpec{ID: "cancelme", Type: "sleep", Payload: time.Minute})
    time.Sleep(10 * time.Millisecond) // let a worker pick "cancelme" up
    q.Cancel("cancelme")
    for _, id := range []string{"delayed", "timeout", "cancelme"} {
        show(q, id)
    }
    q.Shutdown(bg)

    fmt.Println("--- Shutdown with a deadline ---")
    q = New(Options{Workers: 1, Handlers: handlers})
    q.Start()
    q.Submit(TaskSpec{ID: "long", Type: "sleep", Payload: time.Minute})
    q.Submit(TaskSpec{ID: "queued", Type: "echo", Payload: "never runs"})
    ctx, cancel := context.WithTimeout(bg, 50*time.Millisecond)
    defer cancel()
    fmt.Println("  Shutdown:", q.Shutdown(ctx))
    show(q, "long")
    show(q, "queued")
    _, err := q.Submit(TaskSpec{Type: "echo"})
    fmt.Println("  submit after shutdown:", err)
}