Skip to content

Commit

Permalink
Scheduler tests (#13)
Browse files Browse the repository at this point in the history
* Bump version

* Tweak crates.io keyword

* Add docs.rs badge

* Implement time mocking for testing

* Add two_job test
  • Loading branch information
deciduously committed Sep 7, 2021
1 parent a3f92d3 commit 7c2aa0b
Show file tree
Hide file tree
Showing 7 changed files with 274 additions and 85 deletions.
5 changes: 2 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <ben@deciduously.com>"]
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"
Expand All @@ -24,7 +24,6 @@ thiserror = "1"

[dev-dependencies]

mockall = "0.10"
pretty_assertions = "0.7"

[profile]
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
60 changes: 51 additions & 9 deletions src/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -64,6 +72,8 @@ pub struct Job {
start_day: Option<Weekday>,
/// Optional time of final run
cancel_after: Option<Timestamp>,
/// Interface to current time
clock: Option<Box<dyn Timekeeper>>,
}

impl Job {
Expand All @@ -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 {
Expand Down Expand Up @@ -187,7 +229,7 @@ impl Job {
///
///
pub fn until(mut self, until_time: Timestamp) -> Result<Self> {
if until_time < Local::now() {
if until_time < self.now() {
return Err(SkedgeError::InvalidUntilTime);
}
self.cancel_after = Some(until_time);
Expand All @@ -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.
Expand All @@ -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<bool> {
if self.is_overdue(Local::now()) {
if self.is_overdue(self.now()) {
debug!("Deadline already reached, cancelling job {}", self);
return Ok(false);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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());
}
}
Expand Down
61 changes: 3 additions & 58 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Local>;

/// 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};
100 changes: 97 additions & 3 deletions src/scheduler.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
//! 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.
#[derive(Debug, Default)]
pub struct Scheduler {
/// The currently scheduled lob list
jobs: Vec<Job>,
/// Interface to current time
clock: Option<Box<dyn Timekeeper>>,
}

impl Scheduler {
Expand All @@ -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);
Expand Down Expand Up @@ -97,6 +123,74 @@ impl Scheduler {

/// Number of seconds until next run. None if no jobs scheduled
pub fn idle_seconds(&self) -> Option<i64> {
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(())
}
}
Loading

0 comments on commit 7c2aa0b

Please sign in to comment.