#![cfg(any(feature = "postgres", feature = "mysql"))]
mod test_utils;
use hammerwork::{
HammerworkError, Job, JobId, JobQueue, JobStatus, Worker,
queue::DatabaseQueue,
retry::RetryStrategy,
spawn::{ClosureSpawnHandler, JobSpawnExt, SpawnConfig, SpawnContext, SpawnManager},
worker::JobHandler,
workflow::DependencyStatus,
};
use serde_json::json;
use std::{
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use tokio::sync::mpsc;
async fn drain<Q: DatabaseQueue + ?Sized>(queue: &Arc<Q>, queue_name: &str) -> Vec<Job> {
let mut jobs = Vec::new();
while let Some(job) = queue.dequeue_one(queue_name).await.unwrap() {
jobs.push(job);
}
jobs
}
async fn run_worker<DB, F>(
queue: &Arc<JobQueue<DB>>,
queue_name: &str,
manager: SpawnManager<DB>,
id: JobId,
done: F,
) -> Job
where
DB: sqlx::Database + Send + Sync + 'static,
JobQueue<DB>: DatabaseQueue<Database = DB> + Send + Sync + 'static,
F: Fn(&Job) -> bool,
{
let handler: JobHandler = Arc::new(|_job: Job| Box::pin(async { Ok(()) }));
let worker = Worker::new(Arc::clone(queue), queue_name.to_string(), handler)
.with_poll_interval(Duration::from_millis(50))
.with_retry_delay(Duration::ZERO)
.with_spawn_manager(Arc::new(manager));
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
let task = tokio::spawn(async move { worker.run(shutdown_rx).await });
let job = test_utils::wait_for_job(queue, id, Duration::from_secs(15), done).await;
shutdown_tx.send(()).await.unwrap();
task.await.unwrap().unwrap();
job
}
fn children<DB: sqlx::Database + Send + Sync>(
child_queue: &str,
count: usize,
bad_index: Option<usize>,
) -> ClosureSpawnHandler<
impl Fn(SpawnContext<DB>) -> hammerwork::Result<Vec<Job>> + Send + Sync + 'static,
DB,
> {
let child_queue = child_queue.to_string();
ClosureSpawnHandler::new(move |_context: SpawnContext<DB>| {
Ok((0..count)
.map(|i| {
let child = Job::new(child_queue.clone(), json!({ "i": i }));
if Some(i) == bad_index {
child.with_retry_strategy(RetryStrategy::custom(|_| Duration::from_secs(1)))
} else {
child
}
})
.collect())
})
}
fn spawn_config() -> SpawnConfig {
SpawnConfig::default().with_inherit_retry_strategy(false)
}
async fn children_are_enqueued_with_the_completion<DB>(queue: Arc<JobQueue<DB>>)
where
DB: sqlx::Database + Send + Sync + 'static,
JobQueue<DB>: DatabaseQueue<Database = DB> + Send + Sync + 'static,
{
let queue_name = test_utils::unique_queue("spawn_ok");
let child_queue = format!("{queue_name}_child");
let mut manager: SpawnManager<DB> = SpawnManager::new();
manager.register_handler(queue_name.clone(), children::<DB>(&child_queue, 3, None));
let id = queue
.enqueue(
Job::new(queue_name.clone(), json!({}))
.with_spawn_config(spawn_config())
.unwrap(),
)
.await
.unwrap();
let parent = run_worker(&queue, &queue_name, manager, id, |job| {
job.status == JobStatus::Completed
})
.await;
assert_eq!(parent.status, JobStatus::Completed);
assert_eq!(parent.attempts, 1);
let spawned = drain(&queue, &child_queue).await;
assert_eq!(spawned.len(), 3);
for child in &spawned {
assert_eq!(child.depends_on, vec![id]);
assert_ne!(child.dependency_status, DependencyStatus::Waiting);
queue.delete_job(child.id).await.unwrap();
}
queue.delete_job(id).await.unwrap();
}
async fn failing_spawn_handler_retries_the_parent<DB>(queue: Arc<JobQueue<DB>>)
where
DB: sqlx::Database + Send + Sync + 'static,
JobQueue<DB>: DatabaseQueue<Database = DB> + Send + Sync + 'static,
{
let queue_name = test_utils::unique_queue("spawn_retry");
let child_queue = format!("{queue_name}_child");
let calls = Arc::new(AtomicUsize::new(0));
let mut manager: SpawnManager<DB> = SpawnManager::new();
{
let (calls, child_queue) = (Arc::clone(&calls), child_queue.clone());
manager.register_handler(
queue_name.clone(),
ClosureSpawnHandler::new(move |_context: SpawnContext<DB>| {
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
return Err(HammerworkError::Processing("spawn backend down".into()));
}
Ok(vec![
Job::new(child_queue.clone(), json!({ "i": 0 })),
Job::new(child_queue.clone(), json!({ "i": 1 })),
])
}),
);
}
let id = queue
.enqueue(
Job::new(queue_name.clone(), json!({}))
.with_max_attempts(3)
.with_spawning()
.unwrap(),
)
.await
.unwrap();
let parent = run_worker(&queue, &queue_name, manager, id, |job| {
job.status == JobStatus::Completed
})
.await;
assert_eq!(parent.status, JobStatus::Completed, "{parent:?}");
assert_eq!(parent.attempts, 2, "the failed spawn retried the parent");
assert_eq!(calls.load(Ordering::SeqCst), 2);
let spawned = drain(&queue, &child_queue).await;
assert_eq!(spawned.len(), 2, "children are spawned exactly once");
for child in spawned {
queue.delete_job(child.id).await.unwrap();
}
queue.delete_job(id).await.unwrap();
}
async fn child_enqueue_failure_rolls_back_the_completion<DB>(queue: Arc<JobQueue<DB>>)
where
DB: sqlx::Database + Send + Sync + 'static,
JobQueue<DB>: DatabaseQueue<Database = DB> + Send + Sync + 'static,
{
let queue_name = test_utils::unique_queue("spawn_rollback");
let child_queue = format!("{queue_name}_child");
let mut manager: SpawnManager<DB> = SpawnManager::new();
manager.register_handler(queue_name.clone(), children::<DB>(&child_queue, 3, Some(2)));
let id = queue
.enqueue(
Job::new(queue_name.clone(), json!({}))
.with_max_attempts(2)
.with_spawn_config(spawn_config())
.unwrap(),
)
.await
.unwrap();
let parent = run_worker(&queue, &queue_name, manager, id, |job| {
job.status == JobStatus::Dead
})
.await;
assert_eq!(parent.status, JobStatus::Dead, "{parent:?}");
assert_eq!(parent.attempts, 2);
assert!(
parent
.error_message
.as_deref()
.unwrap_or_default()
.contains("retry strategy"),
"{parent:?}"
);
assert!(
drain(&queue, &child_queue).await.is_empty(),
"no child of a failed completion may exist"
);
let stats = queue.get_queue_stats(&child_queue).await.unwrap();
assert_eq!(stats.pending_count + stats.running_count, 0, "{stats:?}");
queue.delete_job(id).await.unwrap();
}
async fn complete_with_children_is_atomic_and_guarded<DB>(queue: Arc<JobQueue<DB>>)
where
DB: sqlx::Database + Send + Sync + 'static,
JobQueue<DB>: DatabaseQueue<Database = DB> + Send + Sync + 'static,
{
let queue_name = test_utils::unique_queue("spawn_api");
let child_queue = format!("{queue_name}_child");
let id = queue
.enqueue(Job::new(queue_name.clone(), json!({})))
.await
.unwrap();
let run = queue.dequeue_one(&queue_name).await.unwrap().unwrap();
let child = |i: u32| Job::new(child_queue.clone(), json!({ "i": i })).depends_on(&id);
let bad = child(1).with_retry_strategy(RetryStrategy::custom(|_| Duration::from_secs(1)));
assert!(
queue
.complete_job_run_with_children(&run, vec![child(0), bad])
.await
.is_err()
);
assert_eq!(
queue.get_job(id).await.unwrap().unwrap().status,
JobStatus::Running
);
assert!(drain(&queue, &child_queue).await.is_empty());
let mut stale = run.clone();
stale.attempts += 1;
assert!(
queue
.complete_job_run_with_children(&stale, vec![child(2)])
.await
.unwrap()
.is_none()
);
assert!(drain(&queue, &child_queue).await.is_empty());
let recorded = queue
.complete_job_run_with_children(&run, vec![child(3), child(4)])
.await
.unwrap()
.expect("current run");
assert_eq!(recorded.status, JobStatus::Completed);
assert_eq!(recorded.spawned.len(), 2);
assert_eq!(
queue.get_job(id).await.unwrap().unwrap().status,
JobStatus::Completed
);
let spawned = drain(&queue, &child_queue).await;
let mut ids: Vec<JobId> = spawned.iter().map(|job| job.id).collect();
let mut expected = recorded.spawned.clone();
ids.sort();
expected.sort();
assert_eq!(ids, expected);
for job in spawned {
queue.delete_job(job.id).await.unwrap();
}
queue.delete_job(id).await.unwrap();
}
#[cfg(feature = "postgres")]
crate::test_utils::backend_tests!(
postgres_tests,
test_utils::setup_postgres_queue,
ignore = "requires PostgreSQL: DATABASE_URL",
[
children_are_enqueued_with_the_completion,
failing_spawn_handler_retries_the_parent,
child_enqueue_failure_rolls_back_the_completion,
complete_with_children_is_atomic_and_guarded,
]
);
#[cfg(feature = "mysql")]
crate::test_utils::backend_tests!(
mysql_tests,
test_utils::setup_mysql_queue,
ignore = "requires MySQL: MYSQL_DATABASE_URL",
[
children_are_enqueued_with_the_completion,
failing_spawn_handler_retries_the_parent,
child_enqueue_failure_rolls_back_the_completion,
complete_with_children_is_atomic_and_guarded,
]
);