cloudiful-scheduler 0.4.1

Single-job async scheduling library for background work with optional Valkey-backed state.
Documentation
use crate::model::RunSkipReason;
use chrono::{DateTime, Utc};
#[cfg(feature = "valkey-store")]
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunStatus {
    Success,
    Failed,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunRecord {
    pub scheduled_at: DateTime<Utc>,
    pub started_at: DateTime<Utc>,
    pub finished_at: DateTime<Utc>,
    pub catch_up: bool,
    pub status: RunStatus,
    pub error: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "valkey-store", derive(Serialize, Deserialize))]
pub struct JobState {
    pub job_id: String,
    pub trigger_count: u32,
    pub last_run_at: Option<DateTime<Utc>>,
    pub last_success_at: Option<DateTime<Utc>>,
    pub next_run_at: Option<DateTime<Utc>>,
    pub last_error: Option<String>,
}

impl JobState {
    pub fn new(job_id: impl Into<String>, next_run_at: Option<DateTime<Utc>>) -> Self {
        Self {
            job_id: job_id.into(),
            trigger_count: 0,
            last_run_at: None,
            last_success_at: None,
            next_run_at,
            last_error: None,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchedulerReport {
    pub job_id: String,
    pub state: JobState,
    pub history: Vec<RunRecord>,
    pub last_skip_reason: Option<RunSkipReason>,
}

pub(crate) fn push_history(
    history: &mut VecDeque<RunRecord>,
    record: RunRecord,
    history_limit: usize,
) {
    if history_limit == 0 {
        return;
    }

    history.push_back(record);
    while history.len() > history_limit {
        history.pop_front();
    }
}

#[cfg(test)]
mod tests {
    use super::{RunRecord, RunStatus, push_history};
    use chrono::Utc;
    use std::collections::VecDeque;

    fn record(second: i64) -> RunRecord {
        let instant = Utc::now() + chrono::TimeDelta::seconds(second);
        RunRecord {
            scheduled_at: instant,
            started_at: instant,
            finished_at: instant,
            catch_up: false,
            status: RunStatus::Success,
            error: None,
        }
    }

    #[test]
    fn push_history_keeps_latest_records_within_limit() {
        let base = Utc::now();
        let mut history = VecDeque::new();
        push_history(&mut history, record_at(base, 1), 2);
        push_history(&mut history, record_at(base, 2), 2);
        push_history(&mut history, record_at(base, 3), 2);

        assert_eq!(history.len(), 2);
        assert_eq!(
            history.front().unwrap().scheduled_at,
            record_at(base, 2).scheduled_at
        );
        assert_eq!(
            history.back().unwrap().scheduled_at,
            record_at(base, 3).scheduled_at
        );
    }

    #[test]
    fn push_history_ignores_records_when_limit_is_zero() {
        let mut history = VecDeque::new();
        push_history(&mut history, record(1), 0);

        assert!(history.is_empty());
    }

    fn record_at(base: chrono::DateTime<Utc>, second: i64) -> RunRecord {
        let instant = base + chrono::TimeDelta::seconds(second);
        RunRecord {
            scheduled_at: instant,
            started_at: instant,
            finished_at: instant,
            catch_up: false,
            status: RunStatus::Success,
            error: None,
        }
    }
}