Rubyでナノ秒、PostgreSQLだとマイクロ秒


環境: circleci/ruby:2.4.2-stretch-node-browsers, circleci/postgres:10.12

とあるアプリケーションで、新しくCircleCIを設定してRspecを動かしてみたら、エラーが大量に出ました。

expected: 2020-03-25 12:36:20.477148202 +0900
got: 2020-03-25 12:36:20.477148000 +0900

Rubyはナノ秒(小数点以下9桁)まで時刻を保持し、PostgreSQLはマイクロ秒(小数点以下6桁)までしか保持しないので、比較が失敗しまくっていると判明。ひえー、どうすんのこれ。助けて!

用語

参照: Orders_of_magnitude_(time)

名前 単位
ミリ秒 10-3 0.123
マイクロ秒 10-6 0.123456
ナノ秒 10-9 0.123456789

以下、3桁ずつピコ 10-12、フェムト 10-15、アット 10-18、ゼプト 10-21、ヨクト 10-24と続きます。

Macでは

Mac(rbenvでインストール)では、RubyのTime#nsecはナノ秒(9桁)を表しますが、実際はマイクロ秒(6桁)で切り取られるので、Postgresとの違いに気づきませんでした。

t = Time.now
t.to_f #=> 1585394326.856343
t.nsec #=> 856343000

Ubuntuだとナノ秒までしっかり入ります。

t = Time.now
t.to_f #=> 1585394640.8082483
t.nsec #=> 808248257

対策

Time#<=> を上書きしてミリ秒で比較することでエラーを減らすことができました。<=> を上書きするだけで、== も != も < も対策できます。Time#<=> を上書きすると、ActiveSupport::TimeWithZone の比較にも使われます。

マイクロ秒で比較(floor(6))ではうまく行きませんでした。

spec/supports/time_patch_helper.rb
if ENV['CIRCLECI']
  class Time
    alias_method :old_cmp, :<=>
    def <=>(val)
      if val.try(:acts_like_time?)
        self.to_f.floor(3) <=> val.to_f.floor(3)
      else
        old_cmp(val)
      end
    end
  end
end

ちなみにRubyには時刻の小数点以下を丸めるメソッド Time#round があり、Ruby 2.7 では切り落としを行う Time#floorが追加されています。

今後やりたいこと

  • 上記の対策よりもうまい方法を考える。
  • Rubyのソースコードをチェックしたり、Linuxでコンパイルしてみたりする(たぶんC言語の#defineに違いがある)。