Railsにおける「1.month」と「1.month.ago」の内部実装の違いを探る

この記事は、[Ruby on Rails Advent Calendar 2023](https://qiita.com/advent-calendar/2023/ruby-on-rails)の10日目の記事です。

# はじめに
[@yayamochi](https://twitter.com/yayamochi)です。業務でRuby on Railsを使ったバックエンド開発に取り組んでいます。

皆さん、Ruby on Railsにおいて、「1.month」はどういった値を返すかご存知でしょうか？

最近、時間操作でよく使う<strong>「1.month」</strong>を通してトラブルに遭遇しました。

頻繁に利用されるはずの<strong>「1.month」</strong>が思わぬ落とし穴につながることがあるので、実装を深掘り[^1]していきます。

# 実際に遭遇したトラブル

<strong>「1.month」</strong>の仕様を勘違いしており、誤ったTTL[^2]を設定してしまい、Redisのデータを消失してしまうというトラブルに遭遇しました。

開発しているサービスでは、RedisとDB上に月次のランキングを保存しており、そのうち、<strong>アクセスの多い2ヶ月分のデータはRedis上で保持するようにして</strong>いました。

毎日のバッチ処理で、前日時点でのマンスリーランキングをDBにバックアップしており、そのタイミングでRedis上のデータに<strong>「1.month</strong>」のTTLをセットすることで2ヶ月分のデータを残すように実装していました。

しかし、<strong>9月中のランキングデータを10/31にRedis経由で閲覧しようとしたが、データが消えてしまっていました。</strong>

#### 10/30時点のRedisのkeyと中身
- key: "ranking_september", value: 9/1~9/30のランキングデータ
- key: "ranking_october", value: 10/1~10/31のランキングデータ

#### 10/31時点のRedisのkeyと中身
- key: "ranking_october", value: 10/1~10/31のランキングデータ

10/1時点で9月分のデータに「1.month」のTTLを設定すれば、31日間データが保持されてほしいところですが、なぜこんなことが起きたのでしょうか。


# 原因
調査をしていると、とあることに気づきました。
<strong>今までなんとなく使っていた「1.month」、これはもしかして実行月の日数のDurationをを返すわけではないのでは・・・？</strong>

実際に1.monthが表す日数を確認してみたところ、
```ruby
# 2023年12月4日に実行
irb(main):023> 1.month / 1.day
=> 30
```

30と出力されました。
12月に実行したら31になるかと思いこんでいましたが、違うようです。

つまり10/1時点で9月分のデータに設定した「1.month」というTTLは「31日」ではなく「30日」であったため、想定よりも1日早くデータが消失してしまっていたのです。

なぜ私はこう思い込んでいたのでしょうか？
<strong>「1.month」と同様に頻繁に使う「1.month.ago」はどの月に実行しても「先月の実行日の日付」が返却されるからです。
</strong>

まずは「1.month.ago」の出力をみてみます。
```ruby
irb(main):031> 1.month.ago
=> Sat, 04 Nov 2023 15:48:42.910798164 UTC +00:00
```

想定どおり、「先月の実行日の日付」が返却されます。
次に、試しに2ヶ月前から1ヶ月前を引いてみます。

```ruby
irb(main):037> a = 2.months.ago
=> Wed, 04 Oct 2023 15:49:43.778188326 UTC +00:00
irb(main):038> b = 1.month.ago
=> Sat, 04 Nov 2023 15:49:45.563940783 UTC +00:00
irb(main):039> (b - a)/1.day
=> 31.000020668431215
```

1.monthは「30日」なのに、2.months.agoから1.month.agoを引くと「31日」になりました。

# 実装を探る
## Integerクラスの#month
まずは「1.month」とは何かを理解していきます。

https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/integer/time.rb#L10
```ruby
class Integer
  # Returns a Duration instance matching the number of months provided.
  #
  #   2.months # => 2 months
  def months
    ActiveSupport::Duration.months(self)
  end
  alias :month :months
```
とてもシンプルなメソッドです。「1.month」はActiveSupport::Durationクラスを返しています。

## Durationクラスのmonth

ActiveSupport::Durationクラスを見ていきます。
上部にこのようなコメントが書いてありました。

https://github.com/rails/rails/blob/main/activesupport/lib/active_support/duration.rb#L7

```ruby
module ActiveSupport
  # = Active Support \Duration
  #
  # Provides accurate date and time measurements using Date#advance and
  # Time#advance, respectively. It mainly supports the methods on Numeric.
  #
  #   1.month.ago       # equivalent to Time.now.advance(months: -1)
```

Durationクラスは<strong>Date#advance</strong>を使うことで1.month.agoを実現しているようです。
初期化部分を見ます。

https://github.com/rails/rails/blob/main/activesupport/lib/active_support/duration.rb#L175
```ruby
  class << self
      # 略
     def months(value) # :nodoc:
        new(value * SECONDS_PER_MONTH, { months: value }, true)
      end
  end


    def initialize(value, parts, variable = nil) # :nodoc:
      @value, @parts = value, parts
      @parts.reject! { |k, v| v.zero? } unless value == 0
      @parts.freeze
      @variable = variable

      if @variable.nil?
        @variable = @parts.any? { |part, _| VARIABLE_PARTS.include?(part) }
      end
    end
```

monthというクラスメソッドでinitializerを隠蔽しています。
initializeの第一引数には、value * 一ヶ月分の秒数、第二引数には単位を把握するためのHashが入っているようです。

```ruby
SECONDS_PER_MONTH  = 2629746 
```
1ヶ月の秒数の定義を、1日の時間の秒数の86400で割るとだいたい30日[^3]になります。
ここまでで、「1.month」が30日と返却される理由が理解できました。

## Durationクラスの#ago

https://github.com/rails/rails/blob/main/activesupport/lib/active_support/duration.rb#L439

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

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

agoの内部ではprivateのsumメソッドが実行されています。
typeは:month になるので、t.advance(:month => -1 * 1)が呼ばれます。
<strong>この段階で、「1.month.ago」であれば、type = :month, number = 1となるので、Durationクラスで返されている「30日」は関係がなくなることがわかります。</strong>

## Timeクラスの#advence
receiverがActiveSupport::TimeWithZoneクラスなので、time周りの実装を探ります。

https://github.com/rails/rails/blob/main/activesupport/lib/active_support/core_ext/time/calculations.rb#L202C1-L225C6

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

<strong>  d = to_date.gregorian.advance(options)</strong> が日付計算の核心になりそうです。

## Dateクラスの#advance

```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>.
  #
  # The increments are applied in order of time units from largest to smallest.
  # In other words, the date is incremented first by +:years+, then by
  # +:months+, then by +:weeks+, then by +:days+. This order can affect the
  # result around the end of a month. For example, incrementing first by months
  # then by days:
  #
  #   Date.new(2004, 9, 30).advance(months: 1, days: 1)
  #   # => Sun, 31 Oct 2004
  #
  # Whereas incrementing first by days then by months yields a different result:
  #
  #   Date.new(2004, 9, 30).advance(days: 1).advance(months: 1)
  #   # => Mon, 01 Nov 2004
  #
  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
```

年・月・週・日の順序で日付を計算していくようです。

https://docs.ruby-lang.org/ja/latest/method/Date/i/=3e=3e.html
```ruby
d >> 1
```
こうしてdateが「先月の実行日の日付」に変換されました。

agoメソッドでは、こうしてDate#advanceクラスで変換した後に、時間をさらに加減算し、新たな日時を返しているようです。

# まとめ

- 「1.month」が固定値で30日のDurationクラスのインスタンスを返すこと
- 「1.month.ago」はDurationクラスのagoを介して年月の単位をベースに計算すること

を理解することができました。

Rubyは型がなく、直感的なコードも多いので、わかった気になっているようなものが多いと改めて感じました。
自分自身開発速度を追い求めて理解できなくてもまず使ってみる、精神でコーディングしてしまうこともあるため、こうしたトラブルに遭遇したことは深追いの良いきっかけになったかと思います。


[^1]: 原因を調べている最中に、[Rails, 1.month.ago とは何か](https://qiita.com/closer/items/e5a0b93a1cbcdf2e7216)の記事を参考にさせていただきました。執筆から数年立っており、自分なりにもう少し深掘りしてみたかったので今回記事を書きました。
[^2]: [Redis TTL](https://mogile.web.fc2.com/redis/redis500/commands/ttl.html)
[^3]: [グレゴリオ暦](https://ja.wikipedia.org/wiki/%E3%82%B0%E3%83%AC%E3%82%B4%E3%83%AA%E3%82%AA%E6%9A%A6)を考慮するとこのような数字になるようです。