Redis
redisq is the Redis adapter for VarMQ. It provides first-in first-out and priority queues on one Redis server, each in a persistent and a distributed form. Read persistence and distribution first for what the two kinds of queue do.
Setup
redisq needs Redis 6.0 or newer, with Lua scripts allowed: it takes, adds and puts back jobs with scripts. Its go.mod asks for Go 1.26. Three steps set it up:
Start Redis
varmq’s repository has
examples/compose.yml, which starts Redis 7.2 on port 6375. The programs on this page use that port.terminaldocker compose -f examples/compose.yml up -dRun it from a clone of github.com/goptics/varmq.
terminaldocker run -d --name varmq-redis -p 6375:6379 redis:7.2-alpineWithout a clone, the same image does the job.
Add the two modules
varmq’s version is the release these pages describe.
terminalgo get github.com/goptics/varmq github.com/goptics/redisqCreate a queue and bind it
redisq.Newtakes a Redis URL and returns aQueuesvalue. It panics when the URL does not parse. Each queue takes a key, which names it in Redis. Pick the kind of queue.main.gordb := redisq.New("redis://localhost:6375") defer rdb.Close() pq := rdb.NewQueue("persistent_queue") defer pq.Close() queue := worker.WithPersistentQueue(pq)First in, first out, on a Redis list. One process keeps its jobs across a restart.
main.gordb := redisq.New("redis://localhost:6375") defer rdb.Close() pq := rdb.NewPriorityQueue("persistent_priority_queue") defer pq.Close() queue := worker.WithPersistentPriorityQueue(pq) queue.Add("alert", 1) // a lower number runs firstLowest number first, on a sorted set.
main.gordb := redisq.New("redis://localhost:6375") defer rdb.Close() dq := rdb.NewDistributedQueue("distributed_queue") defer dq.Close() queue := worker.WithDistributedQueue(dq) // or, in a process that only adds jobs: // producer := varmq.NewDistributedQueue[string](dq)Several processes consume one queue, and each job goes to one of them. Adding a job wakes an idle consumer.
main.gordb := redisq.New("redis://localhost:6375") defer rdb.Close() dq := rdb.NewDistributedPriorityQueue("distributed_priority_queue") defer dq.Close() queue := worker.WithDistributedPriorityQueue(dq)Lowest number first, plus wake-ups for idle consumers.
Queues has one constructor for each kind of queue. Each takes a key, which names the queue in Redis:
| Constructor | Order | Bind it with |
|---|---|---|
NewQueue | first in, first out, on a Redis list | WithPersistentQueue |
NewPriorityQueue | lowest number first, on a sorted set | WithPersistentPriorityQueue |
NewDistributedQueue | first in, first out, plus wake-ups for idle consumers | WithDistributedQueue, or varmq.NewDistributedQueue with no worker |
NewDistributedPriorityQueue | lowest number first, plus wake-ups for idle consumers | WithDistributedPriorityQueue, or varmq.NewDistributedPriorityQueue |
A queue with the key jobs keeps its waiting jobs under jobs. Every other key it uses, and the wake-up channels of a distributed queue, start with jobs:. Give each queue its own key.
Redis keeps these in memory. Redis’s own persistence settings decide whether they survive a restart of Redis itself. Neither VarMQ nor redisq changes these settings.
A persistent queue
The program below binds a persistent queue to a worker with concurrency 1, adds ten jobs, and waits for them.
11 collapsed lines
package main
import (
"fmt"
"time"
"github.com/goptics/redisq"
"github.com/goptics/varmq"
)
func main() {
rdb := redisq.New("redis://localhost:6375")
defer rdb.Close()
pq := rdb.NewQueue("persistent_queue")
defer pq.Close()
worker := varmq.NewWorker(
func(j varmq.Job[int]) {
fmt.Println("processing", j.Data())
time.Sleep(time.Second)
fmt.Println("processed", j.Data())
},
varmq.WithAutoRun(false),
)
queue := worker.WithPersistentQueue(pq)
fmt.Println("left in Redis by an earlier run:", queue.Len())
// Start after the bind, so the worker looks at jobs already in Redis.
worker.Start()
for i := range 10 {
queue.Add(i)
}
worker.WaitAndStop()
}Three choices in it matter:
- The worker is created with
WithAutoRun(false)and started after the bind. Binding a redisq queue already makes the worker look for jobs that a previous run left in Redis. The overview says this. Starting the worker later only delays it while the program counts the jobs. queue.Len()is read beforeStart. It asks Redis, so it counts the waiting jobs from the earlier run.- Every job finishes before the deferred closes run.
WaitAndStoplets every job run, then stops the worker. A job acknowledges when its function returns.Closeon a redisq queue puts back the jobs it took and has not acknowledged. If you close it while a job runs, that job goes back to the queue to run again. The job’s acknowledgement then fails.
Run locally
-
Start Redis, as in Setup.
-
Make a module, save the program as
main.goand add the dependencies:mkdir redis-persistent && cd redis-persistent go mod init redis-persistent go get github.com/goptics/varmq github.com/goptics/redisq -
Run it with
go run .. The first run printsleft in Redis by an earlier run: 0, then aprocessingline and aprocessedline for each job, one second apart. Concurrency is 1, so they run in the order they were added. -
Run it again, and press Ctrl+C a few seconds in. The jobs that had not started are still in Redis.
-
Run it a third time. It prints how many jobs are left, and the worker runs those first, before the ten new ones.
The job that was running when you pressed Ctrl+C is not in that count. It waits in persistent_queue:nacked until redisq treats the run that took it as dead. This happens once the program has run again for 30 seconds in total. Run the program once or twice more, and the job comes back at the front of the queue. The section on unacknowledged jobs explains the timing.
A priority queue
NewPriorityQueue gives a queue that gives out the lowest number first. WithPersistentPriorityQueue binds it, and Add on the handle takes the priority after the data.
10 collapsed lines
package main
import (
"fmt"
"github.com/goptics/redisq"
"github.com/goptics/varmq"
)
func main() {
rdb := redisq.New("redis://localhost:6375")
defer rdb.Close()
pq := rdb.NewPriorityQueue("persistent_priority_queue")
defer pq.Close()
worker := varmq.NewWorker(
func(j varmq.Job[string]) {
fmt.Println("processing", j.Data())
},
varmq.WithAutoRun(false),
)
queue := worker.WithPersistentPriorityQueue(pq)
// A lower number runs first.
queue.Add("report", 10)
queue.Add("alert", 1)
queue.Add("newsletter", 5)
worker.Start()
worker.WaitAndStop()
}Run locally
Follow the steps for the persistent queue, with this program as main.go. It prints:
processing alert
processing newsletter
processing reportredisq keeps a priority queue in a Redis sorted set, with the priority as the score. It stores each job with a sequence number in front. So identical jobs stay separate, and jobs with the same priority come out in the order they were added. A job that goes back to the queue keeps its priority.
A distributed queue
A distributed queue is shared by any number of processes. The producer below has no worker. It wraps the Redis queue with varmq.NewDistributedQueue and adds jobs.
10 collapsed lines
package main
import (
"fmt"
"github.com/goptics/redisq"
"github.com/goptics/varmq"
)
func main() {
rdb := redisq.New("redis://localhost:6375")
defer rdb.Close()
dq := rdb.NewDistributedQueue("distributed_queue")
defer dq.Close()
// No worker here: this process only adds jobs.
producer := varmq.NewDistributedQueue[string](dq)
for i := range 20 {
if !producer.Add(fmt.Sprintf("https://example.com/%d", i)) {
fmt.Println("not added:", i)
}
}
fmt.Println("waiting in Redis:", producer.Len())
}The consumer binds the same Redis queue to a worker with concurrency 5, and runs until you stop it.
15 collapsed lines
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/goptics/redisq"
"github.com/goptics/varmq"
)
func main() {
rdb := redisq.New("redis://localhost:6375")
defer rdb.Close()
dq := rdb.NewDistributedQueue("distributed_queue")
defer dq.Close()
worker := varmq.NewWorker(
func(j varmq.Job[string]) {
fmt.Println("scraping", j.Data())
time.Sleep(2 * time.Second)
fmt.Println("scraped", j.Data())
},
5,
varmq.WithAutoRun(false),
)
queue := worker.WithDistributedQueue(dq)
fmt.Println("waiting in Redis:", queue.Len())
worker.Start()
fmt.Println("listening")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
// Let running jobs finish and acknowledge before the queue closes.
worker.StopAndWait()
}How it fits together:
- A job reaches one consumer. The consumer takes a job from the Redis list. After that, the job is not in the list for another consumer. Two consumers on one key divide the jobs between them.
Addwakes an idle consumer.Enqueueon a distributed queue adds the job. In the same step, it wakes consumers that are waiting for work, at most one per waiting job. Each consumer has a channel of its own. A consumer waits for work once it finds the queue empty. A busy consumer is not woken, so the wake-up goes to a consumer that can take the job. While jobs wait, every consumer also checks once a second. So a consumer still takes a job that was added while no consumer was listening.- The producer has a
Lenand aPurge. On a distributed handle they are redisq’s own.Lenis the length of the Redis list.Purgedeletes the list, which removes every waiting job. A job that a consumer took and is running is not in the list, andPurgedoes not touch it. - Each consumer’s counters are its own. Jobs run in one process and are added in another. So one instance’s
SubmittedandCompletedneed not match. The overview says what is known. - The consumer stops its worker before the deferred
Close.Closeputs back every job the consumer took and has not acknowledged. It also wakes another consumer for them. So if you close while a job runs, the job runs twice.StopAndWaitlets the running jobs finish and acknowledge first. redisq has aListenmethod that blocks until SIGINT or SIGTERM. It does not stop the worker.
Run locally
-
Start Redis, as in Setup.
-
In one module, save the producer as
producer/main.goand the consumer asconsumer/main.go:mkdir redis-distributed && cd redis-distributed go mod init redis-distributed mkdir producer consumer go get github.com/goptics/varmq github.com/goptics/redisq -
In one terminal run
go run ./consumer. It printswaiting in Redis: 0andlistening. -
For a second consumer, run
go run ./consumerin another terminal. Each job goes to one of the two. -
In a third terminal run
go run ./producer. It printswaiting in Redis:and a number. The number is 20 when no consumer is running, and lower when one has already taken some jobs. -
The consumers print a
scrapingline and, two seconds later, ascrapedline for each job. Each consumer runs up to five jobs at a time, so five lines appear together. -
Press Ctrl+C in a consumer terminal. It finishes the jobs it is running, acknowledges them and exits.
To see a crash instead, run the producer again and kill one consumer with kill -9 while it runs jobs. The other consumer runs them a few seconds later, as the next section explains.
Jobs that were not acknowledged
The overview describes when VarMQ acknowledges a job. This is what redisq does with a job that was taken and not acknowledged, for example because the process died.
When such a job runs again:
| Case | The job runs again |
|---|---|
| A distributed consumer crashes | about six seconds later, on another consumer |
| A program on a persistent queue crashes | once the program has run again for 30 seconds in total, or when a queue object finds the job past its visibility timeout (five minutes), whichever comes first |
| A job runs longer than the visibility timeout | at the next sweep, also while the first run goes on |
You call Close on the queue | at once |
Closegives jobs back.Closeputs back at once every job its queue object took and has not acknowledged. After it,EnqueueandAcknowledgeon that object returnfalse. See shutting down.- The timeouts are set per queue object.
SetVisibilityTimeoutchanges the deadline for jobs taken after the call.SetHeartbeatTimeoutsets how long the object may go without reaching Redis: at least five seconds, and 0 turns heartbeats off. Call them right after you create the queue. - Keep the visibility timeout longer than your slowest job. A job still running past its deadline is put back by the next sweep and runs again.
- Keep the heartbeat timeout longer than any freeze you want to survive. A process can be frozen or cut off from Redis for longer than this timeout. Then redisq treats it as dead. Its running jobs run again elsewhere, and their later
Acknowledgestill returnstrue. GetNackedItemsCountcounts the jobs taken and not acknowledged. The handle’sLendoes not, because they have left the queue.
How redisq tracks a taken job
The scene below shows the keys of a queue named jobs. Take a job, then kill the process or close the queue, and move the slider to move time forward.
A scene that shows four Redis keys of a queue named jobs: jobs, jobs:nacked, jobs:timeouts and jobs:heartbeats. Job 7 waits in jobs with jobs 8, 9 and 10. Take job 7 moves it to jobs:nacked and gives it a deadline in jobs:timeouts. Acknowledge removes both entries. Kill the process leaves job 7 in jobs:nacked. A slider then moves time forward. On a persistent queue, redisq puts job 7 back at the front of jobs when the program has run for 30 seconds in all. On a distributed queue another consumer puts it back after about six seconds. Close the queue while the job runs puts the job back at once, and its acknowledgement then fails.
pq := rdb.NewQueue("jobs")- A taken job is recorded in the same step. One script moves the job from the queue to the hash
<key>:nacked, under its acknowledgement ID. The same script gives the job a deadline in<key>:timeouts. The deadline is Redis’s time plus the visibility timeout, five minutes by default.Acknowledgeremoves both entries. - Every queue object sweeps once a second. From its creation until
Close, it puts back the jobs whose deadline has passed. It also puts back the jobs held by a queue object that is treated as dead. On the first-in first-out queues they go to the front, and on a priority queue back at their priority. On a distributed queue the sweep wakes a consumer for them. Creating a queue object also puts back the jobs whose deadline has passed. - A queue object is treated as dead when its heartbeat runs out. Each queue object renews a heartbeat in
<key>:heartbeatswhen it takes jobs and on every sweep. The other queue objects on the key put back its jobs once the heartbeat is 30 seconds old, the default. Time in which no process swept the key does not count, because Redis cannot tell a dead process from a pause of all of them. A distributed consumer is treated as dead sooner. Redis drops its wake-up channel when its process exits. Five seconds later, the other consumers put its jobs back.
Job IDs
redisq checks job IDs in EnqueueOnce. An Add with WithJobId stores the job once per ID within a window, five minutes by default. Another Add with the same ID in that time stores nothing, and still returns true. That covers an Add you retry, and a push that the Redis client sent again after its reply was lost. The ID is remembered for the whole window, even after its job has run. SetDedupWindow on the queue object changes the window, and 0 turns the check off.
A job without an ID is not checked. If the reply to its push is lost, the Redis client sends the push again, and the job can be stored twice. Give jobs IDs when that matters.
Batches
redisq implements IBatchAcknowledgeable. So a worker with only this queue takes as many jobs as it has free slots in one call. Each job starts on its own goroutine at once.