Tickwork - a scheduler library that requires an external call to tick to run scheduled events

Build Status Dependency Status

This started as a stripped down version of clockwork.

Tickwork provides a familiar and compatible config file for scheduled jobs, but instead of it being driven by a background process, it relies on regular calls to Tickwork.run. Tickwork.run efectively ticks the clock forward from the last time it was called scheduling jobs as it goes. By tuning the paramters below, you can call Tickwork.run as little or as often as you like.

Tickwork keeps track of time using a datastore. This is compatible with ActiveSupport::Cache::Store so should be compatible with most cache stores out there. It is important the cache be distributed (so not ActiveSupport::Cache::Store). AWS_Tickwork contains an example of an ActiveRecord implementation.

Note that clockwork allowed schedules to be dynamically set via the database. This functionality does not exist in Tickwork.

Possible Combinations

This was originally built with the idea of using Cloudwatch Events to power time. An implementation doing that is here: AWS_Tickwork. Another option would be Heroku Scheduler. I'm sure there are many more options out there.

Quickstart

Create tick.rb:

require 'tickwork'
module Tickwork
  configure do |config|
    # See DataStore below
    config[:data_store] = MyDataStore
  end
  handler do |job|
    puts "Running #{job}"
  end

  # handler receives the time when job is prepared to run in the 2nd argument
  # handler do |job, time|
  #   puts "Running #{job}, at #{time}"
  # end

  every(10.seconds, 'frequent.job')
  every(3.minutes, 'less.frequent.job')
  every(1.hour, 'hourly.job')

  every(1.day, 'midnight.job', :at => '00:00')
end

Note, this needs to be global to access the config whenever you run Tickwork. If you're on rails, this should be an initializer.

If you need to load your entire environment for your jobs, simply add:

require './config/boot'
require './config/environment'

under the require 'tickwork' declaration.

Then, somewhere else in your app, you need to regularly call Tickwork.run.

Use with queueing

The clock process only makes sense as a place to schedule work to be done, not to do the work. It avoids locking by running as a single process, but this makes it impossible to parallelize. For doing the work, you should be using a job queueing system, such as Delayed Job, Beanstalk/Stalker, RabbitMQ/Minion, Resque, or Sidekiq. This design allows a simple clock process with no locks, but also offers near infinite horizontal scalability.

For example, if you're using Beanstalk/Stalker:

require 'stalker'

module Tickwork
  handler { |job| Stalker.enqueue(job) }

  every(1.hour, 'feeds.refresh')
  every(1.day, 'reminders.send', :at => '01:30')
end

Using a queueing system which doesn't require that your full application be loaded is preferable, because the clock process can keep a tiny memory footprint. If you're using DJ or Resque, however, you can go ahead and load your full application enviroment, and use per-event blocks to call DJ or Resque enqueue methods. For example, with DJ/Rails:

require 'config/boot'
require 'config/environment'

every(1.hour, 'feeds.refresh') { Feed.send_later(:refresh) }
every(1.day, 'reminders.send', :at => '01:30') { Reminder.send_later(:send_reminders) }

Event Parameters

:at

:at parameter specifies when to trigger the event:

Valid formats:

HH:MM
 H:MM
**:MM
HH:**
(Mon|mon|Monday|monday) HH:MM

Examples

The simplest example:

every(1.day, 'reminders.send', :at => '01:30')

You can omit the leading 0 of the hour:

every(1.day, 'reminders.send', :at => '1:30')

Wildcards for hour and minute are supported:

every(1.hour, 'reminders.send', :at => '**:30')
every(10.seconds, 'frequent.job', :at => '9:**')

You can set more than one timing:

every(1.day, 'reminders.send', :at => ['12:00', '18:00'])
# send reminders at noon and evening

You can specify the day of week to run:

every(1.week, 'myjob', :at => 'Monday 16:20')

If another task is already running at the specified time, clockwork will skip execution of the task with the :at option. If this is a problem, please use the :thread option to prevent the long running task from blocking clockwork's scheduler.

:tz

:tz parameter lets you specify a timezone (default is the local timezone):

every(1.day, 'reminders.send', :at => '00:00', :tz => 'UTC')
# Runs the job each day at midnight, UTC.
# The value for :tz can be anything supported by [TZInfo](http://tzinfo.rubyforge.org/)

:if

:if parameter is invoked every time the task is ready to run, and run if the return value is true.

Run on every first day of month.

Tickwork.every(1.day, 'myjob', :if => lambda { |t| t.day == 1 })

The argument is an instance of ActiveSupport::TimeWithZone if the :tz option is set. Otherwise, it's an instance of Time.

This argument cannot be omitted. Please use _ as placeholder if not needed.

Tickwork.every(1.second, 'myjob', :if => lambda { |_| true })

:thread

By default, clockwork runs in a single-process and single-thread. If an event handler takes a long time, the main routine of clockwork is blocked until it ends. Tickwork does not misbehave, but the next event is blocked, and runs when the process is returned to the clockwork routine.

The :thread option is to avoid blocking. An event with thread: true runs in a different thread.

Tickwork.every(1.day, 'run.me.in.new.thread', :thread => true)

If a job is long-running or IO-intensive, this option helps keep the clock precise.

Configuration

Tickwork exposes a couple of configuration options:

:logger

By default Tickwork logs to STDOUT. In case you prefer your own logger implementation you have to specify the logger configuration option. See example below.

:tz

This is the default timezone to use for all events. When not specified this defaults to the local timezone. Specifying :tz in the parameters for an event overrides anything set here.

:max_threads

Tickwork runs handlers in threads. If it exceeds max_threads, it will warn you (log an error) about missing jobs.

