Deferral
An external API answers 429 Too Many Requests with Retry-After: 30.
Nothing went wrong. The job has to wait exactly that long, and then run
before the backlog that piled up meanwhile. queuey gets it there by
republishing the held job at a higher broker priority than ordinary work,
so it rejoins the queue at the front instead of at the back. That is a
deferral, and it is deliberately not a retry.
#[async_trait]impl JobHandler for CallApiHandler { type Job = CallApi;
async fn handle(&self, job: CallApi, ctx: JobContext) -> Result<(), JobError> { let response = self.http.get(&job.url).send().await.map_err(JobError::retryable)?;
if response.status() == 429 { // A handler that wants a cap enforces its own: nothing else will. if ctx.deferrals >= 5 { return Err(JobError::fatal_msg("still rate limited after five deferrals")); } let retry_after = parse_retry_after(&response).unwrap_or(Duration::from_secs(30)); return Err(JobError::deferred_msg(retry_after, "rate limited")); }
Ok(()) }}What it guarantees
Section titled “What it guarantees”A deferral costs no attempt. ctx.attempt is unchanged and the retry
policy is never consulted: neither its backoff, because the handler stated the
delay, nor its max_attempts, because nothing failed and so nothing should be
dead-lettered. The worker logs it at INFO, not WARN or ERROR.
The job goes to the front, not the back. The held envelope is republished
at the highest priority level its queue was declared with, while everything
enqueued normally sits at priority 0. A priority queue hands out the
highest-priority message first, so the deferred job is delivered ahead of the
backlog rather than queueing behind it. ctx.deferrals counts how often it
happened, and ctx.priority is the priority the delivery arrived with.
The priority lasts exactly one round. If the job then fails and is retried,
the retry goes out at priority 0. A retry is not a deferral, and nothing is
owed a permanent place at the front. ctx.deferrals still carries.
“First” means first among what is still on the queue
Section titled ““First” means first among what is still on the queue”A consumer with prefetch 10 may already be holding ten backlog messages when the deferred job is released. Those ten run first. Priority orders what the broker still has, not what a consumer has already been handed.
That is usually fine, and lowering prefetch on a queue where deferral
ordering matters tightens it.
Deferring from the producer
Section titled “Deferring from the producer”The same mechanism, without a first run. Use it to schedule work you already know cannot happen yet:
producer.defer(&CallApi { url }, Duration::from_secs(30)).await?;The contrast with enqueue_after is the whole point of having both:
| Call | Waits | Comes back at | Spends a deferral |
|---|---|---|---|
enqueue(&job) |
not at all | priority 0 |
no |
enqueue_after(&job, d) |
d |
priority 0, behind the backlog |
no |
defer(&job, d) |
d |
the queue’s top priority, ahead of the backlog | no |
defer from the producer leaves deferrals at 0 — the producer never
deferred it once, it scheduled it — while a handler-side deferral increments
it. Both arrive at the same elevated priority.
The queue has to have priority levels
Section titled “The queue has to have priority levels”Deferral works on any queue, but coming back ahead of the backlog requires
the queue to have been declared with priority levels. max_priority defaults
to 10, so this works by default. Set it to 0 and deferred jobs come back
FIFO:
#[queue(name = "slow", prefetch = 2, max_priority = 0)]SlowApi,assert_eq!( runs[1].priority, 0, "`max_priority = 0` is not a priority queue, so the job comes back FIFO");That is a reasonable choice when you cannot redeclare an existing queue. The job still waits the right amount of time; it just takes its turn.
How it works underneath
Section titled “How it works underneath”The envelope is published into a hold queue whose entire purpose is to
expire: q.deferred.30000 has x-message-ttl = 30000 and dead-letters back to
q. When the TTL runs out, the broker moves the message onto the work queue
with its AMQP priority property already set to the queue’s maximum, which is
what puts it at the front. Nothing in your process is waiting, and a worker
restart does not lose the deferral.
Delays are rounded up to deferred_granularity, 1 second by default, so
Retry-After: 30 and a 29.2 second delay share one hold queue. Rounding is
always up, so a job is never released early.
The longest delay a hold queue can express is about 24.8 days. A longer one is refused with an error rather than quietly released early.
Full detail, including why retries use the same mechanism, is in Broker topology.
Observing it
Section titled “Observing it”MemoryBackend counts held envelopes, which is what makes deferral testable:
let id = h.producer .defer(&CallApi::new("scheduled"), Duration::from_secs(30)) .await?;assert_eq!(h.backend.deferred(EMAILS), 1, "held, not deliverable yet");assert_eq!(h.backend.pending(EMAILS), 0);
h.wait_for("the held job to run", || h.log.runs() == 1).await;
let runs = h.log.runs_of("scheduled");assert_eq!(runs[0].attempt, 1);assert_eq!(runs[0].deferrals, 0, "the producer never deferred it once");assert_eq!(runs[0].priority, 10, "it still arrives ahead of the backlog");On RabbitMQ, hold queues show up in the management UI as
myapp.emails.deferred.30000 and disappear on their own once idle, because
they are declared with x-expires = 2 * ttl.
One requirement
Section titled “One requirement”The queue you defer onto must have been declared through the same backend — the
one Producer::new or WorkerBuilder::build gave you. A producer built with
Producer::new_undeclared cannot defer or delay on RabbitMQ, because there
would be no q for the hold queue to dead-letter into. That case returns
Error::UnknownQueue.