pulse

module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jul 13, 2026 License: MIT

README

pulse

CI Go Reference

A backend-agnostic priority job queue and worker engine for Go.

pulse gives you the parts of a background job system that are tedious and easy to get subtly wrong: priority queues, delayed/scheduled tasks, retry with exponential backoff, a dead-letter queue for tasks that exhaust their retries, and a worker pool to run it all — with pluggable handlers, storage, and metrics. You bring your task types, your handler functions, and your database. pulse doesn't ship business logic, an HTTP API, or a specific database integration; those are your app's job, not the library's.

Status: v0.1.0, pre-1.0. APIs may still shift between minor versions — pin a version and read the tag notes before upgrading.


Contents


Why

Every background-job system needs the same handful of unglamorous pieces — priority ordering, retries with backoff, a dead-letter queue, delayed execution, graceful shutdown — before you can write a single line of your actual task logic. Writing that plumbing yourself is easy to get wrong in ways that only show up under load (lost updates, races between the poller and the enqueuer, retries that don't back off, a DLQ nobody's watching).

pulse is that plumbing, isolated from any specific business logic, database, or even a specific queue backend. The interfaces (queue.Queue, worker.Store, worker.HandlerFunc) are deliberately small — derived from what the worker pool's retry/DLQ loop actually needs, not guessed upfront — so a new backend or storage layer is a handful of methods, not a rewrite.

Install

go get github.com/isaacthajunior/pulse@v0.1.0

Quickstart

No Redis, no database — this runs standalone:

package main

import (
	"context"
	"fmt"
	"log/slog"
	"os"
	"strings"

	"github.com/isaacthajunior/pulse/queue/memqueue"
	"github.com/isaacthajunior/pulse/worker"
	"github.com/isaacthajunior/pulse/worker/memstore"
)

func main() {
	q := memqueue.New()
	store := memstore.New()
	mux := worker.NewMux()

	mux.Handle("uppercase", func(_ context.Context, task worker.Task) error {
		fmt.Println(strings.ToUpper(string(task.Payload)))
		return nil
	})

	pool := worker.NewPool(q, store, mux, 3, slog.New(slog.NewTextHandler(os.Stdout, nil)))
	pool.Start()
	defer pool.Stop()

	store.Put(worker.Task{ID: "task-1", Type: "uppercase", Payload: []byte("hello pulse"), Priority: "medium"})
	q.EnqueueWithPriority("task-1", "medium")
}

The full runnable version is in examples/simplego run ./examples/simple.

For production, swap memqueue for queue/redisqueue and memstore for your own worker.Store implementation (Postgres, MySQL, SQLite — whatever you already use):

import "github.com/isaacthajunior/pulse/queue/redisqueue"

client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
q := redisqueue.NewRedisQueue(client)

Packages

Package What it is
queue The Queue interface every backend implements: EnqueueWithPriority, DequeuePriorityBlocking, Schedule, PromoteScheduled, DLQ operations, queue depths.
queue/redisqueue Redis-backed Queue. The production backend — lists for priority classes and the DLQ, a sorted set for scheduled tasks.
queue/memqueue In-process, mutex-protected Queue. No external infra. For tests, local dev, or running pulse without standing up Redis. Polls rather than blocking on a true wakeup signal — not a high-throughput primitive.
worker Pool — the engine. Dequeues tasks, dispatches to handlers registered on a Mux, retries with backoff, moves exhausted tasks to the DLQ. Also defines Store, Metrics, Task, WorkerHealthStats.
worker/memstore In-memory, map-backed Store. Same pairing as memqueue — tests and local dev without a real database.
examples/simple A minimal, runnable, infra-free consumer — the fastest way to see pulse work.

Core concepts

Task
type Task struct {
	ID       string
	Type     string
	Payload  json.RawMessage
	Priority string
	Status   string            // e.g. "pending", "cancelled" — pool skips cancelled tasks
	Metadata map[string]string // generic bag for cross-cutting concerns
}

Metadata is how app-specific, cross-cutting data rides along without pulse needing to know what it means. The pool itself only recognizes one convention: a "trace_context" key, treated as a W3C traceparent header, so a trace can continue across the queue boundary if you set it. Everything else in Metadata — chaining hints, tenant IDs, whatever your app needs — is opaque to pulse and meaningful only to your own code.

HandlerFunc and Mux
type HandlerFunc func(ctx context.Context, task Task) error

mux := worker.NewMux()
mux.Handle("send_email", func(ctx context.Context, task worker.Task) error {
	// do the work; a non-nil error triggers a retry (and eventually the DLQ)
	return nil
})

One function per task type. No hardcoded switch statement to extend, no task types pulse needs to know about in advance.

Store

Pool's retry/DLQ loop needs exactly three things from your persistence layer — nothing about results, nothing about your schema:

type Store interface {
	GetTask(ctx context.Context, id string) (Task, error)
	UpdateStatus(ctx context.Context, id, status string) error
	RecordAttempt(ctx context.Context, id, status string, attempt int, errMsg string) error
}

