use crate::{
Result,
config::RateLimitingConfig,
job::{Job, JobId},
rate_limit::ThrottleConfig,
stats::{DeadJobSummary, QueueStats},
};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Database, Pool};
use std::{collections::HashMap, marker::PhantomData, sync::Arc};
use tokio::sync::RwLock;
pub mod lifecycle;
pub use lifecycle::{JobOutcome, JobTransition, RecordedOutcome};
#[cfg(feature = "postgres")]
pub mod postgres;
#[cfg(feature = "mysql")]
pub mod mysql;
#[cfg(feature = "test")]
pub mod test;
mod payload_encryption;
pub(crate) use payload_encryption::PayloadEncryption;
mod memory_results;
pub use memory_results::DEFAULT_MEMORY_RESULT_CAPACITY;
pub(crate) use memory_results::MemoryResultStore;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueuePauseInfo {
pub queue_name: String,
pub paused_at: DateTime<Utc>,
pub paused_by: Option<String>,
pub reason: Option<String>,
}
#[async_trait]
pub trait DatabaseQueue: Send + Sync {
type Database: Database;
async fn enqueue(&self, job: Job) -> Result<JobId>;
async fn dequeue(&self, queue_name: &str) -> Result<Option<Job>>;
async fn dequeue_with_priority_weights(
&self,
queue_name: &str,
weights: &crate::priority::PriorityWeights,
) -> Result<Option<Job>>;
async fn dequeue_leased(
&self,
queue_name: &str,
weights: Option<&crate::priority::PriorityWeights>,
lease: std::time::Duration,
) -> Result<Option<Job>> {
let _ = lease;
match weights {
Some(weights) => {
self.dequeue_with_priority_weights(queue_name, weights)
.await
}
None => self.dequeue(queue_name).await,
}
}
async fn complete_job(&self, job_id: JobId) -> Result<()>;
async fn fail_job(&self, job_id: JobId, error_message: &str) -> Result<()>;
async fn retry_job(&self, job_id: JobId, retry_at: DateTime<Utc>) -> Result<()>;
async fn get_job(&self, job_id: JobId) -> Result<Option<Job>>;
async fn delete_job(&self, job_id: JobId) -> Result<()>;
async fn enqueue_batch(&self, batch: crate::batch::JobBatch) -> Result<crate::batch::BatchId>;
async fn get_batch_status(
&self,
batch_id: crate::batch::BatchId,
) -> Result<crate::batch::BatchResult>;
async fn get_batch_jobs(&self, batch_id: crate::batch::BatchId) -> Result<Vec<Job>>;
async fn delete_batch(&self, batch_id: crate::batch::BatchId) -> Result<()>;
async fn mark_job_dead(&self, job_id: JobId, error_message: &str) -> Result<()>;
async fn mark_job_timed_out(&self, job_id: JobId, error_message: &str) -> Result<()>;
async fn get_dead_jobs(&self, limit: Option<u32>, offset: Option<u32>) -> Result<Vec<Job>>;
async fn get_dead_jobs_by_queue(
&self,
queue_name: &str,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Vec<Job>>;
async fn retry_dead_job(&self, job_id: JobId) -> Result<()>;
async fn purge_dead_jobs(&self, older_than: DateTime<Utc>) -> Result<u64>;
async fn get_dead_job_summary(&self) -> Result<DeadJobSummary>;
async fn get_queue_stats(&self, queue_name: &str) -> Result<QueueStats>;
async fn get_all_queue_stats(&self) -> Result<Vec<QueueStats>>;
async fn get_job_counts_by_status(
&self,
queue_name: &str,
) -> Result<std::collections::HashMap<String, u64>>;
async fn get_priority_stats(&self, queue_name: &str) -> Result<crate::priority::PriorityStats>;
async fn get_processing_times(
&self,
queue_name: &str,
since: DateTime<Utc>,
) -> Result<Vec<i64>>;
async fn get_error_frequencies(
&self,
queue_name: Option<&str>,
since: DateTime<Utc>,
) -> Result<std::collections::HashMap<String, u64>>;
async fn get_jobs_completed_in_range(
&self,
queue_name: Option<&str>,
start_time: DateTime<Utc>,
end_time: DateTime<Utc>,
limit: Option<u32>,
) -> Result<Vec<Job>>;
async fn enqueue_cron_job(&self, job: Job) -> Result<JobId>;
async fn get_due_cron_jobs(&self, queue_name: Option<&str>) -> Result<Vec<Job>>;
async fn reschedule_cron_job(&self, job_id: JobId, next_run_at: DateTime<Utc>) -> Result<()>;
async fn get_recurring_jobs(&self, queue_name: &str) -> Result<Vec<Job>>;
async fn disable_recurring_job(&self, job_id: JobId) -> Result<()>;
async fn enable_recurring_job(&self, job_id: JobId) -> Result<()>;
async fn set_throttle_config(&self, queue_name: &str, config: ThrottleConfig) -> Result<()>;
async fn get_throttle_config(&self, queue_name: &str) -> Result<Option<ThrottleConfig>>;
async fn remove_throttle_config(&self, queue_name: &str) -> Result<()>;
async fn get_all_throttle_configs(&self) -> Result<HashMap<String, ThrottleConfig>>;
async fn get_queue_depth(&self, queue_name: &str) -> Result<u64>;
async fn store_job_result(
&self,
job_id: JobId,
result_data: serde_json::Value,
expires_at: Option<DateTime<Utc>>,
) -> Result<()>;
async fn get_job_result(&self, job_id: JobId) -> Result<Option<serde_json::Value>>;
async fn delete_job_result(&self, job_id: JobId) -> Result<()>;
async fn cleanup_expired_results(&self) -> Result<u64>;
async fn enqueue_workflow(
&self,
workflow: crate::workflow::JobGroup,
) -> Result<crate::workflow::WorkflowId>;
async fn get_workflow_status(
&self,
workflow_id: crate::workflow::WorkflowId,
) -> Result<Option<crate::workflow::JobGroup>>;
async fn resolve_job_dependencies(&self, completed_job_id: JobId) -> Result<Vec<JobId>>;
async fn get_ready_jobs(&self, queue_name: &str, limit: u32) -> Result<Vec<Job>>;
async fn fail_job_dependencies(&self, failed_job_id: JobId) -> Result<Vec<JobId>>;
async fn get_workflow_jobs(&self, workflow_id: crate::workflow::WorkflowId)
-> Result<Vec<Job>>;
async fn cancel_workflow(&self, workflow_id: crate::workflow::WorkflowId) -> Result<()>;
async fn archive_jobs(
&self,
queue_name: Option<&str>,
policy: &crate::archive::ArchivalPolicy,
config: &crate::archive::ArchivalConfig,
reason: crate::archive::ArchivalReason,
archived_by: Option<&str>,
) -> Result<crate::archive::ArchivalStats>;
async fn restore_archived_job(&self, job_id: JobId) -> Result<Job>;
async fn list_archived_jobs(
&self,
queue_name: Option<&str>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Vec<crate::archive::ArchivedJob>>;
async fn purge_archived_jobs(&self, older_than: DateTime<Utc>) -> Result<u64>;
async fn get_archival_stats(
&self,
queue_name: Option<&str>,
) -> Result<crate::archive::ArchivalStats>;
async fn pause_queue(&self, queue_name: &str, paused_by: Option<&str>) -> Result<()>;
async fn resume_queue(&self, queue_name: &str, resumed_by: Option<&str>) -> Result<()>;
async fn is_queue_paused(&self, queue_name: &str) -> Result<bool>;
async fn get_queue_pause_info(&self, queue_name: &str) -> Result<Option<QueuePauseInfo>>;
async fn get_paused_queues(&self) -> Result<Vec<QueuePauseInfo>>;
async fn finish_job_run(
&self,
run: &Job,
outcome: JobOutcome,
) -> Result<Option<RecordedOutcome>> {
let Some(current) = self.get_job(run.id).await? else {
return Ok(None);
};
if !lifecycle::Guard::Run(run).admits(¤t) {
return Ok(None);
}
let next_run_at = match outcome.terminal_status() {
Some(_) if current.recurring => lifecycle::next_cron_run(¤t, Utc::now()),
_ => None,
};
let mut recorded = match outcome {
JobOutcome::Completed => {
if next_run_at.is_none() {
self.complete_job(run.id).await?;
}
RecordedOutcome::new(crate::job::JobStatus::Completed)
}
JobOutcome::Retry { retry_at, .. } => {
self.retry_job(run.id, retry_at).await?;
RecordedOutcome::new(crate::job::JobStatus::Pending)
}
JobOutcome::Dead { error } => {
self.mark_job_dead(run.id, &error).await?;
RecordedOutcome::new(crate::job::JobStatus::Dead)
}
JobOutcome::TimedOut { error } => {
self.mark_job_timed_out(run.id, &error).await?;
RecordedOutcome::new(crate::job::JobStatus::TimedOut)
}
};
if let Some(next) = next_run_at {
self.reschedule_cron_job(run.id, next).await?;
recorded.status = crate::job::JobStatus::Pending;
recorded.next_run_at = Some(next);
} else if recorded.status == crate::job::JobStatus::Completed {
recorded.unblocked = self.resolve_job_dependencies(run.id).await?;
} else if recorded.status != crate::job::JobStatus::Pending {
recorded.cancelled = self.fail_job_dependencies(run.id).await?;
}
Ok(Some(recorded))
}
async fn complete_job_run_with_children(
&self,
run: &Job,
children: Vec<Job>,
) -> Result<Option<RecordedOutcome>> {
let Some(current) = self.get_job(run.id).await? else {
return Ok(None);
};
if !lifecycle::Guard::Run(run).admits(¤t) {
return Ok(None);
}
let mut spawned = Vec::with_capacity(children.len());
for child in children {
spawned.push(self.enqueue(child).await?);
}
let recorded = self.finish_job_run(run, JobOutcome::Completed).await?;
Ok(recorded.map(|mut recorded| {
recorded.spawned = spawned;
recorded
}))
}
async fn heartbeat_job(&self, run: &Job, lease: std::time::Duration) -> Result<bool> {
let _ = (run, lease);
Err(crate::HammerworkError::Queue {
message: "job leases are not supported by this queue backend".to_string(),
})
}
async fn requeue_stale_jobs(
&self,
older_than: std::time::Duration,
) -> Result<StaleJobRecovery> {
let _ = older_than;
Err(crate::HammerworkError::Queue {
message: "stale job recovery is not supported by this queue backend".to_string(),
})
}
async fn purge_expired_encrypted_jobs(&self) -> Result<EncryptedJobPurge> {
Err(crate::HammerworkError::Queue {
message: "encrypted job retention is not supported by this queue backend".to_string(),
})
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) const FINISHED_STATUS_SQL: &str = "'Completed', 'Failed', 'Dead', 'TimedOut'";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EncryptedJobPurge {
pub jobs: u64,
pub archived_jobs: u64,
}
impl EncryptedJobPurge {
pub fn total(&self) -> u64 {
self.jobs + self.archived_jobs
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct StaleJobRecovery {
pub requeued: Vec<JobId>,
pub dead: Vec<JobId>,
}
impl StaleJobRecovery {
pub fn total(&self) -> usize {
self.requeued.len() + self.dead.len()
}
pub fn is_empty(&self) -> bool {
self.total() == 0
}
}
pub const STALE_JOB_ERROR_MESSAGE: &str =
"Job lease expired while Running; the worker is presumed dead and the job was reclaimed";
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn is_transaction_conflict(error: &crate::HammerworkError) -> bool {
matches!(
error,
crate::HammerworkError::Database(sqlx::Error::Database(db_error))
if matches!(db_error.code().as_deref(), Some("40001") | Some("40P01"))
)
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) async fn retry_on_conflict<F, Fut, T>(mut transaction: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
const ATTEMPTS: u32 = 5;
let mut attempt = 1;
loop {
match transaction().await {
Err(error) if attempt < ATTEMPTS && is_transaction_conflict(&error) => {
tokio::time::sleep(std::time::Duration::from_millis(5 * u64::from(attempt))).await;
attempt += 1;
}
result => return result,
}
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) async fn end_transaction<DB: sqlx::Database, T>(
tx: sqlx::Transaction<'_, DB>,
result: Result<T>,
) -> Result<T> {
match result {
Ok(value) => {
tx.commit().await?;
Ok(value)
}
Err(error) => {
if let Err(rollback_error) = tx.rollback().await {
tracing::warn!("Failed to roll back transaction after error: {rollback_error}");
}
Err(error)
}
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn prepare_cron_job(mut job: Job) -> Result<Job> {
let schedule = match job.get_cron_schedule() {
Some(Ok(schedule)) => schedule,
Some(Err(e)) => {
return Err(crate::HammerworkError::Queue {
message: format!("Invalid cron schedule {:?}: {}", job.cron_schedule, e),
});
}
None => {
return Err(crate::HammerworkError::Queue {
message: "Job must have a cron schedule".to_string(),
});
}
};
job.recurring = true;
if job.next_run_at.is_none() {
job.next_run_at = schedule.next_execution_from_now();
if let Some(next_run_at) = job.next_run_at {
job.scheduled_at = next_run_at;
}
}
Ok(job)
}
#[cfg_attr(
not(any(feature = "postgres", feature = "mysql", feature = "test")),
allow(dead_code)
)]
pub(crate) fn enabled_recurring_next_run(
job: &Job,
now: DateTime<Utc>,
) -> Result<Option<DateTime<Utc>>> {
let schedule = match job.get_cron_schedule() {
Some(Ok(schedule)) => schedule,
Some(Err(e)) => {
return Err(crate::HammerworkError::Queue {
message: format!(
"job {} has an invalid cron schedule {:?}: {e}",
job.id, job.cron_schedule
),
});
}
None => return Err(not_a_cron_job(job.id)),
};
if job.status == crate::job::JobStatus::Running {
return Ok(None);
}
schedule
.next_execution(now)
.map(Some)
.ok_or_else(|| crate::HammerworkError::Queue {
message: format!("the cron schedule of job {} has no next occurrence", job.id),
})
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn disable_recurring_job_not_applied(job: Option<Job>, job_id: JobId) -> Result<()> {
match job {
Some(job) if job.status == crate::job::JobStatus::Archived => {
Err(crate::HammerworkError::JobNotFound {
id: job_id.to_string(),
})
}
Some(job) if job.cron_schedule.is_some() => Ok(()),
Some(_) => Err(not_a_cron_job(job_id)),
None => Err(crate::HammerworkError::JobNotFound {
id: job_id.to_string(),
}),
}
}
#[cfg_attr(
not(any(feature = "postgres", feature = "mysql", feature = "test")),
allow(dead_code)
)]
pub(crate) fn not_a_cron_job(job_id: JobId) -> crate::HammerworkError {
crate::HammerworkError::Queue {
message: format!("job {job_id} has no cron schedule, so it is not a recurring job"),
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn retry_strategy_json(job: &Job) -> Result<Option<serde_json::Value>> {
match &job.retry_strategy {
None => Ok(None),
Some(crate::retry::RetryStrategy::Custom(_)) => {
Err(crate::HammerworkError::InvalidJobPayload {
message: format!(
"job {} has a custom retry strategy, which cannot be stored; use \
Worker::with_default_retry_strategy for custom strategies",
job.id
),
})
}
Some(strategy) => Ok(Some(serde_json::to_value(strategy)?)),
}
}
#[derive(Debug, Default)]
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) struct EncryptionColumns {
pub is_encrypted: bool,
pub key_id: Option<String>,
pub algorithm: Option<&'static str>,
pub ciphertext: Option<Vec<u8>>,
pub nonce: Option<Vec<u8>>,
pub tag: Option<Vec<u8>>,
pub metadata: Option<serde_json::Value>,
pub payload_hash: Option<String>,
pub retention_policy: Option<&'static str>,
pub retention_delete_at: Option<DateTime<Utc>>,
pub encrypted_at: Option<DateTime<Utc>>,
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
impl EncryptionColumns {
pub(crate) fn for_job(job: &Job) -> Result<Self> {
if !job.is_encrypted {
if job.has_encryption() {
return Err(crate::HammerworkError::Encryption {
message: format!(
"Job {} has an encryption config but its payload was not encrypted",
job.id
),
});
}
return Ok(Self::default());
}
#[cfg(feature = "encryption")]
{
use crate::encryption::{EncryptionAlgorithm, RetentionPolicy};
let encrypted = job.encrypted_payload.as_ref().ok_or_else(|| {
crate::HammerworkError::Encryption {
message: format!(
"Job {} is marked encrypted but has no encrypted payload",
job.id
),
}
})?;
let metadata = &encrypted.metadata;
Ok(Self {
is_encrypted: true,
key_id: Some(metadata.key_id.clone()),
algorithm: Some(match metadata.algorithm {
EncryptionAlgorithm::AES256GCM => "AES256GCM",
EncryptionAlgorithm::ChaCha20Poly1305 => "ChaCha20Poly1305",
}),
ciphertext: Some(encrypted.decode_ciphertext()?),
nonce: Some(encrypted.decode_nonce()?),
tag: Some(encrypted.decode_tag()?),
metadata: Some(serde_json::to_value(metadata)?),
payload_hash: Some(metadata.payload_hash.clone()),
retention_policy: Some(match metadata.retention_policy {
RetentionPolicy::DeleteAfter(_) if metadata.delete_at.is_none() => {
"KeepIndefinitely"
}
RetentionPolicy::DeleteAfter(_) => "DeleteAfter",
RetentionPolicy::DeleteAt(_) => "DeleteAt",
RetentionPolicy::KeepIndefinitely => "KeepIndefinitely",
RetentionPolicy::DeleteImmediately => "DeleteImmediately",
RetentionPolicy::UseDefault => "UseDefault",
}),
retention_delete_at: metadata.delete_at,
encrypted_at: Some(metadata.encrypted_at),
})
}
#[cfg(not(feature = "encryption"))]
Err(crate::HammerworkError::Encryption {
message: format!(
"Job {} is marked encrypted; storing encrypted jobs needs the `encryption` feature",
job.id
),
})
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn pii_fields_column(job: &Job) -> Option<Vec<String>> {
(!job.pii_fields.is_empty()).then(|| job.pii_fields.clone())
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn retry_strategy_from_json(
value: Option<serde_json::Value>,
) -> Result<Option<crate::retry::RetryStrategy>> {
Ok(value.map(serde_json::from_value).transpose()?)
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn timeout_db_seconds(timeout: Option<std::time::Duration>) -> Option<i32> {
let timeout = timeout.filter(|timeout| !timeout.is_zero())?;
let seconds = timeout
.as_secs()
.saturating_add(u64::from(timeout.subsec_nanos() > 0));
Some(i32::try_from(seconds).unwrap_or(i32::MAX))
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn decodable_rows<T>(
listing: &str,
rows: impl IntoIterator<Item = (String, Result<T>)>,
) -> Vec<T> {
rows.into_iter()
.filter_map(|(id, decoded)| match decoded {
Ok(value) => Some(value),
Err(error) => {
tracing::warn!(
job_id = %id,
error = %error,
"Skipping a row that cannot be decoded while listing {listing}"
);
None
}
})
.collect()
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn db_int<T, S>(value: S, column: &str) -> Result<T>
where
T: TryFrom<S>,
S: Copy + std::fmt::Display,
{
T::try_from(value).map_err(|_| crate::HammerworkError::Queue {
message: format!("{column} read from the database is out of range: {value}"),
})
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn db_seconds<S>(value: S, column: &str) -> Result<std::time::Duration>
where
u64: TryFrom<S>,
S: Copy + std::fmt::Display,
{
Ok(std::time::Duration::from_secs(db_int(value, column)?))
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn count_map(rows: Vec<(String, i64)>, column: &str) -> Result<HashMap<String, u64>> {
rows.into_iter()
.map(|(key, count)| Ok((key, db_int(count, column)?)))
.collect()
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) const ALL_PRIORITIES: [crate::priority::JobPriority; 5] = [
crate::priority::JobPriority::Critical,
crate::priority::JobPriority::High,
crate::priority::JobPriority::Normal,
crate::priority::JobPriority::Low,
crate::priority::JobPriority::Background,
];
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn runnable_priorities_sql(runnable: &str) -> String {
let levels = ALL_PRIORITIES
.iter()
.map(|priority| format!("SELECT {} AS priority", priority.as_i32()))
.collect::<Vec<_>>()
.join(" UNION ALL ");
format!(
"SELECT levels.priority FROM ({levels}) levels \
WHERE EXISTS (SELECT 1 FROM hammerwork_jobs j WHERE {runnable} \
AND j.priority = levels.priority)"
)
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn pick_weighted_priority(
candidates: &[crate::priority::JobPriority],
weights: &crate::priority::PriorityWeights,
seed: u64,
) -> Option<crate::priority::JobPriority> {
let total: u64 = candidates
.iter()
.map(|priority| u64::from(weights.get_weight(*priority)))
.sum();
if total == 0 {
return candidates.iter().copied().max();
}
let mut point = seed % total;
for priority in candidates {
let weight = u64::from(weights.get_weight(*priority));
if point < weight {
return Some(*priority);
}
point -= weight;
}
None
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn weighted_seed(queue_name: &str, attempt: usize) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
queue_name.hash(&mut hasher);
attempt.hash(&mut hasher);
Utc::now()
.timestamp_nanos_opt()
.unwrap_or(0)
.hash(&mut hasher);
std::thread::current().id().hash(&mut hasher);
hasher.finish()
}
pub const DEFAULT_LEASE_DURATION: std::time::Duration = std::time::Duration::from_secs(300);
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn lease_interval(lease: std::time::Duration) -> chrono::Duration {
if lease.is_zero() {
clamp_interval(std::time::Duration::MAX)
} else {
clamp_interval(lease)
}
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn run_started_range(run: &Job) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
let started = run.started_at?;
let tolerance = chrono::Duration::milliseconds(1);
Some((
started.checked_sub_signed(tolerance).unwrap_or(started),
started.checked_add_signed(tolerance).unwrap_or(started),
))
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) fn clamp_interval(duration: std::time::Duration) -> chrono::Duration {
const MAX: std::time::Duration = std::time::Duration::from_secs(100 * 365 * 24 * 3600);
saturating_chrono_duration(duration.min(MAX))
}
pub(crate) fn saturating_chrono_duration(duration: std::time::Duration) -> chrono::Duration {
chrono::Duration::from_std(duration).unwrap_or(chrono::Duration::MAX)
}
#[cfg_attr(
not(any(feature = "postgres", feature = "mysql", feature = "test")),
allow(dead_code)
)]
pub(crate) fn saturating_sub_from(
now: DateTime<Utc>,
duration: std::time::Duration,
) -> DateTime<Utc> {
now.checked_sub_signed(saturating_chrono_duration(duration))
.unwrap_or(DateTime::<Utc>::MIN_UTC)
}
#[cfg_attr(not(feature = "test"), allow(dead_code))]
pub(crate) fn saturating_add_to(
now: DateTime<Utc>,
duration: std::time::Duration,
) -> DateTime<Utc> {
now.checked_add_signed(saturating_chrono_duration(duration))
.unwrap_or(DateTime::<Utc>::MAX_UTC)
}
pub struct JobQueue<DB: Database> {
#[allow(dead_code)] pub pool: Pool<DB>,
pub(crate) _phantom: PhantomData<DB>,
pub(crate) throttle_configs: Arc<RwLock<HashMap<String, ThrottleConfig>>>,
pub(crate) encryption: PayloadEncryption,
pub(crate) memory_results: Arc<MemoryResultStore>,
}
impl<DB: Database> Clone for JobQueue<DB> {
fn clone(&self) -> Self {
Self {
pool: self.pool.clone(),
_phantom: PhantomData,
throttle_configs: self.throttle_configs.clone(),
encryption: self.encryption.clone(),
memory_results: self.memory_results.clone(),
}
}
}
impl<DB: Database> JobQueue<DB> {
pub fn new(pool: Pool<DB>) -> Self {
Self {
pool,
_phantom: PhantomData,
throttle_configs: Arc::new(RwLock::new(HashMap::new())),
encryption: PayloadEncryption::default(),
memory_results: Arc::new(MemoryResultStore::default()),
}
}
pub fn with_memory_result_capacity(mut self, capacity: usize) -> Self {
self.memory_results = Arc::new(MemoryResultStore::with_capacity(capacity));
self
}
#[cfg(feature = "encryption")]
pub fn with_encryption(
mut self,
engine: impl Into<Arc<crate::encryption::EncryptionEngine>>,
) -> Self {
self.encryption.engine = Some(engine.into());
self
}
#[cfg(feature = "encryption")]
pub fn with_encrypted_queues<I, S>(mut self, queues: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.encryption.encrypted_queues = queues.into_iter().map(Into::into).collect();
self
}
#[cfg(feature = "encryption")]
pub fn encryption_engine(&self) -> Option<&Arc<crate::encryption::EncryptionEngine>> {
self.encryption.engine.as_ref()
}
pub fn with_plaintext_guard(mut self, enabled: bool) -> Self {
self.encryption.plaintext_guard = enabled;
self
}
#[cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
pub(crate) async fn seal_jobs(&self, jobs: &mut [Job]) -> Result<()> {
self.encryption.seal(jobs).await
}
pub async fn decrypt_job(&self, job: Job) -> Result<Job> {
self.encryption.open(job).await
}
pub async fn set_throttle(&self, queue_name: &str, config: ThrottleConfig) -> Result<()> {
let mut configs = self.throttle_configs.write().await;
configs.insert(queue_name.to_string(), config);
Ok(())
}
pub async fn get_throttle(&self, queue_name: &str) -> Option<ThrottleConfig> {
let configs = self.throttle_configs.read().await;
configs.get(queue_name).cloned()
}
pub async fn remove_throttle(&self, queue_name: &str) -> Result<()> {
let mut configs = self.throttle_configs.write().await;
configs.remove(queue_name);
Ok(())
}
pub async fn get_all_throttles(&self) -> HashMap<String, ThrottleConfig> {
let configs = self.throttle_configs.read().await;
configs.clone()
}
pub fn get_pool(&self) -> &Pool<DB> {
&self.pool
}
pub async fn apply_rate_limiting_config(&self, config: &RateLimitingConfig) -> Result<()> {
if !config.enabled {
return Ok(());
}
for (queue_name, throttle) in &config.queue_throttles {
self.set_throttle(queue_name, throttle.clone()).await?;
}
Ok(())
}
}
#[cfg(any(feature = "postgres", feature = "mysql"))]
use crate::config::HammerworkConfig;
#[cfg(all(any(feature = "postgres", feature = "mysql"), feature = "encryption"))]
impl<DB: crate::encryption::KeyManagerBackend> JobQueue<DB> {
pub async fn apply_encryption_config(
self,
config: &crate::config::PayloadEncryptionConfig,
) -> Result<Self> {
config.validate()?;
if !config.enabled {
return Ok(self);
}
let mut engine = crate::encryption::EncryptionEngine::new_with_pool(
config.encryption_config()?,
&self.pool,
)
.await?;
for (key_id, source) in &config.decryption_keys {
engine = engine
.with_decryption_key(key_id.clone(), &source.key_source())
.await?;
}
Ok(self
.with_encryption(engine)
.with_encrypted_queues(config.encrypted_queues.iter().cloned()))
}
}
#[cfg(all(
any(feature = "postgres", feature = "mysql"),
not(feature = "encryption")
))]
impl<DB: Database> JobQueue<DB> {
pub async fn apply_encryption_config(
self,
config: &crate::config::PayloadEncryptionConfig,
) -> Result<Self> {
config.validate()?;
if !config.enabled {
return Ok(self);
}
Err(crate::HammerworkError::Config(
"encryption.enabled is set but hammerwork was built without the `encryption` \
feature"
.to_string(),
))
}
}
#[cfg(feature = "postgres")]
impl JobQueue<sqlx::Postgres> {
pub async fn from_config(config: &HammerworkConfig) -> Result<Self> {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(config.database.pool_size)
.acquire_timeout(config.database.connection_timeout())
.connect(&config.database.url)
.await?;
if config.database.auto_migrate {
let runner = crate::migrations::postgres::PostgresMigrationRunner::new(pool.clone());
crate::migrations::MigrationManager::new(Box::new(runner))
.run_migrations()
.await?;
}
let queue = Self::new(pool)
.apply_encryption_config(&config.encryption)
.await?;
queue
.apply_rate_limiting_config(&config.rate_limiting)
.await?;
Ok(queue)
}
}
#[cfg(feature = "mysql")]
impl JobQueue<sqlx::MySql> {
pub async fn from_config(config: &HammerworkConfig) -> Result<Self> {
let pool = sqlx::mysql::MySqlPoolOptions::new()
.max_connections(config.database.pool_size)
.acquire_timeout(config.database.connection_timeout())
.connect(&config.database.url)
.await?;
if config.database.auto_migrate {
let runner = crate::migrations::mysql::MySqlMigrationRunner::new(pool.clone());
crate::migrations::MigrationManager::new(Box::new(runner))
.run_migrations()
.await?;
}
let queue = Self::new(pool)
.apply_encryption_config(&config.encryption)
.await?;
queue
.apply_rate_limiting_config(&config.rate_limiting)
.await?;
Ok(queue)
}
}
#[cfg(test)]
mod tests {
#[test]
fn timeout_db_seconds_rounds_up_saturates_and_drops_zero() {
use std::time::Duration;
assert_eq!(timeout_db_seconds(None), None);
assert_eq!(timeout_db_seconds(Some(Duration::ZERO)), None);
assert_eq!(
timeout_db_seconds(Some(Duration::from_millis(200))),
Some(1)
);
assert_eq!(
timeout_db_seconds(Some(Duration::from_millis(1500))),
Some(2)
);
assert_eq!(timeout_db_seconds(Some(Duration::from_secs(30))), Some(30));
assert_eq!(timeout_db_seconds(Some(Duration::MAX)), Some(i32::MAX));
assert_eq!(
timeout_db_seconds(Some(Duration::from_secs(u64::from(u32::MAX)))),
Some(i32::MAX)
);
}
use super::*;
#[test]
fn decodable_rows_skips_rows_that_fail_to_decode() {
let rows = vec![
("a".to_string(), Ok(1)),
(
"b".to_string(),
db_seconds(-1i32, "timeout_seconds").map(|_| 2),
),
("c".to_string(), Ok(3)),
];
assert_eq!(decodable_rows("test rows", rows), [1, 3]);
assert!(decodable_rows::<u8>("nothing", Vec::new()).is_empty());
}
#[cfg(feature = "postgres")]
#[tokio::test]
#[ignore] async fn test_public_pool_access() {
let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
"postgres://postgres:hammerwork@localhost:5433/hammerwork".to_string()
});
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
let queue = JobQueue::new(pool.clone());
let _pool_ref = &queue.pool;
let _pool_clone = queue.pool.clone();
let _same_pool = std::ptr::eq(&queue.pool, &pool);
}
#[test]
fn test_queue_creation_with_pool() {
#[cfg(feature = "test")]
{
use crate::queue::test::TestQueue;
let test_queue = TestQueue::new();
let _ = test_queue; }
}
#[test]
fn test_pool_field_visibility() {
#[allow(dead_code)]
fn _check_pool_access<DB: sqlx::Database>(queue: &JobQueue<DB>) -> &sqlx::Pool<DB> {
&queue.pool }
}
#[test]
fn test_throttle_configs_still_private() {
fn _ensure_throttle_configs_private<DB: sqlx::Database>(_queue: &JobQueue<DB>) {
}
}
#[test]
fn test_pool_documentation_examples() {
#[cfg(feature = "postgres")]
#[allow(dead_code)]
async fn _example_archive_integration()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let pool = sqlx::PgPool::connect("postgresql://localhost/hammerwork").await?;
let queue = std::sync::Arc::new(JobQueue::new(pool.clone()));
let _archiver = crate::archive::JobArchiver::new(queue.pool.clone());
Ok(())
}
}
#[test]
fn test_throttle_config_creation() {
use crate::rate_limit::ThrottleConfig;
let throttle_config = ThrottleConfig::new()
.max_concurrent(10)
.rate_per_minute(60)
.enabled(true);
assert_eq!(throttle_config.max_concurrent, Some(10));
assert_eq!(throttle_config.rate_per_minute, Some(60));
assert!(throttle_config.enabled);
let default_config = ThrottleConfig::new();
assert_eq!(default_config.max_concurrent, None);
assert_eq!(default_config.rate_per_minute, None);
assert!(default_config.enabled);
}
#[test]
fn test_job_queue_clone() {
#[cfg(feature = "test")]
{
use crate::queue::test::TestQueue;
let test_queue = TestQueue::new();
let cloned_queue = test_queue.clone();
let _ = test_queue;
let _ = cloned_queue;
}
#[allow(dead_code)]
fn _test_job_queue_clone_trait<DB: sqlx::Database>()
-> impl Fn(&JobQueue<DB>) -> JobQueue<DB> {
|queue: &JobQueue<DB>| queue.clone()
}
}
}