cloudiful-scheduler 0.4.12

Single-job async scheduling library for background work with optional Valkey-backed state.
Documentation
use chrono::{NaiveTime, TimeDelta, Utc, Weekday};
use chrono_tz::Asia::Shanghai;
use scheduler::{
    InMemoryStateStore, Job, JobTimeWindow, Schedule, Scheduler, SchedulerConfig, Task,
    TimeWindowSegment, WindowedIntervalSchedule,
};
use std::sync::{
    Arc,
    atomic::{AtomicUsize, Ordering},
};
use std::time::Duration;

fn all_day_window() -> JobTimeWindow {
    JobTimeWindow {
        timezone: Some(Shanghai),
        weekdays: Vec::new(),
        segments: vec![TimeWindowSegment::new(
            NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
            NaiveTime::from_hms_opt(23, 59, 59).unwrap(),
        )],
    }
}

fn upcoming_window() -> JobTimeWindow {
    let start = (Utc::now() + TimeDelta::seconds(2))
        .with_timezone(&Shanghai)
        .time();
    let end = (Utc::now() + TimeDelta::seconds(4))
        .with_timezone(&Shanghai)
        .time();

    JobTimeWindow {
        timezone: Some(Shanghai),
        weekdays: Vec::new(),
        segments: vec![TimeWindowSegment::new(start, end)],
    }
}

#[tokio::test]
async fn windowed_interval_runs_through_scheduler_public_api() {
    let scheduler = Scheduler::new(SchedulerConfig::default(), InMemoryStateStore::new());
    let invocations = Arc::new(AtomicUsize::new(0));
    let seen = invocations.clone();
    let schedule = WindowedIntervalSchedule::new(Some(Duration::from_secs(60)))
        .with_window(all_day_window(), Some(Duration::from_millis(20)));

    let report = scheduler
        .run(
            Job::without_deps(
                "windowed-fast",
                Schedule::WindowedInterval(schedule),
                Task::from_async(move |_| {
                    let seen = seen.clone();
                    async move {
                        seen.fetch_add(1, Ordering::SeqCst);
                        Ok(())
                    }
                }),
            )
            .with_max_runs(2),
        )
        .await
        .unwrap();

    assert_eq!(invocations.load(Ordering::SeqCst), 2);
    assert_eq!(report.history.len(), 2);
    assert!(report.last_skip_reason.is_none());
    assert!(report.state.next_run_at.is_none());
}

#[tokio::test]
async fn windowed_interval_all_disabled_exits_without_running() {
    let scheduler = Scheduler::new(SchedulerConfig::default(), InMemoryStateStore::new());
    let invocations = Arc::new(AtomicUsize::new(0));
    let seen = invocations.clone();
    let schedule = WindowedIntervalSchedule::new(None).with_window(all_day_window(), None);

    let report = scheduler
        .run(Job::without_deps(
            "windowed-disabled",
            Schedule::WindowedInterval(schedule),
            Task::from_async(move |_| {
                let seen = seen.clone();
                async move {
                    seen.fetch_add(1, Ordering::SeqCst);
                    Ok(())
                }
            }),
        ))
        .await
        .unwrap();

    assert_eq!(invocations.load(Ordering::SeqCst), 0);
    assert!(report.history.is_empty());
    assert!(report.state.next_run_at.is_none());
}

#[test]
fn market_window_example_uses_public_types() {
    let market_open = JobTimeWindow {
        timezone: Some(Shanghai),
        weekdays: vec![
            Weekday::Mon,
            Weekday::Tue,
            Weekday::Wed,
            Weekday::Thu,
            Weekday::Fri,
        ],
        segments: vec![
            TimeWindowSegment::new(
                NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
                NaiveTime::from_hms_opt(11, 30, 0).unwrap(),
            ),
            TimeWindowSegment::new(
                NaiveTime::from_hms_opt(13, 0, 0).unwrap(),
                NaiveTime::from_hms_opt(15, 0, 0).unwrap(),
            ),
        ],
    };

    let schedule = WindowedIntervalSchedule::new(Some(Duration::from_secs(30 * 60)))
        .with_window(market_open, Some(Duration::from_secs(30)));

    assert!(matches!(
        Schedule::WindowedInterval(schedule),
        Schedule::WindowedInterval(_)
    ));
}

#[tokio::test]
async fn grouped_interval_can_align_into_time_window() {
    let scheduler = Scheduler::new(SchedulerConfig::default(), InMemoryStateStore::new());
    let invocations = Arc::new(AtomicUsize::new(0));
    let seen = invocations.clone();

    let report = tokio::time::timeout(
        Duration::from_secs(5),
        scheduler.run(
            Job::without_deps(
                "grouped-window-align",
                Schedule::grouped_interval(Duration::from_millis(40), 2, 1),
                Task::from_async(move |_| {
                    let seen = seen.clone();
                    async move {
                        seen.fetch_add(1, Ordering::SeqCst);
                        Ok(())
                    }
                }),
            )
            .with_time_window(upcoming_window())
            .with_time_window_alignment()
            .with_max_runs(1),
        ),
    )
    .await
    .expect("scheduler run timed out")
    .unwrap();

    assert_eq!(invocations.load(Ordering::SeqCst), 1);
    assert_eq!(report.history.len(), 1);
    assert!(report.last_skip_reason.is_none());
}