Saving a task's result, or creating and enqueueing a follow-up task, is ordinary code inside your own handler — using your own storage and the same Queue you gave the pool. pulse has no concept of task chaining. If you want it, write it as a small wrapper around HandlerFunc in your own app (see the reference app's internal/chaining for a worked example) — that keeps chaining semantics entirely out of the library, where they don't belong for every consumer.

Metrics

Optional, no-op by default:

type Metrics interface {
	TaskProcessed(taskType string)
	TaskRetried(taskType string)
	TaskFailed(taskType string)
	TaskDuration(seconds float64)
	QueueDepth(queueName string, depth int64)
}

Wire in Prometheus, StatsD, or anything else via worker.WithMetrics(...). Tracing is not made pluggable the same way — pulse calls the OpenTelemetry API directly, which already no-ops safely if you never configure a TracerProvider, so a custom interface would add indirection without adding real decoupling.

Options
worker.NewPool(q, store, mux, workers, logger,
	worker.WithMaxRetries(10),         // default 5
	worker.WithBaseDelay(2*time.Second), // default 1s, exponential: 1s→2s→4s→8s→16s
	worker.WithMetrics(myPrometheusAdapter{}),
)

Pool also implements WorkerHealthProvider:

type WorkerHealthStats struct {
	TotalWorkers, ActiveWorkers, IdleWorkers int / int32
	TotalProcessed, TotalFailed              int64
	UptimeSeconds                            int64
}

stats := pool.HealthStats()

What pulse deliberately doesn't do

  • No task chaining. See Store above.
  • No HTTP API. pulse is a library, not a service.
  • No specific database integration. Store is 3 methods; implement it against whatever you already use.
  • No pluggable tracing. OTel is called directly; it already no-ops without a configured provider.
  • No built-in dashboard — yet. A cross-backend job dashboard ("Grafana for background jobs," watching Redis via pulse alongside other queue backends) is the eventual direction, but doesn't exist today. Don't build against it; it's not here.

Reliability

  • A panic inside a handler is recovered and treated as an ordinary failure — it retries with backoff and eventually goes to the DLQ like any other error, instead of crashing the whole pool. A bug in one task type can't take down every other task type running alongside it.
  • Unit tests cover the pool's core state machine: success, retry-then-succeed, retry-exhaustion → DLQ, cancelled-task skip, and handler-panic recovery (worker/pool_test.go).
  • A concurrency stress test (worker/concurrency_test.go) runs 2,000 tasks across 16 workers, enqueued from many goroutines while the pool is already running, under go test -race — exercising Queue/Store under real concurrent load, not just this library's own low-throughput usage.
  • queue/redisqueue has integration tests against a real Redis (testcontainers-go), including a concurrent-enqueue test.
  • Benchmarks for handler dispatch and the full retry pipeline live in worker/pool_bench_test.go.

Run everything: go test ./... -race (needs Docker for the Redis integration tests; everything else is infra-free).

Examples

go run ./examples/simple — the quickstart above, runnable as-is.

Reference app

pulse was extracted from event-processing-platform, a real task-processing API (HTTP submission, Postgres persistence, MinIO file storage, image resizing/URL scraping/report generation as task types) that now imports pulse for everything generic instead of implementing it inline. That app is the proving ground for pulse's design, including the internal/chaining pattern referenced above for adding declarative task chaining on top of pulse without the library needing to know about it.

Contributing

Contributions are welcome — bug reports, fixes, new Queue/Store backends, docs. See CONTRIBUTING.md for setup and the PR checklist. It's v0.1.0; there's real room to shape where this goes.

Directories

Path Synopsis
examples
simple command
Command simple demonstrates the plain, non-chaining common case: one handler, in-memory queue and store, zero external infra.
Command simple demonstrates the plain, non-chaining common case: one handler, in-memory queue and store, zero external infra.
Package queue defines the storage-agnostic contract a task queue backend must satisfy to be used by this project's worker pool and HTTP handlers.
Package queue defines the storage-agnostic contract a task queue backend must satisfy to be used by this project's worker pool and HTTP handlers.
memqueue
Package memqueue is an in-process implementation of queue.Queue, backed by mutex-protected slices instead of Redis.
Package memqueue is an in-process implementation of queue.Queue, backed by mutex-protected slices instead of Redis.
redisqueue
Package redisqueue is a Redis-backed implementation of queue.Queue.
Package redisqueue is a Redis-backed implementation of queue.Queue.
Package worker provides a generic, backend-agnostic job-processing engine: a Pool dequeues tasks from a queue.Queue, dispatches them to handlers registered on a Mux, and tracks status/attempts through a Store.
Package worker provides a generic, backend-agnostic job-processing engine: a Pool dequeues tasks from a queue.Queue, dispatches them to handlers registered on a Mux, and tracks status/attempts through a Store.
memstore
Package memstore is an in-memory implementation of worker.Store.
Package memstore is an in-memory implementation of worker.Store.

Jump to

Keyboard shortcuts

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