Transactions
Write sqlx transactions as state machines whose steps run queries, let wakuwaku own BEGIN, COMMIT and ROLLBACK, and hook into how they end.
A query run through a Db handle runs on its own, in autocommit mode. When several statements must succeed or fail together, write a transaction flow: a type that implements TxFlow. A flow is a state machine whose steps run queries on an open transaction, a Tx, and decide what comes next from their results.
wakuwaku owns the protocol around the steps: it sends BEGIN, COMMIT and ROLLBACK, knows which errors ended the transaction on the server, refuses to commit an aborted transaction, and reports a COMMIT whose answer was lost. Your flow holds only the business logic.
This page assumes you know how to write a query for sqlx; see sqlx (PostgreSQL and MySQL).
The model
The state is the flow value itself, usually an enum: a working state for each point the transaction can be at, and a terminal state. Two functions define the machine:
async fn step(tx: Tx<'_, Self>, state: Self) -> TxState<'_, Self>: run the queries ofstateontxand return the next state. It is never called with a terminal state.fn finished(self) -> Either<Self, TxEnd<Self>>:Rightwith how the transaction ends if the state is terminal,Left(self)otherwise.
The terminal states are the TxEnds:
TxEnd variant | Meaning |
|---|---|
TxEnd::Commit { output, report } | Commit. output goes to the caller, report to the transaction hook |
TxEnd::Rollback { output, report } | Roll back. Same split of output and report |
A flow turns a TxEnd into a state with From<TxEnd<Self>>, typically an End(TxEnd<Self>) variant, and finished must give it back: finished(end.into()) is Right(end).
Inside a step, the Tx is moved in and handed back:
tx.run(query).awaitruns a query and returns(tx, result), whereresultis the query's ownResult<Output, Error>.tx.with(state)goes on withstate. Returning a terminal state this way is fine: the driver ends it before it would step it.tx.end(state).awaitends the transaction now ifstateis terminal (otherwise it behaves likewith).
Both return a TxState, the state together with its transaction. A step can only produce a TxState from the Tx it was given, so the transaction never escapes the step.
The associated items say what the flow talks about. A flow is not a query and doesn't implement Query: what it may touch is the Capability it declares with tx_effect! (see Capabilities and read-only transactions).
Prop
Type
A complete flow: moving money
The example moves amount from one account to another: debit, then credit, then commit. A debit that matches no row means not enough money; a credit that matches no row means an unknown account. Both roll back.
First the queries. Each is an ordinary standalone query with its own Output and Error; the flow runs them unchanged:
use wakuwaku::deps::sqlx::{self, PgConnection, Postgres};
use wakuwaku::entity::sqlx::backend::SqlxSource;
use wakuwaku::entity::sqlx::transaction::{Either, IsolationLevel, Tx, TxEnd, TxFlow, TxState};
use wakuwaku::entity::{Entity, Execute, Query, Read, Write, tx_effect};
pub struct Account;
impl Entity for Account {}
/// Takes `amount` from `from` if the balance allows it. Rows affected.
pub struct Debit {
pub from: i64,
pub amount: i64,
}
impl Query for Debit {
type Effect = (Read<Account>, Write<Account>);
}
impl Execute<SqlxSource<Postgres>> for Debit {
type Output = u64;
type Error = sqlx::Error;
async fn execute(self, conn: &mut PgConnection) -> Result<u64, sqlx::Error> {
let done = sqlx::query(
"UPDATE account SET balance = balance - $2 WHERE id = $1 AND balance >= $2",
)
.bind(self.from)
.bind(self.amount)
.execute(conn)
.await?;
Ok(done.rows_affected())
}
}
/// Adds `amount` to `to`. Rows affected.
pub struct Credit {
pub to: i64,
pub amount: i64,
}
impl Query for Credit {
type Effect = Write<Account>;
}
impl Execute<SqlxSource<Postgres>> for Credit {
type Output = u64;
type Error = sqlx::Error;
async fn execute(self, conn: &mut PgConnection) -> Result<u64, sqlx::Error> {
let done = sqlx::query("UPDATE account SET balance = balance + $2 WHERE id = $1")
.bind(self.to)
.bind(self.amount)
.execute(conn)
.await?;
Ok(done.rows_affected())
}
}Then the flow. A transfer is a fixed sequence of statements followed by a commit, so it has a single working state, Start, and its one step runs the debit and the credit in order. TransferEffect declares the one entity both queries touch. The decisions are plain functions of a query result (check_debit, check_credit), and step only runs the queries and acts on the answers:
/// Published once the money has moved.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Transferred {
pub from: i64,
pub to: i64,
pub amount: i64,
}
/// Why the transfer was rolled back.
#[derive(Debug)]
pub enum TransferFailed {
InsufficientFunds,
UnknownAccount,
/// Serialization failure or deadlock: safe to run the transfer again.
Conflict,
Database(sqlx::Error),
}
impl TransferFailed {
fn from_sqlx(error: sqlx::Error) -> Self {
if is_conflict(&error) {
TransferFailed::Conflict
} else {
TransferFailed::Database(error)
}
}
}
tx_effect! {
/// Everything a transfer touches.
pub enum TransferEffect { write Account }
}
/// The state of a transfer.
#[derive(Debug)]
pub enum Transfer {
Start { from: i64, to: i64, amount: i64 },
End(TxEnd<Transfer>),
}
impl Transfer {
pub fn new(from: i64, to: i64, amount: i64) -> Self {
Transfer::Start { from, to, amount }
}
fn commit(from: i64, to: i64, amount: i64) -> Self {
Transfer::End(TxEnd::Commit {
output: (),
report: Some(Transferred { from, to, amount }),
})
}
fn rollback(why: TransferFailed) -> Self {
Transfer::End(TxEnd::Rollback { output: why, report: None })
}
/// Whether the debit took the money.
fn check_debit(debited: Result<u64, sqlx::Error>) -> Result<(), TransferFailed> {
match debited {
Ok(1) => Ok(()),
Ok(_) => Err(TransferFailed::InsufficientFunds),
Err(error) => Err(TransferFailed::from_sqlx(error)),
}
}
/// Whether the credit reached an account.
fn check_credit(credited: Result<u64, sqlx::Error>) -> Result<(), TransferFailed> {
match credited {
Ok(1) => Ok(()),
Ok(_) => Err(TransferFailed::UnknownAccount),
Err(error) => Err(TransferFailed::from_sqlx(error)),
}
}
}
impl From<TxEnd<Transfer>> for Transfer {
fn from(end: TxEnd<Transfer>) -> Self {
Transfer::End(end)
}
}
impl TxFlow for Transfer {
type Capability = TransferEffect;
type Database = Postgres;
type Commit = ();
type Abort = TransferFailed;
type Report = Option<Transferred>;
const ISOLATION: Option<IsolationLevel> = Some(IsolationLevel::Serializable);
async fn step(tx: Tx<'_, Self>, state: Self) -> TxState<'_, Self> {
let Transfer::Start { from, to, amount } = state else {
// Never stepped: the driver ends a terminal state first.
return tx.end(state).await;
};
let (tx, debited) = tx.run(Debit { from, amount }).await;
if let Err(why) = Self::check_debit(debited) {
return tx.end(Self::rollback(why)).await;
}
let (tx, credited) = tx.run(Credit { to, amount }).await;
match Self::check_credit(credited) {
Ok(()) => tx.end(Self::commit(from, to, amount)).await,
Err(why) => tx.end(Self::rollback(why)).await,
}
}
fn finished(self) -> Either<Self, TxEnd<Self>> {
match self {
Transfer::End(end) => Either::Right(end),
state => Either::Left(state),
}
}
}For MySQL, the flow is identical except type Database = MySql;. Each query result keeps its own type, and the code using it follows the query that produced it: no enum of queries, no enum of rows, no catch-all arm for a result that cannot happen.
TxEnd derives Debug, so a flow enum that embeds it can derive Debug too, as long as its Commit, Abort and Report types are Debug. It is deliberately not Clone: a flow value is one run of the transaction, and its terminal state is consumed once when the transaction ends, the way an awaited future is not awaited again. See Retries for what that means in practice.
The is_conflict helper recognizes the errors that mean "another transaction got in the way, run me again":
/// SQLSTATE 40001 (serialization failure) or 40P01 (deadlock detected).
pub fn is_conflict(error: &sqlx::Error) -> bool {
error
.as_database_error()
.and_then(|error| error.code())
.is_some_and(|code| code == "40001" || code == "40P01")
}Running it
Wrap the first state in Transaction and process it with a Db whose capability covers what the flow declares. What each outcome means is the caller's business, so map the result into the caller's own error where the transaction runs:
use crate::transfer::{Transfer, TransferFailed};
use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::deps::sqlx;
use wakuwaku::entity::sqlx::backend::PgSource;
use wakuwaku::entity::sqlx::transaction::{Transaction, TxError, TxOutput};
use wakuwaku::entity::{Db, ReadWrite};
/// Why a payment did not go through.
#[derive(Debug)]
pub enum PayError {
/// The transfer rolled back, and this is why.
Refused(TransferFailed),
/// `COMMIT` was sent but its answer was lost: the money may have moved.
OutcomeUnknown(sqlx::Error),
/// The transaction did not end as the flow planned.
Transaction(TxError),
}
pub async fn pay(
db: &Db<PgSource, ReadWrite>,
from: i64,
to: i64,
amount: i64,
) -> Result<(), PayError> {
match db.process(Transaction(Transfer::new(from, to, amount))).await {
Ok(TxOutput::Committed(())) => Ok(()),
Ok(TxOutput::RolledBack(why)) => Err(PayError::Refused(why)),
Err(TxError::CommitUnknown(error)) => Err(PayError::OutcomeUnknown(error)),
Err(error) => Err(PayError::Transaction(error)),
}
}process returns Result<TxOutput<Commit, Abort>, TxError>:
| Outcome | Meaning |
|---|---|
Ok(TxOutput::Committed(output)) | The flow committed and the server confirmed it |
Ok(TxOutput::RolledBack(output)) | The flow ended with TxEnd::Rollback |
Err(TxError::Begin(_)) | No connection could be acquired, or BEGIN failed. The flow never ran |
Err(TxError::CommitRejected(_)) | The server refused the COMMIT; the transaction is rolled back |
Err(TxError::CommitUnknown(_)) | COMMIT was sent but its answer was lost. The transaction may or may not have committed |
Err(TxError::Aborted) | The flow ended with TxEnd::Commit after a query had aborted the transaction; it was rolled back |
Err(TxError::EndMismatch) | finished did not give back the end the transaction was closed with: a bug in the flow |
A flow is not a query
Run a flow as db.process(Transaction(flow)) and handle its outcome there, as pay does. Don't hide it behind a Processor<Input> implementation for the Db, so that db.process(CreateServer { … }) runs a whole transaction and answers with one Output and one Error as if it were a single query. That shape loses three things:
TxError::CommitUnknownis folded into an ordinary database error. A caller that retries failed statements then retries a transaction that may already have committed.- To fit one success type, a rollback the caller should treat as success has to travel in
Abort, which becomes a second success channel (see WhatAbortis for). - A call site can no longer tell a transaction from a single statement, though the two fail differently.
The business function is the boundary: it runs the transaction and maps Committed, RolledBack and TxError into its own error, with CommitUnknown as a case of its own. Whoever gets PayError::OutcomeUnknown must reconcile before reporting a failure or trying again; see Retries.
Designing a flow
States are for loops and branches
A state is a point the transaction can come back to: the top of a loop, or a branch that continues in a later step. It is not a slot per statement sent. A straight-line transaction, a fixed sequence of statements followed by a commit, is a single step: it runs its queries in order with Tx::run, ends with a rollback as soon as one answer rules out the rest, and otherwise finishes with tx.end(...), as the transfer does. Splitting it into one state per query (Start, Debited, Credited, …) only carries the same data from variant to variant.
Use several states when the shape of the transaction depends on what it reads:
- A loop whose length is known only at run time.
PublishAllin Delivering changes from transactions steps once per post, and its state carries the posts still pending and those already published. - A branch that comes back, such as retry-with-read: a conditional write that lost a race goes to a state that re-reads the row and tries again.
What Abort is for
Abort is why the flow rolled back: a domain reason, such as TransferFailed::InsufficientFunds, or the statement that failed, such as TransferFailed::Database. It is never a second way to succeed. Don't make it Result<Commit, _> so that a rolled-back transaction can still hand the caller a result: if the flow got what it came for, it commits.
That includes a write that changed nothing. A conditional write that matched no row wrote nothing, so when "nothing matched" is an acceptable answer (the row was already in the wanted state, say), commit that empty result, for example as a Commit of Option<T> or of the rows affected. Roll back when the flow cannot do what it was asked to, as the transfer does when the debit finds too little money.
How the driver runs a flow
The driver does exactly this, and nothing more:
- Acquire a connection from the pool.
- Send
BEGINin the flow's mode: the isolation level fromISOLATION, andREAD ONLYif the flow declares no write,READ WRITEotherwise. If either fails, the result isErr(TxError::Begin)and the flow never runs. - Loop: if the state is terminal (
finishedreturnsRight), end the transaction (COMMITorROLLBACK, asTx::enddoes) and stop. Otherwise callstepwith the openTxand the state, and loop on theTxStateit returns.
So a step can either end the transaction itself with tx.end(state) or return a terminal state with tx.with(state) and let the driver end it; the result is the same.
Queries in a flow are the same queries
Execute::execute receives the transaction's connection, so a query you already run on its own through a Db runs in a flow unchanged.
Capabilities and read-only transactions
A flow declares everything its steps may touch with tx_effect!, and names the declaration as TxFlow::Capability, as TransferEffect does for the transfer. A flow that also reads who owns the accounts would declare:
tx_effect! {
/// Moves money between accounts, after checking who owns them.
pub enum CheckedTransferEffect { write Account, read User }
}write E lets the flow's queries read and write the entity E; read E lets them only read it. List each entity once: write already covers reading, and an entity listed twice does not compile. The macro generates an empty enum that is both a capability (it has the CanRead / CanWrite markers) and the matching effect (TxEffect); don't implement TxEffect by hand.
The declaration is checked at compile time, three ways:
- Each query against the flow.
Tx::runrequires the flow's capability to cover the query's effect. A query that touches an entity the flow did not declare, or writes one it declaredread, iserror[E0277]. - The flow against the handle.
Db<S, C>, andAfterTransactionaround it, processesTransaction<F>only ifCcovers the declared effect, exactly as for single queries. - The mode. A flow that declares no write begins
READ ONLY.
So a handle that may run a flow may run every query in it: a transaction can't reach an entity its handle was never checked for. The transfer writes Account, so it does not compile against a replica handle:
async fn on_replica(replica: &Db<PgSource, ReadOnly>) {
// error[E0277]: the trait bound
// `Db<SqlxSource<Postgres>, ReadOnly>: Processor<Transaction<Transfer>>` is not satisfied
let _ = replica.process(Transaction(Transfer::new(1, 2, 10))).await;
}A flow that only reads (for example a consistent report across several SELECTs, declared as read Account) begins READ ONLY and runs on a Db<_, ReadOnly> handle. In such a flow, Tx::run rejects a query that writes:
async fn step(tx: Tx<'_, Self>, state: Self) -> TxState<'_, Self> {
// Does not compile: `Debit` writes `Account`, and this flow declares `read Account`.
let (tx, _) = tx.run(Debit { from: 1, amount: 10 }).await;
// …
}Isolation levels
Set const ISOLATION to Some(IsolationLevel::…) to choose a level; leave it out for the server's default. The statements sent differ per database:
A single statement, for example BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE, or BEGIN READ ONLY without an isolation level. PostgreSQL runs ReadUncommitted as READ COMMITTED.
Errors inside a transaction
Every query error reaches the step as the query's result, and the step decides what to do with it. What wakuwaku tracks for you is whether the error ended the transaction on the server, which differs between databases. Errors raised by sqlx on the client, after or instead of sending a statement, never change the server's state, so they never abort: RowNotFound, TypeNotFound, ColumnIndexOutOfBounds, ColumnNotFound, ColumnDecode, Encode, Decode, InvalidArgument.
Every other error aborts. After any statement error, PostgreSQL rejects every later statement until the transaction is rolled back, and COMMIT silently rolls back. So a constraint violation, a serialization failure, or a lost connection all abort the transaction.
An aborted transaction can only roll back
Once a query has aborted the transaction (tx.is_aborted() is true), later tx.run calls fail without sending anything, so a flow cannot keep issuing statements that would fail or, on MySQL, run outside the transaction. Ending it always rolls back: a TxEnd::Rollback gives RolledBack as usual, and a TxEnd::Commit gives Err(TxError::Aborted). A flow should go to a rollback state after such an error, as the transfer does.
Queries with their own error type
A query whose Error is not sqlx::Error (for example one that refuses on its own when an optimistic-lock version no longer matches) runs in a transaction if its error implements AsSqlxError. It tells wakuwaku which sqlx error, if any, is behind it. An error of the query's own (None) never aborts the transaction. See queries with their own error type for writing such a query:
use wakuwaku::deps::sqlx;
use wakuwaku::entity::sqlx::transaction::AsSqlxError;
#[derive(Debug)]
pub enum SaveError {
/// Someone else saved first: the version did not match.
Stale,
Database(sqlx::Error),
}
impl From<sqlx::Error> for SaveError {
fn from(error: sqlx::Error) -> Self {
SaveError::Database(error)
}
}
impl AsSqlxError for SaveError {
fn as_sqlx_error(&self) -> Option<&sqlx::Error> {
match self {
SaveError::Database(error) => Some(error),
SaveError::Stale => None,
}
}
}Retries
wakuwaku does not retry transactions for you. A serialization failure or a deadlock reaches the step as a query error (the transfer turns it into TransferFailed::Conflict), and a SERIALIZABLE transaction can also fail at the end with TxError::CommitRejected. What to do next is your decision.
A flow value is one run and is not Clone, so retrying means building a fresh start state from the request, which is plain Copy data:
use crate::transfer::{Transfer, TransferFailed, is_conflict};
use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::entity::sqlx::transaction::{Transaction, TxError, TxOutput};
const MAX_ATTEMPTS: usize = 3;
/// Run the transfer, running it again from the start after a conflict.
pub async fn transfer_with_retry<D>(
db: &D,
from: i64,
to: i64,
amount: i64,
) -> Result<TxOutput<(), TransferFailed>, TxError>
where
D: Processor<Transaction<Transfer>, Output = TxOutput<(), TransferFailed>, Error = TxError>,
{
for _ in 1..MAX_ATTEMPTS {
match db.process(Transaction(Transfer::new(from, to, amount))).await {
// Aborted by a serialization failure or a deadlock.
Ok(TxOutput::RolledBack(TransferFailed::Conflict)) => continue,
// SERIALIZABLE can also reject the COMMIT itself.
Err(TxError::CommitRejected(error)) if is_conflict(&error) => continue,
// Never retry `CommitUnknown`: the transfer may have happened.
outcome => return outcome,
}
}
db.process(Transaction(Transfer::new(from, to, amount))).await
}Only retry when the transaction is known not to have committed. Never retry CommitUnknown blindly: the money may already have moved. Make the operation idempotent (for example with a unique transfer id) or reconcile before trying again.
Hooking the end of a transaction
Often something must happen after a transaction ends: publish an event, invalidate a cache, notify another service. Do not do it inside a step (the transaction is still open and may yet roll back) and do not do it with write hooks: queries inside a transaction run on the transaction's connection and never pass through BeforeWrite or AfterWrite. Use AfterTransaction instead.
AfterTransaction<D, H, EH> wraps a sqlx Db handle. After every transaction whose flow ran, it hands the flow's Report, tagged with what became of the transaction, to the hook H:
TxFinalState | When |
|---|---|
Committed(report) | The transaction committed |
RolledBack(report) | The flow asked for a rollback, a query aborted it, or the server rejected the COMMIT |
CommitUnknown(report) | COMMIT was sent but its result was lost |
The hook is any Processor<TxFinalState<F::Report>>. Acting on a commit, a rollback, or an unknown commit is the hook's choice. If the hook fails, its error goes to an error handler EH, a Processor of the hook's error whose own error type is Infallible: it must deal with the failure itself (log it, retry, park it in an outbox). The caller's result is never changed by the hook.
use crate::transfer::{Transfer, Transferred};
use std::convert::Infallible;
use std::sync::mpsc::Sender;
use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::deps::sqlx::PgPool;
use wakuwaku::entity::sqlx::backend::{PgSource, SqlxSource};
use wakuwaku::entity::sqlx::transaction::hook::AfterTransaction;
use wakuwaku::entity::sqlx::transaction::{Transaction, TxError, TxFinalState};
use wakuwaku::entity::{Db, ReadWrite};
/// Hands each committed transfer to the event publisher.
pub struct PublishTransfers {
events: Sender<Transferred>,
}
impl Processor<TxFinalState<Option<Transferred>>> for PublishTransfers {
type Output = ();
type Error = String;
async fn process(&self, state: TxFinalState<Option<Transferred>>) -> Result<(), String> {
match state {
TxFinalState::Committed(Some(event)) => {
self.events.send(event).map_err(|error| error.to_string())
}
TxFinalState::CommitUnknown(Some(event)) => {
Err(format!("{event:?} may or may not have been committed"))
}
// Rolled back: nothing happened, nothing to publish.
_ => Ok(()),
}
}
}
/// Hook errors end here; they never change what the caller gets.
pub struct LogHookError;
impl Processor<String> for LogHookError {
type Output = ();
type Error = Infallible;
async fn process(&self, error: String) -> Result<(), Infallible> {
eprintln!("transfer hook failed: {error}");
Ok(())
}
}
pub type Accounts = AfterTransaction<Db<PgSource, ReadWrite>, PublishTransfers, LogHookError>;
pub fn accounts(pool: PgPool, events: Sender<Transferred>) -> Accounts {
AfterTransaction::new(
Db::new(SqlxSource::new(pool)),
PublishTransfers { events },
LogHookError,
)
}Accounts processes Transaction<Transfer> with the same output and error as the bare Db, so the transfer_with_retry helper above works with it unchanged. A few details:
- The hook does not run when the flow never began (
TxError::Begin): there is no report to hand over. - In a real service, the hook would publish to Iggy; see Publishing events. Deciding what to publish is what
Reportis for: the flow puts the event in the report, and only the hook sees it. AfterTransactionforwards queries run on their own to the wrappedDb, and their errors are the queries' ownErrortypes, so write hooks can wrap it:AfterWrite<AfterTransaction<Db<_, _>, _, _>, _, _>. Write hooks do not forward transactions; reach the transaction hook throughAfterWrite::inner().AfterTransactionmust wrap theDbdirectly.
Testing flows
A Tx cannot be built outside wakuwaku, so step itself only runs against a real database. Split the testing accordingly:
- Decisions without a database. Keep the logic in pure functions of query results and the request, as
check_debit,check_credit,commitandrollbackare, and test them together withfinished. These tests need no database, no Docker, and no async runtime. - Steps against a database. Run the whole flow through a
Dbin an integration test, to check the SQL and that each step runs the query you meant. - The protocol is not yours to test. Which errors abort, refusing to commit an aborted transaction, and what a lost
COMMITmeans are tested inside wakuwaku. - Hooks are processors. Call
processon the hook with aTxFinalStateyou build yourself.
#[cfg(test)]
mod tests {
use super::*;
use wakuwaku::entity::sqlx::transaction::Either;
#[test]
fn no_row_debited_is_insufficient_funds() {
assert!(matches!(Transfer::check_debit(Ok(0)), Err(TransferFailed::InsufficientFunds)));
}
#[test]
fn no_row_credited_is_an_unknown_account() {
assert!(matches!(Transfer::check_credit(Ok(0)), Err(TransferFailed::UnknownAccount)));
}
#[test]
fn a_failed_query_is_a_database_error() {
let lost = sqlx::Error::Io(std::io::Error::other("connection reset"));
assert!(matches!(Transfer::check_credit(Err(lost)), Err(TransferFailed::Database(_))));
}
#[test]
fn a_commit_reports_the_transfer() {
let Either::Right(TxEnd::Commit { report, .. }) = Transfer::commit(1, 2, 10).finished() else {
panic!("`commit` is a terminal commit");
};
assert_eq!(report, Some(Transferred { from: 1, to: 2, amount: 10 }));
}
#[test]
fn a_rollback_reports_nothing() {
assert!(matches!(
Transfer::rollback(TransferFailed::InsufficientFunds).finished(),
Either::Right(TxEnd::Rollback { output: TransferFailed::InsufficientFunds, report: None })
));
}
#[test]
fn the_start_is_not_terminal() {
assert!(Transfer::new(1, 2, 10).finished().is_left());
}
}An async closure that receives a transaction can do anything with it: hold it across an await on another service, send it elsewhere, or swallow an error and carry on after the database has already aborted the transaction. A TxFlow step is narrower. Each query is followed by the code using its result, and each query keeps its own typed Output and Error. The Tx is moved into the step and can only leave as a TxState, so it cannot escape. A query that touches anything the flow did not declare, or writes what it declared as read-only, does not compile. And the rules that are easy to get wrong (an aborted PostgreSQL transaction, a MySQL deadlock that drops you into autocommit, a COMMIT with an unknown outcome) live in wakuwaku's protocol, implemented and tested once. This is the micro-correctness half of proof-driven programming.
Yes. A step can run several queries and branch on their results, as the transfer's step runs the credit only after a successful debit. When a later step needs what was read, the next state carries it. Choosing what comes next from read data is ordinary Rust; see Designing a flow for when a branch deserves a state of its own.
API reference
sqlx (PostgreSQL and MySQL)
Connect wakuwaku to PostgreSQL or MySQL through sqlx, write queries as typed values, and send reads to a replica with the split checked by the compiler.
Redis
Use Redis as a capability-checked data source, with typed caches, locks, a rate limiter, pipes, live views and stream buffers.