use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::{Arc, Mutex, PoisonError};
use sqlx::{Database, Pool};
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use super::QueueDatabase;
use crate::inbox::broker::Shared;
use crate::inbox::error::SqlxBrokerError;
use crate::inbox::publish::TableWake;
#[cfg(feature = "testing")]
use crate::inbox::testing::{cancelled, off_clock};
const CHANNEL_LIMIT: usize = 63;
#[diagnostic::on_unimplemented(
message = "`LISTEN/NOTIFY` is Postgres's: `{Self}` has no notifications",
label = "this database cannot listen",
note = "drop `.listen_notify()`: the subscriptions wake on the poll interval and on publishes \
from this process"
)]
pub trait Notifies: QueueDatabase + sealed::Listens {}
#[cfg(feature = "postgres")]
impl Notifies for sqlx::Postgres {}
pub(crate) type StartListening<DB> = fn(Pool<DB>) -> Starting;
pub(crate) type Starting = Pin<Box<dyn Future<Output = Result<Listening, sqlx::Error>> + Send>>;
pub(crate) fn start_listening<DB: Notifies>() -> StartListening<DB> {
<DB as sealed::Listens>::listen
}
mod sealed {
use sqlx::{Database, Pool};
use super::Starting;
pub trait Listens: Database {
#[expect(
private_interfaces,
reason = "the trait is sealed: nothing outside the crate names or implements it"
)]
fn listen(pool: Pool<Self>) -> Starting;
}
}
pub(crate) struct Listening {
notify: &'static str,
commands: mpsc::UnboundedSender<Listen>,
stop: CancellationToken,
task: Mutex<Option<JoinHandle<()>>>,
}
#[cfg_attr(
not(feature = "postgres"),
expect(dead_code, reason = "a listening task runs on Postgres alone")
)]
struct Listen {
table: &'static TableWake,
listened: oneshot::Sender<Result<(), sqlx::Error>>,
}
impl Listening {
pub(crate) const fn notify(&self) -> &'static str {
self.notify
}
async fn listen(&self, table: &'static TableWake) -> Result<(), sqlx::Error> {
let (listened, answer) = oneshot::channel();
if self.commands.send(Listen { table, listened }).is_err() {
return Err(stopped());
}
answer.await.unwrap_or_else(|_| Err(stopped()))
}
async fn close(&self) {
self.stop.cancel();
let task = self
.task
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take();
if let Some(task) = task {
let _ = task.await;
}
}
}
fn stopped() -> sqlx::Error {
sqlx::Error::Io(io::Error::other("the listening connection is closed"))
}
pub(crate) async fn listen<DB: Database>(
shared: &Arc<Shared<DB>>,
table: &'static TableWake,
subscription: &str,
row: &'static str,
) -> Result<(), SqlxBrokerError> {
let Some(listening) = &shared.listening else {
return Ok(());
};
if table.table().len() > CHANNEL_LIMIT {
return Err(SqlxBrokerError::Declaration {
subscription: subscription.to_owned(),
table: table.table().to_owned(),
row,
reason: format!(
"the table's qualified name is {} bytes, and a Postgres notification channel \
holds at most {CHANNEL_LIMIT}: shorten the name or drop `.listen_notify()`",
table.table().len()
),
});
}
#[cfg(feature = "testing")]
let listened = if shared.harness.in_process() {
let shared = Arc::clone(shared);
off_clock(async move {
match &shared.listening {
Some(listening) => listening.listen(table).await,
None => Ok(()),
}
})
.await
.unwrap_or_else(|| Err(cancelled()))
} else {
listening.listen(table).await
};
#[cfg(not(feature = "testing"))]
let listened = listening.listen(table).await;
listened.map_err(|source| SqlxBrokerError::Listen {
subscription: subscription.to_owned(),
table: table.table().to_owned(),
row,
source: Box::new(source),
})
}
pub(crate) async fn close<DB: Database>(shared: &Arc<Shared<DB>>) {
let Some(listening) = &shared.listening else {
return;
};
#[cfg(feature = "testing")]
if shared.harness.in_process() {
let shared = Arc::clone(shared);
let _ = off_clock(async move {
if let Some(listening) = &shared.listening {
listening.close().await;
}
})
.await;
return;
}
listening.close().await;
}
pub(crate) async fn announce<DB: QueueDatabase>(
notify: &'static str,
conn: &mut DB::Connection,
table: &TableWake,
name: &str,
) {
let mut arguments = DB::Arguments::default();
let announced = match DB::bind_str(&mut arguments, table.table())
.and_then(|()| DB::bind_str(&mut arguments, name))
{
Ok(()) => DB::execute(conn, notify, arguments).await.map(drop),
Err(error) => Err(error),
};
if let Err(error) = announced {
tracing::warn!(
target: "ruststream_sqlx",
%error,
name,
table = table.table(),
"the row is written, and its notification failed: subscriptions in other processes \
reach it on their poll interval"
);
}
}
#[cfg(feature = "postgres")]
mod postgres {
use std::ptr;
use std::sync::Mutex;
use std::time::Duration;
use sqlx::postgres::{PgListener, PgNotification};
use sqlx::{PgPool, Postgres};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use super::sealed::Listens;
use super::{Listen, Listening, Starting};
use crate::inbox::publish::TableWake;
const RETRY: Duration = Duration::from_secs(1);
impl Listens for Postgres {
#[expect(
private_interfaces,
reason = "the trait is sealed: nothing outside the crate names or implements it"
)]
fn listen(pool: PgPool) -> Starting {
Box::pin(async move {
let listener = open(&pool, &[]).await?;
let (commands, received) = mpsc::unbounded_channel();
let stop = CancellationToken::new();
let task = tokio::spawn(run(pool, listener, received, stop.clone()));
Ok(Listening {
notify: "SELECT pg_notify($1, $2)",
commands,
stop,
task: Mutex::new(Some(task)),
})
})
}
}
async fn open(pool: &PgPool, tables: &[&'static TableWake]) -> Result<PgListener, sqlx::Error> {
let mut listener = PgListener::connect_with(pool).await?;
listener.eager_reconnect(false);
if !tables.is_empty() {
listener
.listen_all(tables.iter().map(|table| table.table()))
.await?;
}
Ok(listener)
}
async fn run(
pool: PgPool,
mut listener: PgListener,
mut commands: mpsc::UnboundedReceiver<Listen>,
stop: CancellationToken,
) {
let mut tables: Vec<&'static TableWake> = Vec::new();
loop {
let lost = tokio::select! {
biased;
() = stop.cancelled() => break,
command = commands.recv() => {
let Some(Listen { table, listened }) = command else { break };
let _ = listened.send(listen(&mut listener, &mut tables, table).await);
continue;
}
received = listener.try_recv() => match received {
Ok(Some(notification)) => {
wake(&tables, ¬ification);
continue;
}
Ok(None) => None,
Err(error) => Some(error),
},
};
tracing::warn!(
target: "ruststream_sqlx",
error = lost.as_ref().map(tracing::field::display),
tables = ?tables.iter().map(|table| table.table()).collect::<Vec<_>>(),
"the listening connection was lost; listening again"
);
let Some(again) = reopen(&pool, &tables, &stop).await else {
break;
};
listener = again;
for table in &tables {
table.wake_all();
}
}
if let Err(error) = listener.unlisten_all().await {
tracing::debug!(
target: "ruststream_sqlx",
%error,
"the listening connection did not stop listening; it is closed"
);
}
drop(listener);
}
async fn listen(
listener: &mut PgListener,
tables: &mut Vec<&'static TableWake>,
table: &'static TableWake,
) -> Result<(), sqlx::Error> {
if tables.iter().any(|listened| ptr::eq(*listened, table)) {
return Ok(());
}
listener.listen(table.table()).await?;
tables.push(table);
Ok(())
}
fn wake(tables: &[&'static TableWake], notification: &PgNotification) {
let Some(table) = tables
.iter()
.find(|table| table.table() == notification.channel())
else {
return;
};
match notification.payload() {
"" => table.wake_all(),
group => table.wake(group),
}
}
async fn reopen(
pool: &PgPool,
tables: &[&'static TableWake],
stop: &CancellationToken,
) -> Option<PgListener> {
loop {
if stop.is_cancelled() {
return None;
}
match open(pool, tables).await {
Ok(listener) => return Some(listener),
Err(error) => {
tracing::warn!(
target: "ruststream_sqlx",
%error,
tables = ?tables.iter().map(|table| table.table()).collect::<Vec<_>>(),
"the listening connection did not open; trying again"
);
tokio::select! {
() = stop.cancelled() => return None,
() = tokio::time::sleep(RETRY) => {}
}
}
}
}
}
}