#[cfg(feature = "queue-redis")]
pub mod redis;
#[cfg(feature = "queue-sqlite")]
pub mod sqlite;
pub mod worker;
#[cfg(all(test, feature = "queue-sqlite"))]
mod tests;
#[cfg(test)]
mod worker_tests;
#[cfg(feature = "queue-redis")]
pub use redis::redis_driver::RedisDriver;
#[cfg(feature = "queue-sqlite")]
pub use sqlite::SqliteDriver;
pub use worker::{JobHandler, Worker, WorkerHandle};
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
const MAX_SCHEDULE_DELAY: Duration = Duration::from_secs(366 * 24 * 60 * 60);
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum QueueError {
#[error("Queue driver error: {0}")]
Driver(String),
#[error("Queue serialization error: {0}")]
Serialization(String),
#[error("No handler registered for job: {0}")]
HandlerNotFound(String),
#[error("Job execution failed: {0}")]
JobFailed(String),
#[error("the queue worker requires an active Tokio runtime")]
RuntimeUnavailable,
#[error("invalid queue worker configuration: {0}")]
InvalidConfiguration(String),
#[error("job '{job_id}' could not transition via '{operation}': {message}")]
StateTransition {
job_id: String,
operation: &'static str,
message: String,
},
#[error("job '{job_id}' exceeded its {timeout_ms}ms execution timeout")]
JobTimedOut {
job_id: String,
timeout_ms: u64,
},
#[error("job '{job_id}' handler panicked")]
JobPanicked {
job_id: String,
},
#[error("queue worker task failed: {0}")]
WorkerTask(String),
#[error("queue operation is unsupported: {0}")]
Unsupported(String),
}
#[derive(Debug, Clone)]
pub struct QueuedJob {
pub id: String,
pub name: String,
pub payload: Value,
pub attempts: u32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct QueuedJobDetail {
pub id: String,
pub name: String,
pub payload: String,
pub status: String,
pub error: Option<String>,
pub attempts: i32,
pub created_at: String,
pub updated_at: String,
}
#[async_trait]
pub trait QueueDriver: Send + Sync {
async fn push(&self, id: &str, job_name: &str, payload: &str) -> Result<(), QueueError>;
async fn push_at(
&self,
id: &str,
job_name: &str,
payload: &str,
available_at: SystemTime,
) -> Result<(), QueueError> {
if available_at > SystemTime::now() {
Err(QueueError::Unsupported(
"this driver cannot persist future scheduled jobs".to_string(),
))
} else {
self.push(id, job_name, payload).await
}
}
async fn pop(&self) -> Result<Option<QueuedJob>, QueueError>;
async fn mark_complete(&self, job_id: &str) -> Result<(), QueueError>;
async fn mark_failed(&self, job_id: &str, error: &str) -> Result<(), QueueError>;
async fn requeue(&self, _job_id: &str, _reason: &str) -> Result<(), QueueError> {
Err(QueueError::Unsupported(
"this driver cannot requeue interrupted jobs".to_string(),
))
}
async fn recover_stalled(&self, _stale_after: std::time::Duration) -> Result<u64, QueueError> {
Err(QueueError::Unsupported(
"this driver cannot recover stalled jobs".to_string(),
))
}
async fn pending_count(&self) -> Result<u64, QueueError>;
async fn list_all_jobs(&self, _limit: u32) -> Result<Vec<QueuedJobDetail>, QueueError> {
Err(QueueError::Unsupported(
"this queue driver does not expose job inspection".to_string(),
))
}
async fn retry_failed_job(&self, _job_id: &str) -> Result<(), QueueError> {
Err(QueueError::Unsupported(
"this queue driver does not expose failed-job retry".to_string(),
))
}
async fn purge_completed_jobs(&self) -> Result<(), QueueError> {
Err(QueueError::Unsupported(
"this queue driver does not expose failed-job purge".to_string(),
))
}
async fn purge_failed_jobs(&self) -> Result<(), QueueError> {
self.purge_completed_jobs().await
}
async fn purge_completed_history(&self) -> Result<(), QueueError> {
Err(QueueError::Unsupported(
"this queue driver does not retain completed job history".to_string(),
))
}
}
pub struct Queue {
driver: Arc<Box<dyn QueueDriver>>,
}
impl Queue {
#[cfg(feature = "queue-sqlite")]
pub async fn sqlite(database_url: impl Into<String>) -> Result<Self, QueueError> {
let driver = SqliteDriver::new(database_url).await?;
Ok(Self {
driver: Arc::new(Box::new(driver)),
})
}
#[cfg(feature = "queue-sqlite")]
pub async fn sqlite_with_completed_history(
database_url: impl Into<String>,
retained_jobs: usize,
) -> Result<Self, QueueError> {
let driver = SqliteDriver::new(database_url)
.await?
.try_with_completed_history_limit(retained_jobs)?;
Ok(Self {
driver: Arc::new(Box::new(driver)),
})
}
#[cfg(feature = "queue-redis")]
#[cfg_attr(mutants, mutants::skip)]
pub fn redis(redis_url: impl Into<String>) -> Result<Self, QueueError> {
let driver = redis::redis_driver::RedisDriver::new(redis_url)?;
Ok(Self {
driver: Arc::new(Box::new(driver)),
})
}
pub fn custom(driver: Box<dyn QueueDriver>) -> Self {
Self {
driver: Arc::new(driver),
}
}
pub async fn dispatch(&self, job_name: &str, payload: Value) -> Result<String, QueueError> {
let id = Uuid::new_v4().to_string();
let payload_str = serde_json::to_string(&payload)
.map_err(|e| QueueError::Serialization(e.to_string()))?;
self.driver.push(&id, job_name, &payload_str).await?;
Ok(id)
}
pub async fn dispatch_at(
&self,
job_name: &str,
payload: Value,
available_at: SystemTime,
) -> Result<String, QueueError> {
if available_at.duration_since(UNIX_EPOCH).is_err() {
return Err(QueueError::InvalidConfiguration(
"scheduled timestamp predates the Unix epoch".to_string(),
));
}
let now = SystemTime::now();
if available_at
.duration_since(now)
.is_ok_and(|delay| delay > MAX_SCHEDULE_DELAY)
{
return Err(QueueError::InvalidConfiguration(
"scheduled jobs may be dispatched at most 366 days ahead".to_string(),
));
}
let id = Uuid::new_v4().to_string();
let payload_str = serde_json::to_string(&payload)
.map_err(|error| QueueError::Serialization(error.to_string()))?;
self.driver
.push_at(&id, job_name, &payload_str, available_at)
.await?;
Ok(id)
}
pub async fn pending_count(&self) -> Result<u64, QueueError> {
self.driver.pending_count().await
}
pub async fn list_all_jobs(&self, limit: u32) -> Result<Vec<QueuedJobDetail>, QueueError> {
self.driver.list_all_jobs(limit).await
}
#[cfg_attr(mutants, mutants::skip)]
pub async fn retry_failed_job(&self, job_id: &str) -> Result<(), QueueError> {
self.driver.retry_failed_job(job_id).await
}
pub async fn purge_failed_jobs(&self) -> Result<(), QueueError> {
self.driver.purge_failed_jobs().await
}
pub async fn purge_completed_history(&self) -> Result<(), QueueError> {
self.driver.purge_completed_history().await
}
#[deprecated(
since = "12.0.0",
note = "use purge_failed_jobs; this method has always targeted failed rows"
)]
#[cfg_attr(mutants, mutants::skip)]
pub async fn purge_completed_jobs(&self) -> Result<(), QueueError> {
self.purge_failed_jobs().await
}
pub(crate) fn driver_ref(&self) -> Arc<Box<dyn QueueDriver>> {
Arc::clone(&self.driver)
}
}
#[cfg(any(feature = "queue-sqlite", feature = "queue-redis"))]
pub(crate) fn unix_timestamp_millis_ceil(timestamp: SystemTime) -> Result<u64, QueueError> {
let duration = timestamp.duration_since(UNIX_EPOCH).map_err(|_| {
QueueError::InvalidConfiguration("timestamp predates Unix epoch".to_string())
})?;
let whole_millis = duration.as_millis();
let has_fractional_millisecond = duration.subsec_nanos() % 1_000_000 != 0;
let rounded_millis = whole_millis
.checked_add(u128::from(has_fractional_millisecond))
.ok_or_else(|| {
QueueError::InvalidConfiguration("timestamp exceeds queue storage range".to_string())
})?;
u64::try_from(rounded_millis).map_err(|_| {
QueueError::InvalidConfiguration("timestamp exceeds queue storage range".to_string())
})
}
#[cfg(feature = "queue-sqlite")]
pub(crate) fn unix_timestamp_millis_floor(timestamp: SystemTime) -> Result<u64, QueueError> {
let duration = timestamp.duration_since(UNIX_EPOCH).map_err(|_| {
QueueError::InvalidConfiguration("timestamp predates Unix epoch".to_string())
})?;
u64::try_from(duration.as_millis()).map_err(|_| {
QueueError::InvalidConfiguration("timestamp exceeds queue storage range".to_string())
})
}