Getting started
This guide installs ruststream-sqlx, creates a queue table and runs a first service on it, with SQLite and no server.
Installation
[dependencies]
ruststream = { version = "0.7", features = ["macros", "json", "logging"] }
ruststream-sqlx = { version = "0.7", features = ["inbox", "sqlite", "chrono"] }
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "derive", "chrono"] }
chrono = "0.4"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tracing = "0.1"
[dev-dependencies]
ruststream-sqlx = { version = "0.7", features = ["inbox", "sqlite", "chrono", "testing"] }
ruststream is the framework: macros gives the attributes, json the default codec, and logging prints the service's log to the terminal.
ruststream-sqlx serves the queue, and sqlx is the client the service already uses for its database.
The testing feature adds the in-process mode that tests run on.
The crate has no default features. A service turns on what it uses:
| Feature | What it gives |
|---|---|
inbox | the task queue in the service's tables, served by SqlxBroker |
outbox | the transactional outbox over any RustStream broker |
postgres, mysql, sqlite | a built-in dialect and its sqlx driver; mysql serves MySQL and MariaDB |
any | an AnyPool, served by the dialect of the database it reaches |
chrono, time | the time columns in that crate's types |
json | a JSON headers column |
testing | the in-process mode behind TestApp, and the outbox's test switch |
asyncapi | the tables in the generated AsyncAPI document |
The table
The queue is a table of the service's own, and the service creates it:
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS thumbnail_jobs (
id INTEGER PRIMARY KEY,
attempt INTEGER NOT NULL DEFAULT 1,
locked_until TEXT,
payload BLOB NOT NULL
)";
Each row is one job.
id names the row, attempt counts its deliveries, and payload holds the encoded message.
locked_until holds the lease of a row in work: SQLite has no row locks, so a SQLite queue claims its rows under a lease.
Long jobs, crashes and the claim form compares the forms a table can take.
The first service
- Macros
- Manual
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::Sqlite;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `thumbnail_jobs`.
#[derive(Inbox, sqlx::FromRow)]
#[inbox(table = "thumbnail_jobs")]
struct MakeThumbnail {
#[field(id, generated)]
id: i64,
#[field(attempt, generated)]
attempt: i16,
#[field(locked_until)]
locked_until: Option<DateTime<Utc>>,
#[field(payload)]
payload: Vec<u8>,
}
/// A publish to `thumbnails` writes a row with the service's own statement.
impl Publish<Sqlite> for MakeThumbnail {
async fn publish(
conn: &mut SqliteConnection,
message: &OutgoingMessage<'_>,
) -> Result<(), sqlx::Error> {
sqlx::query("INSERT INTO thumbnail_jobs (payload) VALUES (?)")
.bind(message.payload())
.execute(conn)
.await?;
Ok(())
}
}
/// The job, as the payload column holds it.
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "thumbnails")]
struct Image {
path: String,
}
#[subscriber(InboxQueue::<MakeThumbnail>::new("thumbnails"))]
async fn thumbnail(image: &Image, Ctx(attempt): Ctx<keys::Attempt>) -> HandlerOutcome {
if image.path.is_empty() {
tracing::warn!("an image without a path, dropped");
return HandlerOutcome::drop();
}
tracing::info!(path = %image.path, ?attempt, "making a thumbnail");
HandlerOutcome::ack()
}
/// 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("thumbnails", "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::<MakeThumbnail>("thumbnails"), |b| {
b.include(thumbnail);
})
}
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::Sqlite;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `thumbnail_jobs`.
#[derive(sqlx::FromRow)]
struct MakeThumbnail {
id: i64,
attempt: i16,
payload: Vec<u8>,
}
impl InboxTable for MakeThumbnail {
type Id = i64;
type Table = InboxSpec<(Attempt, Lease<DateTime<Utc>>, Payload)>;
const TABLE: Self::Table = InboxSpec::new("thumbnail_jobs", Column::new("id").generated())
.attempt(Column::new("attempt").generated())
.lease(Column::new("locked_until"))
.payload(Column::new("payload"));
fn id(&self) -> &i64 {
&self.id
}
}
impl AttemptRow for MakeThumbnail {
type Attempt = i16;
fn attempt(&self) -> &i16 {
&self.attempt
}
}
impl PayloadRow for MakeThumbnail {
type Column = Vec<u8>;
fn payload(&self) -> &[u8] {
&self.payload
}
}
/// A publish to `thumbnails` writes a row with the service's own statement.
impl Publish<Sqlite> for MakeThumbnail {
async fn publish(
conn: &mut SqliteConnection,
message: &OutgoingMessage<'_>,
) -> Result<(), sqlx::Error> {
sqlx::query("INSERT INTO thumbnail_jobs (payload) VALUES (?)")
.bind(message.payload())
.execute(conn)
.await?;
Ok(())
}
}
/// The job, as the payload column holds it.
#[derive(Debug, PartialEq, Deserialize, Serialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Image {
path: String,
}
// `#[outgoing(name = "thumbnails")]` by hand: the type names the queue it is published to.
impl OutgoingDestination for Image {
type Form = FixedName;
const DESTINATION: &'static str = "thumbnails";
}
impl MessageHeaders for Image {
type Contract = NoHeaders;
}
struct Thumbnail;
impl Handle<Image, (), (), InboxContext> for Thumbnail {
async fn handle(
&self,
image: &Image,
_outs: &(),
ctx: &mut Context<'_, InboxContext>,
) -> Result<(), HandlerOutcome> {
let Ctx(attempt) = Ctx::<keys::Attempt>::from_context(ctx).await?;
if image.path.is_empty() {
tracing::warn!("an image without a path, dropped");
return Err(HandlerOutcome::drop());
}
tracing::info!(path = %image.path, ?attempt, "making a thumbnail");
Ok(())
}
}
/// 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("thumbnails", "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::<MakeThumbnail>("thumbnails"), |b| {
let thumbnail = subscriber("thumbnails", Thumbnail)
.map_source(|_| InboxQueue::<MakeThumbnail>::new("thumbnails"));
b.include(thumbnail.build());
})
}
#[derive(Inbox)] describes the table: #[inbox(table = ..)] names it, and each #[field(..)] gives a column its role.
generated marks a column the database fills.
In the manual form, InboxSpec says the same.
InboxQueue::<MakeThumbnail>::new("thumbnails") is the subscription: it claims rows of thumbnail_jobs and hands each payload to the codec.
What the handler returns settles the row: ack() deletes it, and drop() finishes it the same way without a thumbnail.
Ctx<keys::Attempt> reads which delivery of the row this is.
service takes the database's URL.
SqlxBroker::from_url records it with the pool's options and connects nothing, so the app is built before the runtime starts.
on_startup creates the table before the subscription checks it.
A publish to thumbnails writes a row.
route leads the name to MakeThumbnail, and the service's own Publish statement inserts it.
A handler's reply or a test publishes the same way.
Code outside a handler, such as an HTTP endpoint, writes the row with the derive's insert, inside its own transaction: Enqueueing jobs shows it.
Running the service
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App {
service("sqlite:thumbnails.db?mode=rwc")
}
#[ruststream::app]
fn app() -> impl App {
service("sqlite:thumbnails.db?mode=rwc")
}
#[ruststream::app] generates main, which builds the app and runs it.
The broker builds its pool when the service starts, and closes it when the service stops.
cargo run
The service claims rows as they appear in thumbnails.db.
A row another program inserts is seen within the poll interval, one second unless set.
Ctrl+C stops it.
The first test
The example file also holds two tests.
Each opens a SQLite database in memory and starts the same service on it with TestApp::start.
A test publishes an image through the route, as production does, and checks how thumbnail settled it and what is left in the table:
cargo test
Testing a service on SQL walks through these tests.
Moving to Postgres or MySQL
The same struct serves a PgPool or a MySqlPool: the broker takes the dialect of its pool.
Turn on postgres or mysql in both crates, build that pool, and write Publish<Postgres> or Publish<MySql> with the database's placeholders.
On a server, a table without locked_until claims its rows under a row lock, held while the handler runs.
Index the claim order, (group, priority, retry_after, id) for the columns the table has; Running in production explains why.
Coming from a hand-written queue
A queue on sqlx usually is a loop of SELECT .. FOR UPDATE SKIP LOCKED and a few columns of bookkeeping.
Here is where each part goes:
| Hand-written | ruststream-sqlx |
|---|---|
| the claim loop and its statement | an InboxQueue subscription; the dialect builds the statement |
DELETE or UPDATE .. SET done after the work | the handler's outcome |
an attempts column raised by hand | #[field(attempt)] and Ctx<keys::Attempt> |
a run_at column for later | #[field(retry_after)] and retry_after(d) (Retries) |
a locked_until column and a reaper | the lease form (Long jobs) |
| a dead-letter table and its move | max_attempts(n) and dead_letter |
| a job inserted with the data it serves | insert in the same transaction (Enqueueing jobs) |