Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Does anyone know how to appropriately deal with user timezones in rails 2.3?

We're building a rails app that needs to display dates (and more importantly, calculate them) in multiple timezones.

Can anyone point me towards how to work with user timezones in rails 2.3(.5 or .8)

The most inclusive article I've seen detailing how user time zones are supposed to work is here: http://wiki.rubyonrails.org/howtos/time-zones... although it is unclear when this was written or for what version of rails. Specifically it states that:

"Time.zone - The time zone that is actually used for display purposes. This may be set manually to override config.time_zone on a per-request basis."

Keys terms being "display purposes" and "per-request basis".

Locally on my machine, this is true. However on production, neither are true. Setting Time.zone persists past the end of the request (to all subsequent requests) and also affects the way AR saves to the DB (basically treating any date as if it were already in UTC even when its not), thus saving completely inappropriate values.

We run Ruby Enterprise Edition on production with passenger. If this is my problem, do we need to switch to JRuby or something else?

To illustrate the problem I put the following actions in my ApplicationController right now:

def test
p_time = Time.now.utc
s_time = Time.utc(p_time.year, p_time.month, p_time.day, p_time.hour)

logger.error "TIME.ZONE" + Time.zone.inspect
logger.error ENV['TZ'].inspect
logger.error p_time.inspect
logger.error s_time.inspect

jl = JunkLead.create!
jl.date_at = s_time

logger.error s_time.inspect
logger.error jl.date_at.inspect

jl.save!

logger.error s_time.inspect
logger.error jl.date_at.inspect


render :nothing => true, :status => 200
end


def test2
Time.zone = 'Mountain Time (US & Canada)'
logger.error "TIME.ZONE" + Time.zone.inspect
logger.error ENV['TZ'].inspect

render :nothing => true, :status => 200
end


def test3
Time.zone = 'UTC'
logger.error "TIME.ZONE" + Time.zone.inspect
logger.error ENV['TZ'].inspect


render :nothing => true, :status => 200
end

and they yield the following:

