wakuwaku
データアクセス

トランザクション

sqlx のトランザクションを、ステップがクエリを実行するステートマシンとして書き、BEGIN・COMMIT・ROLLBACK は wakuwaku に任せ、終了時の状態にフックします。

Db ハンドルを通じて実行したクエリは、単独でオートコミットモードで実行されます。複数の文をまとめて成功または失敗させたいときは、トランザクションフロー、つまり TxFlow を実装する型を書きます。フローはステートマシンで、各ステップが開いているトランザクション Tx の上でクエリを実行し、その結果から次に何をするかを決めます。

ステップの周りのプロトコルは wakuwaku が担当します。BEGIN、COMMIT、ROLLBACK を送り、どのエラーがサーバー上でトランザクションを終わらせたかを把握し、中断されたトランザクションのコミットを拒み、応答が失われた COMMIT を報告します。フローが持つのはビジネスロジックだけです。

このページは、sqlx 向けのクエリの書き方を知っていることを前提とします。sqlx(PostgreSQL と MySQL) を参照してください。

モデル

トランザクション:あなたの step() と finished() を囲む wakuwaku のプロトコルのループあなたのコード(impl TxFlow)wakuwaku:トランザクションのプロトコル中断と失敗process(Transaction(flow))acquire + BEGINisolation: F::ISOLATIONフローの作用が書き込まなければREAD ONLY で開始失敗:TxError::Begin(フックなし)Txtx.end(state)state.finished()Right(end)終端COMMITCommit・未中断ROLLBACKRollback か中断済み結果Left(state)終端でないtx.with(next)次の状態TxFlow::step(tx, state)let (tx, r) = tx.run(q).await;必要なだけ繰り返し、r で分岐tx.with(next):続けるtx.end(next):終端なら終了query(tx, Result<Output, Error>)tx.run(query)クエリのエラーがサーバー側でトランザクションを終わらせるとTx は中断済みになります。以降のrun() は送信されず、Commit はROLLBACK + TxError::Aborted に。終わり方呼び出し側へAfterTransaction フックへResult<TxOutput<C, A>, TxError>TxFinalState<Report>COMMIT 成功Ok(Committed(commit))Committed(report)サーバーが COMMIT を拒否Err(CommitRejected(e))RolledBack(report)COMMIT の結果が失われたErr(CommitUnknown(e))CommitUnknown(report)TxEnd::Rollback による ROLLBACKOk(RolledBack(abort))RolledBack(report)Commit だが Tx が中断済み → ROLLBACKErr(Aborted)RolledBack(report)接続取得か BEGIN が失敗Err(Begin(e))呼ばれない
wakuwaku がトランザクションを開始し、ループします。tx.end(state) はあなたの finished() で状態が終端かを確かめ、終端なら COMMIT か ROLLBACK を送ります。終端でなければ、あなたの step() が tx.run でクエリを実行し、tx.with か tx.end で次の状態を返します。クエリのエラーがサーバー側でトランザクションを終わらせると Tx は中断済みになり、フローはロールバックしかできません。

