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};
mod backend;
#[allow(unused_imports)] pub(crate) use backend::*;
mod row;
#[cfg(feature = "postgres")]
pub mod postgres;
#[cfg(feature = "mysql")]
pub mod mysql;
#[cfg(feature = "test-queue")]
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;
mod private {
pub trait Sealed {}
#[cfg(feature = "postgres")]
impl Sealed for super::JobQueue<sqlx::Postgres> {}
#[cfg(feature = "mysql")]
impl Sealed for super::JobQueue<sqlx::MySql> {}
#[cfg(feature = "test-queue")]
impl Sealed for super::test::TestQueue {}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
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: private::Sealed + Send + Sync {
type Database: Database;
async fn enqueue(&self, job: Job) -> Result<JobId>;
async fn dequeue(&self, queue_name: &str, options: &DequeueOptions) -> Result<Vec<Job>>;
async fn dequeue_one(&self, queue_name: &str) -> Result<Option<Job>> {
Ok(self
.dequeue(queue_name, &DequeueOptions::new())
.await?
.into_iter()
.next())
}
async fn release_job_run(&self, run: &Job) -> Result<bool>;
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 purge_archived_jobs_in_queue(
&self,
queue_name: &str,
older_than: DateTime<Utc>,
) -> Result<u64>;
async fn list_queue_names(&self) -> Result<Vec<String>>;
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>>;
async fn complete_job_run_with_children(
&self,
run: &Job,
children: Vec<Job>,
) -> Result<Option<RecordedOutcome>>;
async fn heartbeat_job(&self, run: &Job, lease: std::time::Duration) -> Result<bool>;
async fn start_claimed_run(&self, run: &mut Job, lease: std::time::Duration) -> Result<bool>;
async fn requeue_stale_jobs(&self, queue_name: Option<&str>) -> Result<StaleJobRecovery>;
async fn purge_expired_encrypted_jobs(&self) -> Result<EncryptedJobPurge>;
}
#[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";
pub const MAX_DEQUEUE_BATCH_SIZE: usize = 1000;
pub const DEFAULT_LEASE_DURATION: std::time::Duration = std::time::Duration::from_secs(300);
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DequeueOptions {
pub priority_weights: Option<crate::priority::PriorityWeights>,
pub lease_duration: Option<std::time::Duration>,
pub max_jobs: usize,
}
impl Default for DequeueOptions {
fn default() -> Self {
Self {
priority_weights: None,
lease_duration: None,
max_jobs: 1,
}
}
}
impl DequeueOptions {
pub fn new() -> Self {
Self::default()
}
pub fn batch(max_jobs: usize) -> Self {
Self::new().with_max_jobs(max_jobs)
}
pub fn with_max_jobs(mut self, max_jobs: usize) -> Self {
self.max_jobs = max_jobs;
self
}
pub fn with_priority_weights(mut self, weights: crate::priority::PriorityWeights) -> Self {
self.priority_weights = Some(weights);
self
}
pub fn with_lease_duration(mut self, lease: std::time::Duration) -> Self {
self.lease_duration = Some(lease);
self
}
pub fn lease(&self) -> std::time::Duration {
self.lease_duration.unwrap_or(DEFAULT_LEASE_DURATION)
}
}
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(feature = "test-queue"), 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> {
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<()> {
config.validate()?;
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 dequeue_options_default_to_one_job_strict_default_lease() {
let options = super::DequeueOptions::default();
assert_eq!(options.max_jobs, 1);
assert!(options.priority_weights.is_none());
assert_eq!(options.lease_duration, None);
assert_eq!(options.lease(), super::DEFAULT_LEASE_DURATION);
let options = super::DequeueOptions::batch(7)
.with_priority_weights(crate::priority::PriorityWeights::strict())
.with_lease_duration(std::time::Duration::ZERO);
assert_eq!(options.max_jobs, 7);
assert!(options.priority_weights.as_ref().unwrap().is_strict());
assert_eq!(options.lease(), std::time::Duration::ZERO);
assert_eq!(options.with_max_jobs(2).max_jobs, 2);
}
#[test]
fn sort_in_claim_order_is_priority_then_oldest() {
use crate::priority::JobPriority;
let job = |priority: JobPriority, age_secs: i64| {
let mut job =
Job::new("claim".to_string(), serde_json::json!({})).with_priority(priority);
job.scheduled_at = Utc::now() - chrono::Duration::seconds(age_secs);
job
};
let mut jobs = vec![
job(JobPriority::Low, 50),
job(JobPriority::High, 10),
job(JobPriority::High, 30),
job(JobPriority::Critical, 1),
];
let expected = vec![jobs[3].id, jobs[2].id, jobs[1].id, jobs[0].id];
sort_in_claim_order(&mut jobs);
let order: Vec<_> = jobs.iter().map(|job| job.id).collect();
assert_eq!(order, expected);
}
#[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::*;
#[tokio::test]
async fn weighted_batch_keeps_claimed_jobs_when_a_later_claim_fails() {
use crate::priority::{JobPriority, PriorityWeights};
let weights = PriorityWeights::new();
let levels = vec![JobPriority::High, JobPriority::Normal, JobPriority::Low];
let first = vec![
Job::new("q".to_string(), serde_json::json!(1)),
Job::new("q".to_string(), serde_json::json!(2)),
];
let expected: Vec<JobId> = first.iter().map(|job| job.id).collect();
let calls = std::sync::atomic::AtomicUsize::new(0);
let jobs = fill_weighted_batch("q", levels.clone(), &weights, 10, |_, room| {
let call = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let first = first.clone();
async move {
assert!(room > 0);
if call == 0 {
Ok(first)
} else {
Err(crate::HammerworkError::Queue {
message: "connection reset".to_string(),
})
}
}
})
.await
.expect("the claimed jobs are returned, not the error");
assert_eq!(jobs.iter().map(|job| job.id).collect::<Vec<_>>(), expected);
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
let failed = fill_weighted_batch("q", levels.clone(), &weights, 10, |_, _| async {
Err::<Vec<Job>, _>(crate::HammerworkError::Queue {
message: "connection reset".to_string(),
})
})
.await;
assert!(failed.is_err());
let rooms = std::sync::Mutex::new(Vec::new());
let jobs = fill_weighted_batch("q", levels, &weights, 3, |priority, room| {
rooms.lock().unwrap().push((priority, room));
async move { Ok(vec![Job::new("q".to_string(), serde_json::json!(0))]) }
})
.await
.unwrap();
assert_eq!(jobs.len(), 3);
let rooms = rooms.into_inner().unwrap();
assert_eq!(
rooms.iter().map(|(_, room)| *room).collect::<Vec<_>>(),
vec![3, 2, 1]
);
let mut tried: Vec<_> = rooms.iter().map(|(priority, _)| *priority).collect();
tried.sort();
tried.dedup();
assert_eq!(tried.len(), 3);
}
#[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-queue")]
{
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_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-queue")]
{
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()
}
}
}