use chrono::{DateTime, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{validate_schema, OutboxError, Result};
use crate::record::OutboxRecord;
#[derive(sqlx::FromRow)]
struct OutboxRow {
id: Uuid,
event_type: String,
aggregate_type: String,
aggregate_id: String,
company_id: Uuid,
payload: serde_json::Value,
occurred_at: DateTime<Utc>,
correlation_id: Option<String>,
causation_id: Option<String>,
version: i32,
}
impl OutboxRow {
fn into_record(self) -> OutboxRecord {
OutboxRecord {
id: self.id,
event_type: self.event_type,
aggregate_type: self.aggregate_type,
aggregate_id: self.aggregate_id,
company_id: self.company_id,
payload: self.payload,
occurred_at: self.occurred_at,
correlation_id: self.correlation_id,
causation_id: self.causation_id,
version: self.version,
}
}
}
pub async fn drain_once<F, Fut>(pool: &PgPool, schema: &str, batch: i64, publish: F) -> Result<usize>
where
F: Fn(OutboxRecord) -> Fut,
Fut: std::future::Future<Output = std::result::Result<(), OutboxError>>,
{
validate_schema(schema)?;
let rows: Vec<OutboxRow> = sqlx::query_as(&format!(
r#"SELECT id, event_type, aggregate_type, aggregate_id, company_id, payload, occurred_at,
correlation_id, causation_id, version
FROM {schema}.outbox_events
WHERE published_at IS NULL AND failed_at IS NULL
ORDER BY occurred_at, id
LIMIT $1"#
))
.bind(batch)
.fetch_all(pool)
.await?;
let mut published = 0usize;
for row in rows {
let rec = row.into_record();
let id = rec.id;
match publish(rec).await {
Ok(()) => {
sqlx::query(&format!(
"UPDATE {schema}.outbox_events SET published_at=now() WHERE id=$1 AND published_at IS NULL"
))
.bind(id)
.execute(pool)
.await?;
published += 1;
}
Err(OutboxError::Publish(_)) => {}
Err(OutboxError::Exhausted(reason)) => {
sqlx::query(&format!(
"UPDATE {schema}.outbox_events SET failed_at=now(), failure_reason=$2
WHERE id=$1 AND published_at IS NULL"
))
.bind(id)
.bind(&reason)
.execute(pool)
.await?;
}
Err(e) => return Err(e),
}
}
Ok(published)
}
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct DeadRecord {
pub id: Uuid,
pub event_type: String,
pub aggregate_type: String,
pub aggregate_id: String,
pub failed_at: DateTime<Utc>,
pub failure_reason: Option<String>,
}
pub async fn list_dead(pool: &PgPool, schema: &str, limit: i64) -> Result<Vec<DeadRecord>> {
validate_schema(schema)?;
let rows = sqlx::query_as::<_, DeadRecord>(&format!(
r#"SELECT id, event_type, aggregate_type, aggregate_id, failed_at, failure_reason
FROM {schema}.outbox_events
WHERE failed_at IS NOT NULL
ORDER BY failed_at, id
LIMIT $1"#
))
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows)
}
pub async fn reemit(pool: &PgPool, schema: &str, id: Uuid) -> Result<bool> {
validate_schema(schema)?;
let done = sqlx::query(&format!(
"UPDATE {schema}.outbox_events SET failed_at=NULL, failure_reason=NULL
WHERE id=$1 AND failed_at IS NOT NULL AND published_at IS NULL"
))
.bind(id)
.execute(pool)
.await?;
Ok(done.rows_affected() == 1)
}
pub async fn reemit_all(pool: &PgPool, schema: &str) -> Result<u64> {
validate_schema(schema)?;
let done = sqlx::query(&format!(
"UPDATE {schema}.outbox_events SET failed_at=NULL, failure_reason=NULL
WHERE failed_at IS NOT NULL AND published_at IS NULL"
))
.execute(pool)
.await?;
Ok(done.rows_affected())
}
pub async fn drain_all<F, Fut>(
pool: &PgPool,
schema: &str,
batch: i64,
max_passes: usize,
publish: F,
) -> Result<usize>
where
F: Fn(OutboxRecord) -> Fut,
Fut: std::future::Future<Output = std::result::Result<(), OutboxError>>,
{
let mut total = 0;
for _ in 0..max_passes {
let n = drain_once(pool, schema, batch, &publish).await?;
total += n;
if n == 0 {
break;
}
}
Ok(total)
}