LoginSignup
3
0

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

Last updated at Posted at 2023-12-09

この記事は、Ruby on Rails Advent Calendar 2023の10日目の記事です。

はじめに

@yayamochiです。業務でRuby on Railsを使ったバックエンド開発に取り組んでいます。

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

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

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

実際に遭遇したトラブル

「1.month」の仕様を勘違いしており、誤ったTTL2を設定してしまい、Redisのデータを消失してしまうというトラブルに遭遇しました。

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

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

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

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日間データが保持されてほしいところですが、なぜこんなことが起きたのでしょうか。

原因

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

実際に1.monthが表す日数を確認してみたところ、

# 2023年12月4日に実行
irb(main):023> 1.month / 1.day
=> 30

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

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

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

まずは「1.month.ago」の出力をみてみます。

irb(main):031> 1.month.ago
=> Sat, 04 Nov 2023 15:48:42.910798164 UTC +00:00

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

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」とは何かを理解していきます。

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クラスを見ていきます。
上部にこのようなコメントが書いてありました。

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クラスはDate#advanceを使うことで1.month.agoを実現しているようです。
初期化部分を見ます。

  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が入っているようです。

SECONDS_PER_MONTH  = 2629746 

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

Durationクラスの#ago

    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)が呼ばれます。
この段階で、「1.month.ago」であれば、type = :month, number = 1となるので、Durationクラスで返されている「30日」は関係がなくなることがわかります。

Timeクラスの#advence

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

  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

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

Dateクラスの#advance

 # 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

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

d >> 1

こうしてdateが「先月の実行日の日付」に変換されました。

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

まとめ

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

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

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

  1. 原因を調べている最中に、Rails, 1.month.ago とは何かの記事を参考にさせていただきました。執筆から数年立っており、自分なりにもう少し深掘りしてみたかったので今回記事を書きました。

  2. Redis TTL

  3. グレゴリオ暦を考慮するとこのような数字になるようです。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0