use apiplant_core::{App, QueuesConfig};
use apiplant_db::Db;
use sea_orm::sea_query::Value as SqlValue;
use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement};
use serde::Serialize;
use serde_json::{json, Value};
mod listener;
pub use listener::Listener;
#[derive(Debug, thiserror::Error)]
pub enum QueueError {
#[error("invalid queue request: {0}")]
Request(String),
#[error("queue: {0}")]
Backend(String),
}
impl From<sea_orm::DbErr> for QueueError {
fn from(e: sea_orm::DbErr) -> Self {
QueueError::Backend(e.to_string())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Publication {
pub id: String,
pub topic: String,
pub delivered: usize,
}
#[derive(Debug, Clone)]
pub struct Delivery {
pub id: String,
pub topic: String,
pub subscriber: String,
pub payload: Value,
pub attempts: u32,
pub published_by: String,
}
impl Delivery {
pub fn context(&self) -> Value {
json!({
"event": "message",
"topic": self.topic,
"message_id": self.id,
"subscriber": self.subscriber,
"attempts": self.attempts,
"principal_id": self.published_by,
"published_by": self.published_by,
})
}
}
#[derive(Clone)]
pub struct Queue {
conn: DatabaseConnection,
table: String,
config: QueuesConfig,
}
impl std::fmt::Debug for Queue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Queue")
.field("table", &self.table)
.field("topics", &self.config.subscribe.keys().collect::<Vec<_>>())
.finish()
}
}
impl Queue {
pub fn new(db: &Db, app: &App) -> Self {
let table = app
.resources
.get("queue_message")
.map(|r| r.table_name())
.unwrap_or_else(|| "apiplant_queue_message".to_string());
Queue {
conn: db.connection().clone(),
table,
config: app.config.queues.clone(),
}
}
pub fn config(&self) -> &QueuesConfig {
&self.config
}
pub fn topics(&self) -> Vec<String> {
self.config.subscribe.keys().cloned().collect()
}
pub async fn prepare(&self) -> Result<(), QueueError> {
let sql = format!(
"CREATE INDEX IF NOT EXISTS {index} ON {table} (status, available_at)",
index = quote(&format!("idx_{}_claim", self.table))?,
table = quote(&self.table)?,
);
self.execute_sql(sql, vec![]).await?;
Ok(())
}
pub async fn publish(
&self,
topic: &str,
message: &Value,
published_by: &str,
) -> Result<Publication, QueueError> {
let topic = topic.trim();
if !QueuesConfig::valid_topic(topic) {
return Err(QueueError::Request(format!(
"`{topic}` is not a topic: use letters, digits, `.`, `_`, `-` or `:`"
)));
}
let subscribers = self.config.subscribers(topic);
let rows: Vec<&str> = match subscribers.is_empty() {
true => vec![""],
false => subscribers.iter().map(String::as_str).collect(),
};
let mut ids = Vec::with_capacity(rows.len());
for subscriber in &rows {
let id = uuid::Uuid::new_v4();
let status = match subscriber.is_empty() {
true => "done",
false => "pending",
};
let sql = format!(
"INSERT INTO {table} \
(\"id\", \"topic\", \"subscriber\", \"status\", \"payload\", \"attempts\", \
\"available_at\", \"processed_at\", \"published_by\", \"created_at\", \"updated_at\") \
VALUES ($1, $2, $3, $4, $5, 0, now(), \
CASE WHEN $4 = 'done' THEN now() ELSE NULL END, $6, now(), now())",
table = quote(&self.table)?,
);
self.execute_sql(
sql,
vec![
SqlValue::from(id),
SqlValue::from(topic.to_string()),
SqlValue::from(subscriber.to_string()),
SqlValue::from(status.to_string()),
SqlValue::from(message.clone()),
SqlValue::from(published_by.to_string()),
],
)
.await?;
ids.push(id.to_string());
}
if subscribers.is_empty() {
tracing::warn!(
topic,
"published to a topic nothing subscribes to — the message is recorded in \
queue_message but no function will run; check [queues.subscribe]"
);
} else {
self.notify(topic).await?;
}
Ok(Publication {
id: ids.first().cloned().unwrap_or_default(),
topic: topic.to_string(),
delivered: subscribers.len(),
})
}
async fn notify(&self, topic: &str) -> Result<(), QueueError> {
let sql = "SELECT pg_notify($1, $2)".to_string();
let result = self
.execute_sql(
sql,
vec![
SqlValue::from(self.config.channel()),
SqlValue::from(topic.to_string()),
],
)
.await;
if let Err(e) = result {
tracing::warn!(topic, error = %e, "could not notify subscribers; the message will be picked up by the next sweep");
}
Ok(())
}
pub async fn claim(&self, worker: &str) -> Result<Vec<Delivery>, QueueError> {
let topics = self.topics();
if topics.is_empty() {
return Ok(Vec::new());
}
let table = quote(&self.table)?;
let sql = format!(
"UPDATE {table} SET \
\"status\" = 'running', \
\"attempts\" = \"attempts\" + 1, \
\"claimed_at\" = now(), \
\"claimed_by\" = $2, \
\"updated_at\" = now() \
WHERE \"id\" IN ( \
SELECT \"id\" FROM {table} \
WHERE \"status\" = 'pending' \
AND \"available_at\" <= now() \
AND \"subscriber\" <> '' \
AND \"topic\" = ANY($1) \
ORDER BY \"available_at\" \
FOR UPDATE SKIP LOCKED \
LIMIT {limit} \
) \
RETURNING \"id\"::text AS id, \"topic\", \"subscriber\", \"payload\", \
\"attempts\", coalesce(\"published_by\", '') AS published_by",
limit = self.config.batch.max(1),
);
let rows = self
.conn
.query_all(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
sql,
vec![topic_array(&topics), SqlValue::from(worker.to_string())],
))
.await?;
rows.into_iter()
.map(|row| {
Ok(Delivery {
id: row.try_get::<String>("", "id")?,
topic: row.try_get::<String>("", "topic")?,
subscriber: row.try_get::<String>("", "subscriber")?,
payload: row.try_get::<Value>("", "payload")?,
attempts: row.try_get::<i32>("", "attempts")?.max(0) as u32,
published_by: row.try_get::<String>("", "published_by")?,
})
})
.collect()
}
pub async fn next_due(&self) -> Result<Option<u64>, QueueError> {
let topics = self.topics();
if topics.is_empty() {
return Ok(None);
}
let sql = format!(
"SELECT ceil(extract(epoch FROM (min(\"available_at\") - now())))::bigint AS wait \
FROM {table} \
WHERE \"status\" = 'pending' AND \"subscriber\" <> '' AND \"topic\" = ANY($1)",
table = quote(&self.table)?,
);
let row = self
.conn
.query_one(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
sql,
vec![topic_array(&topics)],
))
.await?;
let wait: Option<i64> = match row {
Some(row) => row.try_get("", "wait").ok(),
None => None,
};
Ok(wait.map(|seconds| seconds.max(0) as u64))
}
pub async fn complete(&self, id: &str) -> Result<(), QueueError> {
let sql = format!(
"UPDATE {table} SET \"status\" = 'done', \"processed_at\" = now(), \
\"claimed_by\" = NULL, \"updated_at\" = now() \
WHERE \"id\" = $1::uuid",
table = quote(&self.table)?,
);
self.execute_sql(sql, vec![SqlValue::from(id.to_string())])
.await?;
Ok(())
}
pub async fn fail(&self, delivery: &Delivery, error: &str) -> Result<bool, QueueError> {
let exhausted = delivery.attempts >= self.config.max_attempts.max(1);
let delay = self.config.retry_delay_secs(delivery.attempts);
let sql = match exhausted {
true => format!(
"UPDATE {table} SET \"status\" = 'failed', \"error\" = $2, \
\"processed_at\" = now(), \"claimed_by\" = NULL, \"updated_at\" = now() \
WHERE \"id\" = $1::uuid",
table = quote(&self.table)?,
),
false => format!(
"UPDATE {table} SET \"status\" = 'pending', \"error\" = $2, \
\"available_at\" = now() + make_interval(secs => {delay}), \
\"claimed_by\" = NULL, \"updated_at\" = now() \
WHERE \"id\" = $1::uuid",
table = quote(&self.table)?,
),
};
self.execute_sql(
sql,
vec![
SqlValue::from(delivery.id.clone()),
SqlValue::from(truncate(error, 4000)),
],
)
.await?;
match exhausted {
true => tracing::error!(
topic = %delivery.topic,
subscriber = %delivery.subscriber,
message_id = %delivery.id,
attempts = delivery.attempts,
%error,
"message failed for the last time — left in queue_message with status 'failed'"
),
false => tracing::warn!(
topic = %delivery.topic,
subscriber = %delivery.subscriber,
message_id = %delivery.id,
attempt = delivery.attempts,
retry_in_secs = delay,
%error,
"message failed; will retry"
),
}
Ok(!exhausted)
}
pub async fn reclaim(&self) -> Result<u64, QueueError> {
let sql = format!(
"UPDATE {table} SET \"status\" = 'pending', \"claimed_by\" = NULL, \
\"error\" = 'the subscriber holding this message stopped responding', \
\"updated_at\" = now() \
WHERE \"status\" = 'running' \
AND \"claimed_at\" < now() - make_interval(secs => {lease})",
table = quote(&self.table)?,
lease = self.config.lease_secs.max(1),
);
let affected = self.execute_sql(sql, vec![]).await?;
if affected > 0 {
tracing::warn!(
messages = affected,
"reclaimed messages whose subscriber died mid-handler"
);
}
Ok(affected)
}
pub async fn prune(&self) -> Result<u64, QueueError> {
if self.config.retain_hours == 0 {
return Ok(0);
}
let sql = format!(
"DELETE FROM {table} WHERE \"status\" = 'done' \
AND \"processed_at\" < now() - make_interval(hours => {hours})",
table = quote(&self.table)?,
hours = self.config.retain_hours,
);
self.execute_sql(sql, vec![]).await
}
pub async fn execute(&self, request: &str, published_by: &str) -> Result<Value, QueueError> {
let request: Value = serde_json::from_str(request)
.map_err(|e| QueueError::Request(format!("not JSON: {e}")))?;
let op = request.get("op").and_then(Value::as_str).unwrap_or("publish");
match op {
"publish" => {
let topic = request
.get("topic")
.and_then(Value::as_str)
.ok_or_else(|| QueueError::Request("`topic` is required".into()))?;
let message = request
.get("message")
.cloned()
.unwrap_or_else(|| json!({}));
let publication = self.publish(topic, &message, published_by).await?;
Ok(serde_json::to_value(publication).unwrap_or(Value::Null))
}
other => Err(QueueError::Request(format!(
"`{other}` is not a queue operation; expected `publish`"
))),
}
}
async fn execute_sql(&self, sql: String, params: Vec<SqlValue>) -> Result<u64, QueueError> {
let result = self
.conn
.execute(Statement::from_sql_and_values(
DatabaseBackend::Postgres,
sql,
params,
))
.await?;
Ok(result.rows_affected())
}
}
fn topic_array(topics: &[String]) -> SqlValue {
SqlValue::Array(
sea_orm::sea_query::ArrayType::String,
Some(Box::new(
topics
.iter()
.map(|t| SqlValue::from(t.clone()))
.collect::<Vec<_>>(),
)),
)
}
fn quote(ident: &str) -> Result<String, QueueError> {
if ident.is_empty()
|| !ident
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(QueueError::Backend(format!(
"`{ident}` is not a usable table name"
)));
}
Ok(format!("\"{ident}\""))
}
fn truncate(text: &str, max: usize) -> String {
match text.len() <= max {
true => text.to_string(),
false => {
let mut end = max;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &text[..end])
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_topic_is_an_identifier_not_free_text() {
assert!(QueuesConfig::valid_topic("order.paid"));
assert!(QueuesConfig::valid_topic("user:signed_up"));
assert!(QueuesConfig::valid_topic("a-b_c.d:e"));
assert!(!QueuesConfig::valid_topic(""));
assert!(!QueuesConfig::valid_topic(" "));
assert!(!QueuesConfig::valid_topic("order paid"));
assert!(!QueuesConfig::valid_topic("order'; DROP TABLE"));
assert!(!QueuesConfig::valid_topic(&"x".repeat(201)));
}
#[test]
fn only_identifiers_can_be_interpolated_as_a_table() {
assert_eq!(quote("apiplant_queue_message").unwrap(), "\"apiplant_queue_message\"");
assert!(quote("").is_err());
assert!(quote("queue\"; DROP TABLE x --").is_err());
assert!(quote("public.queue").is_err());
}
#[test]
fn the_retry_backoff_doubles_and_is_capped() {
let config = QueuesConfig {
retry_backoff_secs: 10,
..QueuesConfig::default()
};
assert_eq!(config.retry_delay_secs(1), 10);
assert_eq!(config.retry_delay_secs(2), 20);
assert_eq!(config.retry_delay_secs(3), 40);
assert_eq!(config.retry_delay_secs(4), 80);
assert_eq!(config.retry_delay_secs(50), 3600);
assert_eq!(config.retry_delay_secs(u32::MAX), 3600);
}
#[test]
fn a_long_error_is_truncated_on_a_character_boundary() {
let long = "é".repeat(3000);
let cut = truncate(&long, 4000);
assert!(cut.len() <= 4003, "{} bytes", cut.len());
assert!(cut.ends_with('…'));
assert!(cut.chars().count() > 1);
}
#[test]
fn subscriptions_are_read_as_one_name_or_several() {
let config: QueuesConfig = toml::from_str(
r#"
[subscribe]
"order.paid" = "fulfil"
"user.signed_up" = ["welcome", "crm_sync"]
"ignored" = []
"#,
)
.unwrap();
assert_eq!(config.subscribers("order.paid"), ["fulfil"]);
assert_eq!(config.subscribers("user.signed_up"), ["welcome", "crm_sync"]);
assert!(config.subscribers("ignored").is_empty());
assert!(config.subscribers("never.declared").is_empty());
}
}