#![cfg_attr(not(any(feature = "postgres", feature = "mysql")), allow(dead_code))]
use crate::job::{Job, JobId, JobStatus};
use crate::{Result, workflow::FailurePolicy};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum JobTransition {
Complete,
Fail,
Retry,
MarkDead,
MarkTimedOut,
RescheduleCron,
RetryDead,
}
impl JobTransition {
pub fn allowed_from(self) -> &'static [JobStatus] {
use JobStatus as S;
match self {
Self::Complete | Self::Fail => &[S::Pending, S::Running, S::Retrying],
Self::Retry => &[S::Running, S::Retrying, S::Failed, S::TimedOut],
Self::MarkDead => &[S::Pending, S::Running, S::Retrying, S::Failed, S::TimedOut],
Self::MarkTimedOut => &[S::Running],
Self::RescheduleCron => &[
S::Pending,
S::Running,
S::Retrying,
S::Failed,
S::Dead,
S::TimedOut,
],
Self::RetryDead => &[S::Dead, S::TimedOut],
}
}
pub fn is_allowed_from(self, status: JobStatus) -> bool {
self.allowed_from().contains(&status)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Complete => "completed",
Self::Fail => "failed",
Self::Retry => "retried",
Self::MarkDead => "marked dead",
Self::MarkTimedOut => "marked timed out",
Self::RescheduleCron => "rescheduled",
Self::RetryDead => "retried from dead",
}
}
#[cfg_attr(not(feature = "postgres"), allow(dead_code))]
pub(crate) fn sql_status_list(self) -> String {
self.allowed_from()
.iter()
.map(|status| format!("'{}'", status.as_str()))
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn rejected(self, job_id: JobId, status: JobStatus) -> crate::HammerworkError {
crate::HammerworkError::InvalidJobTransition {
job_id: job_id.to_string(),
status: status.as_str().to_string(),
transition: self.as_str().to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JobOutcome {
Completed,
Retry {
retry_at: DateTime<Utc>,
error: String,
timed_out: bool,
},
Dead {
error: String,
},
TimedOut {
error: String,
},
}
impl JobOutcome {
pub fn terminal_status(&self) -> Option<JobStatus> {
match self {
Self::Completed => Some(JobStatus::Completed),
Self::Retry { .. } => None,
Self::Dead { .. } => Some(JobStatus::Dead),
Self::TimedOut { .. } => Some(JobStatus::TimedOut),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordedOutcome {
pub status: JobStatus,
pub next_run_at: Option<DateTime<Utc>>,
pub unblocked: Vec<JobId>,
pub cancelled: Vec<JobId>,
pub spawned: Vec<JobId>,
}
impl RecordedOutcome {
pub(crate) fn new(status: JobStatus) -> Self {
Self {
status,
next_run_at: None,
unblocked: Vec::new(),
cancelled: Vec::new(),
spawned: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Target {
Completed,
Failed(String),
Dead(String),
TimedOut(String),
Retry {
retry_at: DateTime<Utc>,
error: Option<String>,
timed_out: bool,
},
}
impl Target {
pub fn terminal_status(&self) -> Option<JobStatus> {
match self {
Self::Completed => Some(JobStatus::Completed),
Self::Failed(_) => Some(JobStatus::Failed),
Self::Dead(_) => Some(JobStatus::Dead),
Self::TimedOut(_) => Some(JobStatus::TimedOut),
Self::Retry { .. } => None,
}
}
pub fn on_db_clock(&self, guard: &Guard<'_>, db_now: DateTime<Utc>) -> Self {
match (self, guard) {
(
Self::Retry {
retry_at,
error,
timed_out,
},
Guard::Run(_),
) => {
let delay = *retry_at - Utc::now();
Self::Retry {
retry_at: db_now.checked_add_signed(delay).unwrap_or(*retry_at),
error: error.clone(),
timed_out: *timed_out,
}
}
_ => self.clone(),
}
}
}
impl From<JobOutcome> for Target {
fn from(outcome: JobOutcome) -> Self {
match outcome {
JobOutcome::Completed => Self::Completed,
JobOutcome::Retry {
retry_at,
error,
timed_out,
} => Self::Retry {
retry_at,
error: Some(error),
timed_out,
},
JobOutcome::Dead { error } => Self::Dead(error),
JobOutcome::TimedOut { error } => Self::TimedOut(error),
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum Guard<'a> {
Manual(JobTransition),
Run(&'a Job),
}
impl Guard<'_> {
pub fn admits(&self, current: &Job) -> bool {
match self {
Self::Manual(transition) => transition.is_allowed_from(current.status),
Self::Run(run) => current.status == JobStatus::Running && is_same_run(current, run),
}
}
}
pub(crate) fn is_same_run(current: &Job, run: &Job) -> bool {
if current.id != run.id || current.attempts != run.attempts {
return false;
}
match (current.started_at, run.started_at) {
(Some(stored), Some(claimed)) => (stored - claimed)
.num_microseconds()
.is_some_and(|delta| delta.abs() < 1_000),
(_, None) => true,
(None, Some(_)) => false,
}
}
#[derive(Debug)]
pub(crate) enum TransitionResult {
Applied(RecordedOutcome),
Rejected(JobStatus),
NotFound,
}
impl TransitionResult {
pub fn into_manual(
self,
job_id: JobId,
transition: JobTransition,
) -> crate::Result<RecordedOutcome> {
match self {
Self::Applied(recorded) => Ok(recorded),
Self::Rejected(status) => Err(transition.rejected(job_id, status)),
Self::NotFound => Err(crate::HammerworkError::JobNotFound {
id: job_id.to_string(),
}),
}
}
pub fn into_run(self) -> Option<RecordedOutcome> {
match self {
Self::Applied(recorded) => Some(recorded),
Self::Rejected(_) | Self::NotFound => None,
}
}
}
pub(crate) fn job_status_from_db(value: &str) -> Option<JobStatus> {
Some(match value.trim_matches('"') {
"Pending" => JobStatus::Pending,
"Running" => JobStatus::Running,
"Completed" => JobStatus::Completed,
"Failed" => JobStatus::Failed,
"Dead" => JobStatus::Dead,
"TimedOut" => JobStatus::TimedOut,
"Retrying" => JobStatus::Retrying,
"Archived" => JobStatus::Archived,
_ => return None,
})
}
pub(crate) fn rejection_error(
job_id: JobId,
transition: JobTransition,
current_status: Option<&str>,
) -> crate::HammerworkError {
match current_status {
None => crate::HammerworkError::JobNotFound {
id: job_id.to_string(),
},
Some(raw) => match job_status_from_db(raw) {
Some(status) if !transition.is_allowed_from(status) => {
transition.rejected(job_id, status)
}
_ if transition == JobTransition::RescheduleCron => crate::HammerworkError::Queue {
message: format!("Job {job_id} is not a recurring job"),
},
_ => crate::HammerworkError::InvalidJobTransition {
job_id: job_id.to_string(),
status: raw.to_string(),
transition: transition.as_str().to_string(),
},
},
}
}
pub(crate) fn dependency_failed_message(failed: JobId) -> String {
format!("Dependency failed: job {failed} failed")
}
pub(crate) fn workflow_failed_message(failed: JobId) -> String {
format!("Workflow failed: job {failed} failed (fail-fast policy)")
}
pub(crate) fn batch_failed_message(failed: JobId) -> String {
format!("Batch failed: job {failed} failed (fail-fast mode)")
}
pub(crate) const BATCH_FAILED_BEFORE_START_MESSAGE: &str =
"Batch failed (fail-fast mode) before this claimed job started";
pub(crate) const UNFINISHED_STATUS_SQL: &str = "'Pending', 'Running', 'Retrying'";
pub(crate) const WORKFLOW_CANCELLED_MESSAGE: &str = "Workflow cancelled";
const CRON_CATCH_UP_SCAN_LIMIT: usize = 10_000;
pub(crate) fn next_cron_run(job: &Job, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
let schedule = job.get_cron_schedule()?.ok()?;
let slot = job
.next_run_at
.map_or(job.scheduled_at, |slot| slot.min(job.scheduled_at));
let from = job
.started_at
.filter(|started| *started > slot)
.unwrap_or(slot);
let next = schedule.next_execution(from)?;
if next > now {
return Some(next);
}
let mut latest = next;
for _ in 0..CRON_CATCH_UP_SCAN_LIMIT {
match schedule.next_execution(latest) {
Some(occurrence) if occurrence <= now => latest = occurrence,
_ => return Some(latest),
}
}
Some(now)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ParentState {
Completed,
Failed,
Unfinished,
}
impl ParentState {
pub fn from_db(status: &str, policy: Option<&str>) -> Self {
let manual = policy.is_some_and(|policy| {
crate::workflow::FailurePolicy::parse_from_db(policy)
.is_ok_and(|policy| policy == crate::workflow::FailurePolicy::Manual)
});
match job_status_from_db(status) {
Some(JobStatus::Completed) => Self::Completed,
Some(JobStatus::Failed | JobStatus::Dead | JobStatus::TimedOut) if !manual => {
Self::Failed
}
_ => Self::Unfinished,
}
}
pub fn from_archived(status: &str) -> Self {
Self::from_db(status, None)
}
}
pub(crate) fn reevaluate_dependencies(
job: &Job,
states: &std::collections::HashMap<JobId, JobStatus>,
action: &str,
) -> Result<crate::workflow::DependencyStatus> {
use crate::workflow::DependencyStatus;
if job.depends_on.is_empty() {
return Ok(DependencyStatus::None);
}
if job.dependency_status == DependencyStatus::Satisfied {
return Ok(DependencyStatus::Satisfied);
}
let mut all_completed = true;
for dependency in &job.depends_on {
let problem = match states.get(dependency) {
Some(JobStatus::Completed) => continue,
Some(status @ (JobStatus::Failed | JobStatus::Dead | JobStatus::TimedOut)) => {
format!("is {}", status.as_str())
}
Some(_) => {
all_completed = false;
continue;
}
None => "no longer exists".to_string(),
};
return Err(crate::HammerworkError::Workflow {
message: format!(
"Job {} cannot be {action}: its dependency {dependency} {problem}; retry or \
restore the dependency first",
job.id
),
});
}
Ok(if all_completed {
DependencyStatus::Satisfied
} else {
DependencyStatus::Waiting
})
}
pub(crate) fn external_dependencies(jobs: &[Job]) -> Vec<JobId> {
let new: std::collections::HashSet<JobId> = jobs.iter().map(|job| job.id).collect();
let mut parents: Vec<JobId> = jobs
.iter()
.filter(|job| job.dependency_status == crate::workflow::DependencyStatus::Waiting)
.flat_map(|job| job.depends_on.iter().copied())
.filter(|id| !new.contains(id))
.collect();
parents.sort_unstable();
parents.dedup();
parents
}
pub(crate) fn settle_new_dependents(
jobs: &mut [Job],
parents: &std::collections::HashMap<JobId, ParentState>,
now: DateTime<Utc>,
) -> Vec<JobId> {
use crate::workflow::DependencyStatus;
let index: std::collections::HashMap<JobId, usize> = jobs
.iter()
.enumerate()
.map(|(i, job)| (job.id, i))
.collect();
let mut failed = Vec::new();
loop {
let mut changed = false;
for i in 0..jobs.len() {
let job = &jobs[i];
if job.dependency_status != DependencyStatus::Waiting || job.depends_on.is_empty() {
continue;
}
let state_of = |parent: &JobId| match index.get(parent) {
Some(&j) => match jobs[j].status {
JobStatus::Completed => ParentState::Completed,
JobStatus::Failed | JobStatus::Dead | JobStatus::TimedOut => {
ParentState::Failed
}
_ => ParentState::Unfinished,
},
None => parents
.get(parent)
.copied()
.unwrap_or(ParentState::Unfinished),
};
if let Some(&failed_parent) = job
.depends_on
.iter()
.find(|parent| state_of(parent) == ParentState::Failed)
{
let job = &mut jobs[i];
job.status = JobStatus::Failed;
job.dependency_status = DependencyStatus::Failed;
job.failed_at = Some(now);
job.error_message = Some(dependency_failed_message(failed_parent));
failed.push(job.id);
changed = true;
} else if job
.depends_on
.iter()
.all(|parent| state_of(parent) == ParentState::Completed)
{
jobs[i].dependency_status = DependencyStatus::Satisfied;
}
}
if !changed {
return failed;
}
}
}
pub(crate) struct LockedWorkflow {
pub id: crate::workflow::WorkflowId,
pub policy: FailurePolicy,
pub status: crate::workflow::WorkflowStatus,
pub total_jobs: usize,
}
pub(crate) trait TransitionStore: Sync {
type Conn: Send;
fn transition_job(
&self,
job_id: JobId,
guard: Guard<'_>,
target: &Target,
reschedule_recurring: bool,
) -> impl Future<Output = Result<TransitionResult>> + Send;
fn lock_job(
conn: &mut Self::Conn,
job_id: JobId,
) -> impl Future<Output = Result<Option<Job>>> + Send;
fn db_now(conn: &mut Self::Conn) -> impl Future<Output = Result<DateTime<Utc>>> + Send;
fn write_target(
conn: &mut Self::Conn,
job_id: JobId,
target: &Target,
now: DateTime<Utc>,
) -> impl Future<Output = Result<()>> + Send;
fn can_schedule_at(_next_run_at: DateTime<Utc>) -> bool {
true
}
fn reschedule_after_run(
conn: &mut Self::Conn,
job_id: JobId,
next_run_at: DateTime<Utc>,
run_status: JobStatus,
) -> impl Future<Output = Result<()>> + Send;
fn dependency_states(
conn: &mut Self::Conn,
ids: &[JobId],
) -> impl Future<Output = Result<std::collections::HashMap<JobId, JobStatus>>> + Send;
fn write_dependency_status(
conn: &mut Self::Conn,
job_id: JobId,
status: crate::workflow::DependencyStatus,
) -> impl Future<Output = Result<()>> + Send;
fn lock_workflow(
conn: &mut Self::Conn,
workflow_id: crate::workflow::WorkflowId,
) -> impl Future<Output = Result<Option<LockedWorkflow>>> + Send;
fn resolve_dependents(
conn: &mut Self::Conn,
completed_job_id: JobId,
) -> impl Future<Output = Result<Vec<JobId>>> + Send;
fn fail_dependents(
conn: &mut Self::Conn,
failed_job_id: JobId,
now: DateTime<Utc>,
) -> impl Future<Output = Result<Vec<JobId>>> + Send;
fn fail_workflow_pending(
conn: &mut Self::Conn,
workflow_id: crate::workflow::WorkflowId,
failed_job_id: JobId,
now: DateTime<Utc>,
) -> impl Future<Output = Result<Vec<JobId>>> + Send;
fn refresh_workflow(
conn: &mut Self::Conn,
workflow: &LockedWorkflow,
now: DateTime<Utc>,
) -> impl Future<Output = Result<()>> + Send;
fn apply_batch_outcome(
conn: &mut Self::Conn,
batch_id: crate::batch::BatchId,
job_id: JobId,
status: JobStatus,
now: DateTime<Utc>,
) -> impl Future<Output = Result<Vec<JobId>>> + Send;
}
pub(crate) async fn manual_transition<S: TransitionStore>(
store: &S,
job_id: JobId,
transition: JobTransition,
target: Target,
) -> Result<()> {
store
.transition_job(job_id, Guard::Manual(transition), &target, false)
.await?
.into_manual(job_id, transition)?;
Ok(())
}
pub(crate) async fn finish_run<S: TransitionStore>(
store: &S,
run: &Job,
outcome: JobOutcome,
) -> Result<Option<RecordedOutcome>> {
let target = Target::from(outcome);
Ok(store
.transition_job(run.id, Guard::Run(run), &target, true)
.await?
.into_run())
}
pub(crate) async fn transition_in_tx<S: TransitionStore>(
conn: &mut S::Conn,
job_id: JobId,
guard: Guard<'_>,
target: &Target,
reschedule_recurring: bool,
) -> Result<TransitionResult> {
let Some(job) = S::lock_job(conn, job_id).await? else {
return Ok(TransitionResult::NotFound);
};
if !guard.admits(&job) {
return Ok(TransitionResult::Rejected(job.status));
}
if let (Guard::Manual(_), Target::Retry { .. }) = (&guard, target)
&& !job.depends_on.is_empty()
{
let states = S::dependency_states(conn, &job.depends_on).await?;
let status = reevaluate_dependencies(&job, &states, "retried")?;
if status != job.dependency_status {
S::write_dependency_status(conn, job_id, status).await?;
}
}
let now = S::db_now(conn).await?;
let target = target.on_db_clock(&guard, now);
S::write_target(conn, job_id, &target, now).await?;
let recorded = match target.terminal_status() {
Some(status) => after_terminal::<S>(conn, &job, status, now, reschedule_recurring).await?,
None => RecordedOutcome::new(JobStatus::Pending),
};
Ok(TransitionResult::Applied(recorded))
}
pub(crate) async fn after_terminal<S: TransitionStore>(
conn: &mut S::Conn,
job: &Job,
status: JobStatus,
now: DateTime<Utc>,
reschedule_recurring: bool,
) -> Result<RecordedOutcome> {
if reschedule_recurring && job.recurring {
if let Some(next_run_at) = next_cron_run(job, now).filter(|at| S::can_schedule_at(*at)) {
S::reschedule_after_run(conn, job.id, next_run_at, status).await?;
let mut recorded = RecordedOutcome::new(JobStatus::Pending);
recorded.next_run_at = Some(next_run_at);
return Ok(recorded);
}
tracing::warn!(
"Could not calculate the next run time for recurring job {}; it stays {}",
job.id,
status.as_str()
);
}
let mut recorded = RecordedOutcome::new(status);
let workflow = match job.workflow_id {
Some(id) => S::lock_workflow(conn, id).await?,
None => None,
};
if status == JobStatus::Completed {
recorded.unblocked = S::resolve_dependents(conn, job.id).await?;
} else {
recorded.cancelled =
apply_failure_policy::<S>(conn, workflow.as_ref(), job.id, now).await?;
}
if let Some(workflow) = &workflow {
S::refresh_workflow(conn, workflow, now).await?;
}
if let Some(batch_id) = job.batch_id {
let cancelled = S::apply_batch_outcome(conn, batch_id, job.id, status, now).await?;
recorded.cancelled.extend(cancelled);
}
Ok(recorded)
}
async fn apply_failure_policy<S: TransitionStore>(
conn: &mut S::Conn,
workflow: Option<&LockedWorkflow>,
failed_job_id: JobId,
now: DateTime<Utc>,
) -> Result<Vec<JobId>> {
match workflow {
Some(w) if w.policy == FailurePolicy::Manual => Ok(Vec::new()),
Some(w) if w.policy == FailurePolicy::FailFast => {
let mut failed = S::fail_workflow_pending(conn, w.id, failed_job_id, now).await?;
for id in S::fail_dependents(conn, failed_job_id, now).await? {
if !failed.contains(&id) {
failed.push(id);
}
}
Ok(failed)
}
_ => S::fail_dependents(conn, failed_job_id, now).await,
}
}
pub(crate) async fn fail_job_dependencies<S: TransitionStore>(
conn: &mut S::Conn,
failed_job_id: JobId,
workflow_id: Option<crate::workflow::WorkflowId>,
) -> Result<Vec<JobId>> {
let workflow = match workflow_id {
Some(id) => S::lock_workflow(conn, id).await?,
None => None,
};
let now = S::db_now(conn).await?;
let failed = apply_failure_policy::<S>(conn, workflow.as_ref(), failed_job_id, now).await?;
if let Some(workflow) = &workflow {
S::refresh_workflow(conn, workflow, now).await?;
}
Ok(failed)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn job_in(status: JobStatus) -> Job {
let mut job = Job::new("lifecycle".to_string(), json!({}));
job.status = status;
job
}
#[test]
fn terminal_statuses_cannot_be_resurrected_by_manual_transitions() {
for transition in [
JobTransition::Complete,
JobTransition::Fail,
JobTransition::Retry,
JobTransition::MarkDead,
JobTransition::MarkTimedOut,
] {
assert!(!transition.is_allowed_from(JobStatus::Completed));
assert!(!transition.is_allowed_from(JobStatus::Dead));
assert!(!transition.is_allowed_from(JobStatus::Archived));
}
assert!(!JobTransition::Complete.is_allowed_from(JobStatus::TimedOut));
assert!(!JobTransition::Retry.is_allowed_from(JobStatus::Dead));
assert!(JobTransition::RetryDead.is_allowed_from(JobStatus::Dead));
assert!(JobTransition::RetryDead.is_allowed_from(JobStatus::TimedOut));
assert!(JobTransition::Retry.is_allowed_from(JobStatus::Failed));
assert!(JobTransition::RescheduleCron.is_allowed_from(JobStatus::Dead));
assert!(!JobTransition::RescheduleCron.is_allowed_from(JobStatus::Completed));
assert!(JobTransition::MarkTimedOut.is_allowed_from(JobStatus::Running));
assert!(!JobTransition::MarkTimedOut.is_allowed_from(JobStatus::Pending));
}
#[test]
fn sql_status_list_matches_allowed_from() {
assert_eq!(JobTransition::MarkTimedOut.sql_status_list(), "'Running'");
assert_eq!(
JobTransition::RetryDead.sql_status_list(),
"'Dead', 'TimedOut'"
);
}
#[test]
fn rejected_transition_error_names_job_status_and_transition() {
let id = uuid::Uuid::new_v4();
let error = JobTransition::Complete.rejected(id, JobStatus::TimedOut);
let message = error.to_string();
assert!(message.contains(&id.to_string()));
assert!(message.contains("TimedOut"));
assert!(message.contains("completed"));
}
#[test]
fn run_guard_requires_running_same_attempt_and_start() {
let started = Utc::now();
let mut run = job_in(JobStatus::Running);
run.attempts = 2;
run.started_at = Some(started);
let mut current = run.clone();
current.started_at = Some(started - chrono::Duration::nanoseconds(500));
assert!(Guard::Run(&run).admits(¤t));
current.status = JobStatus::Pending;
assert!(!Guard::Run(&run).admits(¤t));
current.status = JobStatus::Running;
current.attempts = 3;
current.started_at = Some(started + chrono::Duration::seconds(30));
assert!(!Guard::Run(&run).admits(¤t));
current.attempts = 2;
assert!(!Guard::Run(&run).admits(¤t));
for status in [JobStatus::Completed, JobStatus::Dead, JobStatus::TimedOut] {
let mut finished = run.clone();
finished.status = status;
assert!(!Guard::Run(&run).admits(&finished));
}
}
#[test]
fn manual_guard_uses_allowed_from() {
let guard = Guard::Manual(JobTransition::Complete);
assert!(guard.admits(&job_in(JobStatus::Running)));
assert!(guard.admits(&job_in(JobStatus::Pending)));
assert!(!guard.admits(&job_in(JobStatus::TimedOut)));
}
fn daily_job(slot: DateTime<Utc>) -> Job {
let mut job = job_in(JobStatus::Running);
job.cron_schedule = Some("0 0 0 * * *".to_string());
job.recurring = true;
job.scheduled_at = slot;
job.next_run_at = Some(slot);
job
}
fn at(rfc3339: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(rfc3339).unwrap().into()
}
#[test]
fn next_cron_run_is_computed_from_the_slot() {
let job = daily_job(at("2026-03-10T00:00:00Z"));
assert_eq!(
next_cron_run(&job, at("2026-03-10T00:05:00Z")),
Some(at("2026-03-11T00:00:00Z"))
);
assert_eq!(
next_cron_run(&job, at("2026-03-11T00:30:00Z")),
Some(at("2026-03-11T00:00:00Z"))
);
assert_eq!(
next_cron_run(&job, at("2026-03-15T12:00:00Z")),
Some(at("2026-03-15T00:00:00Z"))
);
let mut job = daily_job(at("2026-03-10T00:00:00Z"));
job.scheduled_at = at("2026-03-10T00:03:00Z");
assert_eq!(
next_cron_run(&job, at("2026-03-10T00:05:00Z")),
Some(at("2026-03-11T00:00:00Z"))
);
let mut job = daily_job(at("2026-03-11T00:00:00Z"));
job.scheduled_at = at("2026-03-10T12:00:00Z");
assert_eq!(
next_cron_run(&job, at("2026-03-10T12:05:00Z")),
Some(at("2026-03-11T00:00:00Z"))
);
let mut job = daily_job(at("2026-03-10T00:00:00Z"));
job.next_run_at = None;
assert_eq!(
next_cron_run(&job, at("2026-03-10T00:05:00Z")),
Some(at("2026-03-11T00:00:00Z"))
);
let mut job = daily_job(at("2026-03-10T00:00:00Z"));
job.cron_schedule = None;
assert_eq!(next_cron_run(&job, at("2026-03-10T00:05:00Z")), None);
}
#[test]
fn a_late_run_is_the_catch_up_run() {
let mut job = daily_job(at("2026-03-10T00:00:00Z"));
job.started_at = Some(at("2026-03-15T12:00:00Z"));
assert_eq!(
next_cron_run(&job, at("2026-03-15T12:00:01Z")),
Some(at("2026-03-16T00:00:00Z"))
);
let mut job = daily_job(at("2026-03-10T00:00:00Z"));
job.cron_schedule = Some("*/5 * * * * *".to_string());
job.started_at = Some(at("2026-03-10T00:00:30.950Z"));
assert_eq!(
next_cron_run(&job, at("2026-03-10T00:00:30.960Z")),
Some(at("2026-03-10T00:00:35Z"))
);
let mut job = daily_job(at("2026-03-10T00:00:00Z"));
job.started_at = Some(at("2026-03-10T00:00:01Z"));
assert_eq!(
next_cron_run(&job, at("2026-03-11T00:30:00Z")),
Some(at("2026-03-11T00:00:00Z"))
);
let mut job = daily_job(at("2026-03-10T00:00:00Z"));
job.started_at = Some(at("2026-03-10T00:00:00.040Z"));
assert_eq!(
next_cron_run(&job, at("2026-03-10T00:05:00Z")),
Some(at("2026-03-11T00:00:00Z"))
);
}
#[test]
fn next_cron_run_bounds_the_catch_up_scan() {
let mut job = daily_job(at("2026-03-10T00:00:00Z"));
job.cron_schedule = Some("* * * * * *".to_string());
let now = at("2026-03-20T00:00:00Z");
assert_eq!(next_cron_run(&job, now), Some(now));
}
#[test]
fn parent_state_follows_status_and_policy() {
assert_eq!(
ParentState::from_db("Completed", None),
ParentState::Completed
);
assert_eq!(ParentState::from_db("Dead", None), ParentState::Failed);
assert_eq!(
ParentState::from_db("TimedOut", Some("fail_fast")),
ParentState::Failed
);
assert_eq!(
ParentState::from_db("Failed", Some("manual")),
ParentState::Unfinished
);
assert_eq!(
ParentState::from_db("Running", None),
ParentState::Unfinished
);
assert_eq!(
ParentState::from_archived("\"Completed\""),
ParentState::Completed
);
}
#[test]
fn retried_jobs_reevaluate_their_dependencies() {
use crate::workflow::DependencyStatus;
use std::collections::HashMap;
let (a, b) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4());
let mut job = job_in(JobStatus::Failed);
assert_eq!(
reevaluate_dependencies(&job, &HashMap::new(), "retried").unwrap(),
DependencyStatus::None
);
job.depends_on = vec![a, b];
job.dependency_status = DependencyStatus::Failed;
let states = |sa, sb| HashMap::from([(a, sa), (b, sb)]);
assert_eq!(
reevaluate_dependencies(
&job,
&states(JobStatus::Completed, JobStatus::Completed),
"retried"
)
.unwrap(),
DependencyStatus::Satisfied
);
for unfinished in [JobStatus::Pending, JobStatus::Running, JobStatus::Retrying] {
assert_eq!(
reevaluate_dependencies(&job, &states(JobStatus::Completed, unfinished), "x")
.unwrap(),
DependencyStatus::Waiting
);
}
for failed in [JobStatus::Failed, JobStatus::Dead, JobStatus::TimedOut] {
let error =
reevaluate_dependencies(&job, &states(JobStatus::Pending, failed), "restored")
.unwrap_err()
.to_string();
assert!(error.contains("cannot be restored"), "{error}");
assert!(error.contains(&b.to_string()), "{error}");
}
let missing = HashMap::from([(a, JobStatus::Completed)]);
let error = reevaluate_dependencies(&job, &missing, "retried")
.unwrap_err()
.to_string();
assert!(error.contains("no longer exists"), "{error}");
job.dependency_status = DependencyStatus::Satisfied;
assert_eq!(
reevaluate_dependencies(&job, &HashMap::new(), "retried").unwrap(),
DependencyStatus::Satisfied
);
}
#[test]
fn new_dependents_are_settled_from_their_dependencies() {
use crate::workflow::DependencyStatus;
use std::collections::HashMap;
let (done, failed, running) = (
uuid::Uuid::new_v4(),
uuid::Uuid::new_v4(),
uuid::Uuid::new_v4(),
);
let parents = HashMap::from([
(done, ParentState::Completed),
(failed, ParentState::Failed),
(running, ParentState::Unfinished),
]);
let new = |deps: Vec<JobId>| Job::new("q".to_string(), json!({})).depends_on_jobs(&deps);
let satisfied = new(vec![done]);
let waiting = new(vec![done, running]);
let doomed = new(vec![done, failed]);
let downstream = new(vec![doomed.id]);
let mut jobs = vec![downstream, satisfied, waiting, doomed];
let now = Utc::now();
let mut external = vec![done, failed, running];
external.sort_unstable();
assert_eq!(external_dependencies(&jobs), external);
let failed_ids = settle_new_dependents(&mut jobs, &parents, now);
assert_eq!(jobs[1].dependency_status, DependencyStatus::Satisfied);
assert_eq!(jobs[1].status, JobStatus::Pending);
assert_eq!(jobs[2].dependency_status, DependencyStatus::Waiting);
for job in [&jobs[3], &jobs[0]] {
assert_eq!(job.status, JobStatus::Failed);
assert_eq!(job.dependency_status, DependencyStatus::Failed);
assert_eq!(job.failed_at, Some(now));
}
assert_eq!(
jobs[3].error_message.as_deref(),
Some(dependency_failed_message(failed).as_str())
);
assert_eq!(failed_ids.len(), 2);
}
#[test]
fn outcome_maps_to_target() {
let at = Utc::now();
assert_eq!(Target::from(JobOutcome::Completed), Target::Completed);
assert_eq!(
Target::from(JobOutcome::Retry {
retry_at: at,
error: "e".into(),
timed_out: true
}),
Target::Retry {
retry_at: at,
error: Some("e".into()),
timed_out: true
}
);
assert_eq!(
JobOutcome::TimedOut { error: "t".into() }.terminal_status(),
Some(JobStatus::TimedOut)
);
assert_eq!(
Target::Dead("d".into()).terminal_status(),
Some(JobStatus::Dead)
);
}
}