Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: タイムゾーン処理で重大なバグを何か月も見落としていた話(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: タイムゾーン処理の重大なバグを何か月も見落としていた話(翻訳)

私たちは最近、ある金融プラットフォームにおけるデータ同期の問題を修正しました。この問題は誰にも気づかれないまま数か月に渡ってデータの不整合を引き起こしていたのです。バグの内容は、ある意味洗練されているとも言えるレベルのシンプルさで、過去の日付を変換する時に、過去のではなく「現在の」夏時間(DST)をチェックしていたのです。

🔗 壊れていたコード

私がこの問題に気づいたのは、別の同期問題を調べていたときのことでした。本当のバグは、私がチェックすらしていなかったヘルパーファイルに潜んでいたことが明るみになったのです。

def self.date_to_utc(value, timezone_key)
  offset = Time.now.in_time_zone(TIMEZONE_MAP.fetch(timezone_key)).formatted_offset
  Time.new(*value.to_s.split('-').map(&:to_i), 0, 0, 0, offset).utc
end

タイムゾーンのオフセットを取得してTimeオブジェクトを作成し、UTCに変換しているだけです。別におかしなところはなさそうですよね?

問題はTime.now.in_time_zone().formatted_offsetの部分にあります。これは変換対象のあらゆる日付に適用されますが、ここで取得していたのは「現在」の日時のオフセットだったのです。

🔗 壊れた理由

以下のコードを12月に東部標準時(EST、UTC-5)で実行するとこうなります。

date_to_utc(Date.new(2023, 6, 20), :eastern)
# 取得されるオフセットは-05:00だが、6月20日は夏時間なので本来EDTの-04:00が適用されなければおかしい
# 結果: 1時間ずれる

参考: 東部夏時間(EDT) - Wikipedia

同じコードを6月に東部夏時間(EDT、UTC-4)で実行するとこうなります。

date_to_utc(Date.new(2023, 6, 20), :eastern)
# 取得されるオフセットは-04:00なので6月では正しい
# 結果: 正常

入力がまったく同じであるにもかかわらず、いつ実行したかで出力が変わってしまっています。テストは夏になるとパスし、冬になるとコケます。夏時間(DST)の期間であるかによってデータ同期でレコードを見失ったり、取得するデータ範囲がおかしくなる可能性があります。

訳注

上の問題を手元のRailsコンソールで手軽に再現するには、Railsコンソール内で最初に以下を実行しておくとよいでしょう。これはシステムやRailsの設定に影響を与えません。

require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers
# 再現用のタイムゾーンデータ
TIMEZONE_MAP = { eastern: "America/New_York" }
# 一時的にタイムゾーンを変更
Time.zone = "EST"

後はtravel_to Time.zone.local(2023, 6, 1, 9, 0, 0)でローカル日付を6月にしたりtravel_to Time.zone.local(2023, 12, 1, 9, 0, 0)に変え、Time.currentで変更時刻を確かめつつdate_to_utcで現象を再現できます。コンソールを終了すれば元通りになります。

🔗 修正方法

def self.date_to_utc(value, timezone_key)
  tz = ActiveSupport::TimeZone[TIMEZONE_MAP.fetch(timezone_key)]
  tz.local(value.year, value.month, value.day, 0, 0, 0).utc
end

ActiveSupport::TimeZone#localを使えば、特定の日時の日付を変換するときの夏時間(DST)を正しく処理できます。コードを実行する時期に影響されることなく、6月の日付は常にEDT(東部夏時間)となり、1月の日付は常にEST(東部標準時)になります。

🔗 この問題を検出するテストコード

実装をいじるまえに、私の疑問を裏付けるためのテストを書いてみたところ、案の定速攻で失敗しました。

it 'produces consistent results regardless of system timezone' do
  date = Date.new(2023, 6, 20)
  expected = Time.new(2023, 6, 20, 4, 0, 0, 'UTC')

  %w[UTC Asia/Tokyo America/Los_Angeles].each do |tz|
    Time.use_zone(tz) do
      expect(described_class.date_to_utc(date, :eastern)).to eq(expected)
    end
  end
end

このテストは、同じ変換を「UTC」「Tokyo」「LA」タイムゾーンでそれぞれ実行します。修正前の実装では、実行するときのシステムタイムゾーンや、その年のいつテストを実行したかによって結果が違っていました。

🔗 バグの影響

production環境で目に見える形で問題を引き起こす前に辛くもこのバグをキャッチできたものの、財務データ統合への潜在的な影響は深刻でした。通常時間と夏時間の切り替わりによって時刻が1時間ずれると、日付範囲を指定したクエリでレコードが見つからなくなったり、システム間のバリデーションが一致しなくなってしまう可能性があります。

🔗 教訓

  1. 現在以外の日付を計算するときにはTime.nowを決して使ってはならない
    過去の特定の日付でタイムゾーンが必要になったら、Time.nowではなく、その日付を使うこと。

  2. タイムゾーン操作は明示的にテストすること
    production環境のタイムゾーンと同じタイムゾーンに設定されているシステムに依存してはならない。

  3. 通常時間と夏時間の切り替わりは要注意
    特定の月にならないと発生しないバグは、コードレビューやテストをすり抜けてしまう可能性がある。

  4. ツールをしっかり理解すること
    ここではActiveSupport::TimeZone

関連記事

Ruby: 2026年にもなってDateTimeを使うな(ただしユネスコの仕事は除く)(翻訳)

Rails: Timecopを使わなくても時間を止められた話

Rails: 時間を操作するテストを安定させる方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。