hm / blog

Wtf is an Active Support Duration?

If you have been around Rails for even a short amount of time, you have probably seen some code that looks something like

1.day # => 1 day
2.days + 12.hours # => 2 days and 12 hours

and with a little more time, you may start wondering about

require "active_support/core_ext/integer/time" # <= this require

Rails.application.configure do
  # ...
end

at the top of your config/environments/*.rb files.

How do these magic methods work? Why do environment files need this mystery require ? And wtf is an Active Support Duration??

As you may have guessed already, this mysterious require in config/environments is to ensure that applications are loading one of Active Support’s Core Extensions, and this core extension is what enables the fancy 1.day syntax. And if you inspect what these methods return, you will find that they are instances of ActiveSupport::Duration.

To really understand what ActiveSupport::Duration is and why it exists, it is important to first look back at how things worked before it was added.

Core Extension Origins

The Fixnum Core Extension was first added to Rails in commit 38e55ba (although in January of 2005 Rails was not actually using git yet). The relevant part of the patch looks something like

# activesupport/lib/core_ext/fixnum_ext.rb
class Fixnum
  def minutes
    self * 60
  end
  alias :minute :minutes

  def hours
    self * 60.minutes
  end
  alias :hour :hours

  def days
    self * 24.hours
  end
  alias :day :days

  # ...
end

This file monkeypatches the Fixnum class by re-opening it and defining some new instance methods. This allows developers to write code like 2.days + 4.hours and they’ll have the calculation done for them magically. The secret is that 2.days is simply returning the number of seconds in 2 days, so it can easily be added to the number of seconds in 4 hours.

On their own, these methods are pretty simple, but things start to get more complicated when these methods are used to interact with Time.

Four new methods are added to the Fixnum Core Extension in commit 14ed815: ago, until, since, and from_now. These all build on the previous methods (days, hours, minutes, etc.) by making it super easy to add or subtract values from Time.

While these methods work well for smaller units like hours, days, or weeks, they don’t produce as accurate results for the larger units like months and years.

User.find(:all, :conditions => ['birthday > ?', 50.years.ago])

This is one of the examples given in commit bb6b14b, which changed the years method to use 365.25.days instead of 365.days. Leaving off leap years led to 50.years.ago being many days off of its intuitive value. For example, before this patch 50.years.ago today (2022-09-02) would return 1972-09-14 instead of 1972-09-02. Unfortunately, missing leap days is just one example of the inaccuracies of this approach. At this point, 1.month is still equal to 30.days, which can easily lead to the same kind of non-intuitive calculations.

The Core Extension methods were documented as being “approximations” with alternative methods recommended for more precise Time and Date calculations. However, this all changed with the introduction of ActiveSupport::Duration.

Start of Duration

ActiveSupport::Duration was introduced in commit 276c9f2 to address some of the accuracy issues that come with converting everything to seconds. The commit message doesn’t have a ton of details but the old Rails issue tracker provides us with

Make 1.month.from_now be accurate

The month, day, and year methods on Fixnum are not accurate, which is why Time#advance was added. This patch creates a Duration class which makes these methods use Time#advance for accurate date and time processing:

>> Time.now
# => Tue Dec 12 23:59:38 PST 2006
>> 1.month.from_now
# => Fri Jan 12 23:59:46 PST 2007

The current results:

>> Time.now
# => Tue Dec 12 23:59:38 PST 2006
>> 1.month.from_now
# => Fri Jan 11 23:59:46 PST 2007

The same applies for years and days. This patch also addresses the disconnect between adding to a Time and adding to a Date. See #6803 for the a description of the problem.

I expect to go through at least a few updates of the patch, so feedback is appreciated! Tests are included.

If you thought 30.months.ago was magical before, ActiveSupport::Duration takes it up another notch. This Duration class uses a new approach for its internal representation: instead of converting everything to seconds, it stores each unit of time separately. As mentioned in the issue tracker description, Time#advance allows a Time to be incremented by individual units without any loss of precision

Time.new(2022, 9, 2).advance(months: 30) # => 2025-03-02

By storing each unit of time separately, Duration is now able to use Time#advance internally so that 30.months.ago can return an accurate result!

The End?

That’s a short history of 1.day and ActiveSupport::Duration. Hopefully this helps to understand both how it works and the problems that its intended to solve. There are a few more bits here that didn’t quite get covered, but those are topics for another day…