状態はフローの値そのもの で、ふつうは enum です。トランザクションがとりうる地点ごとの作業中の状態と、終端状態からなります。マシンを定義するのは 2 つの関数です。

  • async fn step(tx: Tx<'_, Self>, state: Self) -> TxState<'_, Self>: state のクエリを tx 上で実行し、次の状態を返します。終端状態で呼ばれることはありません。
  • fn finished(self) -> Either<Self, TxEnd<Self>>: 状態が終端ならトランザクションの終わり方を Right で、そうでなければ Left(self) を返します。

終端状態は TxEnd です。

TxEnd のバリアント意味
TxEnd::Commit { output, report }コミットします。output は呼び出し元に、report はトランザクションフックに渡ります
TxEnd::Rollback { output, report }ロールバックします。output と report の分け方は同じです

フローは From<TxEnd<Self>> で TxEnd を状態に変換します。ふつうは End(TxEnd<Self>) バリアントです。finished はそれをそのまま返さなければなりません。つまり finished(end.into()) は Right(end) です。

ステップの中では、Tx はムーブで受け取り、ムーブで返します。

  • tx.run(query).await はクエリを実行し、(tx, result) を返します。result はそのクエリ自身の Result<Output, Error> です。
  • tx.with(state) は state で続行します。この方法で終端状態を返しても構いません。ドライバーがステップを呼ぶ前にそれを終了させます。
  • tx.end(state).await は、state が終端ならその場でトランザクションを終了します(終端でなければ with と同じです)。

どちらも TxState、つまり状態とそのトランザクションの組を返します。ステップが TxState を作れるのは渡された Tx からだけなので、トランザクションがステップの外に漏れることはありません。

関連アイテムは、フローが何を扱うかを表します。フローはクエリではなく、Query を実装しません。フローが触れてよいものは、tx_effect! で宣言する Capability です(ケイパビリティと読み取り専用トランザクション を参照)。

名前

型

完全なフロー: 送金

この例は、ある口座から別の口座へ amount を移します。引き落とし、入金、コミットの順です。引き落としがどの行にも一致しなければ残高不足、入金がどの行にも一致しなければ口座が存在しないことを意味し、どちらもロールバックします。

まずクエリです。それぞれが独自の Output と Error を持つ普通の単独クエリで、フローはそれをそのまま実行します。

src/transfer.rs
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())
    }
}

次にフローです。送金は決まった順の文の並びの後にコミットするだけなので、作業中の状態は Start の 1 つで、その 1 つのステップが引き落としと入金を順に実行します。TransferEffect は、2 つのクエリが触れる唯一のエンティティを宣言します。判断はクエリ結果を受け取る普通の関数(check_debit、check_credit)にまとめ、step はクエリを実行して答えに応じて動くだけです。

src/transfer.rs
/// 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),
        }
    }
}

MySQL では type Database = MySql; 以外はまったく同じです。各クエリの結果は独自の型のままで、それを使うコードはその結果を返したクエリのすぐ後に続きます。クエリの enum も、行の enum も、起こりえない結果のための catch-all アームも要りません。

TxEnd は Debug を derive しているので、それを埋め込むフローの enum も Debug を derive できます(Commit、Abort、Report の型が Debug である必要があります)。一方、Clone は意図的に実装していません。フローの値はトランザクションの 1 回の実行であり、その終端状態はトランザクションの終了時に一度だけ消費されます。await 済みの future をもう一度 await しないのと同じです。実際にどう影響するかは リトライ を参照してください。

is_conflict ヘルパーは、「別のトランザクションと競合したので、もう一度実行してほしい」という意味のエラーを判別します。

src/transfer.rs
/// 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")
}

実行する

最初の状態を Transaction で包み、フローが宣言したものをカバーするケイパビリティを持つ Db で処理します。それぞれの結果が何を意味するかは呼び出し元が決めることなので、トランザクションを実行するその場所で、結果を呼び出し元自身のエラーに変換します。

src/pay.rs
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 は Result<TxOutput<Commit, Abort>, TxError> を返します。

結果意味
Ok(TxOutput::Committed(output))フローがコミットし、サーバーがそれを確認しました
Ok(TxOutput::RolledBack(output))フローが TxEnd::Rollback で終わりました
Err(TxError::Begin(_))コネクションを取得できなかったか、BEGIN が失敗しました。フローは実行されていません
Err(TxError::CommitRejected(_))サーバーが COMMIT を拒否しました。トランザクションはロールバックされています
Err(TxError::CommitUnknown(_))COMMIT を送りましたが応答が失われました。トランザクションはコミットされた かもしれないし、されていないかもしれません
Err(TxError::Aborted)クエリがトランザクションを中断した後で、フローが TxEnd::Commit で終わりました。ロールバックされています
Err(TxError::EndMismatch)finished が、トランザクションを閉じたときの終わり方を返しませんでした。フローのバグです

フローはクエリではない

