Retries and dead letters
This guide retries a message later, caps the attempts and parks what fails on a dead-letter queue.
ZeroMQ delivers a message at most once and stores nothing. A message that a handler fails on is gone unless the service publishes it again. The framework does that: the mount site declares the cap and where the copies go, and each copy carries a retry count in a header.
The example is a billing service with two workers. The job worker retries a failed job into its own queue. The invoice worker parks a failed invoice on a queue of its own, for a person to look at.
At most once
ack and nack have nothing to tell the socket: ZeroMQ has no acknowledgement.
Each of them reports AckError::Unsupported, and the runtime logs a warning.
A plain retry with no cap therefore brings nothing back, and the handler is not called again.
A retry that must happen goes through a copy, as below.
The job worker
- Macros
- Manual
use std::time::Duration;
use ruststream_zeromq::queue::prelude::*;
use serde::Deserialize;
/// How long a failed job waits before its next delivery.
const BACKOFF: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Deserialize)]
struct Job {
id: u64,
}
#[subscriber("jobs")]
async fn work(job: &Job) -> HandlerOutcome {
match run(job) {
Ok(()) => HandlerOutcome::ack(),
Err(err) => {
tracing::warn!(job.id, %err, "the job failed, retrying later");
HandlerOutcome::retry_after(BACKOFF)
}
}
}
use std::time::Duration;
use ruststream_zeromq::queue::prelude::*;
use serde::Deserialize;
/// How long a failed job waits before its next delivery.
const BACKOFF: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Job {
id: u64,
}
struct Work;
impl Handle<Job> for Work {
fn handle(
&self,
job: &Job,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
ready(run(job).map_err(|err| {
tracing::warn!(job.id, %err, "the job failed, retrying later");
HandlerOutcome::retry_after(BACKOFF)
}))
}
}
A job that fails asks for another try in 30 seconds with retry_after.
The runtime waits out the delay and publishes a copy of the delivery, its payload and headers as they arrived.
The copy waits in the memory of the service, so a process that stops before the delay is over loses it.
The invoice worker
- Macros
- Manual
#[derive(Debug, PartialEq, Deserialize)]
struct Invoice {
id: u64,
amount_cents: u64,
}
#[subscriber("invoices")]
async fn send_invoice(invoice: &Invoice) -> HandlerOutcome {
if invoice.amount_cents == 0 {
tracing::warn!(invoice.id, "an invoice without an amount, parking it");
return HandlerOutcome::retry();
}
tracing::info!(invoice.id, invoice.amount_cents, "invoice sent");
HandlerOutcome::ack()
}
/// Keeps the invoices nobody could send, for a person to look at.
#[subscriber("invoices.dead")]
async fn park(invoice: &Invoice) -> HandlerOutcome {
tracing::error!(invoice.id, "invoice parked");
HandlerOutcome::ack()
}
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Invoice {
id: u64,
amount_cents: u64,
}
struct SendInvoice;
impl Handle<Invoice> for SendInvoice {
fn handle(
&self,
invoice: &Invoice,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
if invoice.amount_cents == 0 {
tracing::warn!(invoice.id, "an invoice without an amount, parking it");
return ready(Err(HandlerOutcome::retry()));
}
tracing::info!(invoice.id, invoice.amount_cents, "invoice sent");
ready(Ok(()))
}
}
/// Keeps the invoices nobody could send, for a person to look at.
struct Park;
impl Handle<Invoice> for Park {
fn handle(
&self,
invoice: &Invoice,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::error!(invoice.id, "invoice parked");
ready(Ok(()))
}
}
An invoice without an amount cannot be sent, and sending it again changes nothing.
send_invoice returns retry, and the mount site turns that into a dead letter.
park reads the dead letters and logs each one.
The mount sites
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App {
let dead = ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5561")).bindable();
let parked = dead.bind(Publish);
RustStream::new(AppInfo::new("billing", "0.1.0"))
.with_broker_labeled(
"jobs",
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5555")),
|b| {
// Five deliveries at most, the first one included; the copies return to this queue.
b.include(work)
.max_attempts(nonzero!(5u32))
.out_retry(Publish);
},
)
.with_broker_labeled(
"invoices",
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5556")),
|b| {
// A failed invoice leaves for the second queue, under the name it is read by.
b.include(send_invoice)
.dead_letter("invoices.dead")
.out_retry(parked);
},
)
.with_broker_labeled("dead", dead, |b| {
b.include(park);
})
}
fn app() -> impl App {
let dead = ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5561")).bindable();
let parked = dead.bind(Publish);
RustStream::new(AppInfo::new("billing", "0.1.0"))
.with_broker_labeled(
"jobs",
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5555")),
|b| {
// Five deliveries at most, the first one included; the copies return to this queue.
b.include(subscriber("jobs", Work).build())
.max_attempts(nonzero!(5u32))
.out_retry(Publish);
},
)
.with_broker_labeled(
"invoices",
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5556")),
|b| {
// A failed invoice leaves for the second queue, under the name it is read by.
b.include(subscriber("invoices", SendInvoice).build())
.dead_letter("invoices.dead")
.out_retry(parked);
},
)
.with_broker_labeled("dead", dead, |b| {
b.include(subscriber("invoices.dead", Park).build());
})
}
fn main() -> ExitCode {
run_main(app)
}
max_attempts counts every delivery of one job, the first included.
ZMTP has no delivery counter, so the framework counts through its x-ruststream-retry-count header, and the cap holds wherever the copies travel.
The fifth delivery of a failing job is not retried; with no dead-letter destination it is dropped with a warning that names the subscription.
out_retry picks the publisher the copies leave through.
The jobs queue is bound by this service, so .out_retry(Publish) sends each copy back into it, and whichever job worker is free takes it.
The invoice mount declares dead_letter("invoices.dead") and no cap, so every failed invoice goes straight there.
A dead letter is a publish like any other, and a bound queue takes only its own subscription's name.
So the dead letters leave through parked, a policy on the dead broker, and park reads them there.
The retry copies of a mount leave through the same publisher, so a mount trades its retries for its dead letters.
A mount that wants both keeps its retries on its own queue and leaves dead-lettering out.
A worker that dials
A worker that dials a ventilator cannot send a copy back: the ventilator only sends.
Its mount names where the copies go, with .out_retry(policy).to("jobs") over a queue the worker binds for itself, and mounts the same handler on that queue.
A mount that names no destination is refused at startup, and a Router chain that names none does not compile.
out_retry shows this mount, and Pipelines with PUSH/PULL uses it.
Testing the delays
The tests run the app in process.
The job test starts on a paused clock, so tb.advance(BACKOFF) lets 30 seconds pass at once.
It checks five deliveries and a last copy that carries the retry count 4.
The invoice test checks that the invoice reached park after one delivery.
Testing a ZeroMQ service covers the harness.