Adding BigDecimal hours to DateTime is wrong by 1 second

Ultimately, it boils down to floating point errors in some of the math that ActiveSupport does internally.

Notice that using Rational instead of BigDecimal works:

DateTime.now.beginning_of_day + Rational(2, 1).hours
# => Mon, 02 Dec 2019 02:00:00 -0800
Time.now.beginning_of_day + Rational(2, 1).hours
# => 2019-12-02 02:00:00 -0800

Here's the relevant code from Time/DateTime/ActiveSupport:

class DateTime
  def since(seconds)
    self + Rational(seconds, 86400)
  end

  def plus_with_duration(other) #:nodoc:
    if ActiveSupport::Duration === other
      other.since(self)
    else
      plus_without_duration(other)
    end
  end
end

class Time
  def since(seconds)
    self + seconds
  rescue
    to_datetime.since(seconds)
  end

  def plus_with_duration(other) #:nodoc:
    if ActiveSupport::Duration === other
      other.since(self)
    else
      plus_without_duration(other)
    end
  end

  def advance(options)
    unless options[:weeks].nil?
      options[:weeks], partial_weeks = options[:weeks].divmod(1)
      options[:days] = options.fetch(:days, 0) + 7 * partial_weeks
    end

    unless options[:days].nil?
      options[:days], partial_days = options[:days].divmod(1)
      options[:hours] = options.fetch(:hours, 0) + 24 * partial_days
    end

    d = to_date.gregorian.advance(options)
    time_advanced_by_date = change(year: d.year, month: d.month, day: d.day)
    seconds_to_advance = \
      options.fetch(:seconds, 0) +
      options.fetch(:minutes, 0) * 60 +
      options.fetch(:hours, 0) * 3600

    if seconds_to_advance.zero?
      time_advanced_by_date
    else
      time_advanced_by_date.since(seconds_to_advance)
    end
  end
end

class ActiveSupport::Duration
  def since(time = ::Time.current)
    sum(1, time)
  end

  def sum(sign, time = ::Time.current)
    parts.inject(time) do |t, (type, number)|
      if t.acts_like?(:time) || t.acts_like?(:date)
        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
      else
        raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
      end
    end
  end
end

What's happening in your case is on the t.since(sign * number * 3600) line, number is BigDecimal(2), and DateTime.since does Rational(seconds, 86400). So the whole expression when using DateTime is Rational(1 * BigDecimal(2) * 3600, 86400).

Since a BigDecimal is used as an argument to Rational, the result isn't rational at all:

Rational(1 * BigDecimal(2) * 3600, 86400)
# => 0.83333333333333333e-1 # Since there's no obvious way to coerce a BigDecimal into a Rational, this returns a BigDecimal
Rational(1 * 2 * 3600, 86400)
# => (1/12)                 # A rational, as expected

This value makes it back to Time#advance. Here are the results of the calculations it makes:

options[:days], partial_days = options[:days].divmod(1)
# => [0.0, 0.83333333333333333e-1] # 0 days, 2 hours
options[:hours] = options.fetch(:hours, 0) + 24 * partial_days
# => 0.1999999999999999992e1 # juuuust under 2 hours.

And finally, 0.1999999999999999992e1 * 3600 = 7199.9999999999999712, which gets floored when it's finally converted back to a time/datetime.

This doesn't happen with Time, since Time doesn't ever need to pass the duration's value into a Rational.

I don't think this should be considered a bug, since if you're passing a BigDecimal then is how you should expect the code to treat your data: As a number with a decimal component, rather than as a ratio. That is, when you use BigDecimals you open yourself up to floating point errors.


It's off by one second, not milisecond. Why not use 2.hours instead of BigDecimal(2).hours?