:thread

Boolean true or false. Default is false. If set to true, every event will be run in its own thread. Can be overridden on a per event basis (see the :thread option in the Event Parameters section above)

:namespace

This prefixes keys with a namespace which is useful to prevent colisions if you are using redis or memcache as the datastore. Defautls to _tickwork_.

Stepping forward in time from the past

Think about Tickwork as having a concept of now built into it, but rather than now moving with the clock, it only moves forward (ticks forward) when you tell it to. You tell it to tick forward through time by calling Tickwork.run. How much it ticks forward is controlled by the following variables.

If you think of a clock, each tick is 1 second, and you take 1 tick each second. With tickwork, you control the size of the ticks, how many you take, and how often you take them.

Tickwork will never tick into the future.

:tick_size

This is the interval in seconds that each tick will step forward. The original clockwork implementation would (by default) wake up every second to check for work. Tickwork defaults to 60 seconds. This effectively puts a floor on your frequency of events you can schedule. So if you scheduled something to run every 30 seconds, it would only be run every other time -- so don't do that.

In general, set this to at least as small as your most frequently run job. If you set this to a value larger than 60, then events schedule to run at a particular time may be missed.

:max_ticks

This is the most number of ticks executed per run. If you have tick_size set to 60, then each tick will be 1 minute. If max_ticks is set to 10, then a call to Tickwork.run could result in as many as 10 minutes worth of jobs being scheduled. If you had 1 job that ran every minute, up to 10 jobs would be run. Tickwork will not tick into the future, so you may run fewer than this number of jobs.

In any given call to Tickwork.run, you can move foward through time at most tick_size * max_ticks

:max_catchup

When running tickwork, the last time you run it is important since that is what now. But what if your system goes down? max_catchup sets a floor on how far back Tickwork look back for jobs. This defaults to 3600 which is 1 hour. This means that if you run Tickwork for a day, then turn your system off for a day, then start running Tickwork again, it will start scheduling jobs from 1 hour ago.

Setting to 0 or nil disables the feature, and Tickwork will start from where it left off.

If there is no last timestamp, Tickwork starts from now.

This must be larger than your tick_size, and probably significantly larger to avoid missing any jobs.

:data_store

A datastore is required to save the times that jobs last run. This is how Tickwork keeps track of time. This can be anything that implements the following methods:

def read(key)
end
def write(key, value)
end

ActiveSupport::Cache::Store satisfies this, so Rails users can use that. This must be a shared cache to work properly in an environment with multiple servers.

Configuration example

module Tickwork
  configure do |config|
    config[:logger] = Logger.new(log_file_path)
    config[:tz] = 'EST'
    config[:max_threads] = 15
    config[:thread] = true
    config[:tick_size] = 60
    config[:max_ticks] = 10
    config[:max_catchup] = 3600
  end
end

External call frequency & configs

Since tickwork requires on some external system to make calls into Tickwork.run, you must balance whatever that system is against the config settings.

Lets say you call Tickwork.run every 5 minutes and you have no jobs trying to run faster than 1x/min. The default values will work well (tick_size: 60, max_ticks: 10). Every 5 minutes, you would expect to run 5 minutes worth of jobs. If you miss 1 period, you will catch up and run 10 minutes worth of jobs. However, if you miss 2 periods, then call back (after 15 min), it will take 2 calls to catch up since there are 15 minutes waiting to run, but max_ticks limits this to just 10 per call.

error_handler

You can add error_handler to define your own logging or error rescue.

module Tickwork
  error_handler do |error|
    Airbrake.notify_or_ignore(error)
  end
end

Current specifications are as follows.

  • defining error_handler does not disable original logging
  • errors from error_handler itself are not rescued, and stop clockwork

Any suggestion about these specifications is welcome.

Anatomy of a tick file

tick.rb is standard Ruby. Since we include the Tickwork module, this exposes a small DSL to define the handler for events, and then the events themselves.

The handler typically looks like this:

handler { |job| enqueue_your_job(job) }

This block will be invoked every time an event is triggered, with the job name passed in. In most cases, you should be able to pass the job name directly through to your queueing system.

The second part of the file, which lists the events, roughly resembles a crontab:

every(5.minutes, 'thing.do')
every(1.hour, 'otherthing.do')

In the first line of this example, an event will be triggered once every five minutes, passing the job name 'thing.do' into the handler. The handler shown above would thus call enqueue_your_job('thing.do').

You can also pass a custom block to the handler, for job queueing systems that rely on classes rather than job names (i.e. DJ and Resque). In this case, you need not define a general event handler, and instead provide one with each event:

every(5.minutes, 'thing.do') { Thing.send_later(:do) }

If you provide a custom handler for the block, the job name is used only for logging.

You can also use blocks to do more complex checks:

every(1.day, 'check.leap.year') do
  Stalker.enqueue('leap.year.party') if Date.leap?(Time.now.year)
end

In addition, Tickwork also supports :before_tick and after_tick callbacks. They are optional, and run every tick (a tick being whatever your :sleep_timeout is set to, default is 1 second):

on(:before_tick) do
  puts "tick"
end

on(:after_tick) do
  puts "tock"
end

Use cases

Feel free to add your idea or experience and send a pull-request.

Meta

Created by Adam Wiggins

Inspired by rufus-scheduler and resque-scheduler

Design assistance from Peter van Hardenberg and Matthew Soldo

Patches contributed by Mark McGranaghan and Lukáš Konarovský

Released under the MIT License: http://www.opensource.org/licenses/mit-license.php

http://github.com/tomykaira/clockwork http://github.com/softwaregravy/tickwork