v0.1.0 — backed by Redis

Background jobs & scheduling
for Go.

A simple, reliable, distributed job scheduler — delayed tasks, cron, retries, and at-least-once delivery, in a single import.

$ go get github.com/MohamedAklamaash/relay
Capabilities

Everything a job queue needs

Reliable primitives that survive crashes, redeploys, and load — without a heavyweight framework.

At-least-once delivery

Every task runs to completion. Idempotent handlers are the only contract.

Delayed & cron jobs

Run later with ProcessIn, or on a schedule with standard cron specs.

Retries & dead-letter

Exponential backoff with jitter; exhausted tasks land in an archive you can replay.

Crash recovery

Visibility-timeout leases reclaim tasks from workers that die mid-flight.

Priority queues

Weighted, strict, or paused — route critical work ahead of the rest.

Dedup & cancellation

Suppress duplicate enqueues with Unique; cancel running tasks remotely.

Prometheus metrics

Processed, failed, retried, in-flight, and latency — exported out of the box.

Built-in CLI

Inspect queues, list states, requeue, pause, and cancel from your terminal.

One dependency

Just Redis. No broker to operate, no result backend to wire up.

Quickstart

Three pieces, one import

A client enqueues, a server processes, a scheduler fires cron. Point them all at the same Redis.

producer.go
client := relay.NewClient(relay.RedisClientOpt{Addr: "127.0.0.1:6379"})
defer client.Close()

// fire-and-forget
client.Enqueue(relay.NewTask("email:send", []byte(`{"to":"a@b.com"}`)))

// run in 10 minutes, retry up to 5 times
client.Enqueue(
    relay.NewTask("report:build", []byte(`{"month":"june"}`)),
    relay.ProcessIn(10*time.Minute),
    relay.MaxRetry(5),
)
worker.go
srv := relay.NewServer(relay.RedisClientOpt{Addr: "127.0.0.1:6379"}, relay.Config{
    Concurrency: 10,
    Queues:      map[string]int{"critical": 6, "default": 3, "low": 1},
})

mux := relay.NewServeMux()
mux.HandleFunc("email:send", func(ctx context.Context, t *relay.Task) error {
    return sendEmail(t.Payload())
})

srv.Run(mux) // blocks; drains gracefully on SIGTERM
scheduler.go
scheduler := relay.NewScheduler(relay.RedisClientOpt{Addr: "127.0.0.1:6379"}, nil)

// run every hour, on the hour
scheduler.Register("0 * * * *", relay.NewTask("report:build", nil))

// safe to run several instances — duplicate fires collapse to one
scheduler.Run()
Recipes

Common patterns

Every feature is an option or a one-liner. Mix and match per task.

Deduplicate enqueues

// reject identical tasks within the window
client.Enqueue(task, relay.Unique(time.Hour))

Timeouts & deadlines

client.Enqueue(task,
    relay.Timeout(30*time.Second),
    relay.Queue("critical"),
)

Skip retry / archive now

mux.HandleFunc("charge", func(ctx context.Context, t *relay.Task) error {
    if invalid(t) {
        return relay.SkipRetry // straight to the archive
    }
    return charge(t)
})

Cancel a running task

insp := relay.NewInspector(relay.RedisClientOpt{Addr: "127.0.0.1:6379"})
insp.CancelTask(taskID) // cancels the handler's ctx

Expose metrics

http.Handle("/metrics", relay.MetricsHandler())
go http.ListenAndServe(":2112", nil)

Manage from the CLI

relay stats
relay ls default retry
relay run default <task-id>   # requeue
relay cancel <task-id>
relay pause low
Deployment

Embed it, or split it out

relay is a library, not a daemon — run the worker inside your app, or as its own service. Same code, your call.

Embedded — one process

// main.go — your API also runs the workers
client := relay.NewClient(opt)
srv := relay.NewServer(opt, relay.Config{Concurrency: 10})

srv.Start(mux)                       // non-blocking
http.ListenAndServe(":8080", api(client))

Simplest deploy. One binary, one rollout. Jobs share CPU and memory with request handling — ideal for dev and small services.

Dedicated worker — its own service

// cmd/worker/main.go — this binary only runs jobs
srv := relay.NewServer(opt, relay.Config{Concurrency: 20})
srv.Run(mux)                         // blocks; drains on SIGTERM

// your API process only enqueues:
//   client := relay.NewClient(opt)

Scale & isolate independently. Add worker replicas without touching the web tier; a job leak can't take down request handling. Recommended at scale.

Both point at the same Redis. Start embedded, then graduate to a dedicated worker when you need independent scaling or isolation — no rewrite, just a different main. Unlike Celery, the separate process is an option, not a requirement.

Under the hood

How a task flows

Redis lists and sorted sets, mutated by atomic Lua scripts. Nothing is lost when a worker dies.

Client
Enqueue writes the task and pushes its ID
pending
Ready queue, per priority
Worker
Leases, runs your handler, heartbeats
done / retry / archive
Ack, back off, or dead-letter
{q}:pendinglist of ready task IDs
{q}:scheduledzset by run-at — delayed jobs
{q}:leasezset by expiry — crash recovery
{q}:retryzset by next-attempt time
{q}:archivedretry-exhausted dead letters
{q}:unique:*NX locks for dedup
Positioning

Where relay fits

The same family as Celery and asynq — a lean, Go-native, Redis-only slice of it.

CapabilityrelayCelery
LanguageGo (embedded library)Python (worker processes)
BrokerRedisRedis / RabbitMQ
Delayed + cron
Retries + dead-letter
At-least-once delivery
Multi-instance scheduler, no leaderbeat is single
Result backend
Workflow chains / chords— (roadmap)