フローは db.process(Transaction(flow)) として実行し、その結果をその場で扱います。pay がそうしているとおりです。Db に Processor<Input> を実装してフローを隠し、db.process(CreateServer { … }) がトランザクション全体を実行して、単独のクエリのように 1 つの Output と 1 つの Error で答える、という形にしてはいけません。この形では 3 つのものが失われます。

  • TxError::CommitUnknown が普通のデータベースエラーに紛れます。失敗した文をリトライする呼び出し元は、すでにコミットされたかもしれないトランザクションをリトライすることになります。
  • 成功の型を 1 つにするために、呼び出し元が成功として扱うべきロールバックを Abort に載せることになり、Abort が 2 つ目の成功の経路になります(Abort の役割 を参照)。
  • 呼び出し側から、トランザクションと単独の文を区別できなくなります。両者の失敗の仕方は異なるにもかかわらずです。

境界になるのはビジネスロジックの関数です。その関数がトランザクションを実行し、Committed、RolledBack、TxError を自身のエラーに変換します。CommitUnknown はそれだけで独立したケースにします。PayError::OutcomeUnknown を受け取った側は、失敗を報告したり再試行したりする前に照合しなければなりません。リトライ を参照してください。

フローの設計

状態はループと分岐のためにある

状態は、トランザクションが戻ってこられる地点です。ループの先頭や、後のステップで続く分岐です。送る文ごとの枠ではありません。直線的なトランザクション、つまり決まった順の文の並びの後にコミットするものは、1 つのステップで書きます。そのステップはクエリを Tx::run で順に実行し、残りを実行する意味がない答えが返ればその時点でロールバックで終え、そうでなければ tx.end(...) で終えます。送金がその例です。クエリごとに状態を分ける(Start、Debited、Credited、…)と、同じデータをバリアントからバリアントへ持ち回すだけになります。

トランザクションの形が読み取った内容によって変わるときに、複数の状態を使います。

  • ループで、回数が実行時にしか分からないもの。トランザクションから変更を届ける の PublishAll は投稿ごとに 1 回ステップを進め、その状態はまだ残っている投稿と公開済みの投稿を持ちます。
  • 戻ってくる分岐、たとえば読み直してのリトライ。競合に負けた条件付き書き込みは、行を読み直して再試行する状態へ進みます。

Abort の役割

Abort は、フローがロールバックした理由です。TransferFailed::InsufficientFunds のようなドメインの理由か、TransferFailed::Database のような失敗した文です。2 つ目の成功の経路ではありません。ロールバックしたトランザクションでも呼び出し元に結果を渡せるように Result<Commit, _> にしてはいけません。フローが目的を果たしたなら、コミットします。

何も変えなかった書き込みも同じです。どの行にも一致しなかった条件付き書き込みは何も書いていません。「一致なし」が受け入れられる答えなら(たとえば行がすでに望む状態だった場合)、その空の結果をコミットします。たとえば Option<T> や影響を受けた行数を Commit にします。ロールバックするのは、頼まれたことをフローが果たせないときです。送金が、引き落としで残高不足を見つけたときにそうするように。

ドライバーがフローを実行する手順

ドライバーが行うのは次の手順だけです。

  1. プールからコネクションを取得します。
  2. フローのモードで BEGIN を送ります。分離レベルは ISOLATION から取り、フローが書き込みを宣言していなければ READ ONLY、そうでなければ READ WRITE です。どちらかが失敗すると結果は Err(TxError::Begin) になり、フローは実行されません。
  3. ループします。状態が終端なら(finished が Right を返したら)、Tx::end と同じようにトランザクションを終了し(COMMIT または ROLLBACK)、止まります。そうでなければ、開いている Tx と状態で step を呼び、返された TxState でループを続けます。

つまりステップは、tx.end(state) で自分でトランザクションを終了しても、tx.with(state) で終端状態を返してドライバーに終了させても構いません。結果は同じです。

フロー内のクエリは同じクエリ

Execute::execute はトランザクションのコネクションを受け取るので、Db を通じて単独で実行しているクエリを、そのままフローの中で実行できます。

ケイパビリティと読み取り専用トランザクション

フローは、ステップが触れうるものすべてを tx_effect! で宣言し、その宣言を TxFlow::Capability として指定します。送金の TransferEffect がそうです。口座の所有者も読むフローなら、次のように宣言します。

