Retries and dead letters
This guide retries a job now or later, caps its attempts and parks what keeps failing in a dead-letter group of the same table.
A retry here is a statement on the job's row. The row keeps its id and its place in the table, counts one more attempt, and a delay is a time in one of its columns. Nothing lives only in the process: a job waiting for its retry survives a restart.
The example is a mailer that sends emails through a provider.
The table
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS mail_jobs (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
retry_after TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now')),
locked_until TEXT,
payload BLOB NOT NULL
)";
name is the group a row waits in: the subscription to emails reads the rows whose name is emails.
attempt counts the deliveries of a row, the first one included.
retry_after is the time a row becomes due; its default makes a new row due at once.
The subscriber
The provider refuses an email in three ways, and each needs a different outcome.
Busy means its rate limit is spent: a retry right now would be refused again, so the email waits.
Unavailable means it did not answer: retry tries again at once.
Rejected means the mailbox does not exist, and another try gets the same answer, so the email is dropped.
- Macros
- Manual
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::Sqlite;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `mail_jobs`; `name` is the queue the row waits in.
#[derive(Inbox, sqlx::FromRow)]
#[inbox(table = "mail_jobs")]
struct SendMail {
#[field(id, generated)]
id: i64,
#[field(group)]
name: String,
#[field(attempt, generated)]
attempt: i16,
#[field(retry_after)]
retry_after: DateTime<Utc>,
#[field(locked_until)]
locked_until: Option<DateTime<Utc>>,
#[field(payload)]
payload: Vec<u8>,
}
/// How much longer each attempt waits after the provider said it was busy.
const BACKOFF: Duration = Duration::from_secs(1);
#[subscriber(InboxQueue::<SendMail>::new("emails"))]
async fn send(mail: &Mail, Ctx(attempt): Ctx<keys::Attempt>) -> HandlerOutcome {
let attempt = attempt.unwrap_or(1);
match deliver(mail, attempt).await {
Ok(()) => HandlerOutcome::ack(),
// The provider's rate limit is spent: a retry right now would be refused again.
Err(Refusal::Busy) => {
HandlerOutcome::retry_after(BACKOFF * u32::try_from(attempt).unwrap_or(u32::MAX))
}
// The provider did not answer: the next attempt may reach it.
Err(Refusal::Unavailable) => HandlerOutcome::retry(),
// The mailbox does not exist: every attempt gets the same answer.
Err(Refusal::Rejected) => {
tracing::warn!(to = %mail.to, "address rejected, dropped");
HandlerOutcome::drop()
}
}
}
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::Sqlite;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `mail_jobs`; `name` is the queue the row waits in.
#[derive(sqlx::FromRow)]
struct SendMail {
id: i64,
attempt: i16,
payload: Vec<u8>,
}
impl InboxTable for SendMail {
type Id = i64;
type Table = InboxSpec<(
Attempt,
RetryAfter<DateTime<Utc>>,
Lease<DateTime<Utc>>,
Payload,
)>;
const TABLE: Self::Table = InboxSpec::new("mail_jobs", Column::new("id").generated())
.group(Column::new("name"))
.attempt(Column::new("attempt").generated())
.retry_after(Column::new("retry_after"))
.lease(Column::new("locked_until"))
.payload(Column::new("payload"));
fn id(&self) -> &i64 {
&self.id
}
}
impl AttemptRow for SendMail {
type Attempt = i16;
fn attempt(&self) -> &i16 {
&self.attempt
}
}
impl PayloadRow for SendMail {
type Column = Vec<u8>;
fn payload(&self) -> &[u8] {
&self.payload
}
}
/// How much longer each attempt waits after the provider said it was busy.
const BACKOFF: Duration = Duration::from_secs(1);
struct Sender;
impl Handle<Mail, (), (), InboxContext> for Sender {
async fn handle(
&self,
mail: &Mail,
_outs: &(),
ctx: &mut Context<'_, InboxContext>,
) -> Result<(), HandlerOutcome> {
let Ctx(attempt) = Ctx::<keys::Attempt>::from_context(ctx).await?;
let attempt = attempt.unwrap_or(1);
match deliver(mail, attempt).await {
Ok(()) => Ok(()),
// The provider's rate limit is spent: a retry right now would be refused again.
Err(Refusal::Busy) => Err(HandlerOutcome::retry_after(
BACKOFF * u32::try_from(attempt).unwrap_or(u32::MAX),
)),
// The provider did not answer: the next attempt may reach it.
Err(Refusal::Unavailable) => Err(HandlerOutcome::retry()),
// The mailbox does not exist: every attempt gets the same answer.
Err(Refusal::Rejected) => {
tracing::warn!(to = %mail.to, "address rejected, dropped");
Err(HandlerOutcome::drop())
}
}
}
}
The handler owns the backoff.
It reads the delivery's attempt and asks for a second more with each one: retry_after(d) writes "now" plus d into the row's retry_after, and no claim takes the row before then.
A table without that column returns the row at once, and the runtime logs a warning.
retry() returns the row to the queue at once; its next delivery reads one more attempt.
drop() finishes the row as an acknowledgement does, so a rejected email leaves the table.
A row whose payload does not decode spends its attempts like any other failure (Decoding).
The cap and the dead-letter group
- Macros
- Manual
/// The service on the database `url` names.
fn service(url: &str) -> RustStream {
let schema_url = url.to_owned();
// Nothing connects here: the broker builds its pool when the service starts.
let broker = SqlxBroker::from_url(url, SqlitePoolOptions::new());
RustStream::new(AppInfo::new("mailer", "0.1.0"))
// The table, created before the subscription checks it.
.on_startup(async move |()| {
let mut conn = SqliteConnection::connect(&schema_url).await?;
sqlx::raw_sql(SCHEMA).execute(&mut conn).await?;
conn.close().await
})
.with_broker(broker.route::<SendMail>("emails"), |b| {
// Five deliveries, the first included; the fifth failure moves the row into the
// `emails.dead` group.
b.include(send)
.max_attempts(nonzero!(5u32))
.dead_letter("emails.dead");
b.include(review);
})
}
/// The service on the database `url` names.
fn service(url: &str) -> RustStream {
let schema_url = url.to_owned();
// Nothing connects here: the broker builds its pool when the service starts.
let broker = SqlxBroker::from_url(url, SqlitePoolOptions::new());
RustStream::new(AppInfo::new("mailer", "0.1.0"))
// The table, created before the subscription checks it.
.on_startup(async move |()| {
let mut conn = SqliteConnection::connect(&schema_url).await?;
sqlx::raw_sql(SCHEMA).execute(&mut conn).await?;
conn.close().await
})
.with_broker(broker.route::<SendMail>("emails"), |b| {
// Five deliveries, the first included; the fifth failure moves the row into the
// `emails.dead` group.
let send =
subscriber("emails", Sender).map_source(|_| InboxQueue::<SendMail>::new("emails"));
b.include(send.build())
.max_attempts(nonzero!(5u32))
.dead_letter("emails.dead");
let review = subscriber("emails.dead", Review)
.map_source(|_| InboxQueue::<SendMail>::new("emails.dead"));
b.include(review.build());
})
}
max_attempts reads the row's attempt.
The fifth delivery that fails is not retried: dead_letter moves the row into the emails.dead group, payload and attempt as they were.
A delayed retry counts against the cap as an immediate one does.
The two steps come together: a mount that declares one without the other stops the service at startup.
A table without a group column takes a table name in dead_letter instead, and the row moves into that table, which has the same columns.
Reading the dead letters
The dead-letter group is an ordinary queue of the table, and review reads it like any other.
- Macros
- Manual
#[subscriber(InboxQueue::<SendMail>::new("emails.dead"))]
async fn review(mail: &Mail, Ctx(attempt): Ctx<keys::Attempt>) -> HandlerOutcome {
tracing::error!(to = %mail.to, ?attempt, "email not sent, needs a person");
HandlerOutcome::ack()
}
struct Review;
impl Handle<Mail, (), (), InboxContext> for Review {
async fn handle(
&self,
mail: &Mail,
_outs: &(),
ctx: &mut Context<'_, InboxContext>,
) -> Result<(), HandlerOutcome> {
let Ctx(attempt) = Ctx::<keys::Attempt>::from_context(ctx).await?;
tracing::error!(to = %mail.to, ?attempt, "email not sent, needs a person");
Ok(())
}
}
review logs the email for a person to look at and acknowledges it.
A service that keeps its dead letters for an operator mounts no subscription on the group; the rows wait in the table.
Sending them again is one statement of the service's own, which moves them back and resets their count:
UPDATE mail_jobs SET name = 'emails', attempt = 1, locked_until = NULL WHERE name = 'emails.dead';
In a table with FIFO groups, retry() keeps a row at the head of its group, and retry_after(d) lets the rows due earlier pass it (Ordering).
Testing
The tests in the example file publish an email through the harness, as the service's own Publish writes it.
A rejected address is dropped at once, which TestApp::start shows when the publish returns.
An unanswered email and a busy provider need time to pass: the delayed row and the dead letter come back on the subscriptions' next poll.
Those tests run on the real clock with TestApp::start_live, with a backoff of one second, and tb.advance waits it out.
Testing a service on SQL covers the harness.