Schedule Some Time with The Deuce

[timeclock]

In this installment of our series on getting started with Immutant 2, we'll take a detailed look at the API of our library for scheduling jobs, and show a few examples of usage.

If you're coming from Immutant 1.x, you'll notice that the namespace and artifact have been renamed (what used to be immutant.jobs and org.immutant/immutant-jobs is now immutant.scheduling and org.immutant/scheduling), and the API has changed a bit. It's still based on Quartz 2.2, though.

The API

At first glance, the API for immutant.scheduling appears bigger than it really is, but there are only two essential functions:

  • schedule - for scheduling your jobs
  • stop - for canceling them

The remainder of the namespace is syntactic sugar: functions that can be composed to create the specification for when your job should run.

Your "job" will take the form of a plain ol' Clojure function taking no arguments. The schedule function takes your job and a specification map as arguments. The map determines when your function gets called. It may contain any of the following keys:

  • :in - a period after which your function will be called
  • :at - an instant in time after which your function will be called
  • :every - the period between calls
  • :until - stops the calls at a specific time
  • :limit - limits the calls to a specific count
  • :cron - calls your function according to a Quartz-style cron spec

For each key there is a corresponding "sugar function". We'll see those in the examples below.

Units for periods (:in and :every) are milliseconds, but can also be represented as a keyword or a vector of multiplier/keyword pairs, e.g. [1 :week, 4 :days, 2 :hours, 30 :minutes, 59 :seconds]. Both singular and plural keywords are valid.

Time values (:at and :until) can be a java.util.Date, a long representing milliseconds-since-epoch, or a String in HH:mm format. The latter will be interpreted as the next occurence of HH:mm:00 in the currently active timezone.

Two additional options may be passed in the spec map:

  • :id - a unique identifier for the scheduled job
  • :singleton - a boolean denoting the job's behavior in a cluster [true]

In Immutant 1.x, a name for the job was required. In Immutant 2, the :id is optional, and if not provided, a UUID will be generated. If schedule is called with an :id for a job that has already been scheduled, the prior job will be replaced.

The return value from schedule is a map of the options with any missing defaults filled in, including a generated id if necessary. This result can be passed to stop to cancel the job.

Some Examples

The following code fragments were tested against 2.x.incremental.119. You should read through the getting started post and require the immutant.scheduling namespace at a REPL to follow along:

(require '[immutant.scheduling :refer :all])

We'll need a job to schedule. Here's one!

(defn job []
  (prn 'fire!))

Let's schedule it:

(schedule job)

That was pretty useless. Without a spec, the job will be immediately called asynchronously on one of the Quartz scheduler's threads. Instead, let's have it run in 5 minutes:

(schedule job (in 5 :minutes))

And maybe run again every second after that:

(schedule job
  (-> (in 5 :minutes)
    (every :second)))

But no more than 60 times:

(schedule job
  (-> (in 5 :minutes)
    (every :second)
    (limit 60)))

We could also anticipate getting stupid bored about halfway through, and schedule another job to cancel the first one:

(let [it (schedule job
           (-> (in 5 :minutes)
             (every :second)
             (limit 60)))]
  (schedule #(stop it) (in 5 :minutes, 30 :seconds)))

Of course, you can bring your own job id's if you like:

(schedule job (-> (id :purge) (every 30 :minutes)))
(schedule job (-> (id :purge) (every :hour)))  ; reschedule
(stop (id :purge))

If a job is successfully canceled, stop returns true.

It's Just Maps

Ultimately, the spec passed to schedule is just a map, and the sugar functions are just assoc'ing keys corresponding to their names. The map can be passed either explicitly or via keyword arguments, so all of the following are equivalent:

(schedule job (-> (in 5 :minutes) (every :day)))
(schedule job {:in [5 :minutes], :every :day})
(schedule job :in [5 :minutes], :every :day)

Supports Joda clj-time

If you're using the clj-time library in your project, you can load the immutant.scheduling.joda namespace. This will extend org.joda.time.DateTime instances to the AsTime protocol, enabling them to be used as arguments to at and until, e.g.

(require '[clj-time.core :refer [today-at plus hours]])

(let [t (today-at 9 00)]
  (schedule job
    (-> (at t)
      (every 2 :hours)
      (until (plus t (hours 8))))))

It also provides the function, schedule-seq. Inspired by chime-at, it takes not a specification map but a sequence of times, as might be returned from clj-time.periodic/periodic-seq, subject to the application of any of Clojure's core sequence-manipulating functions.

When defining complex recurring schedules, this presents an interesting alternative to traditional cron specs. For example, consider a job that must run at 10am every weekday. Here's how we'd schedule that with a Quartz-style cron spec:

(schedule job (cron "0 0 10 ? * MON-FRI"))

And here's the same schedule using a lazy sequence:

(require '[immutant.scheduling.joda :refer [schedule-seq]]
         '[clj-time.core            :refer [today-at days]]
         '[clj-time.periodic        :refer [periodic-seq]]
         '[clj-time.predicates      :refer [weekday?]])

(schedule-seq job
  (->> (periodic-seq (today-at 10 0) (days 1))
    (filter weekday?)))

So each has trade-offs, of course. The cron spec is more concise, but also arguably more error-prone, e.g. what is that ? for!?

One very cool thing about the sequence is that I can test it without actually scheduling it. On the other hand, my cron spec test is going to take more than a week to run! ;)

Try it out!

As always, we'd love to incorporate your feedback. Find us via our community page and join the fun!

Thanks to Phil Hart for the image, used under CC BY-NC-SA