Usage of TimeWithZone - An Under-Appreciated Notion
February 8th, 2008
Over at Ryan's Scraps, in a post about the new TimeWithZone functionality in edge Rails, there are a pair of comments that I want to highlight. A fella named Ben asks "Couldn’t this be pushed deeper so that current_user.registered_at is a TimeWithZone?"
Then there's a response from the main guy who developed the TimeWithZone functionality, Geoff Buesig, in regards to how they intend it to be used (and with a bunch of other neat and helpful notes that you should check out):
1.TimeWithZone is similar to the Duration class, in that, you should never need to create an instance directly—in the TWZ case, you’ve got the #in_time_zone, #in_current_time_zone, #change_time_zone and #change_time_zone_to_current methods on Time and DateTime instances that will handle that for you.
So, for example, you can do this:
current_user.registered_at.in_current_time_zone... and the result will automatically be wrapped in a TimeWithZone
What Ben is asking for, and what Geoff seems to be distancing himself from, is exactly what we here at Zetetic would find incredibly useful: the ability to harness our database backend's time zone support, PostgreSQL's 'timestamp with time zone'.
Here's the deal. PingMe was designed for users around the globe so it supports time zones. We set it up so that all timestamps (:datetime) were stored in UTC in the database, and converted to the user's local time on display. We also convert from the user's local time on datetime input. Nothing fancy or unexpected there, really. And hey, the tzinfo gem supports DST, so we're good, right?
Well, PingMe is a scheduling system. It has a scheduler daemon that's constantly checking to see which pings need to be sent out, then it creates outbound events for the dispatcher daemons to deliver. Never mind the terminology, the important thing here is that it's working in UTC. And that Rails is storing the timestamps in Postgres' default TIMESTAMP WITHOUT TIME ZONE data type. Here's an illustrative query:
def lock_a_block(type_name)
before = (Time.now.utc).to_s(:db)
ActiveRecord::Base.connection.execute(
<<-END_OF_SQL
UPDATE events SET dispatcher = '#{@name}'
WHERE id IN (
SELECT e.id FROM
(( events e INNER JOIN targets t ON e.target_id = t.id )
INNER JOIN pings p ON e.ping_id = p.id)
INNER JOIN target_types tt ON t.target_type_id = tt.id
WHERE
tt.const = '#{type_name}'
AND
(
(e.dt_when < '#{before}' AND e.status = '#{Event::STATUS_PENDING}')
OR
(e.retry_at < '#{before}' AND e.status = '#{Event::STATUS_RETRY}')
)
AND e.dispatcher IS NULL
AND t.activated_at IS NOT NULL
AND (p.is_done = 'f' OR p.is_done IS NULL)
AND (p.deleted_at IS NULL)
ORDER BY
e.dt_when ASC
LIMIT #{@block_size}
);
END_OF_SQL
)
end
So the app is providing a UTC timestamp for the before variable, and the timestamps are in UTC in the database. What happens when DST begins or ends? Nothing changes. Everything is sent at the set time, for UTC. So a ping set for 5pm EST was stored at 12:00 UTC, and when 5pm shifts an hour for EDT, that ping is still stored at 12:00 UTC and will be sent either an hour early or an hour late, depending on the circumstance.
The only way we could break this up to work off the time zone setting on the user model is to execute separate queries for all of our users all the time joining against their timezone. Ridiculous! And following Geoff's notion of things above, it's just not a clean solution -- storing the ping's time without the time zone is decidedly *inaccurate*. I hate to say it.
I think the best solution is not to store in UTC here, but to store as a timestamp with time zone. I realize that sounds like an impure solution, but it's not: PostgreSQL actually stores the data in UTC and can do all sorts of magical conversions for us. We could still use the code above and work in proper UTC, but any DST on the timezone would be respected:
WHERE ... e.dt_when AT TIME ZONE 'UTC' < '#{before}'
And that is why I hope Geoff changes his mind, because we do need TimeWithZone as a data type in Rails, or perhaps a col definition that will provide a TimeWithZone instead of Time objects:
col.datetime :col_name, :with_time_zone => true
As an aside, we don't leave PingMe users to hang when DST rolls around, we update the relevant time stamps via SQL. But I would like to get us to a better solution. Being able to store TimeWithZone would do just the thing.
2 Responses to “Usage of TimeWithZone - An Under-Appreciated Notion”
Sorry, comments are closed for this article.


May 10th, 2008 at 01:32 AM
Thanks for writing this up. A couple points in response:
1. Re: "you should never need to create [a TimeWithZone] instance directly" -- that was in reference to an example in Ryan's original post (which he has since updated), in which he created a TWZ by calling ActiveSupport::TimeWithZone.new(). The more friendly way to create a TWZ is via the new TWZ creation methods mixed into Time and DateTime (i.e., #in_time_zone and friends,) or via Time.zone.new().
2. Re: Ben's comment, "Couldn’t this be pushed deeper so that current_user.registered_at is a TimeWithZone?" -- as of [8806], you can declare a time zone for the entire app in environment.rb, and AR time attributes will be automatically converted to this time zone -- so, you'll automatically get a TWZ when you call current_user.registered_at.
3. Re: "a ping set for 5pm EST was stored at 12:00 UTC, and when 5pm shifts an hour for EDT, that ping is still stored at 12:00 UTC" -- if this is the case, you're not doing your local-to-utc conversion correctly -- 5:00PM EST (US) is equivalent to 10:00PM UTC, and 5:00PM EDT is equivalent to 9:00PM UTC.
4. Re: leveraging Postgres' timestamp with time zone column type -- this would be plugin territory, since not all databases have a column type that can contain a time plus an arbitrary zone.
May 10th, 2008 at 01:32 AM
Geoff, thanks for weighing in with the extra info on TWZ./p>
In regards to point 3 in your comment - sorry, that was just off the top of my head, a bad conversion, our local to utc is working just fine on the server. I'm the one who needs a test suite.
4. We're still thinking about how to best achieve this. A plugin might not be a bad idea, at least for our own purposes, and then if it can be adapted to other DB vendors, maybe it will become more useful. Our scenario is certainly an abnormal case.
Thanks again,
B