tx_effect! {
    /// Moves money between accounts, after checking who owns them.
    pub enum CheckedTransferEffect { write Account, read User }
}

write E は、フローのクエリがエンティティ E を読み書きすることを、read E は読むことだけを許します。各エンティティは 1 回だけ書きます。write は読み取りも含み、同じエンティティを 2 回書くとコンパイルできません。マクロは、ケイパビリティ(CanRead / CanWrite マーカーを持つ)であり、かつ対応するエフェクト(TxEffect)でもある空の enum を生成します。TxEffect を手で実装しないでください。

宣言は、コンパイル時に 3 つの方向から検査されます。

  • 各クエリをフローに対して。 Tx::run は、フローのケイパビリティがクエリのエフェクトをカバーすることを要求します。フローが宣言していないエンティティに触れるクエリや、read と宣言したエンティティに書き込むクエリは error[E0277] になります。
  • フローをハンドルに対して。 Db<S, C>(とそれを包む AfterTransaction)が Transaction<F> を処理できるのは、単独のクエリと同じく C が宣言されたエフェクトをカバーする場合だけです。
  • モード。 書き込みを宣言しないフローは READ ONLY で開始します。

したがって、あるフローを実行できるハンドルは、その中のすべてのクエリを実行できます。トランザクションが、ハンドルについて検査されていないエンティティに触れることはありません。送金は Account に書き込むので、レプリカのハンドルに対してはコンパイルできません。

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;
}

読み取りだけのフロー(たとえば複数の SELECT にまたがる一貫したレポートのための、read Account と宣言したもの)は READ ONLY で開始し、Db<_, ReadOnly> ハンドルで実行できます。そのようなフローでは、Tx::run は書き込むクエリを拒否します。

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;
    // …
}

分離レベル

レベルを選ぶには const ISOLATION に Some(IsolationLevel::…) を設定します。省略するとサーバーのデフォルトになります。送信される文はデータベースによって異なります。

1 つの文で開始します。たとえば BEGIN ISOLATION LEVEL SERIALIZABLE READ WRITE、分離レベルがなければ BEGIN READ ONLY などです。PostgreSQL は ReadUncommitted を READ COMMITTED として実行します。

トランザクション内のエラー

クエリのエラーはすべて、クエリの結果としてステップに届き、どう扱うかはステップが決めます。wakuwaku が代わりに追跡するのは、そのエラーが サーバー上でトランザクションを終わらせたかどうか で、これはデータベースによって異なります。sqlx がクライアント側で、文の送信後または送信の代わりに発生させたエラーはサーバーの状態を変えないので、中断扱いにはなりません。RowNotFound、TypeNotFound、ColumnIndexOutOfBounds、ColumnNotFound、ColumnDecode、Encode、Decode、InvalidArgument です。

それ以外のエラーはすべて中断扱いです。 PostgreSQL では、文がエラーになると、ロールバックするまで以降のすべての文が拒否され、COMMIT は黙ってロールバックします。そのため制約違反、シリアライゼーション失敗、コネクション切断はいずれもトランザクションを中断します。

中断されたトランザクションはロールバックしかできない

クエリがトランザクションを中断すると(tx.is_aborted() が true)、以降の tx.run は何も送らずに失敗します。そのため、失敗する文や、MySQL ではトランザクションの外で実行されてしまう文を、フローが発行し続けることはできません。終了は常にロールバックになります。TxEnd::Rollback ならいつもどおり RolledBack になり、TxEnd::Commit なら Err(TxError::Aborted) になります。送金の例のように、このようなエラーの後はロールバックの状態に進んでください。

独自のエラー型を持つクエリ

Error が sqlx::Error ではないクエリ(たとえば楽観ロックのバージョンが一致しなくなったときに自ら拒否するクエリ)は、そのエラーが AsSqlxError を実装していればトランザクション内で実行できます。これは、背後にある sqlx エラーがあればそれを wakuwaku に伝えます。クエリ独自のエラー(None)がトランザクションを中断することはありません。このようなクエリの書き方は 独自のエラー型を持つクエリ を参照してください。

src/document.rs
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,
        }
    }
}

