use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::mem;
use std::panic::resume_unwind;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
use std::time::{Duration, SystemTime};
use ruststream::testing::{Backlog, Coordinator, InProcess, TestableBroker};
use ruststream::{HeaderMap, OutgoingMessage, RawMessage};
use ruststream_sqlx_dialect::{Dialect, Role};
use tokio::runtime::Handle;
use tokio::sync::oneshot;
use tokio::task::spawn_blocking;
use tokio::time::Instant;
use super::broker::{ConnectedSqlxBroker, Shared, SqlxBroker};
use super::database::QueueDatabase;
use super::engine::{Claiming, Event, Events, Now, Values, arguments, intern};
use super::error::SqlxBrokerError;
use super::publish::{TableWake, insert_routed};
use super::queue::Queue;
#[derive(Debug, Clone, Copy)]
pub(crate) struct TestClock {
system: SystemTime,
instant: Instant,
}
impl TestClock {
fn start() -> Self {
Self {
system: SystemTime::now(),
instant: Instant::now(),
}
}
pub(crate) fn now(self) -> SystemTime {
self.system + self.instant.elapsed()
}
}
pub(crate) async fn off_clock<T, Work>(work: Work) -> Option<T>
where
T: Send + 'static,
Work: Future<Output = T> + Send + 'static,
{
let runtime = Handle::current();
match spawn_blocking(move || runtime.block_on(work)).await {
Ok(output) => Some(output),
Err(join) if join.is_panic() => resume_unwind(join.into_panic()),
Err(_) => None,
}
}
pub(crate) fn cancelled() -> sqlx::Error {
sqlx::Error::Io(io::Error::other(
"the runtime shut down during a database call",
))
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct QueueAt<'a> {
table: &'a str,
group: &'a str,
}
impl<'a> QueueAt<'a> {
pub(crate) fn written(wake: &'a TableWake, name: &'a str) -> Self {
Self {
table: wake.table(),
group: wake.group(name),
}
}
pub(crate) fn read(queue: &'a Queue) -> Self {
Self {
table: queue.table,
group: queue.spec.column(Role::Group).map_or("", |_| queue.name),
}
}
pub(crate) fn dead_letter(queue: &'a Queue, destination: &'a str) -> Self {
match queue.spec.column(Role::Group) {
Some(_) => Self {
table: queue.table,
group: destination,
},
None => Self {
table: destination,
group: "",
},
}
}
pub(crate) fn registered(table: &'a str, group: Option<&'a str>) -> Self {
Self {
table,
group: group.unwrap_or_default(),
}
}
}
#[derive(Debug, Default)]
struct Tally {
ahead: usize,
returning: Vec<Return>,
}
impl Tally {
fn take_return(&mut self, ticket: Ticket) -> bool {
let Some(at) = self.returning.iter().position(|back| back.ticket == ticket) else {
return false;
};
self.returning.swap_remove(at);
true
}
fn take_earliest_return(&mut self) {
if let Some(at) = (0..self.returning.len()).min_by_key(|&at| self.returning[at].due) {
self.returning.swap_remove(at);
}
}
}
#[derive(Debug, Clone, Copy)]
struct Return {
due: Instant,
ticket: Ticket,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Ticket(u64);
type Credits = HashMap<Box<str>, HashMap<Box<str>, Tally>>;
#[derive(Debug, Default)]
pub(crate) struct Harness {
coordinator: OnceLock<Coordinator>,
log: Mutex<Vec<RawMessage>>,
clock: OnceLock<TestClock>,
credits: Mutex<Credits>,
tickets: AtomicU64,
writes: Mutex<Option<oneshot::Receiver<()>>>,
}
impl Harness {
pub(crate) fn in_process(&self) -> bool {
self.clock.get().is_some()
}
pub(crate) fn now(&self) -> Now {
Now::test(self.clock.get().copied())
}
fn books(&self) -> Option<&Coordinator> {
self.clock.get().and_then(|_| self.coordinator.get())
}
fn credits(&self) -> MutexGuard<'_, Credits> {
self.credits.lock().unwrap_or_else(PoisonError::into_inner)
}
fn credit<'books>(
credits: &'books mut Credits,
queue: QueueAt<'_>,
) -> Option<&'books mut Tally> {
credits.get_mut(queue.table)?.get_mut(queue.group)
}
pub(crate) fn opened(&self, queue: QueueAt<'_>, waiting: usize) {
self.credits()
.entry(queue.table.into())
.or_default()
.entry(queue.group.into())
.or_default()
.ahead += waiting;
if let Some(coordinator) = self.books() {
for _ in 0..waiting {
coordinator.enqueued();
}
}
}
pub(crate) fn closed(&self, queue: QueueAt<'_>) {
let mut credits = self.credits();
if let Some(groups) = credits.get_mut(queue.table) {
groups.remove(queue.group);
if groups.is_empty() {
credits.remove(queue.table);
}
}
}
pub(crate) fn expect(&self, queue: QueueAt<'_>) {
let Some(coordinator) = self.books() else {
return;
};
if let Some(tally) = Self::credit(&mut self.credits(), queue) {
tally.ahead += 1;
coordinator.enqueued();
}
}
pub(crate) fn refused(&self, queue: QueueAt<'_>) {
let Some(coordinator) = self.books() else {
return;
};
if let Some(tally) = Self::credit(&mut self.credits(), queue)
&& tally.ahead > 0
{
tally.ahead -= 1;
coordinator.consumed();
}
}
pub(crate) fn holds_back(&self, queue: QueueAt<'_>, delay: Duration) -> Option<Ticket> {
self.books()?;
let ticket = Ticket(self.tickets.fetch_add(1, Ordering::Relaxed));
let due = Instant::now() + delay;
Self::credit(&mut self.credits(), queue)?
.returning
.push(Return { due, ticket });
Some(ticket)
}
pub(crate) fn withdraw(&self, queue: QueueAt<'_>, ticket: Ticket) {
if let Some(tally) = Self::credit(&mut self.credits(), queue) {
tally.take_return(ticket);
}
}
fn comes_due(&self, queue: QueueAt<'_>, ticket: Ticket) {
let Some(coordinator) = self.books() else {
return;
};
if let Some(tally) = Self::credit(&mut self.credits(), queue)
&& tally.take_return(ticket)
{
tally.ahead += 1;
coordinator.enqueued();
}
}
pub(crate) fn claimed(&self, queue: QueueAt<'_>, count: usize) {
let Some(coordinator) = self.books() else {
return;
};
let covered = Self::credit(&mut self.credits(), queue).map_or(0, |tally| {
let covered = tally.ahead.min(count);
tally.ahead -= covered;
for _ in covered..count {
tally.take_earliest_return();
}
covered
});
for _ in covered..count {
coordinator.enqueued();
}
}
pub(crate) fn released(&self) {
if let Some(coordinator) = self.books() {
coordinator.consumed();
}
}
pub(crate) fn returned(&self, queue: QueueAt<'_>) {
self.expect(queue);
self.released();
}
pub(crate) fn published(&self, message: &OutgoingMessage<'_>) {
self.record(message);
}
fn record(&self, message: &OutgoingMessage<'_>) {
self.log
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(
RawMessage::new(message.name().to_owned(), message.payload().to_vec())
.with_headers(message.headers().clone()),
);
}
}
pub(crate) struct BatchMoves<DB: QueueDatabase> {
connection: OnceLock<Arc<Shared<DB>>>,
counted: Mutex<Vec<QueueAt<'static>>>,
}
impl<DB: QueueDatabase> Default for BatchMoves<DB> {
fn default() -> Self {
Self {
connection: OnceLock::new(),
counted: Mutex::default(),
}
}
}
impl<DB: QueueDatabase> BatchMoves<DB> {
pub(crate) fn watch(&self, connection: &Arc<Shared<DB>>) {
if connection.harness.books().is_some() {
let _ = self.connection.set(Arc::clone(connection));
}
}
fn counted(&self) -> MutexGuard<'_, Vec<QueueAt<'static>>> {
self.counted.lock().unwrap_or_else(PoisonError::into_inner)
}
pub(crate) fn counted_at(&self, at: QueueAt<'static>) {
if self.connection.get().is_some() {
self.counted().push(at);
}
}
pub(crate) fn ended(&self, committed: bool) {
if committed {
self.counted().clear();
} else {
self.undone();
}
}
pub(crate) fn undone(&self) {
let Some(connection) = self.connection.get() else {
return;
};
let undone = mem::take(&mut *self.counted());
for at in undone {
connection.harness.refused(at);
}
}
}
pub(crate) fn returns_after<DB: QueueDatabase>(
connection: &Arc<Shared<DB>>,
queue: &'static Queue,
delay: Duration,
ticket: Ticket,
) {
let Some(coordinator) = connection.harness.books() else {
return;
};
let connection = Arc::clone(connection);
coordinator.schedule_redelivery(delay, move || {
connection.harness.comes_due(QueueAt::read(queue), ticket);
});
}
pub(crate) async fn open_books<DB, Row>(
connection: &Arc<Shared<DB>>,
dialect: &dyn Dialect,
queue: &'static Queue,
group: Option<&str>,
) -> Result<(), SqlxBrokerError>
where
DB: QueueDatabase,
Row: Events<DB>,
{
let waiting = claimable::<DB, Row>(connection, dialect, queue).await?;
connection
.harness
.opened(QueueAt::registered(queue.table, group), waiting);
Ok(())
}
async fn claimable<DB, Row>(
connection: &Arc<Shared<DB>>,
dialect: &dyn Dialect,
queue: &'static Queue,
) -> Result<usize, SqlxBrokerError>
where
DB: QueueDatabase,
Row: Events<DB>,
{
if connection.harness.books().is_none() || Row::SHAPE.custom_claim {
return Ok(0);
}
let counting =
dialect
.count_claimable(&queue.spec)
.map_err(|source| SqlxBrokerError::Dialect {
subscription: queue.name.to_owned(),
table: queue.table.to_owned(),
row: queue.row,
source,
})?;
let Some(counting) = counting else {
return Ok(0);
};
let statement = intern(&counting);
let (pool, now) = (connection.pool.clone(), connection.harness.now());
let counted = off_clock(async move {
let lease = queue.lease.map(|_| Row::lease(queue, now)).transpose()?;
let cx = Claiming {
queue,
limit: 0,
now,
};
let values = Values::claiming(cx, Event::Claim, lease.as_ref());
let arguments = arguments::<DB, Row>(statement, &values)?;
let mut conn = pool.acquire().await?;
DB::fetch_count(&mut conn, statement.sql, arguments).await
})
.await
.unwrap_or_else(|| Err(cancelled()))
.map_err(|source| SqlxBrokerError::Sqlx {
subscription: queue.name.to_owned(),
table: queue.table.to_owned(),
row: queue.row,
statement: statement.sql,
source: Box::new(source),
})?;
Ok(usize::try_from(counted).unwrap_or_default())
}
impl<DB: QueueDatabase, D: Dialect + 'static> InProcess for SqlxBroker<DB, D> {
async fn connect_in_process(self) -> Result<ConnectedSqlxBroker<DB, D>, SqlxBrokerError> {
let (pool, owner) = self.pool.open()?;
let choice = self.dialect.clone();
let checked = pool.clone();
let dialect = off_clock(async move {
let conn = checked
.acquire()
.await
.map_err(|source| SqlxBrokerError::Connect { source })?;
choice.resolve(&conn)
})
.await
.unwrap_or_else(|| {
Err(SqlxBrokerError::Connect {
source: cancelled(),
})
})?;
let listening = match self.listen {
Some(start) => Some(
off_clock(start(pool.clone()))
.await
.unwrap_or_else(|| Err(cancelled()))
.map_err(|source| SqlxBrokerError::Connect { source })?,
),
None => None,
};
let connected =
ConnectedSqlxBroker::new(self, (pool, owner), dialect, Handle::current(), listening);
let _ = connected.shared.harness.clock.set(TestClock::start());
connected.shared.closing.run_in_process();
Ok(connected)
}
}
impl<DB: QueueDatabase, D: Dialect + 'static> TestableBroker for ConnectedSqlxBroker<DB, D> {
fn install_coordinator(&self, coordinator: Coordinator) {
let _ = self.shared.harness.coordinator.set(coordinator);
}
fn inject(&self, message: OutgoingMessage<'_>) {
let harness = &self.shared.harness;
harness.record(&message);
let wake = self
.shared
.routes
.find_with_wake(message.name())
.map(|(_, wake)| wake);
if let Some(wake) = wake {
harness.expect(QueueAt::written(wake, message.name()));
}
let (name, payload, headers) = message.into_parts();
let (name, payload) = (name.to_owned(), payload.to_vec());
let (written, next) = oneshot::channel();
let previous = harness
.writes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.replace(next);
let shared = Arc::clone(&self.shared);
tokio::spawn(async move {
if let Some(previous) = previous {
let _ = previous.await;
}
inject(&shared, &name, &payload, headers).await;
let _ = written.send(());
});
}
fn published(&self, name: &str) -> Vec<RawMessage> {
self.shared
.harness
.log
.lock()
.unwrap_or_else(PoisonError::into_inner)
.iter()
.filter(|message| message.name() == name)
.cloned()
.collect()
}
fn backlog(&self) -> Backlog {
Backlog::Delivered
}
}
async fn inject<DB: QueueDatabase>(
shared: &Shared<DB>,
name: &str,
payload: &[u8],
headers: HeaderMap,
) {
let message = OutgoingMessage::new(name, payload).with_headers(headers);
let written = match shared.routes.find_with_wake(name) {
Some((route, wake)) => insert_routed(shared, route, wake, &message).await,
None => Err(SqlxBrokerError::NoRoute {
name: name.to_owned(),
}),
};
if let Err(error) = written {
if let Some((_, wake)) = shared.routes.find_with_wake(name) {
shared.harness.refused(QueueAt::written(wake, name));
}
tracing::warn!(target: "ruststream_sqlx", %error, name, "an injected message was refused");
}
}
#[cfg(feature = "postgres")]
ruststream::register_testable_broker!(SqlxBroker<sqlx::Postgres>);
#[cfg(feature = "mysql")]
ruststream::register_testable_broker!(SqlxBroker<sqlx::MySql>);
#[cfg(feature = "sqlite")]
ruststream::register_testable_broker!(SqlxBroker<sqlx::Sqlite>);
#[cfg(feature = "any")]
ruststream::register_testable_broker!(SqlxBroker<sqlx::Any>);