pub mod envelope;
pub mod sweeper;
pub use envelope::{ClientMessage, EnvelopeError, ServerMessage, DELIVERY_PROTOCOL_VERSION};
pub use sweeper::{DeliverySweeper, SweeperConfig};
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::{watch, Notify};
use tracing::{debug, error, warn};
use crate::dal::DAL;
use crate::models::delivery_outbox::DeliveryOutbox;
const DEFAULT_DRAIN_BATCH: i64 = 256;
#[derive(Debug, thiserror::Error)]
pub enum DeliveryError {
#[error("sink delivery failed: {0}")]
Sink(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryOutcome {
Delivered,
NoRoute,
}
#[async_trait]
pub trait DeliverySink: Send + Sync {
async fn deliver(&self, row: &DeliveryOutbox) -> Result<DeliveryOutcome, DeliveryError>;
}
#[derive(Clone)]
pub struct WakeHandle {
notify: Arc<Notify>,
}
impl WakeHandle {
pub fn wake(&self) {
self.notify.notify_one();
}
}
pub struct DeliveryRelay {
dal: DAL,
sink: Arc<dyn DeliverySink>,
notify: Arc<Notify>,
drain_batch: i64,
}
impl DeliveryRelay {
pub fn new(dal: DAL, sink: Arc<dyn DeliverySink>) -> Self {
Self {
dal,
sink,
notify: Arc::new(Notify::new()),
drain_batch: DEFAULT_DRAIN_BATCH,
}
}
pub fn with_drain_batch(mut self, batch: i64) -> Self {
self.drain_batch = batch;
self
}
pub fn wake_handle(&self) -> WakeHandle {
WakeHandle {
notify: self.notify.clone(),
}
}
pub async fn drain_once(&self) -> Result<usize, crate::error::ValidationError> {
let rows = self
.dal
.delivery_outbox()
.list_pending(self.drain_batch)
.await?;
let mut delivered = 0usize;
for row in rows {
match self.sink.deliver(&row).await {
Ok(DeliveryOutcome::Delivered) => {
match self.dal.delivery_outbox().mark_delivered(row.id).await {
Ok(()) => delivered += 1,
Err(crate::error::ValidationError::InvalidStateTransition { .. }) => {
debug!(id = row.id, "mark_delivered skipped — row already advanced");
}
Err(e) => warn!(
id = row.id,
error = %e,
"delivery_outbox: mark_delivered failed; row stays pending for retry"
),
}
}
Ok(DeliveryOutcome::NoRoute) => debug!(
id = row.id,
recipient = %row.recipient,
"delivery_outbox: no local route; leaving pending"
),
Err(e) => warn!(
id = row.id,
error = %e,
"delivery_outbox: sink delivery failed; leaving pending"
),
}
}
Ok(delivered)
}
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
if let Err(e) = self.drain_once().await {
error!(error = %e, "delivery_outbox: initial catch-up drain failed");
}
loop {
tokio::select! {
_ = self.notify.notified() => {
if let Err(e) = self.drain_once().await {
error!(error = %e, "delivery_outbox: drain failed");
}
}
res = shutdown.changed() => {
if res.is_err() || *shutdown.borrow() {
break;
}
}
}
}
debug!("delivery_outbox: relay shut down");
}
}
#[cfg(feature = "postgres")]
pub async fn run_pg_listener(
conn_str: String,
channel: String,
wake: WakeHandle,
mut shutdown: watch::Receiver<bool>,
) {
use std::time::Duration;
const BACKOFF: Duration = Duration::from_secs(1);
loop {
if *shutdown.borrow() {
break;
}
match listen_once(&conn_str, &channel, &wake, &mut shutdown).await {
Ok(()) => break, Err(e) => {
warn!(error = %e, "delivery_outbox: LISTEN connection lost; reconnecting after backoff");
tokio::select! {
_ = tokio::time::sleep(BACKOFF) => {}
_ = shutdown.changed() => {}
}
}
}
}
debug!("delivery_outbox: LISTEN loop stopped");
}
#[cfg(feature = "postgres")]
async fn listen_once(
conn_str: &str,
channel: &str,
wake: &WakeHandle,
shutdown: &mut watch::Receiver<bool>,
) -> Result<(), DeliveryError> {
use futures::StreamExt;
use std::pin::Pin;
let (client, mut connection) = tokio_postgres::connect(conn_str, tokio_postgres::NoTls)
.await
.map_err(|e| DeliveryError::Sink(format!("connect: {e}")))?;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let driver = tokio::spawn(async move {
let mut stream =
futures::stream::poll_fn(move |cx| Pin::new(&mut connection).poll_message(cx));
while let Some(msg) = stream.next().await {
match msg {
Ok(tokio_postgres::AsyncMessage::Notification(_)) => {
if tx.send(()).is_err() {
break;
}
}
Ok(_) => {} Err(_) => break, }
}
});
client
.batch_execute(&format!("LISTEN {channel}"))
.await
.map_err(|e| DeliveryError::Sink(format!("LISTEN: {e}")))?;
wake.wake();
let outcome = loop {
tokio::select! {
maybe = rx.recv() => match maybe {
Some(()) => wake.wake(),
None => break Err(DeliveryError::Sink("LISTEN connection closed".to_string())),
},
res = shutdown.changed() => {
if res.is_err() || *shutdown.borrow() {
break Ok(());
}
}
}
};
driver.abort();
drop(client); outcome
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
use crate::database::Database;
use crate::models::delivery_outbox::NewDeliveryOutbox;
use std::sync::Mutex;
use std::time::Duration;
async fn unique_dal() -> DAL {
let url = format!(
"file:delivery_relay_test_{}?mode=memory&cache=shared",
uuid::Uuid::new_v4()
);
let db = Database::new(&url, "", 5);
db.run_migrations()
.await
.expect("migrations should succeed");
DAL::new(db)
}
fn work(recipient: &str) -> NewDeliveryOutbox {
NewDeliveryOutbox {
recipient: recipient.to_string(),
kind: "work".to_string(),
tenant_id: None,
payload: b"payload".to_vec(),
}
}
struct CollectingSink {
seen: Mutex<Vec<i64>>,
outcome: DeliveryOutcome,
}
impl CollectingSink {
fn new(outcome: DeliveryOutcome) -> Arc<Self> {
Arc::new(Self {
seen: Mutex::new(Vec::new()),
outcome,
})
}
fn seen(&self) -> Vec<i64> {
self.seen.lock().unwrap().clone()
}
}
#[async_trait]
impl DeliverySink for CollectingSink {
async fn deliver(&self, row: &DeliveryOutbox) -> Result<DeliveryOutcome, DeliveryError> {
self.seen.lock().unwrap().push(row.id);
Ok(self.outcome)
}
}
#[tokio::test]
async fn test_drain_delivers_pending_and_marks_delivered() {
let dal = unique_dal().await;
let r1 = dal
.delivery_outbox()
.enqueue(work("agent:1"))
.await
.unwrap();
let sink = CollectingSink::new(DeliveryOutcome::Delivered);
let relay = DeliveryRelay::new(dal.clone(), sink.clone());
let delivered = relay.drain_once().await.unwrap();
assert_eq!(delivered, 1);
assert_eq!(sink.seen(), vec![r1.id]);
assert_eq!(relay.drain_once().await.unwrap(), 0);
}
#[tokio::test]
async fn test_no_route_leaves_row_pending() {
let dal = unique_dal().await;
dal.delivery_outbox()
.enqueue(work("agent:1"))
.await
.unwrap();
let sink = CollectingSink::new(DeliveryOutcome::NoRoute);
let relay = DeliveryRelay::new(dal.clone(), sink.clone());
assert_eq!(relay.drain_once().await.unwrap(), 0);
assert_eq!(sink.seen().len(), 1);
assert_eq!(
dal.delivery_outbox().list_pending(10).await.unwrap().len(),
1
);
}
#[tokio::test]
async fn test_in_process_wake_triggers_drain() {
let dal = unique_dal().await;
let sink = CollectingSink::new(DeliveryOutcome::Delivered);
let relay = DeliveryRelay::new(dal.clone(), sink.clone());
let wake = relay.wake_handle();
let (tx, rx) = watch::channel(false);
let handle = tokio::spawn(relay.run(rx));
let r = dal
.delivery_outbox()
.enqueue(work("agent:1"))
.await
.unwrap();
wake.wake();
let mut got = false;
for _ in 0..50 {
if sink.seen().contains(&r.id) {
got = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(got, "wake did not trigger delivery");
tx.send(true).unwrap();
let _ = handle.await;
}
}