リトライ

wakuwaku はトランザクションを自動でリトライしません。シリアライゼーション失敗やデッドロックはクエリのエラーとしてステップに届き(送金の例では TransferFailed::Conflict に変換しています)、SERIALIZABLE のトランザクションは最後に TxError::CommitRejected で失敗することもあります。次にどうするかはあなたが決めます。

フローの値は 1 回の実行であり Clone ではないので、リトライでは、普通の Copy なデータであるリクエストから新しい開始状態を作り直します。

src/retry.rs
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
}

リトライするのは、トランザクションがコミットされていないと分かっている場合だけにしてください。CommitUnknown を無条件にリトライしてはいけません。お金はすでに移動しているかもしれません。操作を冪等にする(たとえば一意な送金 ID を使う)か、照合してから再試行してください。

トランザクションの終了にフックする

トランザクションの 終了後 に何かを行う必要はよくあります。イベントの発行、キャッシュの無効化、他サービスへの通知などです。これをステップの中で行ってはいけませんし(トランザクションはまだ開いていて、ロールバックするかもしれません)、書き込みフック で行ってもいけません。トランザクション内のクエリはトランザクションのコネクション上で実行され、BeforeWrite や AfterWrite を通りません。代わりに AfterTransaction を使います。

AfterTransaction<D, H, EH> は sqlx の Db ハンドルを包みます。フローが実行されたトランザクションのたびに、フローの Report に、トランザクションがどうなったかのタグを付けてフック H に渡します。

TxFinalStateいつ
Committed(report)トランザクションがコミットされた
RolledBack(report)フローがロールバックを求めた、クエリが中断した、またはサーバーが COMMIT を拒否した
CommitUnknown(report)COMMIT を送ったが結果が失われた

フックは任意の Processor<TxFinalState<F::Report>> です。コミット、ロールバック、不明なコミットのどれに対応するかはフックが決めます。フックが失敗すると、そのエラーはエラーハンドラー EH に渡ります。これはフックのエラーを処理する Processor で、自身のエラー型は Infallible です。失敗は自分で処理しなければなりません(ログに残す、リトライする、アウトボックスに退避するなど)。フックが呼び出し元の結果を変えることはありません。

src/accounts.rs
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 は素の Db と同じ出力とエラーで Transaction<Transfer> を処理するので、上の transfer_with_retry ヘルパーはそのまま使えます。細かい点をいくつか挙げます。

  • フローが開始されなかった場合(TxError::Begin)、フックは実行されません。渡すレポートがないからです。
  • 実際のサービスでは、フックは Iggy に発行するでしょう。イベントの発行 を参照してください。何を発行するかを決めるのが Report の役割です。フローがイベントをレポートに入れ、それを見るのはフックだけです。
  • AfterTransaction は単独で実行するクエリをラップした Db に転送し、そのエラーはクエリ自身の Error 型なので、書き込みフックで包めます: AfterWrite<AfterTransaction<Db<_, _>, _, _>, _, _>。書き込みフックはトランザクションを転送しないので、トランザクションフックには AfterWrite::inner() を通じて到達します。
  • AfterTransaction は Db を直接包まなければなりません。

フローのテスト

Tx は wakuwaku の外では作れないので、step そのものは本物のデータベースに対してしか実行できません。テストはそれに合わせて分けます。

  • 判断はデータベースなしで。 ロジックは、check_debit、check_credit、commit、rollback のように、クエリ結果とリクエストを受け取る純粋な関数にまとめ、finished と合わせてテストします。データベースも Docker も async ランタイムも要りません。
  • ステップはデータベースに対して。 結合テストでフロー全体を Db を通じて実行し、SQL と、各ステップが意図したクエリを実行することを確かめます。
  • プロトコルはあなたがテストするものではありません。 どのエラーが中断扱いか、中断されたトランザクションのコミットを拒むこと、失われた COMMIT が何を意味するかは wakuwaku の中でテストされています。
  • フックはプロセッサーです。 自分で組み立てた TxFinalState でフックの process を呼びます。
src/transfer.rs
#[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());
    }
}

API リファレンス

目次