Processing ApplicationController#test (for 98.202.196.203 at 2010-12-24 22:15:50) [GET]
TIME.ZONE#<ActiveSupport::TimeZone:0x2c57a68 @tzinfo=#<TZInfo::DataTimezone: Etc/UTC>, @name="UTC", @utc_offset=0>
nil
Fri Dec 24 22:15:50 UTC 2010
Fri Dec 24 22:00:00 UTC 2010
Fri Dec 24 22:00:00 UTC 2010
Fri, 24 Dec 2010 22:00:00 UTC +00:00
Fri Dec 24 22:00:00 UTC 2010
Fri, 24 Dec 2010 22:00:00 UTC +00:00
Completed in 21ms (View: 0, DB: 4) | 200 OK [http://www.dealsthatmatter.com/test]


Processing ApplicationController#test2 (for 98.202.196.203 at 2010-12-24 22:15:53) [GET]
TIME.ZONE#<ActiveSupport::TimeZone:0x2c580a8 @tzinfo=#<TZInfo::DataTimezone: America/Denver>, @name="Mountain Time (US & Canada)", @utc_offset=-25200>
nil
Completed in 143ms (View: 1, DB: 3) | 200 OK [http://www.dealsthatmatter.com/test2]


Processing ApplicationController#test (for 98.202.196.203 at 2010-12-24 22:15:59) [GET]
TIME.ZONE#<ActiveSupport::TimeZone:0x2c580a8 @tzinfo=#<TZInfo::DataTimezone: America/Denver>, @name="Mountain Time (US & Canada)", @utc_offset=-25200>
nil
Fri Dec 24 22:15:59 UTC 2010
Fri Dec 24 22:00:00 UTC 2010
Fri Dec 24 22:00:00 UTC 2010
Fri, 24 Dec 2010 15:00:00 MST -07:00
Fri Dec 24 22:00:00 UTC 2010
Fri, 24 Dec 2010 15:00:00 MST -07:00
Completed in 20ms (View: 0, DB: 4) | 200 OK [http://www.dealsthatmatter.com/test]

Processing ApplicationController#test3 (for 98.202.196.203 at 2010-12-24 22:16:03) [GET]
TIME.ZONE#<ActiveSupport::TimeZone:0x2c57a68 @tzinfo=#<TZInfo::DataTimezone: Etc/UTC>, @name="UTC", @utc_offset=0>
nil
Completed in 17ms (View: 0, DB: 2) | 200 OK [http://www.dealsthatmatter.com/test3]

Processing ApplicationController#test (for 98.202.196.203 at 2010-12-24 22:16:04) [GET]
TIME.ZONE#<ActiveSupport::TimeZone:0x2c57a68 @tzinfo=#<TZInfo::DataTimezone: Etc/UTC>, @name="UTC", @utc_offset=0>
nil
Fri Dec 24 22:16:05 UTC 2010
Fri Dec 24 22:00:00 UTC 2010
Fri Dec 24 22:00:00 UTC 2010
Fri, 24 Dec 2010 22:00:00 UTC +00:00
Fri Dec 24 22:00:00 UTC 2010
Fri, 24 Dec 2010 22:00:00 UTC +00:00
Completed in 151ms (View: 0, DB: 4) | 200 OK [http://www.dealsthatmatter.com/test]

It should be clear above that the 2nd call to /test shows Time.zone set to Mountain, even though it shouldn't.

Additionally, checking the database reveals that the test action when run after test2 saved a JunkLead record with a date of 2010-12-22 15:00:00, which is clearly wrong.

like image 213
Amazing Jay Avatar asked Dec 25 '10 05:12

Amazing Jay


2 Answers

After exhaustive research it is now COMPLETELY clear that Time.zone is broken in most all versions of Rails (including 2.3 & 3). This feature uses a central thread hash to store the value that is set (which is supposed to be thread safe, and isn't) and ends up modifying the behavior for all subsequent requests. Additionally, contrary to documentation, setting Time.zone modifies ActiveRecord behavior and saves datetimes in the new zone, instead of the one specified in config (which is usually UTC).

Until Rails gets this fixed, we chose instead to work with Timezones manually, which can be accessed via the undocumented default method:

ActiveSupport::TimeZone['Arizona'].now (or .local, or .parse).

Additionally I patched Time and ActiveSupport::TimeWithZone to provide easy conversion of a time moment to a different zone. To be clear, I mean the corresponding moment of time in a different zone, not the simultaneous moment.

>> Time.utc(2011)
=> Sat Jan 01 00:00:00 UTC 2011
>> Time.utc(2011).in_time_zone('Arizona')
=> Fri, 31 Dec 2010 17:00:00 MST -07:00 #Simultaneous
>> Time.utc(2011).change_zone('Arizona')
=> Sat, 01 Jan 2011 00:00:00 MST -07:00 #Corresponding

The patch is as follows:

module ActiveSupport #:nodoc:
  module CoreExtensions #:nodoc:
    module Time #:nodoc:
      module ZoneCalculations
        def self.included(base) #:nodoc:
          base.class_eval do
            alias_method_chain :change, :zone
          end
        end

        def change_zone(new_zone)
          new_zone = ::Time.__send__(:get_zone, new_zone)
          return self if new_zone.name == zone
          new_zone.local(year,month,day,hour,min,sec,usec)
        end

        def change_with_zone(options)
          result = change_without_zone(options)
          options[:zone] ? result.change_zone(options[:zone]) : result
        end

      end
    end
  end
  class TimeWithZone
    def change_zone(new_zone)
      time.change_zone(new_zone)
    end
    def change(options)
      time.change(options)
    end
  end

end

class Time
  include ActiveSupport::CoreExtensions::Time::ZoneCalculations
end
like image 134
Jay Crouch Avatar answered Oct 19 '22 20:10

Jay Crouch


You are correct in that Rails does not automatically reset the timezone per request. The wiki author gives an example where the timezone is set in a before filter, thus he never experiences the problem with time zones "leaking" between requests (as the time zone is set up correctly before the request). The example used in the RDoc documentation for TimeZone.zone= is similar. So I think it's a documentation issue.

Changing the time zone is not request local, it is thread local. Rails stores the selected time zone in the current thread (see Thread.current.[:time_zone]), not in the current request. As the same thread handles multiple requests, changes to Time.zone are persistent between requests.

I think the proper way to use time zones in the your scenario is to use Time.use_zone:

def my_action
  Time.zone # 'UTC'
  Time.use_zone('Mountain Time (US & Canada)') do
    Time.zone # 'Mountain Time (US & Canada)'
  end
  Time.zone # 'UTC'
end

I have not had the time to look through your ActiveRecord issues. Will return with an update when I have done so.

like image 44
Daniel Abrahamsson Avatar answered Oct 19 '22 18:10

Daniel Abrahamsson