Be careful with time durations in Rails
Tags: rails
Published: 2023-04-17 20:29:02

*This article was originally written by [Jonathan Miles](https://www.honeybadger.io/blog/authors/jonathanmiles/) on the [Honeybadger Developer Blog](https://www.honeybadger.io/blog/activesupport-duration-helpers/).*

Rails has been described as a dialect of Ruby. If you’ve ever tried to program in pure Ruby after spending most of your time in Rails, you’ll understand why. Rails provides a lot of useful methods in core Ruby classes; for example, in Rails, `nil` allows you to call `.present?`, `.blank?`, `.to_i`, `.to_a`, and many more methods, all of which would raise an exception in a pure Ruby file.

Rails does this by "monkey patching" some of Ruby's classes, which refers to adding methods to a class that are not in the original class definition. This is a powerful feature of Ruby and is what allows Rails to create its unique 'dialect'. One of the most well known of these extensions is the date/time helpers that let you write code like `3.days.ago`.

Date manipulation is a tricky part of programming, and these helpers are pretty smart, but they can get you in trouble if you're not aware of what they are doing. Take a look at these two lines of code:

```ruby
Time.current + 1.month + 1.day
Time.current + 1.day + 1.month
```

Do these lines produce the same results? It depends when you run them. To understand why, we need to understand a bit about how these methods work so that we can use them effectively.

## Unix time

In ye olden days of computers, programmers needed a way to manage Datetimes with some degree of accuracy. This lead to what became known as “Unix time”: an integer value holding the number of seconds since midnight January 1st 1970.

This is still used as the underlying way to store Datetimes in many systems.

Ruby lets us convert dates to these values:

```ruby
> Time.new
=> 2022-08-20 11:36:26.785387828 +1200
> Time.new.to_i
=> 1660952189 # Unix time
```

If we want to add an amount of time, then we just need to work out how many seconds we need. Therefore, we could add one day’s worth of seconds to change the time to tomorrow:

```ruby
> time = Time.new
=> 2022-08-20 11:40:27 +1200
> time + 86_400
=> 2022-08-21 11:40:27 +1200
> time + (24 * 60 * 60) # more verbose version
=> 2022-08-21 11:40:27 +1200
```

## The .day and .days ActiveSupport helpers

Instead of developers across the world implementing their own days-to-seconds methods, ActiveSupport helpfully gives us one out-of-the-box in the form of `.day` and `.days` methods on Numerics:

```ruby
> 1.day
=> 1 day
> 1.day.to_i
=> 86400
> (2.7).days.to_i # works with all Numeric types, not just integers
=> 233280
> (2.7 * 24 * 60 * 60).round #equivalent calculation to 2.7 days
=> 233280
```

## ActiveSupport::Duration

Although the value returned by the `.day` helper does a good job of imitating an integer, it’s not an integer. When you call `.day` or any of the other calendar-related helpers on Numerics, what you get back is an `ActiveSupport::Duration`. If we look at some of the other helpers, we can see why this is the case; we’ll choose `.month` here.

First, unlike `day`, we can’t have `.month` just return a fixed integer; because the months have different durations, it could be anywhere from 28 to 31 days. Let’s start with January 30th and add a month to it:

```ruby
> time = Time.new(2022, 1, 30)
=> 2022-01-30 00:00:00 +1300
> time + 1.month
=> 2022-02-28 00:00:00 +1300
```

Here we see that the value has been capped to Feb 28th. In most cases, this is probably what we want so that February isn’t skipped. However, this also creates a counter-intuitive situation that could cause problems in a codebase:

```ruby
Time.new + 1.month + 1.day
Time.new + 1.day + 1.month
```

These lines look like they should give the same result. However, because of this “capping” behavior, they may not, depending on the time of year these commands are run. Indeed, our CI had failures due to this discrepancy that only showed up in late January.

```ruby
time = Time.new(2022, 1, 30)
=> 2022-01-30 00:00:00 +1300
> time + 1.month + 1.day
=> 2022-03-01 00:00:00 +1300
> time + 1.day + 1.month
=> 2022-02-28 00:00:00 +1300
> time + (1.day + 1.month) # even adding brackets doesn't change it
=> 2022-02-28 00:00:00 +1300
> time + (1.month + 1.day)
=> 2022-03-01 00:00:00 +1300
```

The order of operations here will determine which result is returned, and even adding brackets doesn’t help. What’s going on inside `ActiveSupport::Duration`? How does it know February only has 28 days? Let’s dive in and take a look.

## ActiveSupport::Duration source

Looking at the [source](https://github.com/rails/rails/blob/main/activesupport/lib/active_support/duration.rb) for `Duration`, we’ll start with the method for addition, as it should give us some clues to what is going on:

```ruby
# Adds another Duration or a Numeric to this Duration. Numeric values
# are treated as seconds.
def +(other)
  if Duration === other
    parts = @parts.merge(other._parts) do |_key, value, other_value|
      value + other_value
    end
    Duration.new(value + other.value, parts, @variable || other.variable?)
  else
    seconds = @parts.fetch(:seconds, 0) + other
    Duration.new(value + other, @parts.merge(seconds: seconds), @variable)
  end
end
```

What is interesting to me here is this `@parts` variable. It seems that a Duration stores the value in two ways: as the number of seconds and as a `parts` hash. While some of these are private to the class, fortunately for us, Ruby gives us some tools, such as `#instance_variable_get`, to see the values being stored here:

```ruby
> duration = (1.year + 5.months + 1.month + 3.days)
=> 1 year, 6 months, and 3 days
> duration.instance_variable_get :@parts
=> {:years=>1, :months=>6, :days=>3}
> duration.instance_variable_get :@value
=> 47594628
```

Therefore, Duration has more granularity that just X-number-of-seconds. Let’s see what happens when it is added to a `Time`.

## Time

 Looking into the [source](https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/time/calculations.rb) of Rails’ Time calculations, we see that `+` is actually aliased to this method:

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

We’re only concerned with `Duration` right now, so it looks like our next stop is `Duration#since`:

```ruby
def since(time = ::Time.current)
  sum(1, time)
end
```

Checking `sum` in the same class, we find:

```ruby
def sum(sign, time = ::Time.current)
  unless time.acts_like?(:time) || time.acts_like?(:date)
    raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
  end

  if @parts.empty?
    time.since(sign * value)
  else
    @parts.inject(time) do |t, (type, number)|
      if type == :seconds
        t.since(sign * number)
      elsif type == :minutes
        t.since(sign * number * 60)
      elsif type == :hours
        t.since(sign * number * 3600)
      else
        t.advance(type => sign * number)
      end
    end
  end
end
```

Now we’re getting somewhere. It seems that for seconds, minutes, and hours, Rails just adds the raw number of seconds to the Time. This makes sense because these values will always be the same regardless of when the code is called. For `month` and `year`, though, it uses `Time#advance`. Looking up this method gives us the following:

```ruby
# Provides precise Date calculations for years, months, and days. The +options+ parameter takes a hash with
# any of these keys: <tt>:years</tt>, <tt>:months</tt>, <tt>:weeks</tt>, <tt>:days</tt>.
def advance(options)
  d = self

  d = d >> options[:years] * 12 if options[:years]
  d = d >> options[:months] if options[:months]
  d = d + options[:weeks] * 7 if options[:weeks]
  d = d + options[:days] if options[:days]

  d
end
```

Here, at last, we have our answer. `>>` and `+` are Ruby’s native `Date` methods. `>>` increments the month, while `+` increments the day. The Ruby docs for `>>` state that “When the same day does not exist for the corresponding month, the last day of the month is used instead”.

## Conclusion

Rails’ date and time helpers are great. They save us from duplicating simple add-duration-to-time logic across our applications and make the code more readable. However, complex date manipulations are dangerous places full of edge-cases (and I didn’t even mention time zones in this article).

So, what is a Rails’ developer to do? Well, based on what I’ve learned here, this is my personal rule of thumb: use Rails’ helpers for single values (`Time.current + 3.days`, etc.) or simple ranges (`1.month...3.months`), but for anything more complicated, particularly calculations that involve mixing units (`1.month + 3.days`), it is better to use the `Date#advance` method (`time.advance(months: 1, days: 3)`). This sacrifices a little on readability but ensures the result is consistent. It also helps to highlight the fact that there is more than just simple mathematical operations going on, hopefully so other developers are more mindful of the way days and months will be treated by this code.