#![cfg(all(
feature = "inbox",
feature = "chrono",
feature = "json",
feature = "testing"
))]
use std::future::{Future, ready};
use std::pin::pin;
use std::time::Duration;
use futures::{StreamExt, poll};
use ruststream::prelude::*;
use ruststream::runtime::RustStreamError;
use ruststream::testing::{InProcess, TestApp};
use ruststream::{
AckError, Broker, ConnectedBroker, IncomingMessage, OutgoingMessage, Subscriber,
SubscriptionSource,
};
use ruststream_sqlx::{
Inbox, InboxQueue, Insert, Publish, QueueDatabase, SqlxBroker, SqlxBrokerError,
};
use serde::{Deserialize, Serialize};
use sqlx::{Database, FromRow, Pool};
use crate::live;
const POLL: Duration = Duration::from_millis(20);
const AT_ONCE: Duration = Duration::from_secs(5);
#[derive(Debug, Serialize, Deserialize, PartialEq, Outgoing)]
struct Email {
to: String,
}
fn email() -> Email {
Email {
to: "a@example.com".to_owned(),
}
}
fn tenant_of(test: &str) -> String {
format!("{}-{test}", std::process::id())
}
#[derive(Debug, Inbox, FromRow)]
#[inbox(table = "plain_jobs", advisory_lock = "shutdown-{tenant}-{id}")]
struct Job {
#[field(id, generated)]
id: i64,
#[field(attempt, generated)]
attempt: i16,
tenant: String,
#[field(payload)]
payload: Vec<u8>,
}
impl Job {
fn of(tenant: String, payload: &[u8]) -> Self {
Self {
id: 0,
attempt: 1,
tenant,
payload: payload.to_vec(),
}
}
}
impl<DB> Publish<DB> for Job
where
DB: QueueDatabase,
Self: Insert<DB::Connection>,
{
async fn publish(
conn: &mut DB::Connection,
message: &OutgoingMessage<'_>,
) -> Result<(), sqlx::Error> {
Self::of(tenant_of(message.name()), message.payload())
.insert(conn)
.await
}
}
fn key(tenant: &str) -> String {
format!("shutdown-{tenant}-1")
}
trait Locks: Database {
fn locked(pool: &Pool<Self>, key: &str) -> impl Future<Output = Option<bool>> + Send;
}
#[cfg(feature = "postgres")]
impl Locks for sqlx::Postgres {
async fn locked(pool: &Pool<Self>, _: &str) -> Option<bool> {
let held: i64 = sqlx::query_scalar(
"SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND database = \
(SELECT oid FROM pg_database WHERE datname = current_database())",
)
.fetch_one(pool)
.await
.expect("the locks read");
Some(held > 0)
}
}
#[cfg(feature = "mysql")]
impl Locks for sqlx::MySql {
async fn locked(pool: &Pool<Self>, key: &str) -> Option<bool> {
let name = live::mysql::lock_name(pool, key).await;
Some(live::mysql::lock_held(pool, &name).await)
}
}
#[cfg(feature = "sqlite")]
impl Locks for sqlx::Sqlite {
fn locked(_: &Pool<Self>, _: &str) -> impl Future<Output = Option<bool>> + Send {
ready(None)
}
}
fn is_closed(refused: &AckError) -> bool {
matches!(refused, AckError::Broker(source)
if matches!(source.downcast_ref::<SqlxBrokerError>(), Some(SqlxBrokerError::Closed)))
}
mod in_work {
use super::*;
live::stands! {
async fn write_job(pool: &Pool<Db>, tenant: &str) {
let mut conn = pool.acquire().await.expect("a connection");
Job::of(tenant.to_owned(), b"x")
.insert(&mut *conn)
.await
.expect("the job writes");
}
async fn claimed_at_once(pool: &Pool<Db>) {
let again = SqlxBroker::new(pool.clone())
.poll_interval(POLL)
.connect_in_process()
.await
.expect("a second broker connects");
let mut subscriber = InboxQueue::<Job>::new("jobs")
.subscribe(&again)
.await
.expect("the subscription opens");
{
let mut deliveries = pin!(subscriber.stream());
let taken = tokio::time::timeout(AT_ONCE, deliveries.next())
.await
.expect("the job is claimable at once")
.expect("the stream goes on")
.expect("the claim takes the job");
taken.ack().await.expect("the job settles");
}
drop(subscriber);
again.shutdown().await.expect("the broker shuts down");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_releases_a_lock_in_work() {
let Some(db) = database().await else { return };
let tenant = tenant_of("in-work");
write_job(&db.pool, &tenant).await;
let connected = SqlxBroker::new(db.pool.clone())
.poll_interval(POLL)
.connect_in_process()
.await
.expect("the broker connects");
let mut subscriber = InboxQueue::<Job>::new("jobs")
.subscribe(&connected)
.await
.expect("the subscription opens");
let held = {
let mut deliveries = pin!(subscriber.stream());
deliveries
.next()
.await
.expect("the stream goes on")
.expect("the claim takes the job")
};
drop(subscriber);
assert_ne!(
Db::locked(&db.pool, &key(&tenant)).await,
Some(false),
"the delivery's session holds its job's lock"
);
let closed = connected.shutdown().await.expect("the broker shuts down");
assert_eq!(
(closed.locks_released(), closed.connections_closed()),
(1, 0),
"shutdown released the lock in work and returned its session to the pool: \
{closed:?}"
);
assert_ne!(
Db::locked(&db.pool, &key(&tenant)).await,
Some(true),
"no lock of the broker is left"
);
claimed_at_once(&db.pool).await;
let refused = held
.ack()
.await
.expect_err("a settlement after shutdown released its lock fails");
assert!(is_closed(&refused), "{refused:?}");
assert_eq!(db.count("plain_jobs").await, 0, "the second broker settled the job");
db.finish().await;
}
#[subscriber(InboxQueue::<Job>::new("fail-fast"))]
async fn panics(_email: &Email) -> HandlerOutcome {
panic!("the handler fails")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_fail_fast_panic_leaves_no_lock() {
let Some(db) = database().await else { return };
let broker = SqlxBroker::new(db.pool.clone())
.poll_interval(POLL)
.route::<Job>("fail-fast");
let app = RustStream::new(AppInfo::new("shutdown", "0.0.0")).with_broker(broker, |b| {
b.include(panics);
});
let tb = TestApp::start_live(app).await.expect("the app starts");
let _ = tb
.broker::<SqlxBroker<Db>>()
.message(&email())
.to("fail-fast")
.publish()
.await;
let stopped = tb.shutdown().await;
assert!(
matches!(stopped, Err(RustStreamError::Dispatch(_))),
"the panic failed the service fast: {stopped:?}"
);
assert_ne!(
Db::locked(&db.pool, &key(&tenant_of("fail-fast"))).await,
Some(true),
"the delivery left unsettled holds no lock once the service stopped"
);
claimed_at_once(&db.pool).await;
db.finish().await;
}
}
}
mod held_back {
use super::*;
live::row_lock_stands! {
async fn held_job(
pool: &Pool<Db>,
tenant: &str,
) -> sqlx::Transaction<'static, Db> {
let mut conn = pool.acquire().await.expect("a connection");
Job::of(tenant.to_owned(), b"x")
.insert(&mut *conn)
.await
.expect("the job writes");
drop(conn);
let mut held = pool.begin().await.expect("a transaction");
sqlx::query("SELECT id FROM plain_jobs WHERE id = 1 FOR UPDATE")
.execute(&mut *held)
.await
.expect("the row locks");
held
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_claim_cancelled_midway_leaves_no_lock() {
let Some(db) = database().await else { return };
let tenant = tenant_of("cancelled");
let held = held_job(&db.pool, &tenant).await;
let connected = SqlxBroker::new(db.pool.clone())
.poll_interval(POLL)
.connect()
.await
.expect("the broker connects");
let mut subscriber = InboxQueue::<Job>::new("jobs")
.subscribe(&connected)
.await
.expect("the subscription opens");
{
let mut deliveries = pin!(subscriber.stream());
let waited =
tokio::time::timeout(Duration::from_millis(500), deliveries.next()).await;
assert!(waited.is_err(), "the claim waits in its take: {waited:?}");
}
drop(subscriber);
assert_eq!(
Db::locked(&db.pool, &key(&tenant)).await,
Some(true),
"the dropped claim's session holds the key's lock until its close ends"
);
let shutting_down = connected.shutdown();
held.rollback().await.expect("the row is let go");
let closed = shutting_down.await.expect("the broker shuts down");
assert_eq!(
(closed.locks_released(), closed.connections_closed()),
(0, 1),
"shutdown waited for the claim's session to close: {closed:?}"
);
assert_eq!(
Db::locked(&db.pool, &key(&tenant)).await,
Some(false),
"no lock of the broker is left"
);
db.finish().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shutdown_waits_for_a_settlement_in_flight() {
let Some(db) = database().await else { return };
let tenant = tenant_of("settling");
let mut conn = db.pool.acquire().await.expect("a connection");
Job::of(tenant.clone(), b"x")
.insert(&mut *conn)
.await
.expect("the job writes");
drop(conn);
let connected = SqlxBroker::new(db.pool.clone())
.poll_interval(POLL)
.connect()
.await
.expect("the broker connects");
let mut subscriber = InboxQueue::<Job>::new("jobs")
.subscribe(&connected)
.await
.expect("the subscription opens");
let delivery = {
let mut deliveries = pin!(subscriber.stream());
deliveries
.next()
.await
.expect("the stream goes on")
.expect("the claim takes the job")
};
drop(subscriber);
let mut held = db.pool.begin().await.expect("a transaction");
sqlx::query("SELECT id FROM plain_jobs WHERE id = 1 FOR UPDATE")
.execute(&mut *held)
.await
.expect("the row locks");
let mut acking = pin!(delivery.ack());
assert!(
poll!(acking.as_mut()).is_pending(),
"the acknowledgement's delete waits on the row"
);
let mut shutting_down = pin!(connected.shutdown());
assert!(
poll!(shutting_down.as_mut()).is_pending(),
"shutdown waits for the settlement in flight"
);
held.rollback().await.expect("the row is let go");
let (acked, closed) = tokio::join!(acking, shutting_down);
acked.expect("the acknowledgement in flight settles");
let closed = closed.expect("the broker shuts down");
assert_eq!(
(closed.locks_released(), closed.connections_closed()),
(0, 0),
"the settlement released its own lock: {closed:?}"
);
assert_eq!(
Db::locked(&db.pool, &key(&tenant)).await,
Some(false),
"no lock of the broker is left"
);
assert_eq!(db.count("plain_jobs").await, 0, "the acknowledgement deleted the job");
db.finish().await;
}
}
}
#[cfg(feature = "postgres")]
mod unconfirmed {
use ruststream_sqlx::{Lock, Unlock};
use sqlx::{PgConnection, Postgres};
use super::*;
use crate::live::postgres::database;
#[derive(Debug, Inbox, FromRow)]
#[inbox(
table = "plain_jobs",
advisory_lock = "unconfirmed-{id}",
custom(lock, unlock)
)]
struct Unconfirmed {
#[field(id, generated)]
id: i64,
#[field(attempt, generated)]
attempt: i16,
#[field(payload)]
payload: Vec<u8>,
}
impl Lock<Postgres> for Unconfirmed {
async fn lock(conn: &mut PgConnection, key: &str) -> Result<bool, sqlx::Error> {
sqlx::query_scalar("SELECT pg_try_advisory_lock(hashtext($1))")
.bind(key)
.fetch_one(conn)
.await
}
}
impl Unlock<Postgres> for Unconfirmed {
async fn unlock(conn: &mut PgConnection, key: &str) -> Result<bool, sqlx::Error> {
sqlx::query_scalar("SELECT pg_advisory_unlock(hashtext($1)) AND false")
.bind(key)
.fetch_one(conn)
.await
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_release_the_database_does_not_confirm_closes_its_session() {
let Some(db) = database().await else { return };
db.plain(&[b"x".as_slice()]).await;
let connected = SqlxBroker::new(db.pool.clone())
.poll_interval(POLL)
.connect_in_process()
.await
.expect("the broker connects");
let mut subscriber = InboxQueue::<Unconfirmed>::new("jobs")
.subscribe(&connected)
.await
.expect("the subscription opens");
let held = {
let mut deliveries = pin!(subscriber.stream());
deliveries
.next()
.await
.expect("the stream goes on")
.expect("the claim takes the job")
};
drop(subscriber);
let closed = connected.shutdown().await.expect("the broker shuts down");
assert_eq!(
(closed.locks_released(), closed.connections_closed()),
(0, 1),
"the unconfirmed release closed the session: {closed:?}"
);
assert_eq!(
<Postgres as Locks>::locked(&db.pool, "").await,
Some(false),
"no lock of the broker is left"
);
let refused = held
.ack()
.await
.expect_err("a settlement after shutdown released its lock fails");
assert!(is_closed(&refused), "{refused:?}");
db.finish().await;
}
}