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)
}