跳到主要内容
本页尚未翻译

下面显示的是英文原文。

Transactional outbox

The outbox records each message a service publishes under a registered name until a consumer processed it, and publishes the rest again at startup.

The outbox is a plugin over any RustStream broker and needs no broker of its own. The outbox feature turns it on, beside a driver feature: postgres, mysql, sqlite or any. It is independent of the inbox, and a service may enable both.

A tracked message goes through four steps. The publish middleware writes a record of the message, then sends it with the record's id in the x-ruststream-outbox-id header. The subscription middleware takes a delivery that carries an id into work by that id, and runs the handler. The handler's outcome then settles the record: an acknowledgement marks it processed. At startup the republish sends every unprocessed record again. A message under a name the registry does not hold passes both middlewares after a comparison of names.

A message is delivered at least once. The record is written before the message is sent, so a send that fails, a consumer that stops before its handler finishes, or a handler that asks for the message again leaves the record unprocessed for the next startup. A copy whose record is processed already is acknowledged without running the handler. Two instances of a service that start together may both send the same record again, so a consumer that must not process a message twice checks that itself.

A message whose name is not registered costs one comparison per registered name: no allocation and no database call. A tracked publish takes a connection for the record's insert and allocates the id header's value. A tracked delivery takes a connection for the fetch, returns it before the handler runs, and takes one again for the settlement. Under sqlx's default test_before_acquire, each acquire pings the connection first; a pool built with test_before_acquire(false) skips that round trip. The default retry takes no connection, and the republish allocates its body once per startup.

The repository's examples/outbox runs the same flow over Redis Pub/Sub, with migrations and a fetch of its own.

Example​

The service answers a request on checkout with an OrderPlaced on orders, a registered name: the reply is recorded before it is sent, the acknowledgement of fulfil marks the record, and the startup sends what no consumer processed. RUSTSTREAM_SQLX_OUTBOX=on cargo run -p ruststream-sqlx --example sqlx_outbox --features outbox,sqlite,testing starts it; the switch is there because testing is among the example's features.

use ruststream::memory::prelude::*;
use ruststream_sqlx::{Outbox, outbox};
use sqlx::SqlitePool;

/// One record of the `outbox` table.
#[derive(Outbox, sqlx::FromRow)]
#[outbox(table = "outbox")]
struct OrderOutbox {
#[field(id)]
id: i64,
#[field(name)]
name: String,
#[field(payload)]
payload: Vec<u8>,
#[field(processed_at)]
processed_at: Option<DateTime<Utc>>,
}

/// The service writes the record; the outbox sends its id with the message.
impl outbox::Publish<Sqlite> for OrderOutbox {
async fn publish(conn: &mut SqliteConnection, msg: &OutgoingMessage<'_>) -> sqlx::Result<i64> {
sqlx::query_scalar("INSERT INTO outbox (name, payload) VALUES (?, ?) RETURNING id")
.bind(msg.name())
.bind(msg.payload())
.fetch_one(conn)
.await
}
}

/// The reply to `orders` is recorded before it is sent.
#[subscriber("checkout", reply)]
async fn place(cmd: &PlaceOrder) -> OrderPlaced {
OrderPlaced { id: cmd.id }
}

/// After the acknowledgement, the record is marked processed.
#[subscriber("orders")]
async fn fulfil(order: &OrderPlaced) -> HandlerOutcome {
tracing::info!(order = order.id, "fulfilling the order");
HandlerOutcome::ack()
}

fn service(pool: SqlitePool) -> impl App {
let tracking = outbox! { pool: pool, "orders" => OrderOutbox };
RustStream::new(AppInfo::new("orders", "0.1.0"))
.layer(tracking.layer())
.publish_layer(tracking.publish_layer())
.with_broker(MemoryBroker::new(), |b| {
b.include(place).out_reply(Publish);
b.include(fulfil);
// Once the subscriptions are open, the unprocessed records are sent again.
b.after_startup(Publish, tracking.republish());
})
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let pool = SqlitePool::connect("sqlite:orders.db?mode=rwc").await?;
sqlx::raw_sql(SCHEMA).execute(&pool).await?;
service(pool.clone()).run().await?;
pool.close().await;
Ok(())
}