use cloacina::runner::DefaultRunner;
use cloacina::*;
use serde_json::Value;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use crate::fixtures::get_or_init_fixture;
static TRANSIENT_ATTEMPTS: AtomicU32 = AtomicU32::new(0);
static NEVER_ATTEMPTS: AtomicU32 = AtomicU32::new(0);
#[derive(Debug)]
struct WorkflowTask {
id: String,
dependencies: Vec<TaskNamespace>,
}
impl WorkflowTask {
fn new(id: &str) -> Self {
Self {
id: id.to_string(),
dependencies: Vec::new(),
}
}
}
#[async_trait::async_trait]
impl Task for WorkflowTask {
async fn execute(&self, context: Context<Value>) -> Result<Context<Value>, TaskError> {
Ok(context)
}
fn id(&self) -> &str {
&self.id
}
fn dependencies(&self) -> &[TaskNamespace] {
&self.dependencies
}
}
#[task(
id = "transient_retry_task",
dependencies = [],
retry_attempts = 3,
retry_delay_ms = 50,
retry_max_delay_ms = 200,
retry_jitter = false,
retry_condition = "transient"
)]
async fn transient_retry_task(_context: &mut Context<Value>) -> Result<(), TaskError> {
let attempt = TRANSIENT_ATTEMPTS.fetch_add(1, Ordering::SeqCst) + 1;
if attempt < 3 {
return Err(TaskError::ExecutionFailed {
message: "simulated connection refused (transient)".into(),
task_id: "transient_retry_task".into(),
timestamp: chrono::Utc::now(),
});
}
Ok(())
}
#[task(
id = "never_retry_task",
dependencies = [],
retry_attempts = 5,
retry_delay_ms = 50,
retry_max_delay_ms = 200,
retry_jitter = false,
retry_condition = "never"
)]
async fn never_retry_task(_context: &mut Context<Value>) -> Result<(), TaskError> {
NEVER_ATTEMPTS.fetch_add(1, Ordering::SeqCst);
Err(TaskError::ExecutionFailed {
message: "validation failed (do not retry)".into(),
task_id: "never_retry_task".into(),
timestamp: chrono::Utc::now(),
})
}
async fn run_with_task(
task_factory: impl Fn() -> Arc<dyn Task> + Send + Sync + 'static,
workflow_name: &str,
task_id: &str,
) -> (uuid::Uuid, cloacina::dal::DAL, DefaultRunner) {
let fixture = get_or_init_fixture().await;
let mut fixture = fixture.lock().unwrap_or_else(|e| e.into_inner());
fixture.reset_database().await;
fixture.initialize().await;
let database_url = fixture.get_database_url();
let database = fixture.get_database();
let workflow = Workflow::builder(workflow_name)
.description("retry_condition integration coverage")
.add_task(Arc::new(WorkflowTask::new(task_id)))
.unwrap()
.build()
.unwrap();
let runtime = cloacina::Runtime::empty();
let namespace = TaskNamespace::new(
workflow.tenant(),
workflow.package(),
workflow.name(),
task_id,
);
runtime.register_task(namespace, task_factory);
runtime.register_workflow(workflow_name.to_string(), {
let workflow = workflow.clone();
move || workflow.clone()
});
let schema = fixture.get_schema();
let runner = DefaultRunner::builder()
.database_url(&database_url)
.schema(&schema)
.runtime(runtime)
.build()
.await
.unwrap();
let execution = runner
.execute_async(workflow_name, Context::new())
.await
.unwrap();
let exec_id = execution.execution_id;
let dal = cloacina::dal::DAL::new(database.clone());
(exec_id, dal, runner)
}
#[tokio::test]
#[serial_test::serial]
async fn test_retry_condition_transient_retries_and_succeeds() {
TRANSIENT_ATTEMPTS.store(0, Ordering::SeqCst);
let (exec_id, dal, runner) = run_with_task(
|| Arc::new(transient_retry_task_task()) as Arc<dyn Task>,
"retry_pipeline_transient",
"transient_retry_task",
)
.await;
crate::fixtures::poll_until(
Duration::from_secs(30),
Duration::from_millis(50),
"transient retry task should reach a terminal state",
|| {
let dal = dal.clone();
async move {
let tasks = dal
.task_execution()
.get_all_tasks_for_workflow(UniversalUuid(exec_id))
.await
.unwrap_or_default();
tasks
.iter()
.any(|t| t.status == "Completed" || t.status == "Failed")
}
},
)
.await;
let tasks = dal
.task_execution()
.get_all_tasks_for_workflow(UniversalUuid(exec_id))
.await
.unwrap();
assert_eq!(
tasks.len(),
1,
"exactly one task row tracks the latest attempt"
);
assert_eq!(
tasks[0].status, "Completed",
"transient retries should let the task succeed on attempt 3"
);
let attempts = TRANSIENT_ATTEMPTS.load(Ordering::SeqCst);
assert_eq!(
attempts, 3,
"expected exactly 3 attempts (2 transient failures + 1 success), got {}",
attempts
);
runner.shutdown().await.unwrap();
}
#[tokio::test]
#[serial_test::serial]
async fn test_retry_condition_never_skips_retries() {
NEVER_ATTEMPTS.store(0, Ordering::SeqCst);
let (exec_id, dal, runner) = run_with_task(
|| Arc::new(never_retry_task_task()) as Arc<dyn Task>,
"retry_pipeline_never",
"never_retry_task",
)
.await;
crate::fixtures::poll_until(
Duration::from_secs(30),
Duration::from_millis(50),
"never-retry task should reach Failed",
|| {
let dal = dal.clone();
async move {
let tasks = dal
.task_execution()
.get_all_tasks_for_workflow(UniversalUuid(exec_id))
.await
.unwrap_or_default();
tasks.iter().any(|t| t.status == "Failed")
}
},
)
.await;
let tasks = dal
.task_execution()
.get_all_tasks_for_workflow(UniversalUuid(exec_id))
.await
.unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(
tasks[0].status, "Failed",
"retry_condition=never must surface the failure without retrying"
);
let attempts = NEVER_ATTEMPTS.load(Ordering::SeqCst);
assert_eq!(
attempts, 1,
"expected exactly 1 attempt with retry_condition=never (got {})",
attempts
);
runner.shutdown().await.unwrap();
}