use crate::ports::{Clock, Database, DbError, Statement};
use crate::scheduled::ScheduledBudget;
use sea_query::{Alias, Expr, Order, Query};
use std::collections::VecDeque;
use std::future::Future;
use time::{Duration, OffsetDateTime};
fn iden(name: &str) -> Alias {
Alias::new(name)
}
fn rfc3339(at: OffsetDateTime) -> String {
use time::format_description::well_known::Rfc3339;
let at = at.to_offset(time::UtcOffset::UTC);
at.replace_nanosecond(0)
.unwrap_or(at)
.format(&Rfc3339)
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutboxRecord {
pub id: String,
pub topic: String,
pub payload: String,
pub attempts: i64,
}
#[derive(Debug, Clone)]
pub struct Outbox {
table: String,
}
impl Outbox {
#[must_use]
pub fn new(table: impl Into<String>) -> Self {
Self {
table: table.into(),
}
}
#[must_use]
pub fn create_table_sql(&self) -> String {
format!(
"CREATE TABLE IF NOT EXISTS {table} (\n \
id TEXT PRIMARY KEY,\n \
topic TEXT NOT NULL,\n \
payload TEXT NOT NULL,\n \
subject TEXT,\n \
attempts INTEGER NOT NULL DEFAULT 0,\n \
next_attempt_at TEXT NOT NULL,\n \
locked_until TEXT,\n \
created_at TEXT NOT NULL\n);",
table = self.table
)
}
#[must_use]
pub fn enqueue_statement(
&self,
id: &str,
topic: &str,
payload: &str,
subject: Option<&str>,
at: &str,
) -> Statement {
let mut insert = Query::insert();
insert
.into_table(iden(&self.table))
.columns([
"id",
"topic",
"payload",
"subject",
"attempts",
"next_attempt_at",
"created_at",
])
.values_panic([
id.to_owned().into(),
topic.to_owned().into(),
payload.to_owned().into(),
subject.map(str::to_owned).into(),
0i64.into(),
at.to_owned().into(),
at.to_owned().into(),
]);
Statement::render(&insert)
}
pub async fn claim_due(
&self,
db: &dyn Database,
now: &str,
lease_until: &str,
limit: u64,
) -> Result<Vec<OutboxRecord>, DbError> {
let mut select = Query::select();
select
.columns([iden("id"), iden("topic"), iden("payload"), iden("attempts")])
.from(iden(&self.table))
.and_where(Expr::col(iden("next_attempt_at")).lte(now))
.and_where(
Expr::col(iden("locked_until"))
.is_null()
.or(Expr::col(iden("locked_until")).lt(now)),
)
.order_by(iden("next_attempt_at"), Order::Asc)
.limit(limit);
let rows = db.query(&Statement::render(&select)).await?;
let mut leased = Vec::new();
for row in &rows.rows {
let id = row.get::<String>("id").unwrap_or_default();
let mut lease = Query::update();
lease
.table(iden(&self.table))
.value(iden("locked_until"), lease_until)
.and_where(Expr::col(iden("id")).eq(id.as_str()))
.and_where(
Expr::col(iden("locked_until"))
.is_null()
.or(Expr::col(iden("locked_until")).lt(now)),
);
if db.execute(&Statement::render(&lease)).await? == 1 {
leased.push(OutboxRecord {
id,
topic: row.get::<String>("topic").unwrap_or_default(),
payload: row.get::<String>("payload").unwrap_or_default(),
attempts: row.get::<i64>("attempts").unwrap_or(0),
});
}
}
Ok(leased)
}
pub async fn complete(&self, db: &dyn Database, id: &str) -> Result<(), DbError> {
let mut delete = Query::delete();
delete
.from_table(iden(&self.table))
.and_where(Expr::col(iden("id")).eq(id));
db.execute(&Statement::render(&delete)).await?;
Ok(())
}
pub async fn retry_later(
&self,
db: &dyn Database,
id: &str,
next_attempt_at: &str,
) -> Result<(), DbError> {
let mut update = Query::update();
update
.table(iden(&self.table))
.value(iden("attempts"), Expr::col(iden("attempts")).add(1))
.value(iden("next_attempt_at"), next_attempt_at)
.value(iden("locked_until"), Option::<String>::None)
.and_where(Expr::col(iden("id")).eq(id));
db.execute(&Statement::render(&update)).await?;
Ok(())
}
pub async fn reschedule(
&self,
db: &dyn Database,
id: &str,
next_attempt_at: &str,
) -> Result<(), DbError> {
let mut update = Query::update();
update
.table(iden(&self.table))
.value(iden("next_attempt_at"), next_attempt_at)
.value(iden("locked_until"), Option::<String>::None)
.and_where(Expr::col(iden("id")).eq(id));
db.execute(&Statement::render(&update)).await?;
Ok(())
}
pub async fn drain_within<F, Fut>(
&self,
db: &dyn Database,
clock: &dyn Clock,
budget: &ScheduledBudget,
opts: DrainOptions,
handle: F,
) -> Result<DrainReport, DbError>
where
F: FnMut(OutboxRecord) -> Fut,
Fut: Future<Output = Processed>,
{
let mut handle = handle;
let mut report = DrainReport::default();
let now = clock.now();
let out_of_budget = if opts.subrequests_per_item == 0 {
budget.expired(now)
} else {
budget.exhausted(now)
};
if out_of_budget {
report.stopped_by_budget = true;
return Ok(report);
}
let limit = match budget.remaining_subrequests() {
Some(remaining) if opts.subrequests_per_item > 0 => {
(u64::from(remaining) / u64::from(opts.subrequests_per_item)).min(opts.limit)
}
_ => opts.limit,
};
let capped_by_budget = limit < opts.limit;
let now_iso = rfc3339(now);
let lease_until = rfc3339(now.saturating_add(opts.lease));
let mut queue: VecDeque<OutboxRecord> = self
.claim_due(db, &now_iso, &lease_until, limit)
.await?
.into();
if capped_by_budget && queue.len() as u64 >= limit {
report.stopped_by_budget = true;
}
while let Some(record) = queue.pop_front() {
let now = clock.now();
if budget.expired(now) || !budget.try_spend(opts.subrequests_per_item) {
report.stopped_by_budget = true;
for unspent in &queue {
self.reschedule(db, &unspent.id, &now_iso).await?;
report.released += 1;
}
self.reschedule(db, &record.id, &now_iso).await?;
report.released += 1;
break;
}
let id = record.id.clone();
match handle(record).await {
Processed::Done => self.complete(db, &id).await?,
Processed::RetryAt(at) => {
self.retry_later(db, &id, &rfc3339(at)).await?;
}
Processed::NextAt(at) => {
self.reschedule(db, &id, &rfc3339(at)).await?;
}
}
report.processed += 1;
}
Ok(report)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DrainOptions {
pub limit: u64,
pub lease: Duration,
pub subrequests_per_item: u32,
}
#[derive(Debug)]
pub enum Processed {
Done,
RetryAt(OffsetDateTime),
NextAt(OffsetDateTime),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct DrainReport {
pub processed: u64,
pub released: u64,
pub stopped_by_budget: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_table_sql_is_portable_ddl() {
let sql = Outbox::new("mail_outbox").create_table_sql();
assert!(sql.contains("CREATE TABLE IF NOT EXISTS mail_outbox"));
assert!(sql.contains("next_attempt_at TEXT NOT NULL"));
assert!(sql.contains("locked_until TEXT"));
assert!(sql.contains("subject TEXT"));
}
#[test]
fn enqueue_statement_inserts_the_row() {
let stmt = Outbox::new("mail_outbox").enqueue_statement(
"01J",
"confirmation",
"{\"to\":\"a@b\"}",
Some("acct-1"),
"2026-09-07T00:00:00Z",
);
assert!(stmt.sql.contains("INSERT INTO"));
assert!(stmt.sql.contains("mail_outbox"));
assert!(stmt.sql.contains("subject"));
assert_eq!(stmt.values.0.len(), 7);
assert_eq!(stmt.values.0[3], "acct-1".into());
}
#[test]
fn enqueue_statement_without_a_subject_binds_null() {
let stmt = Outbox::new("mail_outbox").enqueue_statement(
"01J",
"confirmation",
"{\"to\":\"a@b\"}",
None,
"2026-09-07T00:00:00Z",
);
assert!(stmt.sql.contains("subject"));
assert_eq!(
stmt.values.0[3],
sea_query::Value::from(Option::<String>::None)
);
}
}