Skip to main content

Isolation

isolation = <level> opens a table's transactions at an isolation level, mode = <mode> at a SQLite mode; a level the dialect does not open does not compile.

isolation in #[inbox(..)] takes read_uncommitted, read_committed, repeatable_read or serializable. A SQLite table declares mode instead: deferred, immediate or exclusive. SQLite runs every transaction serializable, and its mode decides when a transaction takes the write lock (Mode). A table declares one of the two at most. The declaration governs the transactions the crate opens for a delivery's work: in the row lock form the claim's transaction, and in transactional mode the one the handler writes through, in every form. A table that declares neither opens them with a plain BEGIN on Postgres and SQLite, at the database's default, and at READ COMMITTED on MySQL and MariaDB. Each database opens the levels it keeps:

DatabaseOpens
Postgresread_committed, repeatable_read, serializable; it runs READ UNCOMMITTED as READ COMMITTED, so its dialect does not open it
MySQL and MariaDBall four levels
SQLitethe three modes

A subscription to a table at a level its broker's dialect does not open does not compile, and neither does a route into that table; the error names the dialect and the level. An AnyPool names its database only when the broker connects, so there such a subscription stops at startup with SqlxBrokerError::Dialect. On Postgres a row lock table at repeatable_read or serializable fails claims with serialization errors when claims and settlements of its rows run at once, so read_committed is the practical level there. A lease table on Postgres at a strict level refuses transactional mode at startup: its transaction would not see the lease extended after its first statement. On MySQL and MariaDB a row lock claim at repeatable_read or serializable locks every row it reads until its settlement; an index on the group's column keeps a claim to its own group. By hand, .opens::<Level>() takes a marker of dialect::level, and Opens<Level> goes into the description's type.

Example​

A withdrawal reads the balance and writes it back in one transaction; mode = immediate takes the write lock when the transaction begins, so no other writer changes the balance between the read and the write.

use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::Sqlite;
use sqlx::sqlite::SqlitePoolOptions;

/// One row of `withdrawal_jobs`; its transactions open with `BEGIN IMMEDIATE`.
#[derive(Inbox, sqlx::FromRow)]
#[inbox(table = "withdrawal_jobs", mode = immediate)]
struct Withdraw {
#[field(id, generated)]
id: i64,
#[field(locked_until)]
locked_until: Option<DateTime<Utc>>,
#[field(payload)]
payload: Vec<u8>,
}

#[subscriber(InboxQueue::<Withdraw>::new("withdrawals"))]
async fn withdraw(order: &Withdrawal, Ctx(mut tx): Ctx<keys::Tx<Sqlite>>) -> HandlerOutcome {
let balance: Result<i64, _> =
sqlx::query_scalar("SELECT cents FROM balances WHERE account = ?")
.bind(&order.account)
.fetch_one(&mut *tx)
.await;
let Ok(balance) = balance else {
return HandlerOutcome::retry();
};
if balance < order.cents {
tracing::warn!(account = %order.account, "insufficient funds");
return HandlerOutcome::drop();
}
let paid = sqlx::query("UPDATE balances SET cents = ? WHERE account = ?")
.bind(balance - order.cents)
.bind(&order.account)
.execute(&mut *tx)
.await;
if paid.is_ok() {
HandlerOutcome::ack()
} else {
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("wallet", "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::<Withdraw>("withdrawals"), |b| {
b.include(withdraw.transactional());
})
}