This happens with the update to ActiveSupport 6
start_time = DateTime.now.beginning_of_day
start_time + BigDecimal(2).hours #=>  Wed, 11 Sep 2019 01:59:59 +0000
Oddly enough this works fine with Time
start_time = Time.now.beginning_of_day
start_time + BigDecimal(2).hours #=>  2019-09-11 02:00:00 +0000
Can anybody explain why?
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?
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With