use chrono::{DateTime, Utc};
use ruststream::OutgoingMessage;
use serde::{Deserialize, Serialize};
use sqlx::{Sqlite, SqliteConnection};
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS outbox (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
payload BLOB NOT NULL,
processed_at TEXT
)";
use ruststream_sqlx::{Outbox, outbox};
#[derive(Outbox, sqlx::FromRow)]
#[outbox(table = "outbox")]
struct PaymentOutbox {
#[field(id)]
id: i64,
#[field(name)]
name: String,
#[field(payload)]
payload: Vec<u8>,
#[field(processed_at)]
processed_at: Option<DateTime<Utc>>,
}
impl outbox::Publish<Sqlite> for PaymentOutbox {
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
}
}
use ruststream::memory::prelude::*;
#[subscriber("checkout", reply)]
async fn take_payment(checkout: &Checkout) -> PaymentTaken {
tracing::info!(order = checkout.order, "payment taken");
PaymentTaken {
order: checkout.order,
}
}
#[subscriber("payments")]
async fn ship(payment: &PaymentTaken) -> HandlerOutcome {
tracing::info!(order = payment.order, "shipping");
HandlerOutcome::ack()
}
use std::convert::Infallible;
use std::io;
use sqlx::SqlitePool;
fn service(url: String) -> impl App {
let tracking = outbox! { "payments" => PaymentOutbox };
let registry = tracking.clone();
RustStream::new(AppInfo::new("payments", "0.1.0"))
.on_startup(async move |()| {
let pool = SqlitePool::connect(&url).await.map_err(io::Error::other)?;
sqlx::raw_sql(SCHEMA)
.execute(&pool)
.await
.map_err(io::Error::other)?;
registry.set_pool(pool.clone()).map_err(io::Error::other)?;
Ok::<_, io::Error>(pool)
})
.layer(tracking.layer())
.publish_layer(tracking.publish_layer())
.after_shutdown(async move |pool| {
pool.close().await;
Ok::<_, Infallible>(())
})
.with_broker(MemoryBroker::new(), |b| {
b.include(take_payment).out_reply(Publish);
b.include(ship);
b.after_startup(Publish, tracking.republish());
})
}
#[ruststream::app]
fn app() -> impl App {
service("sqlite:payments.db?mode=rwc".to_owned())
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "checkout")]
struct Checkout {
order: u64,
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "payments")]
struct PaymentTaken {
order: u64,
}
#[cfg(test)]
mod tests {
use std::env;
use std::error::Error;
use ruststream::memory::prelude::*;
use ruststream::testing::TestApp;
use ruststream_sqlx::outbox::OUTBOX_ID_HEADER;
use sqlx::{Connection, SqliteConnection};
use super::{Checkout, PaymentTaken, SCHEMA, service};
fn tracking_on() -> bool {
if env::var("RUSTSTREAM_SQLX_OUTBOX").as_deref() == Ok("on") {
return true;
}
assert!(
env::var("RUSTSTREAM_REQUIRE_LIVE").map_or(true, |value| value.is_empty()),
"RUSTSTREAM_REQUIRE_LIVE is set, but RUSTSTREAM_SQLX_OUTBOX is not `on`"
);
eprintln!("RUSTSTREAM_SQLX_OUTBOX is not `on`; skipping the outbox test");
false
}
async fn database(name: &str) -> Result<(String, SqliteConnection), Box<dyn Error>> {
let url = format!("sqlite:file:{name}?mode=memory&cache=shared");
let mut keeper = SqliteConnection::connect(&url).await?;
sqlx::raw_sql(SCHEMA).execute(&mut keeper).await?;
Ok((url, keeper))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_payment_is_recorded_sent_with_its_id_and_marked_once_shipped()
-> Result<(), Box<dyn Error>> {
if !tracking_on() {
return Ok(());
}
let (url, mut keeper) = database("guide_outbox_tracked").await?;
let tb = TestApp::start(service(url)).await?;
tb.broker::<MemoryBroker>()
.message(&Checkout { order: 7 })
.publish()
.await?;
let published = tb.broker::<MemoryBroker>().published::<()>("payments");
let ids: Vec<_> = published
.messages()
.iter()
.map(|message| message.headers().get_str(OUTBOX_ID_HEADER))
.collect();
assert_eq!(ids, [Some("1")], "the payment carries its record's id");
tb.broker::<MemoryBroker>()
.subscriber("payments")
.assert_called_once()
.with(&PaymentTaken { order: 7 })
.settled(HandlerOutcome::ack());
let records: Vec<(String, bool)> =
sqlx::query_as("SELECT name, processed_at IS NOT NULL FROM outbox")
.fetch_all(&mut keeper)
.await?;
assert_eq!(records, [("payments".to_owned(), true)]);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn an_unprocessed_payment_is_sent_again_at_startup() -> Result<(), Box<dyn Error>> {
if !tracking_on() {
return Ok(());
}
let (url, mut keeper) = database("guide_outbox_republish").await?;
sqlx::query("INSERT INTO outbox (name, payload) VALUES ('payments', ?)")
.bind(br#"{"order":3}"#.as_slice())
.execute(&mut keeper)
.await?;
let tb = TestApp::start(service(url)).await?;
tb.settle().await?;
tb.broker::<MemoryBroker>()
.subscriber("payments")
.assert_called_once()
.with(&PaymentTaken { order: 3 });
let left: i64 =
sqlx::query_scalar("SELECT count(*) FROM outbox WHERE processed_at IS NULL")
.fetch_one(&mut keeper)
.await?;
assert_eq!(
left, 0,
"the shipping handler processed the payment sent again"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn with_the_outbox_off_the_payment_is_shipped_untracked() -> Result<(), Box<dyn Error>> {
if env::var("RUSTSTREAM_SQLX_OUTBOX").as_deref() == Ok("on") {
return Ok(());
}
let (url, mut keeper) = database("guide_outbox_off").await?;
let tb = TestApp::start(service(url)).await?;
tb.broker::<MemoryBroker>()
.message(&Checkout { order: 7 })
.publish()
.await?;
tb.broker::<MemoryBroker>()
.subscriber("payments")
.assert_called_once()
.settled(HandlerOutcome::ack());
let records: i64 = sqlx::query_scalar("SELECT count(*) FROM outbox")
.fetch_one(&mut keeper)
.await?;
assert_eq!(records, 0, "nothing recorded");
Ok(())
}
}