Delayed jobs without a scheduler

Send the reminder in 30 minutes, retry the webhook in 5. Two ways to do it with nothing but Ruvio: a stream entry that stays hidden until its time, and a sorted set you drain in one call.

Salih Cantekin · · 4 min read

Delayed queueWorker+3s+6s+9snow+12s3 jobs doneJobs wait, hidden, until their time comes.A job is due: ZPOPBYSCORE hands it to a worker.Each due job is taken once, by one worker.Nothing is polled, nothing is run early.
Delayed jobs that run on timeZPOPBYSCORE and ZMOVEDUE take only the members whose time has come, in one command.

Almost every backend I have built needed some version of “do this later”. Send a reminder tomorrow morning. Retry a failed webhook in five minutes. Close an unpaid order after an hour. The usual answer is another moving part: a cron table, a scheduler service, or a polling loop that wakes up every second and asks the database what is due.

If you already run Ruvio for queues or caching, you can skip that part. There are two ways to schedule work, and which one fits depends on whether you want consumer groups or the simplest thing that works.

DELAY
one option on XADD; the entry is stored at once and delivered later
10,000
due jobs ZMOVEDUE can hand to a list queue in one call
0
extra WAL records when you do not use a delay

Option 1: a stream entry that waits

Streams with consumer groups are the solid way to run workers: every entry is delivered to one consumer, unacknowledged entries stay pending, and a crashed worker’s entries can be claimed by another one. Ruvio adds one option to XADD:

XGROUP CREATE jobs workers $ MKSTREAM
XADD jobs DELAY 30000 * kind send-reminder user 42
# 1793295045919-0 -> stored now
XREADGROUP GROUP workers w1 COUNT 10 BLOCK 5000 STREAMS jobs >
# (nil) until the 30 s have passed
XACK jobs workers 1793295045919-0

ClientsRustC#GoJavaPythonNode.js

The entry is written right away, so it is in the log and survives a restart. XREADGROUP simply skips it until the delay has passed; after that it is delivered like any other entry. Pending lists, XCLAIM and XAUTOCLAIM work as usual, so your retry and dead-letter logic does not change. A plain XREAD still sees the entry, which is handy when you want to look at what is scheduled.

The worker side has nothing special in it: pick your language in the tabs above.

Option 2: a sorted set as a timetable

Sometimes a stream is more than you need. Put each job in a sorted set with its run time as the score, and take what is due:

redis-cli
ZADD {jobs}:due 1700000300 job:9 1700000360 job:10
ZPOPBYSCORE {jobs}:due 1700000330 COUNT 10
# job:9 -> due and removed in the same call

ZPOPBYSCORE returns the members whose score is at or below the limit and removes them in the same step. Two workers calling it at the same moment never get the same job, which is the part that used to need a script.

If your workers already read from a list, ZMOVEDUE moves the due jobs onto it for you:

redis-cli
ZMOVEDUE {jobs}:due {jobs}:ready 1700000400 COUNT 100
# 1 -> one job moved to the tail of {jobs}:ready

It is one recorded step, up to 10,000 members per call. Run it from a small loop, or from any worker before it blocks on the list.

Which one to pick

Stream with DELAYSorted set
Deliveryone consumer per entry, with ackswhoever pops first
Crashed workerentry stays pending, XAUTOCLAIM itgone once popped, add your own retry
Payloadfields on the entrymember name, details in another key
Change the time laterno, add a new entryyes, ZADD the new score
Best forreminders, retries, anything that must not be losttimetables, rescheduling, simple queues

My own rule: if losing a job would be a bug report, use the stream. If jobs get rescheduled a lot, or the “job” is just an id you look up anyway, the sorted set is simpler and easier to inspect.

Things worth knowing

  • One time unitDELAY takes milliseconds; sorted-set scores take whatever you choose. I use Unix milliseconds everywhere.
  • Trimming counts themA delayed entry is part of the stream’s length, so MAXLEN can remove it before it is delivered.
  • Ordinary writesWith everysec or always durability both options are in the WAL and replicate like everything else.

If you also want to see jobs as they are written, without polling, CHANGES SUBSCRIBE MATCH jobs* gives you a resumable feed of every write when the server runs with a WAL. That is a story for another post. The full syntax for everything here is in the command reference and on the use cases page.