A Case Study in Multiple Time Zones

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: string
  • wday: integer
  • hour: integer
  • time_zone: string
  • last_scheduled_run_at: timestamp
  • suite: 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 in ScheduleRule 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.