mod common;
use azums::jobs::retry::RetryConfig;
use azums::jobs::runner::JobRunner;
use azums::jobs::{AttemptsRepo, JobsRepo};
use common::setup_db;
use sqlx::Row;
use std::time::Instant;
use uuid::Uuid;
async fn insert_job(pool: &sqlx::PgPool, queue: &str, job_type: &str, max_attempts: i32) -> Uuid {
sqlx::query_scalar(
r#"
INSERT INTO jobs (queue, job_type, payload_json, run_at, status, priority, max_attempts)
VALUES ($1, $2, '{}'::jsonb, now(), 'queued', 0, $3)
RETURNING id
"#,
)
.bind(queue)
.bind(job_type)
.bind(max_attempts)
.fetch_one(pool)
.await
.expect("insert job failed")
}
#[tokio::test]
async fn exhausted_retries_moves_job_to_dlq_and_preserves_attempts() {
let Some(pool) = setup_db().await else {
return;
};
let jobs = JobsRepo::new(pool.clone());
let attempts = AttemptsRepo::new(pool.clone());
let runner = JobRunner::new(jobs.clone(), attempts.clone(), RetryConfig::default());
let job_id = insert_job(&pool, "default", "always_fail", 2).await;
let job = jobs
.lease_one_job("default", "worker-1", 30)
.await
.unwrap()
.expect("should lease job");
assert_eq!(job.id, job_id);
let a1 = attempts.start_attempt(job_id, "worker-1").await.unwrap();
let start = Instant::now();
runner
.on_failure(
job_id,
a1.id,
"worker-1",
start.elapsed().as_millis() as i32,
"TIMEOUT",
"sim timeout",
a1.attempt_no,
job.max_attempts,
)
.await
.unwrap();
sqlx::query("UPDATE jobs SET run_at = now() WHERE id = $1")
.bind(job_id)
.execute(&pool)
.await
.unwrap();
let job2 = jobs
.lease_one_job("default", "worker-1", 30)
.await
.unwrap()
.expect("should lease again");
assert_eq!(job2.id, job_id);
let a2 = attempts.start_attempt(job_id, "worker-1").await.unwrap();
let start2 = Instant::now();
runner
.on_failure(
job_id,
a2.id,
"worker-1",
start2.elapsed().as_millis() as i32,
"TIMEOUT",
"sim timeout",
a2.attempt_no,
job2.max_attempts,
)
.await
.unwrap();
let row = sqlx::query("SELECT status, dlq_reason_code, dlq_at FROM jobs WHERE id = $1")
.bind(job_id)
.fetch_one(&pool)
.await
.unwrap();
let status: String = row.get("status");
let dlq_reason_code: Option<String> = row.get("dlq_reason_code");
let dlq_at: Option<chrono::DateTime<chrono::Utc>> = row.get("dlq_at");
assert_eq!(status, "dlq");
assert_eq!(dlq_reason_code.as_deref(), Some("MAX_ATTEMPTS_EXCEEDED"));
assert!(dlq_at.is_some(), "dlq_at should be set");
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM job_attempts WHERE job_id = $1")
.bind(job_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count, 2, "attempt history must be preserved");
}
#[tokio::test]
async fn non_retryable_goes_to_dlq_immediately() {
let Some(pool) = setup_db().await else {
return;
};
let jobs = JobsRepo::new(pool.clone());
let attempts = AttemptsRepo::new(pool.clone());
let runner = JobRunner::new(jobs.clone(), attempts.clone(), RetryConfig::default());
let job_id = insert_job(&pool, "default", "bad_payload", 10).await;
let job = jobs
.lease_one_job("default", "worker-1", 30)
.await
.unwrap()
.expect("should lease job");
assert_eq!(job.id, job_id);
let a1 = attempts.start_attempt(job_id, "worker-1").await.unwrap();
runner
.on_failure(
job_id,
a1.id,
"worker-1",
1,
"BAD_PAYLOAD",
"invalid json",
a1.attempt_no,
job.max_attempts,
)
.await
.unwrap();
let (status, reason): (String, Option<String>) =
sqlx::query_as("SELECT status, dlq_reason_code FROM jobs WHERE id = $1")
.bind(job_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(status, "dlq");
assert_eq!(reason.as_deref(), Some("NON_RETRYABLE"));
}