use std::time::Duration;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use super::config::RetryPolicy;
use super::dialect::{JobDb, JobPool, sql, stored_time};
use super::error::{JobError, WorkerError, truncate_for_storage};
pub trait Fenced<'e>: sqlx::Executor<'e, Database = JobDb> {}
impl<'e, E: sqlx::Executor<'e, Database = JobDb>> Fenced<'e> for E {}
#[derive(Debug, Clone)]
pub(crate) struct JobState {
pub attempts: i32,
pub max_attempts: i32,
}
#[derive(Debug)]
pub(crate) enum DispatchOutcome {
Succeeded,
Malformed,
Timeout,
HandlerError(JobError),
}
#[derive(Debug, Clone)]
pub(crate) enum Outcome {
Succeeded,
Retry {
available_at: DateTime<Utc>,
},
Dead {
error_kind: ErrorKind,
message: String,
},
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ErrorKind {
Permanent,
Exhausted,
Malformed,
Unknown,
Panic,
Timeout,
}
impl ErrorKind {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Permanent => "permanent",
Self::Exhausted => "exhausted",
Self::Malformed => "malformed",
Self::Unknown => "unknown",
Self::Panic => "panic",
Self::Timeout => "timeout",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ClaimTransition {
Updated,
Lost,
}
impl ClaimTransition {
fn from_affected(rows: u64) -> Self {
if rows > 0 { Self::Updated } else { Self::Lost }
}
}
pub(crate) fn decide(
state: &JobState,
dispatch: &DispatchOutcome,
policy: &RetryPolicy,
now: DateTime<Utc>,
) -> Outcome {
match dispatch {
DispatchOutcome::Succeeded => Outcome::Succeeded,
DispatchOutcome::Malformed => Outcome::Dead {
error_kind: ErrorKind::Malformed,
message: "payload did not deserialize to the registered schema".to_string(),
},
DispatchOutcome::Timeout => {
if state.attempts >= state.max_attempts {
Outcome::Dead {
error_kind: ErrorKind::Timeout,
message: format!(
"job exceeded its per-attempt timeout after {} attempt(s)",
state.attempts
),
}
} else {
Outcome::Retry {
available_at: now
+ chrono::Duration::from_std(policy.delay_for(state.attempts as u32))
.unwrap_or_default(),
}
}
}
DispatchOutcome::HandlerError(err) => {
let message = err.stored_message();
if err.is_permanent() {
Outcome::Dead {
error_kind: ErrorKind::Permanent,
message,
}
} else if state.attempts >= state.max_attempts {
Outcome::Dead {
error_kind: ErrorKind::Exhausted,
message,
}
} else {
Outcome::Retry {
available_at: now
+ chrono::Duration::from_std(policy.delay_for(state.attempts as u32))
.unwrap_or_default(),
}
}
}
}
}
pub(crate) async fn mark_succeeded(
executor: impl for<'e> Fenced<'e>,
job_id: Uuid,
claim_token: Uuid,
) -> Result<ClaimTransition, WorkerError> {
let rows = sqlx::query(sql::MARK_SUCCEEDED)
.bind(job_id)
.bind(claim_token)
.execute(executor)
.await?
.rows_affected();
Ok(ClaimTransition::from_affected(rows))
}
pub(crate) async fn mark_retry(
executor: impl for<'e> Fenced<'e>,
job_id: Uuid,
claim_token: Uuid,
available_at: DateTime<Utc>,
message: String,
) -> Result<ClaimTransition, WorkerError> {
let message = truncate_for_storage(&message);
let rows = sqlx::query(sql::MARK_RETRY)
.bind(stored_time(available_at))
.bind(message)
.bind(job_id)
.bind(claim_token)
.execute(executor)
.await?
.rows_affected();
Ok(ClaimTransition::from_affected(rows))
}
pub(crate) async fn mark_dead(
executor: impl for<'e> Fenced<'e>,
job_id: Uuid,
claim_token: Uuid,
error_kind: ErrorKind,
message: String,
) -> Result<ClaimTransition, WorkerError> {
let message = truncate_for_storage(&message);
let rows = sqlx::query(sql::MARK_DEAD)
.bind(message)
.bind(error_kind.as_str())
.bind(job_id)
.bind(claim_token)
.execute(executor)
.await?
.rows_affected();
Ok(ClaimTransition::from_affected(rows))
}
pub(crate) async fn heartbeat(
executor: impl for<'e> Fenced<'e>,
job_id: Uuid,
claim_token: Uuid,
lease: Duration,
) -> Result<bool, WorkerError> {
let lease_seconds = lease.as_secs().min(i32::MAX as u64) as i32;
let rows = sqlx::query(sql::HEARTBEAT)
.bind(lease_seconds)
.bind(job_id)
.bind(claim_token)
.execute(executor)
.await?
.rows_affected();
Ok(rows > 0)
}
pub async fn sweep_expired_leases(pool: &JobPool, batch: i64) -> Result<u64, WorkerError> {
let dead = sqlx::query(sql::SWEEP_DEAD)
.bind(batch)
.execute(pool)
.await?
.rows_affected();
let requeued = sqlx::query(sql::SWEEP_REQUEUE)
.bind(batch)
.execute(pool)
.await?
.rows_affected();
Ok(dead + requeued)
}
pub async fn cancel(executor: impl for<'e> Fenced<'e>, job_id: Uuid) -> Result<u64, WorkerError> {
let rows = sqlx::query(sql::CANCEL)
.bind(job_id)
.execute(executor)
.await?
.rows_affected();
Ok(rows)
}
pub async fn requeue_dead(
executor: impl for<'e> Fenced<'e>,
job_id: Uuid,
) -> Result<u64, WorkerError> {
let rows = sqlx::query(sql::REQUEUE_DEAD)
.bind(job_id)
.execute(executor)
.await?
.rows_affected();
Ok(rows)
}
#[cfg(all(test, feature = "test-kit"))]
mod tests {
use std::time::Duration;
use super::*;
use crate::jobs::test_support::{enqueue, queue, row};
const SHORT_LEASE: Duration = Duration::from_secs(1);
const PAST_THE_LEASE: Duration = Duration::from_millis(1_600);
async fn zombie_and_owner(
pool: &JobPool,
) -> (
/* job */ Uuid,
/* zombie */ Uuid,
/* owner */ Uuid,
) {
let enqueued = enqueue(pool, 1).await;
let job = enqueued[0];
let first = crate::jobs::admin::claim_jobs(pool, "zombie", SHORT_LEASE, 1)
.await
.expect("the first claim");
assert_eq!(first.len(), 1, "the only pending job was not claimed");
let zombie = first[0].claim_token;
tokio::time::sleep(PAST_THE_LEASE).await;
let swept = sweep_expired_leases(pool, 10)
.await
.expect("sweep expired leases");
assert_eq!(swept, 1, "the expired lease was not swept");
let second = crate::jobs::admin::claim_jobs(pool, "owner", Duration::from_secs(60), 1)
.await
.expect("the second claim");
assert_eq!(second.len(), 1, "the requeued job was not claimable again");
let owner = second[0].claim_token;
assert_ne!(zombie, owner, "the reclaim reused the token it fences on");
(job, zombie, owner)
}
#[tokio::test]
async fn a_zombie_worker_cannot_complete_a_job_it_no_longer_owns() {
let Some(fixture) = queue().await else {
return;
};
let pool = fixture.pool();
let (job, zombie, owner) = zombie_and_owner(pool).await;
let refused = mark_succeeded(pool, job, zombie)
.await
.expect("run the fenced update");
assert_eq!(
refused,
ClaimTransition::Lost,
"a stale worker completed a job it had lost"
);
let (status, attempts, token) = row(pool, job).await;
assert_eq!(status, "running", "the row left the owner's hands");
assert_eq!(attempts, 2, "the reclaim did not count as an attempt");
assert_eq!(
token,
Some(owner),
"the row is no longer fenced to the owner"
);
let accepted = mark_succeeded(pool, job, owner)
.await
.expect("run the fenced update");
assert_eq!(
accepted,
ClaimTransition::Updated,
"the current owner was fenced out of its own job"
);
let (status, _, token) = row(pool, job).await;
assert_eq!(status, "succeeded");
assert_eq!(token, None, "a finished job kept its claim token");
}
#[tokio::test]
async fn a_zombie_worker_cannot_retry_kill_or_heartbeat_a_lost_job() {
let Some(fixture) = queue().await else {
return;
};
let pool = fixture.pool();
let (job, zombie, owner) = zombie_and_owner(pool).await;
let retried = mark_retry(pool, job, zombie, Utc::now(), "stale retry".to_owned())
.await
.expect("run the fenced update");
assert_eq!(
retried,
ClaimTransition::Lost,
"a stale worker requeued a job"
);
let killed = mark_dead(
pool,
job,
zombie,
ErrorKind::Unknown,
"stale death".to_owned(),
)
.await
.expect("run the fenced update");
assert_eq!(killed, ClaimTransition::Lost, "a stale worker killed a job");
let refreshed = heartbeat(pool, job, zombie, Duration::from_secs(600))
.await
.expect("run the fenced update");
assert!(
!refreshed,
"a stale worker extended a lease it did not hold"
);
let (status, attempts, token) = row(pool, job).await;
assert_eq!(status, "running");
assert_eq!(attempts, 2);
assert_eq!(token, Some(owner));
let refreshed = heartbeat(pool, job, owner, Duration::from_secs(600))
.await
.expect("run the fenced update");
assert!(refreshed, "the owner could not refresh its own lease");
}
}