R8.03.2 · Pinterest: task queues & partitioning — dive 2
Never lose a job to a crash: list-based reliable queues.
A naive queue removes a job the instant a worker takes it; a crash before the result is durable loses it. The reliable-queue pattern moves the job onto a second list as it is claimed, and takes it off only on a positive ack.
Grounding: the Redis docs for LMOVE / RPOPLPUSH (the pattern + the processing-list recovery note) · PinLater — explicit acks + automatic retry at six million jobs per minute · the real echo_mq bus — the same guarantee from a server-clock lease + an attempts counter.
Static verdicts — claim → LMOVE work processing RIGHT LEFT, the job moves in one atomic step · ack → LREM processing 1, the only path off the processing list under normal operation · recover → a stranded job moves back to work, then a healthy worker claims it again. At-least-once: a worker that crashes after the work but before the LREM runs the job twice.
The gap a naive pop leaves
Redis docs · RPOPLPUSH — reliable queueA queue built on a single list is one command from losing work. The producer pushes a job with
LPUSH; the consumer takes one with RPOP. The job leaves the list the moment
RPOP returns it. If the worker then crashes — a deploy, an out-of-memory kill, a
hardware fault — before the job’s effect is durable, the job is gone. Nothing on the server
records that it was ever in flight.
The fix is to make claiming a job a move, not a removal. The job does not leave the system when a worker takes it; it moves to a second list — a processing list — where it stays visible until the worker confirms the job is done. A crash now leaves a trace: the job sits on the processing list, and a recovery sweep can find it and put it back.
- work list
- The queue of jobs waiting to run. Producers
LPUSHthe head. - processing list
- The jobs a worker currently holds in flight. A job here is claimed but not yet acknowledged.
- claim
- Atomically move one job from the work list to the processing list.
- ack
- On success, remove that job from the processing list with
LREM. - recover
- A separate worker re-queues any job that has sat on the processing list too long.
The pattern is three beats — claim, ack, recover — and the point of all three is that a job is never on neither list. A crash leaves it on the processing list, recoverable, never gone.
The atomic move on claim
Redis docs · LMOVE / BLMOVEThe claim is one server-side operation, so there is no window in which the job belongs to neither
list. The original Redis primitive for this is RPOPLPUSH: it pops the tail of the source
list and pushes it onto the head of the destination list, returning the element, all in one step.
# the original primitive — pop the work tail, push onto processing, atomically
RPOPLPUSH queue:work queue:processing
For a worker that should wait when the queue is empty rather than spin, the blocking form
BRPOPLPUSH blocks until an element arrives or a timeout elapses:
# block up to 5 seconds for a job, then move it onto processing
BRPOPLPUSH queue:work queue:processing 5
RPOPLPUSH and BRPOPLPUSH were deprecated in Redis 6.2.0 in
favour of LMOVE and BLMOVE, which take an explicit direction on each end
(LEFT or RIGHT) instead of fixing the ends. LMOVE queue:work
queue:processing RIGHT LEFT is the modern spelling of RPOPLPUSH — pop the right
(tail) of the work list, push to the left (head) of processing. Teach LMOVE /
BLMOVE as today’s primitive; RPOPLPUSH is the original.
# the modern primitive (Redis 6.2.0+) — explicit ends, same atomic move LMOVE queue:work queue:processing RIGHT LEFT # claim a job BLMOVE queue:work queue:processing RIGHT LEFT 5 # block up to 5s, then claim
After the move, the worker holds the job and the job is recorded as in-flight on the processing list. There is no instant at which a crash could lose it.
The ack and the recovery worker
Redis docs · LREM — remove on ackA worker that finishes a job removes it from the processing list with LREM.
LREM deletes matching elements by value; with a count of 1 it removes the
first occurrence of the job from the head:
# on a positive ack — remove the finished job from the processing list
LREM queue:processing 1 "<the job payload>"
That is the only path off the processing list under normal operation. If the worker never reaches the
LREM — it crashed mid-job — the job stays on the processing list. A separate
recovery worker scans the processing list, finds entries that have sat there longer than
a job should take, and moves them back onto the work list with another LMOVE (processing
→ work). The crashed job is re-queued, and a healthy worker claims it again.
The cost of the pattern is the choice between exactly-once and at-least-once. A worker that does its
work and then crashes before the LREM will have its job re-queued and run a second
time. Reliable-queue work is therefore at-least-once; the job handler must be
idempotent. That is the trade the pattern makes — it would rather run a job twice than lose it.
Two commands carry the guarantee: LMOVE claims, LREM acks, and a
recovery sweep covers the crash between them. The bill is at-least-once delivery, paid with an idempotent
handler.
PinLater — the same guarantee at scale
Pinterest Engineering · Open-sourcing PinLaterPinterest’s asynchronous job system, PinLater, names the same contract at production scale. PinLater was a Thrift service whose three core actions were enqueue, dequeue, and ACK. Its reliability mechanism, in its own words, was “explicit acks and automatically retries with configurable delay.” A worker replied with a positive or negative ACK depending on whether execution succeeded, and a job that was not positively acknowledged was retried after a configurable delay — the recovery worker, generalized into the service.
PinLater ran “more than 500 job queues processing north of six million jobs per minute” across more than ten clusters on EC2. The explicit-ack contract is what let it run at that rate without silently dropping work: a job is not done until a worker says it is done.
PinLater offered two storage backends, MySQL and Redis, and used Redis as the high-throughput one. The ack-and-retry guarantee held regardless of the backend: the list-in-RAM form is fast; a durable form trades some speed for a record that survives a restart. That tension is the next dive’s punch line.
Explicit acks plus configurable retry are the reliable-queue pattern spelled at six million jobs per minute. The three primitives become three service calls; the guarantee — no job done until it is acknowledged — is the same.
The bus reaches the same goal with a lease
echo_mq/lib/echo_mq/jobs.ex · the lease + attemptsThe course’s worked bus is the Valkey-native echo_mq — the owned protocol over
the braced emq:{q}: keyspace that the codemojex game’s queue runs on. It solves the
identical problem — no lost job, bounded retries — but it does not use a
processing list. It uses a server-clock lease plus an attempts counter
carried in the job’s own hash.
A job is an entity: its row is a hash at the job key, written when the job is enqueued. The enqueue
script sets three fields on that hash — state, attempts, and
payload — in one atomic step:
-- the job row is written with state, attempts, and payload, atomically
redis.call('HSET', KEYS[1], 'state', 'pending', 'attempts', '0', 'payload', ARGV[2])
redis.call('ZADD', KEYS[2], 0, ARGV[1])
When a worker claims the job, the claim script reads the server clock and stamps a
lease deadline, moves the job from pending to active, and increments attempts — the
fencing counter:
-- claim mints a lease: bump the fencing counter, then read the server clock local att = redis.call('HINCRBY', jk, 'attempts', 1) -- ... local t = redis.call('TIME') -- the server clock; the deadline is computed wire-side
The lease is the processing list’s job, done by a clock rather than a second list. If the worker
finishes, it calls complete and the job’s row is cleared. If the worker crashes, it
never calls complete; the lease deadline passes; a sweep finds the expired lease and the job
is re-claimed — and because the claim does HINCRBY 'attempts' 1, the retry count is
already there to bound the retries. The reader never scans a processing list for stuck items; the
deadline on the lease is the staleness signal, read from the same TIME clock every
claim, promote, and reap uses.
The owned wire still knows the list family — EchoWire.Cmd.rpoplpush/2
(“Open an RPOPLPUSH builder”) and blmove (the blocking move) are both real
builders in echo_wire/lib/echo_wire/cmd.ex — but the bus’s crash-safety is the
lease, not a processing-list move.
Notes on ValkeyThe bus runs an LMOVE in its lane
scheduler, but it is LMOVE KEYS[1] KEYS[1] 'LEFT' 'RIGHT' — a single-key rotation of the
ring of lane names for round-robin fairness, not a source-to-processing move. The list move and
the lane rotation are different operations; the documented family is
valkey.io/commands/lmove.
Claim a job by atomically moving it to a processing list (LMOVE / BLMOVE),
remove it on ack (LREM), and let a recovery worker re-queue items stuck too long. No job
is lost to a crash between claim and ack.
The bus stamps a server-clock lease (TIME) and an
attempts counter (HSET … 'attempts' '0' on enqueue;
HINCRBY … 'attempts' 1 on claim) on the job’s hash, so a crash → lease expiry
→ re-claim with attempts+1 — the lease-based equivalent of the processing list.
A reliable queue refuses to lose a job between claim and ack. Redis’s classic form
does it with a processing list, an LREM on ack, and a recovery sweep; the bus does it with a
server-clock lease and an attempts counter. Both are at-least-once, so both want an idempotent
handler — they would rather run a job twice than drop it.
The lease lives in RAM — until the record is made durable
The lease and the job hash live in Valkey, so a process crash is survived but a full-node loss is not. Making the in-flight record outlive the node is the durability dial — the in-memory job hash, a bounded checkpoint, and a commit-per-record tier that replicates off-box. That dial, and where the bus turns it, is the persistence course.
/echo-persistence — the durability dial (ETS → Valkey → a durable page tier → remote), and /echomq/queue — jobs, lanes, and the lease, in depth.
Recap — claimed, acked, never lost
next → R8.03.3The gap is the instant between a naive pop and a durable result; a crash in that instant loses the
job. The list-based reliable queue closes it by making the claim an atomic move onto a processing list
(LMOVE / BLMOVE), taking the job off only on a positive ack (LREM),
and re-queuing strays with a recovery worker. PinLater names the same contract — explicit acks and
configurable retry — at six million jobs per minute. The bus reaches the same goal with a
server-clock lease and an attempts counter instead of a processing list. The next dive turns
to scale: how “add a shard” becomes a configuration change, not a data migration, and where
the in-RAM record gets made durable.
References
sources · relatedSources
- Redis — LMOVE — the modern reliable-queue primitive;
RPOPLPUSHwas deprecated in 6.2.0 in its favour. - Redis — RPOPLPUSH — the original pattern, with the processing-list recovery note for crash safety.
- Pinterest Engineering — Open-sourcing PinLater — explicit acks + automatic retry with configurable delay, at six million jobs per minute.
- GitHub — pinterest/pinlater — the Thrift async-job service: enqueue, dequeue, ACK (Java, Apache-2.0; archived).
Related in this course
- R8.03.1 · Functional partitioningthe previous dive — placement decided by identity
- R8.03.3 · 1 → 1000+ scalingthe next dive — making “add a shard” trivial, and the durability turn
- R8.03 · Pinterest: task queues & partitioningthe module hub
- /echomq/queuethe Queue pillar — jobs, lanes, and the lease, in depth
- /echo-persistencethe durability dial — where the in-flight record is made to survive a restart