Background processing

Task pool

The pkg/task package provides a bounded worker pool for fire-and-forget background tasks. It fills the gap between synchronous inline execution and the persistent, SQLite-backed job queue: tasks run concurrently in memory with panic recovery and graceful shutdown, but are not persisted or retried.

import "github.com/stanza-go/framework/pkg/task"

When to use task vs queue

Task poolJob queue
PersistenceIn-memory only — lost on crashSQLite-backed — survives restarts
RetryNoYes, with configurable backoff
Use caseEmail sends, cache warming, webhook fanoutPayment processing, report generation, data imports
OverheadNear zero (goroutine + channel)DB write per job

Use the task pool when losing the work on a crash is acceptable. Use the queue when the work must complete eventually.


Creating a pool

p := task.New(
    task.WithWorkers(4),
    task.WithBuffer(100),
    task.WithLogger(logger),
)

Options

OptionDefaultDescription
WithWorkers(n)4Number of concurrent worker goroutines
WithBuffer(n)100Task buffer capacity; Submit returns false when full
WithLogger(l)Logger for panic recovery messages

Lifecycle integration

The pool must be started before use and stopped on shutdown. Integrate with the lifecycle system:

lc.Append(lifecycle.Hook{
    OnStart: p.Start,
    OnStop:  p.Stop,
})

Stop closes the task channel, drains any buffered tasks, and waits for all in-flight workers to finish before returning.


Submitting tasks

Submit enqueues a function for background execution. It returns true if the task was accepted, false if the buffer is full or the pool is stopped.

ok := p.Submit(func() {
    _, _ = emailClient.Send(context.Background(), msg)
})

Fallback pattern

When the pool is full, fall back to synchronous execution so the work still gets done:

send := func() {
    _, _ = emailClient.Send(context.Background(), msg)
}
if !p.Submit(send) {
    // Pool full — send synchronously as fallback.
    send()
}

This is the pattern used in the standalone app's notification service and password reset module.

Context considerations

Tasks submitted to the pool should not use the original HTTP request context. The request may complete (and its context cancel) before the pool runs the task. Use context.Background() or a detached context:

// Wrong — context may be cancelled before the task runs.
p.Submit(func() {
    emailClient.Send(r.Context(), msg)
})

// Correct — detached context survives the request.
p.Submit(func() {
    emailClient.Send(context.Background(), msg)
})

Panic recovery

If a submitted task panics, the worker recovers the panic, logs it (if a logger is configured), increments the panic counter, and continues processing the next task. Workers are never killed by panics.


Pool stats

Stats returns a snapshot of pool counters — useful for monitoring and Prometheus metrics:

s := p.Stats()
fmt.Println(s.Submitted, s.Completed, s.Dropped, s.Panics)
FieldTypeDescription
WorkersintConfigured worker count
BufferintConfigured buffer capacity
PendingintTasks currently waiting in the buffer
Submittedint64Total tasks accepted by Submit
Completedint64Total tasks finished successfully
Panicsint64Total tasks that panicked (recovered)
Droppedint64Total tasks rejected (buffer full or pool stopped)

Counters are cumulative and use sync/atomic — calling Stats is lock-free.


Prometheus metrics

The standalone app exports pool stats as Prometheus metrics at GET /api/metrics:

MetricTypeDescription
stanza_task_pool_workersgaugeWorker goroutine count
stanza_task_pool_pendinggaugeTasks waiting in buffer
stanza_task_pool_submitted_totalcounterTotal tasks submitted
stanza_task_pool_completed_totalcounterTotal tasks completed
stanza_task_pool_dropped_totalcounterTotal tasks dropped
stanza_task_pool_panics_totalcounterTotal panicked tasks

API reference

MethodSignatureDescription
NewNew(opts ...Option) *PoolCreate a new pool
Start(ctx context.Context) errorLaunch workers
Stop(ctx context.Context) errorDrain and wait for all tasks
Submit(fn func()) boolEnqueue a task; false if full/stopped
Stats() StatsPool statistics snapshot

Tips

  • Keep tasks short. The pool has a fixed number of workers. A long-running task blocks a worker slot. For work that takes more than a few seconds, use the job queue instead.
  • Don't rely on ordering. Tasks may execute in any order depending on which worker picks them up.
  • Size the buffer for bursts. The default buffer of 100 handles most cases. If you see Dropped increasing, either increase the buffer or add more workers.
  • Nil functions are ignored. Submit(nil) returns true without queuing anything.

See the Sending emails recipe for the async email pattern used in the standalone app.

Previous
CLI toolkit