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