use super::models::{NewUnifiedDeliveryOutbox, UnifiedDeliveryOutbox};
use super::DAL;
use crate::database::schema::unified::delivery_outbox;
use crate::database::universal_types::{UniversalBinary, UniversalTimestamp};
use crate::error::ValidationError;
use crate::models::delivery_outbox::{DeliveryOutbox, NewDeliveryOutbox};
use diesel::prelude::*;
const STATE_PENDING: &str = "pending";
const STATE_DELIVERED: &str = "delivered";
const STATE_ACKED: &str = "acked";
#[derive(Clone)]
pub struct DeliveryOutboxDAL<'a> {
dal: &'a DAL,
}
fn to_domain(r: UnifiedDeliveryOutbox) -> DeliveryOutbox {
DeliveryOutbox {
id: r.id,
recipient: r.recipient,
kind: r.kind,
tenant_id: r.tenant_id,
payload: r.payload.into_inner(),
delivery_state: r.delivery_state,
delivery_attempts: r.delivery_attempts,
created_at: r.created_at,
delivered_at: r.delivered_at,
acked_at: r.acked_at,
}
}
impl<'a> DeliveryOutboxDAL<'a> {
pub fn new(dal: &'a DAL) -> Self {
Self { dal }
}
pub async fn enqueue(&self, new: NewDeliveryOutbox) -> Result<DeliveryOutbox, ValidationError> {
crate::dispatch_backend!(
self.dal.backend(),
self.enqueue_postgres(new).await,
self.enqueue_sqlite(new).await
)
}
#[cfg(feature = "postgres")]
async fn enqueue_postgres(
&self,
new: NewDeliveryOutbox,
) -> Result<DeliveryOutbox, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let row = build_insert(new);
let result: UnifiedDeliveryOutbox = conn
.interact(move |conn| {
diesel::insert_into(delivery_outbox::table)
.values(&row)
.get_result(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(to_domain(result))
}
#[cfg(feature = "sqlite")]
async fn enqueue_sqlite(
&self,
new: NewDeliveryOutbox,
) -> Result<DeliveryOutbox, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let row = build_insert(new);
let result: UnifiedDeliveryOutbox = conn
.interact(move |conn| {
diesel::insert_into(delivery_outbox::table)
.values(&row)
.get_result(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(to_domain(result))
}
pub async fn mark_delivered(&self, id: i64) -> Result<(), ValidationError> {
let affected = crate::dispatch_backend!(
self.dal.backend(),
self.mark_delivered_postgres(id).await,
self.mark_delivered_sqlite(id).await
)?;
transition_result(id, STATE_PENDING, STATE_DELIVERED, affected)
}
#[cfg(feature = "postgres")]
async fn mark_delivered_postgres(&self, id: i64) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let now = UniversalTimestamp::now();
conn.interact(move |conn| {
diesel::update(
delivery_outbox::table
.filter(delivery_outbox::id.eq(id))
.filter(delivery_outbox::delivery_state.eq(STATE_PENDING)),
)
.set((
delivery_outbox::delivery_state.eq(STATE_DELIVERED),
delivery_outbox::delivered_at.eq(Some(now)),
delivery_outbox::delivery_attempts.eq(delivery_outbox::delivery_attempts + 1),
))
.execute(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
#[cfg(feature = "sqlite")]
async fn mark_delivered_sqlite(&self, id: i64) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let now = UniversalTimestamp::now();
conn.interact(move |conn| {
diesel::update(
delivery_outbox::table
.filter(delivery_outbox::id.eq(id))
.filter(delivery_outbox::delivery_state.eq(STATE_PENDING)),
)
.set((
delivery_outbox::delivery_state.eq(STATE_DELIVERED),
delivery_outbox::delivered_at.eq(Some(now)),
delivery_outbox::delivery_attempts.eq(delivery_outbox::delivery_attempts + 1),
))
.execute(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
pub async fn mark_acked(&self, id: i64) -> Result<(), ValidationError> {
let affected = crate::dispatch_backend!(
self.dal.backend(),
self.mark_acked_postgres(id).await,
self.mark_acked_sqlite(id).await
)?;
transition_result(id, "pending|delivered", STATE_ACKED, affected)
}
#[cfg(feature = "postgres")]
async fn mark_acked_postgres(&self, id: i64) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let now = UniversalTimestamp::now();
conn.interact(move |conn| {
diesel::update(
delivery_outbox::table
.filter(delivery_outbox::id.eq(id))
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED)),
)
.set((
delivery_outbox::delivery_state.eq(STATE_ACKED),
delivery_outbox::acked_at.eq(Some(now)),
))
.execute(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
#[cfg(feature = "sqlite")]
async fn mark_acked_sqlite(&self, id: i64) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let now = UniversalTimestamp::now();
conn.interact(move |conn| {
diesel::update(
delivery_outbox::table
.filter(delivery_outbox::id.eq(id))
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED)),
)
.set((
delivery_outbox::delivery_state.eq(STATE_ACKED),
delivery_outbox::acked_at.eq(Some(now)),
))
.execute(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
pub async fn reset_to_pending(&self, id: i64) -> Result<(), ValidationError> {
let affected = crate::dispatch_backend!(
self.dal.backend(),
self.reset_to_pending_postgres(id).await,
self.reset_to_pending_sqlite(id).await
)?;
transition_result(id, STATE_DELIVERED, STATE_PENDING, affected)
}
#[cfg(feature = "postgres")]
async fn reset_to_pending_postgres(&self, id: i64) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
conn.interact(move |conn| {
diesel::update(
delivery_outbox::table
.filter(delivery_outbox::id.eq(id))
.filter(delivery_outbox::delivery_state.eq(STATE_DELIVERED)),
)
.set(delivery_outbox::delivery_state.eq(STATE_PENDING))
.execute(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
#[cfg(feature = "sqlite")]
async fn reset_to_pending_sqlite(&self, id: i64) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
conn.interact(move |conn| {
diesel::update(
delivery_outbox::table
.filter(delivery_outbox::id.eq(id))
.filter(delivery_outbox::delivery_state.eq(STATE_DELIVERED)),
)
.set(delivery_outbox::delivery_state.eq(STATE_PENDING))
.execute(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
pub async fn reset_delivered_to_pending_for_recipient(
&self,
recipient: &str,
tenant_id: Option<&str>,
) -> Result<usize, ValidationError> {
let recipient = recipient.to_string();
let tenant_id = tenant_id.map(|s| s.to_string());
crate::dispatch_backend!(
self.dal.backend(),
self.reset_delivered_for_recipient_postgres(recipient, tenant_id)
.await,
self.reset_delivered_for_recipient_sqlite(recipient, tenant_id)
.await
)
}
#[cfg(feature = "postgres")]
async fn reset_delivered_for_recipient_postgres(
&self,
recipient: String,
tenant_id: Option<String>,
) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
conn.interact(move |conn| {
let base = delivery_outbox::table
.filter(delivery_outbox::recipient.eq(recipient))
.filter(delivery_outbox::delivery_state.eq(STATE_DELIVERED));
match tenant_id {
Some(t) => diesel::update(base.filter(delivery_outbox::tenant_id.eq(t)))
.set(delivery_outbox::delivery_state.eq(STATE_PENDING))
.execute(conn),
None => diesel::update(base.filter(delivery_outbox::tenant_id.is_null()))
.set(delivery_outbox::delivery_state.eq(STATE_PENDING))
.execute(conn),
}
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
#[cfg(feature = "sqlite")]
async fn reset_delivered_for_recipient_sqlite(
&self,
recipient: String,
tenant_id: Option<String>,
) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
conn.interact(move |conn| {
let base = delivery_outbox::table
.filter(delivery_outbox::recipient.eq(recipient))
.filter(delivery_outbox::delivery_state.eq(STATE_DELIVERED));
match tenant_id {
Some(t) => diesel::update(base.filter(delivery_outbox::tenant_id.eq(t)))
.set(delivery_outbox::delivery_state.eq(STATE_PENDING))
.execute(conn),
None => diesel::update(base.filter(delivery_outbox::tenant_id.is_null()))
.set(delivery_outbox::delivery_state.eq(STATE_PENDING))
.execute(conn),
}
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
pub async fn reassign_open_rows(
&self,
from_recipient: &str,
to_recipient: &str,
) -> Result<usize, ValidationError> {
let from_recipient = from_recipient.to_string();
let to_recipient = to_recipient.to_string();
crate::dispatch_backend!(
self.dal.backend(),
self.reassign_open_rows_postgres(from_recipient, to_recipient)
.await,
self.reassign_open_rows_sqlite(from_recipient, to_recipient)
.await
)
}
#[cfg(feature = "postgres")]
async fn reassign_open_rows_postgres(
&self,
from_recipient: String,
to_recipient: String,
) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
conn.interact(move |conn| {
diesel::update(
delivery_outbox::table
.filter(delivery_outbox::recipient.eq(from_recipient))
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED)),
)
.set((
delivery_outbox::recipient.eq(to_recipient),
delivery_outbox::delivery_state.eq(STATE_PENDING),
delivery_outbox::delivered_at
.eq(None::<crate::database::universal_types::UniversalTimestamp>),
))
.execute(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
#[cfg(feature = "sqlite")]
async fn reassign_open_rows_sqlite(
&self,
from_recipient: String,
to_recipient: String,
) -> Result<usize, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
conn.interact(move |conn| {
diesel::update(
delivery_outbox::table
.filter(delivery_outbox::recipient.eq(from_recipient))
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED)),
)
.set((
delivery_outbox::recipient.eq(to_recipient),
delivery_outbox::delivery_state.eq(STATE_PENDING),
delivery_outbox::delivered_at
.eq(None::<crate::database::universal_types::UniversalTimestamp>),
))
.execute(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?
.map_err(ValidationError::from)
}
pub async fn list_open_for_recipient(
&self,
recipient: &str,
limit: i64,
) -> Result<Vec<DeliveryOutbox>, ValidationError> {
let recipient = recipient.to_string();
crate::dispatch_backend!(
self.dal.backend(),
self.list_open_for_recipient_postgres(recipient, limit)
.await,
self.list_open_for_recipient_sqlite(recipient, limit).await
)
}
#[cfg(feature = "postgres")]
async fn list_open_for_recipient_postgres(
&self,
recipient: String,
limit: i64,
) -> Result<Vec<DeliveryOutbox>, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let rows: Vec<UnifiedDeliveryOutbox> = conn
.interact(move |conn| {
delivery_outbox::table
.filter(delivery_outbox::recipient.eq(recipient))
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED))
.order(delivery_outbox::id.asc())
.limit(limit)
.load(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(rows.into_iter().map(to_domain).collect())
}
#[cfg(feature = "sqlite")]
async fn list_open_for_recipient_sqlite(
&self,
recipient: String,
limit: i64,
) -> Result<Vec<DeliveryOutbox>, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let rows: Vec<UnifiedDeliveryOutbox> = conn
.interact(move |conn| {
delivery_outbox::table
.filter(delivery_outbox::recipient.eq(recipient))
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED))
.order(delivery_outbox::id.asc())
.limit(limit)
.load(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(rows.into_iter().map(to_domain).collect())
}
pub async fn list_pending(&self, limit: i64) -> Result<Vec<DeliveryOutbox>, ValidationError> {
crate::dispatch_backend!(
self.dal.backend(),
self.list_pending_postgres(limit).await,
self.list_pending_sqlite(limit).await
)
}
#[cfg(feature = "postgres")]
async fn list_pending_postgres(
&self,
limit: i64,
) -> Result<Vec<DeliveryOutbox>, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let rows: Vec<UnifiedDeliveryOutbox> = conn
.interact(move |conn| {
delivery_outbox::table
.filter(delivery_outbox::delivery_state.eq(STATE_PENDING))
.order(delivery_outbox::id.asc())
.limit(limit)
.load(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(rows.into_iter().map(to_domain).collect())
}
#[cfg(feature = "sqlite")]
async fn list_pending_sqlite(
&self,
limit: i64,
) -> Result<Vec<DeliveryOutbox>, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let rows: Vec<UnifiedDeliveryOutbox> = conn
.interact(move |conn| {
delivery_outbox::table
.filter(delivery_outbox::delivery_state.eq(STATE_PENDING))
.order(delivery_outbox::id.asc())
.limit(limit)
.load(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(rows.into_iter().map(to_domain).collect())
}
pub async fn list_stuck(
&self,
cutoff: UniversalTimestamp,
limit: i64,
) -> Result<Vec<DeliveryOutbox>, ValidationError> {
crate::dispatch_backend!(
self.dal.backend(),
self.list_stuck_postgres(cutoff, limit).await,
self.list_stuck_sqlite(cutoff, limit).await
)
}
#[cfg(feature = "postgres")]
async fn list_stuck_postgres(
&self,
cutoff: UniversalTimestamp,
limit: i64,
) -> Result<Vec<DeliveryOutbox>, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let rows: Vec<UnifiedDeliveryOutbox> = conn
.interact(move |conn| {
delivery_outbox::table
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED))
.filter(delivery_outbox::created_at.lt(cutoff))
.order(delivery_outbox::created_at.asc())
.limit(limit)
.load(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(rows.into_iter().map(to_domain).collect())
}
#[cfg(feature = "sqlite")]
async fn list_stuck_sqlite(
&self,
cutoff: UniversalTimestamp,
limit: i64,
) -> Result<Vec<DeliveryOutbox>, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let rows: Vec<UnifiedDeliveryOutbox> = conn
.interact(move |conn| {
delivery_outbox::table
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED))
.filter(delivery_outbox::created_at.lt(cutoff))
.order(delivery_outbox::created_at.asc())
.limit(limit)
.load(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(rows.into_iter().map(to_domain).collect())
}
pub async fn count_open(&self) -> Result<i64, ValidationError> {
crate::dispatch_backend!(
self.dal.backend(),
self.count_open_postgres().await,
self.count_open_sqlite().await
)
}
#[cfg(feature = "postgres")]
async fn count_open_postgres(&self) -> Result<i64, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let count: i64 = conn
.interact(move |conn| {
delivery_outbox::table
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED))
.count()
.get_result(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(count)
}
#[cfg(feature = "sqlite")]
async fn count_open_sqlite(&self) -> Result<i64, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let count: i64 = conn
.interact(move |conn| {
delivery_outbox::table
.filter(delivery_outbox::delivery_state.ne(STATE_ACKED))
.count()
.get_result(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(count)
}
}
fn build_insert(new: NewDeliveryOutbox) -> NewUnifiedDeliveryOutbox {
NewUnifiedDeliveryOutbox {
recipient: new.recipient,
kind: new.kind,
tenant_id: new.tenant_id,
payload: UniversalBinary::from(new.payload),
delivery_state: STATE_PENDING.to_string(),
delivery_attempts: 0,
created_at: UniversalTimestamp::now(),
}
}
fn transition_result(
id: i64,
from: &str,
to: &str,
affected: usize,
) -> Result<(), ValidationError> {
if affected == 1 {
Ok(())
} else {
Err(ValidationError::InvalidStateTransition {
id,
from: from.to_string(),
to: to.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
#[cfg(feature = "sqlite")]
async fn unique_dal() -> DAL {
let url = format!(
"file:delivery_outbox_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)
}
#[cfg(feature = "sqlite")]
fn new_row(recipient: &str) -> NewDeliveryOutbox {
NewDeliveryOutbox {
recipient: recipient.to_string(),
kind: "work".to_string(),
tenant_id: None,
payload: b"hello".to_vec(),
}
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_enqueue_starts_pending() {
let dal = unique_dal().await;
let row = dal
.delivery_outbox()
.enqueue(new_row("agent:1"))
.await
.unwrap();
assert_eq!(row.delivery_state, STATE_PENDING);
assert_eq!(row.delivery_attempts, 0);
assert_eq!(row.payload, b"hello".to_vec());
assert!(row.delivered_at.is_none());
assert!(row.acked_at.is_none());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_reassign_open_rows_retargets_and_resets() {
let dal = unique_dal().await;
let row = dal
.delivery_outbox()
.enqueue(new_row("agent:dead"))
.await
.unwrap();
dal.delivery_outbox().mark_delivered(row.id).await.unwrap();
let acked = dal
.delivery_outbox()
.enqueue(new_row("agent:dead"))
.await
.unwrap();
dal.delivery_outbox()
.mark_delivered(acked.id)
.await
.unwrap();
dal.delivery_outbox().mark_acked(acked.id).await.unwrap();
let moved = dal
.delivery_outbox()
.reassign_open_rows("agent:dead", "agent:live")
.await
.unwrap();
assert_eq!(moved, 1, "only the non-acked row is reassigned");
let live_open = dal
.delivery_outbox()
.list_open_for_recipient("agent:live", 10)
.await
.unwrap();
assert_eq!(live_open.len(), 1);
assert_eq!(live_open[0].id, row.id);
assert_eq!(live_open[0].delivery_state, STATE_PENDING);
assert!(live_open[0].delivered_at.is_none());
let dead_open = dal
.delivery_outbox()
.list_open_for_recipient("agent:dead", 10)
.await
.unwrap();
assert!(dead_open.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_full_lifecycle_pending_delivered_acked() {
let dal = unique_dal().await;
let row = dal
.delivery_outbox()
.enqueue(new_row("agent:1"))
.await
.unwrap();
dal.delivery_outbox().mark_delivered(row.id).await.unwrap();
dal.delivery_outbox().mark_acked(row.id).await.unwrap();
let open = dal
.delivery_outbox()
.list_open_for_recipient("agent:1", 10)
.await
.unwrap();
assert!(open.is_empty());
assert_eq!(dal.delivery_outbox().count_open().await.unwrap(), 0);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_mark_delivered_increments_attempts_and_stamps() {
let dal = unique_dal().await;
let row = dal
.delivery_outbox()
.enqueue(new_row("agent:1"))
.await
.unwrap();
dal.delivery_outbox().mark_delivered(row.id).await.unwrap();
let open = dal
.delivery_outbox()
.list_open_for_recipient("agent:1", 10)
.await
.unwrap();
assert_eq!(open.len(), 1);
assert_eq!(open[0].delivery_state, STATE_DELIVERED);
assert_eq!(open[0].delivery_attempts, 1);
assert!(open[0].delivered_at.is_some());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_redelivery_resets_then_redelivers_incrementing_attempts() {
let dal = unique_dal().await;
let row = dal
.delivery_outbox()
.enqueue(new_row("agent:1"))
.await
.unwrap();
dal.delivery_outbox().mark_delivered(row.id).await.unwrap();
dal.delivery_outbox()
.reset_to_pending(row.id)
.await
.unwrap();
dal.delivery_outbox().mark_delivered(row.id).await.unwrap();
let open = dal
.delivery_outbox()
.list_open_for_recipient("agent:1", 10)
.await
.unwrap();
assert_eq!(open[0].delivery_attempts, 2);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_ack_on_pending_succeeds_for_relay_recipient_race() {
let dal = unique_dal().await;
let row = dal
.delivery_outbox()
.enqueue(new_row("agent:1"))
.await
.unwrap();
dal.delivery_outbox().mark_acked(row.id).await.unwrap();
assert!(dal
.delivery_outbox()
.list_open_for_recipient("agent:1", 10)
.await
.unwrap()
.is_empty());
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_invalid_transition_rejected() {
let dal = unique_dal().await;
let row = dal
.delivery_outbox()
.enqueue(new_row("agent:1"))
.await
.unwrap();
let err = dal
.delivery_outbox()
.reset_to_pending(row.id)
.await
.unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidStateTransition { .. }
));
dal.delivery_outbox().mark_acked(row.id).await.unwrap();
let err = dal.delivery_outbox().mark_acked(row.id).await.unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidStateTransition { .. }
));
let err = dal
.delivery_outbox()
.mark_delivered(row.id)
.await
.unwrap_err();
assert!(matches!(
err,
ValidationError::InvalidStateTransition { .. }
));
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_list_open_for_recipient_isolates_and_orders() {
let dal = unique_dal().await;
let r1 = dal
.delivery_outbox()
.enqueue(new_row("agent:1"))
.await
.unwrap();
let _r2 = dal
.delivery_outbox()
.enqueue(new_row("agent:2"))
.await
.unwrap();
let r3 = dal
.delivery_outbox()
.enqueue(new_row("agent:1"))
.await
.unwrap();
let open = dal
.delivery_outbox()
.list_open_for_recipient("agent:1", 10)
.await
.unwrap();
assert_eq!(open.len(), 2);
assert_eq!(open[0].id, r1.id);
assert_eq!(open[1].id, r3.id);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_reset_delivered_to_pending_isolates_by_recipient_and_tenant() {
let dal = unique_dal().await;
let make = |recipient: &str, tenant: Option<&str>| NewDeliveryOutbox {
recipient: recipient.to_string(),
kind: "work".to_string(),
tenant_id: tenant.map(|s| s.to_string()),
payload: b"x".to_vec(),
};
let target = dal
.delivery_outbox()
.enqueue(make("agent:1", Some("t1")))
.await
.unwrap();
let other_recipient = dal
.delivery_outbox()
.enqueue(make("agent:2", Some("t1")))
.await
.unwrap();
let other_tenant = dal
.delivery_outbox()
.enqueue(make("agent:1", Some("t2")))
.await
.unwrap();
let global = dal
.delivery_outbox()
.enqueue(make("agent:1", None))
.await
.unwrap();
for id in [target.id, other_recipient.id, other_tenant.id, global.id] {
dal.delivery_outbox().mark_delivered(id).await.unwrap();
}
let n = dal
.delivery_outbox()
.reset_delivered_to_pending_for_recipient("agent:1", Some("t1"))
.await
.unwrap();
assert_eq!(
n, 1,
"only the matching (recipient, tenant) row should reset"
);
let agent1_rows = dal
.delivery_outbox()
.list_open_for_recipient("agent:1", 10)
.await
.unwrap();
assert_eq!(agent1_rows.len(), 3);
let by_id = |id: i64| agent1_rows.iter().find(|r| r.id == id).unwrap();
assert_eq!(by_id(target.id).delivery_state, STATE_PENDING);
assert_eq!(by_id(other_tenant.id).delivery_state, STATE_DELIVERED);
assert_eq!(by_id(global.id).delivery_state, STATE_DELIVERED);
let agent2_rows = dal
.delivery_outbox()
.list_open_for_recipient("agent:2", 10)
.await
.unwrap();
assert_eq!(agent2_rows.len(), 1);
assert_eq!(agent2_rows[0].delivery_state, STATE_DELIVERED);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_reset_delivered_to_pending_matches_null_tenant() {
let dal = unique_dal().await;
let row = dal
.delivery_outbox()
.enqueue(NewDeliveryOutbox {
recipient: "global:1".to_string(),
kind: "work".to_string(),
tenant_id: None,
payload: b"x".to_vec(),
})
.await
.unwrap();
dal.delivery_outbox().mark_delivered(row.id).await.unwrap();
let n = dal
.delivery_outbox()
.reset_delivered_to_pending_for_recipient("global:1", None)
.await
.unwrap();
assert_eq!(n, 1, "reset must match NULL tenant via IS NULL, not = NULL");
let row2 = dal
.delivery_outbox()
.enqueue(NewDeliveryOutbox {
recipient: "global:2".to_string(),
kind: "work".to_string(),
tenant_id: None,
payload: b"x".to_vec(),
})
.await
.unwrap();
dal.delivery_outbox().mark_delivered(row2.id).await.unwrap();
let n = dal
.delivery_outbox()
.reset_delivered_to_pending_for_recipient("global:2", Some("t1"))
.await
.unwrap();
assert_eq!(n, 0, "Some(tenant) must not match a NULL-tenant row");
}
}