dead_letter
max_attempts(n) with dead_letter(..) moves a row whose attempts are spent into another group, or into a table with the same columns.
The two steps come together at the mount site, and a mount that declares one without the other stops the service at startup.
The cap reads the row's #[field(attempt)] column, which counts the deliveries, the first one included; a cap on a table without it stops startup too.
A delivery that fails its n-th attempt moves the row instead of returning it: a table with a group column moves it to the dead_letter group, a table without one into the dead_letter table.
A dead-letter table has the same columns as the queue's; on MySQL and MariaDB the move is two statements in one transaction.
With max_attempts(1) every failure moves the row at once.
A delayed retry obeys the cap too.
The dead-letter group is an ordinary queue of the table: an InboxQueue of that name reads it, or an operator reviews it in place.
A subscription by name refuses both steps at startup.
A table may move its rows with its own SQL instead, through the DeadLetter event (Events).
Example
A payment the card network keeps declining is tried three times, then moves to the payments.failed group.
- Macros
- Manual
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `payment_jobs`: `attempt` counts the deliveries the cap reads.
#[derive(Inbox, sqlx::FromRow)]
#[inbox(table = "payment_jobs")]
struct Charge {
#[field(id, generated)]
id: i64,
#[field(group)]
name: String,
#[field(attempt, generated)]
attempt: i16,
#[field(locked_until)]
locked_until: Option<DateTime<Utc>>,
#[field(payload)]
payload: Vec<u8>,
}
#[subscriber(InboxQueue::<Charge>::new("payments"))]
async fn charge(payment: &Payment) -> HandlerOutcome {
if authorize(payment).await {
return HandlerOutcome::ack();
}
HandlerOutcome::retry()
}
/// 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("payments", "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::<Charge>("payments"), |b| {
// Three attempts, the first included; the third failure moves the row into the
// `payments.failed` group instead.
b.include(charge)
.max_attempts(nonzero!(3u32))
.dead_letter("payments.failed");
})
}
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `payment_jobs`: `attempt` counts the deliveries the cap reads.
#[derive(sqlx::FromRow)]
struct Charge {
id: i64,
attempt: i16,
payload: Vec<u8>,
}
impl InboxTable for Charge {
type Id = i64;
type Table = InboxSpec<(Attempt, Lease<DateTime<Utc>>, Payload)>;
const TABLE: Self::Table = InboxSpec::new("payment_jobs", Column::new("id").generated())
.group(Column::new("name"))
.attempt(Column::new("attempt").generated())
.lease(Column::new("locked_until"))
.payload(Column::new("payload"));
fn id(&self) -> &i64 {
&self.id
}
}
impl AttemptRow for Charge {
type Attempt = i16;
fn attempt(&self) -> &i16 {
&self.attempt
}
}
impl PayloadRow for Charge {
type Column = Vec<u8>;
fn payload(&self) -> &[u8] {
&self.payload
}
}
struct ChargeCard;
impl Handle<Payment> for ChargeCard {
async fn handle(
&self,
payment: &Payment,
_outs: &(),
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
if authorize(payment).await {
return Ok(());
}
Err(HandlerOutcome::retry())
}
}
/// 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("payments", "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::<Charge>("payments"), |b| {
let charge = subscriber("payments", ChargeCard)
.map_source(|_| InboxQueue::<Charge>::new("payments"));
// Three attempts, the first included; the third failure moves the row into the
// `payments.failed` group instead.
b.include(charge.build())
.max_attempts(nonzero!(3u32))
.dead_letter("payments.failed");
})
}