---
title: A Case Study in Multiple Time Zones
teaser: |
  Handle multiple user-defined time zones in Rails
  with scheduled background workers.
tags: web,rails,ruby
author: Elle Meredith
published_on: 2015-07-23
---

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:

```yaml
# 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:

```ruby
# 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:

```ruby
# 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:

```ruby
# 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`:

```ruby
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`.

```ruby
# 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.

[all about time zones]: https://thoughtbot.com/blog/its-about-time-zones
[ResqueScheduler]: https://github.com/resque/resque-scheduler
[`ActiveSupport::TimeZone`]: http://api.rubyonrails.org/classes/ActiveSupport/TimeZone.html
