What’s more fun than bugs? Bugs involving time! And none are so fascinating as bugs involving daylight saving time.
With Daylight saving time almost upon us again, I thought it would be fun to revisit this bug my team and I stumbled across last fall.
When subscribing a customer to a monthly plan in our billing system, we calculate the date of the customer’s next bill by adding 30 days to their current time, and using that to create a subscription on Stripe.
However, at some point in October, we started to see this fail:
Stripe::InvalidRequestError:
billing_cycle_anchor cannot be later than next
natural billing date (1669817382) for plan
When comparing the “natural billing date” to the time we sent the request, it was indeed more than 30 days in the future – by exactly an hour. Curious!
In the code, we were doing something roughly like this to calculate the billing date:
subscription.started_at + 30.days
started_at
is a Time
object, and we’re using the ActiveSupport::Duration
helpers to advance that time by exactly 30 days. So what’s the issue?
I fired up a console and started poking at this particular line of code to learn how it behaved.
First, a sanity-check: I want to make sure I know that .days
is giving me what I expect:
$> Time.parse("2022-10-15 12:00:00 -0700") + 1.day
=> 2022-10-16 12:00:00 -0700
$> Time.parse("2022-10-15 12:00:00 -0700") + (1 * 24 * 60 * 60)
=> 2022-10-16 12:00:00 -0700
Ok, good! I get the same answer if I add the total number of seconds in a day. Makes sense.
Now, what happens if I do the same thing, but add 30 days?
$> Time.parse("2022-10-15 12:00:00 -0700") + 30.days
=> 2022-11-14 12:00:00 -0800
$> Time.parse("2022-10-15 12:00:00 -0700") + (30 * 24 * 60 * 60)
=> 2022-11-14 11:00:00 -0800
Aha! There’s our bug. Depending on whether I use a Duration
object or plain old seconds, I get a different answer. This feels like a daylight saving issue, since we’re crossing the boundary here, and our two times disagree by exactly an hour.
Next, I want to confirm that this has something to do with crossing the daylight saving boundary. Let’s try the same test, but using a time that’s right before the boundary. Daylight saving ended on November 6, 2022 at 2am, so let’s start from 1 second before and add 1 day:
$> Time.parse("2022-11-06 01:59:59 -0700") + 1.day
=> 2022-11-07 01:59:59 -0800
$> Time.parse("2022-11-06 01:59:59 -0700") + (1 * 24 * 60 * 60)
=> 2022-11-07 00:59:59 -0800
Still behaves the same. So we can confidently say that when crossing the DST boundary, adding a Duration to a timestamp yields a different time than adding a number of seconds.
So which of the two answers above is correct?
It’s easier to answer that question with a shorter duration, so let’s work with plain seconds:
$> Time.parse("2022-11-06 01:59:59 -0700") + 5
=> 2022-11-06 01:00:04 -0800
This makes sense:
Numbers don’t lie, but it seems like ActiveSupport::Duration
may be stretching the truth in this case.
So why does 1.day
behave so differently?
In order to explain the bug, we need some context on what happens when we add these two values together. Remember, since everything in Ruby is an object, an operation like 2 + 1
is really invoking the +
method on 2
, and passing 1
as an argument.
ActiveSupport implements methods like +
on Duration
objects so that you can add two of them together:
$> 1.day + 1.day
=> 2 days
That’s all fine and good when you have two Duration
objects, or at least when a Duration
object is the receiver (on the left-hand side of the operation). But we’ve got a plain ol’ Ruby class on the left:
Time.parse("2022-11-06 01:59:59 -0700") + 1.day
Here, we know the +
method is being invoked on our Time
object, yet somehow it knows what to do with an ActiveSupport::Duration
. How is that possible? Let’s introspect +
and see where it takes us:
$> Time.now.method(:+)
=> #<Method: Time#+(plus_with_duration)(other) /ruby/gems/2.7.0/gems/activesupport-5.2.8.1/lib/active_support/core_ext/time/calculations.rb:261>
# active_support/core_ext/time/calculations.rb:261
Hey, look! A monkey patch! 🙈
def plus_with_duration(other) #:nodoc:
if ActiveSupport::Duration === other
other.since(self)
else
plus_without_duration(other)
end
end
alias_method :plus_without_duration, :+
alias_method :+, :plus_with_duration
Here, ActiveSupport has hooked into +
to customize the path taken if the value being added is a Duration
class. Otherwise, we can fall back on the original +
method.
This is the point where I break out a debugger like pry to step through the code and see where it takes me. After step
ping into #since
and following the code path, I end up here:
time_advanced_by_date = change(year: d.year, month: d.month, day: d.day)
Well, that’s interesting. Just looking at that method call, I can see that we’re dropping the hour, minute and second. And since I’m investigating a bug relating to an incorrect hour offset, this really piques my curiosity.
Let’s step down one more level into #change
, which lives in another monkey-patch on the Time
class:
def change(options)
new_year = options.fetch(:year, year)
new_month = options.fetch(:month, month)
new_day = options.fetch(:day, day)
new_hour = options.fetch(:hour, hour)
new_min = options.fetch(:min, options[:hour] ? 0 : min)
new_sec = options.fetch(:sec, (options[:hour] || options[:min]) ? 0 : sec)
new_offset = options.fetch(:offset, nil)
...
if new_offset
::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec, new_offset)
elsif utc?
::Time.utc(new_year, new_month, new_day, new_hour, new_min, new_sec)
elsif zone
::Time.local(new_year, new_month, new_day, new_hour, new_min, new_sec)
else
::Time.new(new_year, new_month, new_day, new_hour, new_min, new_sec, utc_offset)
end
change
is finally the method which returns a new Time
object based on the results of the operation. Ultimately, we’re calling some variant of Time.new
, providing all-new values for the year, month, etc.
And where do we get all these new values?
new_hour = options.fetch(:hour, hour)
If we didn’t pass it in from the arguments, then we fall back to the existing hour
, min
and sec
value on the current Time
object.
In other words, given our initial Time
object of Time.parse("2022-11-06 01:59:59 -0700")
, we’re going to add 1.day
to the date only, then pin 01:59:59
on the end of it.
And there’s our bug!
Ultimately, the workaround on our side was a simple 5-character change: do our math with seconds instead of a Duration
object:
subscription.started_at + 30.days.to_i
I was also curious to see if others had run into it, and sure enough, this bug had been reported a few months prior. It was fixed shortly afterwards in edge Rails, although it does seem like there’s still some discrepancies when doing arithmetic around the DST boundary. My takeaway: when in doubt, stick with plain ol’ seconds.
I always have a hard … ahem, time … wrapping my head around time-related bugs. This one was a fun opportunity to dive in and get to the bottom of a hairy problem.
Thanks for reading!