use std::{future::Future, pin::Pin, time::Duration};
use fraiseql_functions::InboundMessage;
use sqlx::{PgPool, Postgres, Row, Transaction};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LedgerSettings {
pub lease: Duration,
pub sweep_interval: Duration,
pub batch_size: u32,
}
impl Default for LedgerSettings {
fn default() -> Self {
Self {
lease: Duration::from_mins(5),
sweep_interval: Duration::from_secs(30),
batch_size: 100,
}
}
}
pub use crate::routes::after_mutation::DispatchOutcome;
fn db_err(context: &str, error: &sqlx::Error) -> fraiseql_error::FraiseQLError {
fraiseql_error::FraiseQLError::database(format!("inbound dispatch ledger: {context}: {error}"))
}
const fn secs(duration: Duration) -> f64 {
duration.as_secs_f64()
}
pub async fn record_pending_in_tx(
tx: &mut Transaction<'_, Postgres>,
message_id: uuid::Uuid,
functions: &[String],
lease: Duration,
) -> fraiseql_error::Result<()> {
if functions.is_empty() {
return Ok(());
}
sqlx::query(
"INSERT INTO _fraiseql_inbound_dispatch (fk_inbound_message, function_name, lease_until) \
SELECT m.pk_inbound_message, f, now() + make_interval(secs => $3) \
FROM _fraiseql_inbound_message m CROSS JOIN unnest($2::text[]) AS f \
WHERE m.id = $1 \
ON CONFLICT DO NOTHING",
)
.bind(message_id)
.bind(functions)
.bind(secs(lease))
.execute(&mut **tx)
.await
.map_err(|error| db_err("record", &error))?;
Ok(())
}
pub async fn settle(
pool: &PgPool,
message_id: uuid::Uuid,
function_name: &str,
outcome: DispatchOutcome,
) -> fraiseql_error::Result<()> {
let state = match outcome {
DispatchOutcome::Dispatched => "dispatched",
DispatchOutcome::DeadLettered => "dead_lettered",
DispatchOutcome::Unsettled => return Ok(()),
};
sqlx::query(
"UPDATE _fraiseql_inbound_dispatch d SET state = $3, settled_at = now() \
FROM _fraiseql_inbound_message m \
WHERE d.fk_inbound_message = m.pk_inbound_message AND m.id = $1 \
AND d.function_name = $2 AND d.state = 'pending'",
)
.bind(message_id)
.bind(function_name)
.bind(state)
.execute(pool)
.await
.map_err(|error| db_err("settle", &error))?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct DueDispatch {
pub message_id: uuid::Uuid,
pub function_name: String,
pub message: InboundMessage,
pub attempts: i32,
}
pub async fn claim_due(
pool: &PgPool,
batch: u32,
lease: Duration,
) -> fraiseql_error::Result<Vec<DueDispatch>> {
let rows = sqlx::query(
"WITH due AS ( \
SELECT fk_inbound_message, function_name FROM _fraiseql_inbound_dispatch \
WHERE state = 'pending' AND lease_until <= now() \
ORDER BY lease_until LIMIT $1 \
FOR UPDATE SKIP LOCKED) \
UPDATE _fraiseql_inbound_dispatch d \
SET lease_until = now() + make_interval(secs => $2), attempts = d.attempts + 1 \
FROM due, _fraiseql_inbound_message m \
WHERE d.fk_inbound_message = due.fk_inbound_message \
AND d.function_name = due.function_name \
AND m.pk_inbound_message = d.fk_inbound_message \
RETURNING m.id, d.function_name, m.payload, d.attempts",
)
.bind(i64::from(batch))
.bind(secs(lease))
.fetch_all(pool)
.await
.map_err(|error| db_err("claim", &error))?;
rows.iter()
.map(|row| {
let payload: serde_json::Value = row.get("payload");
Ok(DueDispatch {
message_id: row.get("id"),
function_name: row.get("function_name"),
message: serde_json::from_value(payload).map_err(|error| {
fraiseql_error::FraiseQLError::database(format!(
"inbound dispatch ledger: read message: {error}"
))
})?,
attempts: row.get("attempts"),
})
})
.collect()
}
pub trait IngestDispatcher: Send + Sync {
fn dispatch<'a>(
&'a self,
message: &'a InboundMessage,
function_name: &'a str,
) -> Pin<Box<dyn Future<Output = DispatchOutcome> + Send + 'a>>;
}
pub async fn sweep_once(
pool: &PgPool,
dispatcher: &dyn IngestDispatcher,
settings: LedgerSettings,
) -> fraiseql_error::Result<usize> {
let due = claim_due(pool, settings.batch_size, settings.lease).await?;
let claimed = due.len();
futures::future::join_all(due.iter().map(|dispatch| async move {
tracing::info!(
message_id = %dispatch.message_id,
function = %dispatch.function_name,
attempts = dispatch.attempts,
"after:ingest dispatch replayed: its lease ran out before it settled"
);
let function = &dispatch.function_name;
let outcome = dispatcher.dispatch(&dispatch.message, function).await;
if let Err(error) = settle(pool, dispatch.message_id, function, outcome).await {
tracing::warn!(
%error,
"after:ingest dispatch ran but could not be settled; it will run again"
);
}
}))
.await;
Ok(claimed)
}
pub async fn run_sweeper(
pool: PgPool,
dispatcher: std::sync::Arc<dyn IngestDispatcher>,
settings: LedgerSettings,
) {
loop {
if let Err(error) = sweep_once(&pool, dispatcher.as_ref(), settings).await {
tracing::warn!(%error, "after:ingest dispatch sweep failed; retrying next interval");
}
tokio::time::sleep(settings.sweep_interval).await;
}
}
#[cfg(test)]
mod tests;