diff --git a/Cargo.toml b/Cargo.toml index ab7a321..b687b62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "skedge" description = "Ergonomic single-process job scheduling for Rust programs." -version = "0.0.5" +version = "0.0.6" edition = "2018" authors = ["Ben Lovy "] documentation = "https://docs.rs/skedge" homepage = "https://crates.io/crates/skedge" include = ["**/*.rs", "Cargo.toml"] -keywords = ["utilities", "scheduling"] +keywords = ["utility", "scheduling"] license = "BSD-3-Clause" readme = "README.md" repository = "https://github.com/deciduously/skedge" @@ -24,7 +24,6 @@ thiserror = "1" [dev-dependencies] -mockall = "0.10" pretty_assertions = "0.7" [profile] diff --git a/README.md b/README.md index 420e443..4caa529 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Crates.io](https://img.shields.io/crates/v/skedge.svg)](https://crates.io/crates/skedge) [![rust action](https://github.com/deciduously/skedge/actions/workflows/rust.yml/badge.svg)](https://github.com/deciduously/skedge/actions/workflows/rust.yml) +[![docs.rs](https://img.shields.io/docsrs/skedge)](https://docs.rs/skedge/) Rust single-process scheduling. Ported from [`schedule`](https://github.com/dbader/schedule) for Python, in turn inspired by [`clockwork`](https://github.com/Rykian/clockwork) (Ruby), and ["Rethinking Cron"](https://adam.herokuapp.com/past/2010/4/13/rethinking_cron/) by [Adam Wiggins](https://github.com/adamwiggins). diff --git a/src/job.rs b/src/job.rs index b489493..39e176e 100644 --- a/src/job.rs +++ b/src/job.rs @@ -12,7 +12,15 @@ use std::{ fmt, }; -use crate::{error::*, Callable, Interval, Scheduler, Tag, TimeUnit, Timestamp, UnitToUnit}; +use crate::{ + error::*, time::RealTime, Callable, Scheduler, TimeUnit, Timekeeper, Timestamp, UnitToUnit, +}; + +/// A Tag is used to categorize a job. +pub type Tag = String; + +/// Each interval value is an unsigned 32-bit integer +pub type Interval = u32; lazy_static! { // Regexes for validating `.at()` strings are only computed once @@ -64,6 +72,8 @@ pub struct Job { start_day: Option, /// Optional time of final run cancel_after: Option, + /// Interface to current time + clock: Option>, } impl Job { @@ -80,9 +90,41 @@ impl Job { period: None, start_day: None, cancel_after: None, + clock: Some(Box::new(RealTime::default())), + } + } + + #[cfg(test)] + /// Build a job with a fake timer + pub fn with_mock_time(interval: Interval, clock: crate::time::mock::MockTime) -> Self { + Self { + interval, + latest: None, + job: None, + tags: HashSet::new(), + unit: None, + at_time: None, + last_run: None, + next_run: None, + period: None, + start_day: None, + cancel_after: None, + clock: Some(Box::new(clock)), } } + #[cfg(test)] + /// Add a duration to the clock + pub fn add_duration(&mut self, duration: Duration) { + self.clock.as_mut().unwrap().add_duration(duration); + } + + /// Helper function to get the current time + fn now(&self) -> Timestamp { + // unwrap is safe, there will always be one + self.clock.as_ref().unwrap().now() + } + /// Tag the job with one or more unique identifiers pub fn tag(&mut self, tags: &[&str]) { for &t in tags { @@ -187,7 +229,7 @@ impl Job { /// /// pub fn until(mut self, until_time: Timestamp) -> Result { - if until_time < Local::now() { + if until_time < self.now() { return Err(SkedgeError::InvalidUntilTime); } self.cancel_after = Some(until_time); @@ -205,7 +247,7 @@ impl Job { /// Check whether this job should be run now pub fn should_run(&self) -> bool { - self.next_run.is_some() && Local::now() >= self.next_run.unwrap() + self.next_run.is_some() && self.now() >= self.next_run.unwrap() } /// Run this job and immediately reschedule it, returning true. If job should cancel, return false. @@ -215,7 +257,7 @@ impl Job { /// If this execution causes the deadline to reach, it will run once and then return false. // FIXME: if we support return values from job fns, this fn should return that. pub fn execute(&mut self) -> Result { - if self.is_overdue(Local::now()) { + if self.is_overdue(self.now()) { debug!("Deadline already reached, cancelling job {}", self); return Ok(false); } @@ -227,10 +269,10 @@ impl Job { } // FIXME - here's the return value capture let _ = self.job.as_ref().unwrap().call(); - self.last_run = Some(Local::now()); + self.last_run = Some(self.now()); self.schedule_next_run()?; - if self.is_overdue(Local::now()) { + if self.is_overdue(self.now()) { debug!("Execution went over deadline, cancelling job {}", self); return Ok(false); } @@ -390,7 +432,7 @@ impl Job { // Calculate period (Duration) let period = self.unit.unwrap().duration(interval); self.period = Some(period); - self.next_run = Some(Local::now() + period); + self.next_run = Some(self.now() + period); // Handle start day for weekly jobs if let Some(w) = self.start_day { @@ -452,7 +494,7 @@ impl Job { if self.last_run.is_none() || (self.next_run.unwrap() - self.last_run.unwrap()) > self.period.unwrap() { - let now = Local::now(); + let now = self.now(); if self.unit == Some(Day) && self.at_time.unwrap() > now.time() && self.interval == 1 @@ -475,7 +517,7 @@ impl Job { if self.start_day.is_some() && self.at_time.is_some() { // unwraps are safe, we already set them in this function let next = self.next_run.unwrap(); // safe, we already set it - if (next - Local::now()).num_days() >= 7 { + if (next - self.now()).num_days() >= 7 { self.next_run = Some(next - self.period.unwrap()); } } diff --git a/src/lib.rs b/src/lib.rs index 5171eec..d917127 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,68 +48,13 @@ //! } //! ``` -use chrono::{prelude::*, Duration}; -use std::fmt; - mod callable; mod error; mod job; mod scheduler; +mod time; use callable::{Callable, UnitToUnit}; -pub use job::{every, every_single, Job}; +pub use job::{every, every_single, Interval, Job, Tag}; pub use scheduler::Scheduler; - -/// Each interval value is an unsigned 32-bit integer -type Interval = u32; - -/// A Tag is used to categorize a job. -type Tag = String; - -/// Timestamps are in the users local timezone -type Timestamp = DateTime; - -/// Jobs can be periodic over one of these units of time -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TimeUnit { - Second, - Minute, - Hour, - Day, - Week, - Month, - Year, -} - -impl TimeUnit { - /// Get a chrono::Duration from an interval based on time unit - fn duration(&self, interval: u32) -> Duration { - use TimeUnit::*; - let interval = interval as i64; - match self { - Second => Duration::seconds(interval), - Minute => Duration::minutes(interval), - Hour => Duration::hours(interval), - Day => Duration::days(interval), - Week => Duration::weeks(interval), - Month => Duration::weeks(interval * 4), - Year => Duration::weeks(interval * 52), - } - } -} - -impl fmt::Display for TimeUnit { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - use TimeUnit::*; - let s = match self { - Second => "second", - Minute => "minute", - Hour => "hour", - Day => "day", - Week => "week", - Month => "month", - Year => "year", - }; - write!(f, "{}", s) - } -} +use time::{TimeUnit, Timekeeper, Timestamp}; diff --git a/src/scheduler.rs b/src/scheduler.rs index 2ed7a96..59d1967 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -1,7 +1,6 @@ //! The scheduler is responsible for managing all scheduled jobs. -use crate::{error::*, job::Job, Tag, Timestamp}; -use chrono::Local; +use crate::{error::*, Job, Tag, Timekeeper, Timestamp}; use log::*; /// A Scheduler creates jobs, tracks recorded jobs, and executes jobs. @@ -9,6 +8,8 @@ use log::*; pub struct Scheduler { /// The currently scheduled lob list jobs: Vec, + /// Interface to current time + clock: Option>, } impl Scheduler { @@ -18,6 +19,31 @@ impl Scheduler { Self::default() } + /// Instantiate with mocked time + #[cfg(test)] + fn with_mock_time(clock: crate::time::mock::MockTime) -> Self { + let mut ret = Self::new(); + ret.clock = Some(Box::new(clock)); + ret + } + + /// Advance all clocks by a certain duration + #[cfg(test)] + fn bump_times(&mut self, duration: chrono::Duration) -> Result<()> { + self.clock.as_mut().unwrap().add_duration(duration); + for job in &mut self.jobs { + job.add_duration(duration); + } + self.run_pending()?; + Ok(()) + } + + /// Helper function to get the current time + fn now(&self) -> Timestamp { + // unwrap is safe, there will always be one + self.clock.as_ref().unwrap().now() + } + /// Add a new job to the list pub fn add_job(&mut self, job: Job) { self.jobs.push(job); @@ -97,6 +123,74 @@ impl Scheduler { /// Number of seconds until next run. None if no jobs scheduled pub fn idle_seconds(&self) -> Option { - Some((self.next_run()? - Local::now()).num_seconds()) + Some((self.next_run()? - self.now()).num_seconds()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + error::Result, + time::mock::{MockTime, START}, + Interval, + }; + use chrono::Duration; + use pretty_assertions::assert_eq; + + /// Overshadow scheduler, every() and every_single() to use our clock instead + fn setup() -> (Scheduler, impl Fn(Interval) -> Job, impl Fn() -> Job) { + let clock = MockTime::default(); + let scheduler = Scheduler::with_mock_time(clock); + + let every = move |interval: Interval| -> Job { Job::with_mock_time(interval, clock) }; + + let every_single = move || -> Job { Job::with_mock_time(1, clock) }; + + (scheduler, every, every_single) + } + + /// Empty mock job + fn job() {} + + #[test] + fn test_two_jobs() -> Result<()> { + let (mut scheduler, every, every_single) = setup(); + + assert_eq!(scheduler.idle_seconds(), None); + + every(17).seconds()?.run(&mut scheduler, job)?; + assert_eq!(scheduler.idle_seconds(), Some(17)); + + every_single().minute()?.run(&mut scheduler, job)?; + assert_eq!(scheduler.idle_seconds(), Some(17)); + assert_eq!(scheduler.next_run(), Some(*START + Duration::seconds(17))); + + scheduler.bump_times(Duration::seconds(17))?; + assert_eq!( + scheduler.next_run(), + Some(*START + Duration::seconds(17 * 2)) + ); + + scheduler.bump_times(Duration::seconds(17))?; + assert_eq!( + scheduler.next_run(), + Some(*START + Duration::seconds(17 * 3)) + ); + + // This time, we should hit the minute mark next, not the next 17 second mark + scheduler.bump_times(Duration::seconds(17))?; + assert_eq!(scheduler.idle_seconds(), Some(9)); + assert_eq!(scheduler.next_run(), Some(*START + Duration::minutes(1))); + + // Afterwards, back to the 17 second job + scheduler.bump_times(Duration::seconds(9))?; + assert_eq!(scheduler.idle_seconds(), Some(8)); + assert_eq!( + scheduler.next_run(), + Some(*START + Duration::seconds(17 * 4)) + ); + + Ok(()) } } diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..c5a391d --- /dev/null +++ b/src/time.rs @@ -0,0 +1,120 @@ +//! For mocking purposes, access to the current time is controlled directed through this struct. + +use chrono::{prelude::*, Duration}; +use std::fmt; + +/// Timestamps are in the users local timezone +pub type Timestamp = DateTime; + +pub(crate) trait Timekeeper: std::fmt::Debug { + /// Return the current time + fn now(&self) -> Timestamp; + /// Add a specific duration for testing purposes + #[cfg(test)] + fn add_duration(&mut self, duration: Duration); +} + +impl PartialEq for dyn Timekeeper { + fn eq(&self, other: &Self) -> bool { + self.now() - other.now() < Duration::milliseconds(10) + } +} + +impl Eq for dyn Timekeeper {} + +#[derive(Debug, Default, Clone, Copy)] +pub struct RealTime; + +impl Timekeeper for RealTime { + fn now(&self) -> Timestamp { + Local::now() + } + #[cfg(test)] + fn add_duration(&mut self, _duration: Duration) { + unreachable!() // unneeded + } +} + +/// Jobs can be periodic over one of these units of time +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TimeUnit { + Second, + Minute, + Hour, + Day, + Week, + Month, + Year, +} + +impl TimeUnit { + /// Get a chrono::Duration from an interval based on time unit + pub fn duration(&self, interval: u32) -> Duration { + use TimeUnit::*; + let interval = interval as i64; + match self { + Second => Duration::seconds(interval), + Minute => Duration::minutes(interval), + Hour => Duration::hours(interval), + Day => Duration::days(interval), + Week => Duration::weeks(interval), + Month => Duration::weeks(interval * 4), + Year => Duration::weeks(interval * 52), + } + } +} + +impl fmt::Display for TimeUnit { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use TimeUnit::*; + let s = match self { + Second => "second", + Minute => "minute", + Hour => "hour", + Day => "day", + Week => "week", + Month => "month", + Year => "year", + }; + write!(f, "{}", s) + } +} + +#[cfg(test)] +pub mod mock { + use super::*; + use lazy_static::lazy_static; + + lazy_static! { + /// Default starting time + pub static ref START: Timestamp = Local.ymd(2021, 1, 1).and_hms(12, 30, 0); + } + + /// Mock the datetime for predictable results. + #[derive(Debug, Clone, Copy)] + pub struct MockTime { + stamp: Timestamp, + } + + impl MockTime { + pub fn new(stamp: Timestamp) -> Self { + Self { stamp } + } + } + + impl Default for MockTime { + fn default() -> Self { + Self::new(*START) + } + } + + impl Timekeeper for MockTime { + fn now(&self) -> Timestamp { + self.stamp + } + + fn add_duration(&mut self, duration: chrono::Duration) { + self.stamp = self.stamp + duration; + } + } +} diff --git a/tests/test_scheduler.rs b/tests/test_scheduler.rs deleted file mode 100644 index 8397ebc..0000000 --- a/tests/test_scheduler.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Integration tests - -//use pretty_assertions::assert_eq; -//use skedge::{every, every_single, TimeUnit}; - -type Result = std::result::Result>; - -#[test] -fn integration_test() -> Result<()> { - //todo?! - Ok(()) -}