One month ago, Ben and I investigated on 1.day
not being an Object
(that’s an interesting post by Ben, I suggest you read it if you want to know
what’s happening under the hood).
Well I’ve got news for you, things only get weirder!
Discovering the problem
Let’s use Timecop to freeze time first, so that we know where we’re at (or should I say when?).
irb(main):001:0> Timecop.freeze '2015/02/19'
=> 2015-02-19 00:00:00 +0900
Next, let’s check how long a month is.
irb(main):002:0> 1.month
=> 2592000
irb(main):003:0> 1.month == 30*24*3600
=> true
So, it looks like 1.month
is 30 days, even in February, right?
Wait, does that mean that if I add 1.month
to today (Feb. 19th, remember?), then I
won’t get March 19th as one would expect?
Let’s check:
irb(main):004:0> Date.today + 1.month
=> 2015-03-19
irb(main):005:0> 1.month.since.to_date
=> 2015-03-19
Actually I do…
But you said a month is 30 days, and I’m pretty sure that if I add 30 days to Feb. 19th, I won’t get March 19th…
Right:
irb(main):006:0> Date.today + 30.days
=> 2015-03-21
irb(main):007:0> 30.days.since.to_date
=> 2015-03-21
So what’s up? Is 1.month
equal to 30 days, or to 28?
The answer to that is “it depends”, obviously.
Understanding how it’s working
As we found out one month ago with Ben, 1.day
is not an Object
.
1.month
follows the same pattern, it is an instance of ActiveSupport::Duration
,
which allows it to behave interestingly.
As seen in Ben’s post, ActiveSupport’s Date and Time calculations are defined
in a couple of files, and we’ll focus here on the Date#+
method:
activesupport/lib/active_support/core_ext/date/calculations.rb#L96
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
When adding something to a Date using the +
operator, if the right operand is an
instance of ActiveSupport::Duration
, then the calculation is delegated to the
method ActiveSupport::Duration#since
, which itself calls #sum
.
def sum(sign, time = ::Time.current) #:nodoc:
parts.inject(time) do |t,(type,number)|
if t.acts_like?(:time) || t.acts_like?(:date)
if type == :seconds
t.since(sign * number)
else
t.advance(type => sign * number)
end
else
raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
end
end
end
I don’t understand why the :seconds
case is treated separately (it looks like it
would work as well with the else
code), but the important line is
t.advance(type => sign * number)
. Long story short,
time + 1.month
is equivalent to:
time.advance(:months => 1)
Note that, at no point, the value of 1.month
was converted in days, or in seconds.
It was represented as a “quantity of 1, on the month unit”, all along.
Now if we jump back to Date#advance
definition
activesupport/lib/active_support/core_ext/date/calculations.rb#L110
def advance(options)
options = options.dup
d = self
d = d >> options.delete(:years) * 12 if options[:years]
d = d >> options.delete(:months) if options[:months]
d = d + options.delete(:weeks) * 7 if options[:weeks]
d = d + options.delete(:days) if options[:days]
d
end
Advancing a date one month will use Date#>>
operator, which, according to the
Ruby documentation:
returns a date object pointing n months after self
There you have it! At no point, from beginning to end, was a number of days, or seconds, involved.
Consequences
Well, the consequences of such behavior are multiple, some come very handy, while others can be dangerous.
First of all we can admit that it’s pretty cool we don’t have to worry about the number of days in a month when adding a number of months to a date.
Problems arise when using the same expression in the same piece of code, but this expression ends up having different logical values. Here’s a real-life example from the code I’m working on at the moment.
Let’s consider a simple Article
model, that has a #valid_until
attribute.
class Article < ActiveRecord::Base
DEFAULT_VALIDITY = 1.month
after_create :set_valid_until
def set_valid_until
self.update(valid_until: Time.now + DEFAULT_VALIDITY)
# Schedule a Sidekiq worker to unpublish in one month.
ArticleUnpublishWorker.perform_in(DEFAULT_VALIDITY, self.id)
end
end
This code is not very pretty, but it’ll do. When an article is created:
- Its
valid_until
attribute is set to one month in the future. - A Sidekiq job is scheduled to unpublish the Article in one month.
(Did you notice how I used the same “one month” expression in the two statements
above? That mirrors the code using the same 1.month
expression in both places.)
One would expect the job to be triggered around the time the article becomes
invalid (ideally when now is article.valid_until
or else a few milliseconds
to seconds after). That would be right only on 30-day months.
Let’s say I create an Article today (Feb. 19th 2015). Its valid_until
attribute
will be set to Mar. 19th, because as we saw above, adding 1.month
to a Date (or a
Time) will advance it exactly one month.
But the Sidekiq worker is scheduled to be run in 1.month
, which, all alone, is
always equal to 30 days!
There you have it, you thought you used 1.month
consistently and that dates would
match, but you’re getting a 2-day shift between the time the article becomes
invalid, and the time it’s actually unpublished.
Update: after checking Sidekiq’s code, I believe this behavior is a bug, and filed a pull request trying to solve it: MyWorker.perform_in(1.month) does not always schedule job in one month.
Last one for the fun
Now for the fun, let’s consider the two following expressions.
Time.now + 1.month - Time.now - 30.days
Time.now - Time.now + 1.month - 30.days
All I did was reorder the members of a simple arithmetic operation, right? They should have the same results.
Not in Rails!
irb(main):008:0> Time.now + 1.month - Time.now - 30.days
=> -172800.0
irb(main):009:0> Time.now - Time.now + 1.month - 30.days
=> 0.0
(remember, time is still frozen using Timecop)
Considering the explanation above, it will be easy for you to understand why…