SQLite
The sqlite feature serves the lease and advisory lock forms in the service's process: one writer at a time, times as text, and no server to run in tests.
SQLite locks the whole database for a writer, so no claim can hold rows for a handler.
Its dialect implements no row lock form, and a subscription to a table without locked_until or advisory_lock does not compile.
A lease claim is one UPDATE .. RETURNING that leases its rows, and one writer at a time keeps two claims apart.
The rows of one lease claim come in no particular order.
A subscription mounted with workers(n) claims a row for every worker in that one statement.
A writer that finds the write lock taken waits in SQLite's busy handler, which sleeps a millisecond or more instead of queueing it.
So a transactional subscription whose handlers write runs one worker in the advisory lock form, where its deliveries' transactions would mostly wait for each other (Workers).
A claim of the service's own opens its transaction with BEGIN IMMEDIATE and comes with a Fetch of its own.
The advisory lock form keeps its keys in the process, beside the pool.
One process therefore serves a database file: two processes on one file may deliver a row twice.
A table in that form needs no time column, so it needs neither chrono nor time.
A transaction the crate opens takes the mode the table declares: deferred, immediate or exclusive.
SQLite keeps times as text and compares them as text.
chrono times sort exactly.
time values sort right only across seconds, so with them a lease may end up to a second late, and a delayed retry may come back up to a second early or late (QueueTime).
The AsyncAPI document describes the server by its protocol alone, with no host.
An in-memory database named in a shared-cache URL, sqlite:file:<name>?mode=memory&cache=shared, serves every connection that names it and lives while one of them is open.
A test runs the service on one with no server.
Example
Thumbnails in the advisory lock form: two workers each hold a row's key in the process while their handler runs.
- Macros
- Manual
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `thumbnail_jobs`, held by the lock on its key while a handler runs.
#[derive(Inbox, sqlx::FromRow)]
#[inbox(table = "thumbnail_jobs", advisory_lock = "thumbnails-{id}")]
struct MakeThumbnail {
#[field(id, generated)]
id: i64,
#[field(attempt, generated)]
attempt: i16,
#[field(payload)]
payload: Vec<u8>,
}
// The keys live in this process: one process serves the database file.
#[subscriber(InboxQueue::<MakeThumbnail>::new("thumbnails"))]
async fn thumbnail(image: &Image) -> HandlerOutcome {
tracing::info!(path = %image.path, "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.workers(nonzero!(2)));
})
}
#[ruststream::app]
fn app() -> impl App {
service("sqlite:thumbnails.db?mode=rwc")
}
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `thumbnail_jobs`, held by the lock on its key while a handler runs.
#[derive(sqlx::FromRow)]
struct MakeThumbnail {
id: i64,
attempt: i16,
payload: Vec<u8>,
}
impl InboxTable for MakeThumbnail {
type Id = i64;
type Table = InboxSpec<(Advisory, Attempt, Payload)>;
const TABLE: Self::Table = InboxSpec::new("thumbnail_jobs", Column::new("id").generated())
.advisory(&[KeyPart::Literal("thumbnails-"), KeyPart::Column("id")])
.attempt(Column::new("attempt").generated())
.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
}
}
struct Thumbnail;
impl Handle<Image> for Thumbnail {
fn handle(
&self,
image: &Image,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(path = %image.path, "making a thumbnail");
ready(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| {
// The keys live in this process: one process serves the database file.
let thumbnail = subscriber("thumbnails", Thumbnail)
.map_source(|_| InboxQueue::<MakeThumbnail>::new("thumbnails"));
b.include(thumbnail.workers(nonzero!(2)).build());
})
}
#[ruststream::app]
fn app() -> impl App {
service("sqlite:thumbnails.db?mode=rwc")
}