This is the second article of a 2-part series. The first article is all about time zones.
I recently worked on a project where we had test suites (that users in the application created, not ones that we have written to test our code), that the user could run, either manually or on a defined schedule. Originally, we needed the following functionality:
- Test suites needed to run daily or weekly,
- at a set time (1AM or 2AM),
- and use ResqueScheduler to set the schedule to run the background workers.
We have a ScheduleRule
class that
captured the rules of when each test suite should run
and we used background workers to find those rules
and run the appropriate test suites.
The ResqueScheduler
setup looked like so:
# config/resque_schedule.yml
weekly_test:
cron: "0 1 * * 0"
class: ScheduledWeeklyRunsWorker
args:
description: 'Run test suites weekly'
daily_test:
cron: "0 2 * * *"
class: ScheduledDailyRunsWorker
args:
description: 'Run test suites daily'
If you are unfamiliar with cron
syntax, here’s a quick overview:
* * * * * command to execute
┬ ┬ ┬ ┬ ┬
│ │ │ │ │
│ │ │ │ │
│ │ │ │ └───── day of week (0 - 7) from Sunday
│ │ │ └────────── month (1 - 12)
│ │ └─────────────── day of month (1 - 31)
│ └──────────────────── hour (0 - 23)
└───────────────────────── min (0 - 59)
So what was the problem?
The scheduled tests were running at 2AM AEST regardless of the user’s time zone. Unless you lived in Australia, your test suites would run at various times in the day.
Second go
What we wanted was:
- Test runs to happen according to user’s settings, at a specific day of the week, hour, and time zone.
ResqueScheduler
to run hourly and look for test suites that are due to run.- The
ResqueScheduler
would also look for ones where the background job failed and thus due to be run as well.
So we changed our ScheduleRule
class to have the following attributes:
every
: stringwday
: integerhour
: integertime_zone
: stringlast_scheduled_run_at
: timestampsuite
: references
We added a method to ActiveSupport::TimeZone
to
find all the current time zones in a specific hour:
# in lib/extensions.rb
module ActiveSupport
class TimeZone
def self.current_zones(hour)
all.select { |zone|
t = Time.current.in_time_zone(zone)
t.hour == hour
}.map(&:tzinfo).map(&:name)
end
end
end
We used the current_zones
method to find
all the test suites that needed to be run by matching time zones:
# app/models/schedule_rule.rb
class ScheduleRule < ActiveRecord::Base
def self.find_rules(options={})
hour = options[:hour]
where(zone: ActiveSupport::TimeZone.current_zones(hour)).where(options)
end
def self.suites_to_run(time_ago = Time.current, options={})
find_rules(options).older_than(time_ago).map(&:suite)
end
end
And lastly, the background worker was changed to use the new methods:
# app/workers/scheduled_runs_worker.rb
class ScheduledRunsWorker
def self.perform
ScheduleRule.
run_scheduled.
daily.
suites_to_run(yesterday, { hour: Time.now.utc.hour })
end
end
Did the above code work? Yes.
Was it readable? So-so.
Could we do better? Yes, we could, by using hour_in_utc
.
Third go
- Remove the
.current_zones
method. - Introduce the
:hour_in_utc
column inScheduleRule
class.
We created a migration to add hour_in_utc
to ScheduleRule
:
class AddHourInUtc < ActiveRecord::Migration
def change
add_column :schedule_rules, :hour_in_utc, :integer
end
end
We then calculate and set hour_in_utc
based on the local hour and time zone.
This makes querying which rules should be selected so much easier
because we pass in an hour in UTC and all we have to do is
match it to our hour_in_utc
.
# app/models/schedule_rule.rb
class ScheduleRule < ActiveRecord::Base
before_save :set_hour_in_utc
def self.suites_to_run(time_ago = Time.current, options={})
where(options).older_than(time_ago).map(&:suite)
end
private
def set_hour_in_utc
self.hour_in_utc =
ActiveSupport::TimeZone[zone].parse("#{hour}:00:00").utc.hour
end
end
Our ScheduledRunsWorker
did not have to change and
our ScheduleRule
is much simplified.
Notice that we are passing the system hour in UTC from the ScheduledRunsWorker
.
And since our hour_in_utc
was saved based on the user’s time zone,
all we have to do is match the two. Easy.