scheduler is a Go library for reliable distributed delayed-job execution with Redis as the first production backend.
It is designed for systems that need:
- delayed jobs with
run_at - distributed workers across many processes or nodes
- bounded parallelism and backpressure
- at-least-once delivery with retries
- lease-based recovery when workers crash
- a storage abstraction that can support more backends over time
- βοΈ Library-first Go API with producer and worker runtimes
- πΉ Go-native design with simple package-first integration
- π¦ Redis backend with batched hot paths for better coordination efficiency
- π Retry policies, lease renewals, dead-letter handling, and recovery loops
- π§ Versioned queue topology for safe repartitioning over time
- π§ͺ In-memory backend, integration tests, and benchmark coverage
- π Runnable examples for Redis queue migration and Redis worker demos
Many task queues are easy to start with, but become harder to reason about once you need:
- predictable retry behavior
- safe recovery after consumer failure
- bounded worker concurrency
- operational tuning for throughput and latency
- a clean separation between queue semantics and datastore details
scheduler focuses on those primitives first.
This repository contains a strong v1 foundation:
- producer API for delayed jobs
- worker runtime with configurable concurrency
- explicit job lifecycle states
- batched Redis promotion, leasing, renewals, completions, and recovery
- internal queue partitioning for Redis
- in-memory backend for tests and local development
- integration tests and benchmark scaffolding
Not in scope for v1:
- cron scheduling
- workflow / DAG orchestration
- exactly-once processing
- dashboard / admin UI
The delivery contract is at-least-once.
That means:
- a job is expected to run one or more times
- duplicate execution is possible during failures or lease recovery
- handlers should be idempotent
This is the tradeoff that makes reliable distributed processing practical at scale.
Each job moves through explicit states:
scheduled -> ready -> leased -> succeeded
or, on failure:
scheduled -> ready -> leased -> retry_wait -> ready
or, after retry exhaustion:
scheduled -> ready -> leased -> dead
- A producer enqueues a job with a future
run_at. - Workers poll the store and promote due jobs from
scheduledorretry_waittoready. - Workers lease ready jobs in batches.
- Leased jobs are processed in parallel up to configured limits.
- Active leases are renewed in batches.
- Completed jobs are acknowledged in batches.
- Failed jobs are retried with backoff or moved to
dead. - Expired leases are recovered automatically and returned to
ready.
Multiple workers can safely share the same Redis backend because ownership is temporary and explicit:
- a worker does not permanently remove a job
- it leases the job for a bounded time
- if the worker crashes, another worker can recover the job after lease expiry
Redis is the first production backend and the current hot path is heavily batched:
- due-job promotion is batched
- ready-job leasing is batched
- lease renewal is batched
- completion flushes are batched
- expired lease recovery is batched
To reduce hot-key pressure, each logical queue is internally partitioned into multiple Redis key groups:
scheduledreadyleasedretry
Jobs are assigned to a partition by hashing the job ID, while the public queue name stays stable for application code.
Queue routing is versioned. Each job stores:
queue_versionpartition
That makes repartitioning safer because a running system can keep draining old queue layouts while new jobs are written to a newer version.
- producer.go Producer API
- runtime.go Worker runtime, batching, lease tracking
- store.go Storage abstraction
- memory_store.go In-memory backend
- redis_store.go Redis backend with batching and partitions
- bench_test.go Store and worker benchmarks
- examples/redis_demo/main.go End-to-end Redis demo with producer and worker
- examples/redis_migration/main.go Queue topology migration utility
- assets/logo.svg Primary project logo
- assets/scheduler-hero.svg README hero artwork
- assets/scheduler-flow.svg Lifecycle diagram artwork
go get github.com/yuseferi/schedulerThe project targets Go 1.26+.
If you want the repo task runner used by many Go projects, this repository now includes a Taskfile.yml for Task. You can install Task from the official project, for example:
brew install go-taskor:
go install github.com/go-task/task/v3/cmd/task@latestOfficial docs:
package main
import (
"context"
"log/slog"
"time"
"github.com/yuseferi/scheduler"
)
func main() {
store := scheduler.NewMemoryStore()
producer, err := scheduler.NewProducer(store, scheduler.DefaultProducerConfig())
if err != nil {
panic(err)
}
worker, err := scheduler.NewWorker(
store,
scheduler.DefaultWorkerConfig("worker-1"),
slog.Default(),
scheduler.NopMetrics{},
)
if err != nil {
panic(err)
}
worker.Handle("email.send", func(ctx context.Context, job *scheduler.Job) error {
slog.Info("processing job", "id", job.ID, "type", job.Type)
return nil
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
if err := worker.Start(ctx); err != nil {
panic(err)
}
}()
_, err = producer.Enqueue(context.Background(), scheduler.EnqueueOptions{
Queue: "default",
Type: "email.send",
Payload: []byte(`{"to":"team@example.com"}`),
RunAt: time.Now().Add(2 * time.Second),
MaxRetries: 3,
Timeout: 15 * time.Second,
})
if err != nil {
panic(err)
}
time.Sleep(3 * time.Second)
}Once Task is installed, the usual project workflow becomes:
task
task check
task ci
task demo
task bench-short
task cover
task test-integrationUseful tasks included in this repository:
task fmtFormat all Go source files withgofmttask fmt-checkFail if any Go files are not formattedtask vetRungo vet ./...task lintRun the basic lint suite (fmt-check+vet)task testRun the default unit-focused test suitetask test-integrationRun the Redis-backed integration suite with-tags=integrationtask test-raceRun unit-focused tests with the race detectortask test-race-integrationRun unit + Redis integration tests with the race detectortask coverRun unit-focused library coverage outputtask cover-integrationRun library coverage with Redis integration tests enabledtask benchRun the benchmark suitetask bench-shortRun a short benchmark smoke testtask tidyRungo mod tidytask checkRun the standard local verification flowtask ciRun the CI-style verification flowtask demoRun the Redis demo apptask migrate -- ...Forward commands to the Redis queue migration example
scheduler is designed to be embedded into another Go service rather than forcing you into a separate platform from day one.
Typical ways to use it in another project:
Use the same application to:
- accept requests
- enqueue background jobs
- run one or more worker loops
Good fit for:
- internal tools
- SaaS backends
- early-stage products
- systems where the API and workers can live together
Run producers in your API service and run workers in separate processes or deployments.
Good fit for:
- larger systems
- higher throughput workloads
- teams that want independent scaling for API nodes and job consumers
Example:
api-serviceenqueues jobs into Redisworker-servicerunsschedulerworkers against the same Redis backend
Wrap scheduler inside your own internal package so every service uses the same conventions.
Good fit for:
- platform teams
- multi-service Go environments
- organizations that want standardized retries, queue names, and metrics
Example:
package background
import (
"log/slog"
"github.com/redis/go-redis/v9"
"github.com/yuseferi/scheduler"
)
func NewRedisScheduler(workerID string) (*scheduler.Producer, *scheduler.Worker, error) {
client := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
store := scheduler.NewRedisStoreWithConfig(client, scheduler.RedisConfig{
Prefix: "my-company-jobs",
Partitions: 8,
ActiveVersion: 1,
})
producer, err := scheduler.NewProducer(store, scheduler.DefaultProducerConfig())
if err != nil {
return nil, nil, err
}
worker, err := scheduler.NewWorker(
store,
scheduler.DefaultWorkerConfig(workerID),
slog.Default(),
scheduler.NopMetrics{},
)
if err != nil {
return nil, nil, err
}
return producer, worker, nil
}Another project can use scheduler for different background domains without changing the core runtime.
Examples:
email.sendwebhook.dispatchinvoice.generatereport.buildimage.processpayment.sync
Each service can register handlers by job type and keep its own queue conventions.
In a real application, the common shape looks like this:
- Initialize Redis and create a
scheduler.Store - Create one
Producerfor enqueueing jobs - Create one or more
Workerinstances for processing jobs - Register handlers by job type
- Start workers during application startup
- Enqueue jobs from request handlers, domain services, or event consumers
Example:
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
})
store := scheduler.NewRedisStoreWithConfig(client, scheduler.RedisConfig{
Prefix: "my-app",
Partitions: 8,
ActiveVersion: 1,
})
producer, _ := scheduler.NewProducer(store, scheduler.DefaultProducerConfig())
workerCfg := scheduler.DefaultWorkerConfig("orders-worker-1")
workerCfg.Queues = []string{"default"}
workerCfg.Concurrency = 16
workerCfg.BatchSize = 64
workerCfg.MaxInFlight = 128
worker, _ := scheduler.NewWorker(store, workerCfg, slog.Default(), scheduler.NopMetrics{})
worker.Handle("order.confirmation.send", func(ctx context.Context, job *scheduler.Job) error {
// decode payload
// send email / push / webhook
return nil
})- Keep handlers idempotent because delivery is at-least-once
- Start with a small number of queues and partition counts, then benchmark
- Scale workers horizontally before over-tuning a single process
- Use your own package around
schedulerif multiple services need the same setup - Treat Redis prefix and queue topology as production infrastructure choices
There is a runnable demo at examples/redis_demo/main.go.
Start Redis locally, then run:
go run ./examples/redis_demoWhat it does:
- connects to Redis on
127.0.0.1:6379 - configures a Redis-backed scheduler store
- starts a worker
- enqueues a handful of delayed email jobs
- logs processing activity, queue version, and partition placement
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
})
store := scheduler.NewRedisStoreWithConfig(client, scheduler.RedisConfig{
Prefix: "scheduler",
Partitions: 8,
})Recommended starting point:
Partitions: 1for simple development or small workloadsPartitions: 8or higher when benchmarking higher throughput
If you want to manage queue topology explicitly:
store := scheduler.NewRedisStoreWithConfig(client, scheduler.RedisConfig{
Prefix: "scheduler",
Partitions: 4,
ActiveVersion: 1,
})
_, err := store.PromoteQueueVersion(ctx, "default", scheduler.QueueVersionConfig{
Version: 2,
Partitions: 8,
})
if err != nil {
panic(err)
}There is also a small CLI example for topology changes:
go run ./examples/redis_migration topology -addr 127.0.0.1:6379 -prefix scheduler -queue default
go run ./examples/redis_migration promote -addr 127.0.0.1:6379 -prefix scheduler -queue default -version 2 -partitions 8
go run ./examples/redis_migration complete -addr 127.0.0.1:6379 -prefix scheduler -queue default -version 1DefaultProducerConfig() gives you:
DefaultQueue: "default"DefaultMaxRetries: 3DefaultTimeout: 30s- exponential retry policy with:
1s -> 2s -> 4s ...up to1m
Important knobs:
WorkerIDUnique worker identityQueuesLogical queues this worker should consumeConcurrencyNumber of handlers that may execute in parallelBatchSizeNumber of jobs to claim or flush per batchQueueBufferExtra local buffer multiplier when leasingPollIntervalHow often workers promote, recover, and attempt to leaseLeaseDurationVisibility / ownership window for a leased jobHeartbeatIntervalHow often active leases are renewedMaxInFlightMaximum number of active jobs in one workerRetryPolicyBackoff and retry controls
Example:
cfg := scheduler.DefaultWorkerConfig("email-worker-1")
cfg.Concurrency = 32
cfg.BatchSize = 64
cfg.QueueBuffer = 2
cfg.MaxInFlight = 128
cfg.PollInterval = 5 * time.Millisecond
cfg.HeartbeatInterval = 50 * time.Millisecond
cfg.LeaseDuration = 500 * time.MillisecondThe worker runtime is designed around failure recovery:
- if a handler returns an error, the job is retried with backoff
- if retries are exhausted, the job moves to
dead - if a worker dies mid-flight, lease expiry allows another worker to recover the job
- if Redis is shared by many workers, lease ownership prevents simultaneous execution by healthy workers
Because the system is at-least-once, idempotency should be treated as part of the handler contract.
scheduler now supports versioned Redis queue topology.
That means partition changes should be done as a version transition, not by mutating one live layout in place.
- Start with an active queue version
Example:
- queue:
default - active version:
v1 - partitions:
4
- Promote a new active version
topology, err := store.PromoteQueueVersion(ctx, "default", scheduler.QueueVersionConfig{
Version: 2,
Partitions: 8,
})After this:
- new jobs go to
v2 v1becomes draining- workers keep scanning both active and draining versions
- Wait for the draining version to empty
Use queue stats and operational checks to confirm v1 is empty before completing migration.
- Mark the draining version complete
topology, err = store.CompleteDrainingVersion(ctx, "default", 1)After this:
v2remains activev1metadata is removed- workers stop scanning
v1
Changing partition count in place can strand jobs because hash routing changes.
Versioned queue topology avoids that by:
- storing routing on each job
- keeping old and new layouts visible during migration
- allowing safe active + draining reads
The repository includes a runnable example at examples/redis_migration/main.go.
Inspect current topology:
go run ./examples/redis_migration topology \
-addr 127.0.0.1:6379 \
-prefix scheduler \
-queue defaultPromote a new active version:
go run ./examples/redis_migration promote \
-addr 127.0.0.1:6379 \
-prefix scheduler \
-queue default \
-version 2 \
-partitions 8Remove a drained version after it is empty:
go run ./examples/redis_migration complete \
-addr 127.0.0.1:6379 \
-prefix scheduler \
-queue default \
-version 1The project includes two main benchmark groups in bench_test.go:
BenchmarkStoreLifecycleMeasures enqueue -> promote -> lease -> renew -> completeBenchmarkWorkerThroughputMeasures end-to-end runtime-driven processing
Run them with:
go test -run '^$' -bench 'Benchmark(StoreLifecycle|WorkerThroughput)' ./...Short sample results from this machine:
| Benchmark | Result |
|---|---|
BenchmarkStoreLifecycle/memory/full-cycle |
49 us/op |
BenchmarkStoreLifecycle/redis/full-cycle/p1 |
2262 us/op |
BenchmarkStoreLifecycle/redis/full-cycle/p8 |
2813 us/op |
BenchmarkWorkerThroughput/memory/worker |
91 us/op |
BenchmarkWorkerThroughput/redis/worker/p1 |
1227 us/op |
BenchmarkWorkerThroughput/redis/worker/p8 |
3525 us/op |
These numbers were produced from a very short smoke run with -benchtime=1x, so they are useful for correctness and relative direction, not for capacity claims.
For meaningful sizing work, run longer benchmarks, for example:
go test -run '^$' -bench 'Benchmark(StoreLifecycle|WorkerThroughput)' -benchtime=3s ./...The repository now separates unit-style tests from Redis-backed integration tests.
Default local test run:
go test ./...Redis integration test run:
go test -tags=integration ./...Combined coverage run used in CI:
go test -tags=integration -coverprofile=coverage.out .Integration tests do not connect to an already-running Redis instance. They use testcontainers-go to start an isolated ephemeral Redis just for the test run.
Local prerequisites for integration tests:
- Docker or another compatible container runtime available to
testcontainers-go
If no compatible container runtime is available, the Redis integration tests are skipped. Coverage reporting intentionally targets the main library package and does not count the runnable examples/ binaries.
Run the Redis benchmarks only:
go test -run '^$' -bench BenchmarkStoreLifecycle/redis ./...Strong next steps for the project:
- multi-worker contention benchmarks
- producer burst benchmarks
- Lua-based stronger atomicity for more Redis transitions
- benchmark presets and tuning guides
- more backends such as SQLite or Postgres
- queue-level rate limiting and priority support
The project is still early and evolving quickly. Good contributions would include:
- correctness hardening
- higher-quality benchmarks
- Redis tuning and load-testing
- additional datastore implementations
- examples and production guidance
This project is licensed under the MIT License.