upinetree's memo

Web系技術の話題や日常について。

できる! mathn 脱却

この記事は STORES.jp Advent Calendar 2019 の 6 日目の記事です。

STORES.jp のバックエンド基盤チームでは、絶賛 Ruby のバージョンアップを進行中です。Ruby のバージョンアップはコード全体に影響を及ぼすため毎回一大イベントなのですが、本記事ではその中でも特に過酷だった事例を紹介します。それは「いつの間にか依存していた mathn を取り除かないと Ruby のバージョンを上げられない」というものでした。

mathn とは

Ruby 2.4 まで標準添付されていたライブラリで、数値ライブラリの挙動をグローバルに変更するものです。以下のように mathn の require の有無によってグローバルに計算結果が変わります。

1/2                #=> 0
2 * Rational(1,2)  #=> Rational(1,1)

require 'mathn'
1/2                #=> Rational(1,2)
2 * Rational(1,2)  #=> 1 (Fixnum)

Ruby 2.2 では Deprecated warning が表示されるようになり、Ruby 2.5 では外部ライブラリとして切り出されました。そのため完全なドキュメントは Ruby 2.1 のものが最後となっています。

https://docs.ruby-lang.org/ja/2.1.0/library/mathn.html

上記ドキュメントから引用すると、 mathn を require することにより以下の挙動が変化するようです。なかなかにダイナミックなモジュールですね。

  • 整数の除算が割り切れない場合、 Rational オブジェクトを返すようになります。
  • 複素数有理数の演算結果が実数や整数に収まる場合、 Float オブジェクトや Integer オブジェクトを返します。
  • Math モジュールの数学関数の定義域と終域を、実数のみから複素数へと拡大します。

いつの間にか mathn に依存していた

mathn は数値計算を行うようなスクリプトなど、使い所によっては便利なモジュールです。ただ、Ruby の標準の動作を上書きし、あらゆる計算結果に影響を及ぼすので、混乱を招く面があります。そのため、Web アプリケーションやライブラリで読み込むようなことはオススメできません。

しかし、思いもよらぬことは起こるものです。 stylish という gem が require 'mathn' していて、それが Rails アプリケーションから読み込まれていたのです。つまり、この Rails アプリケーションでは、気付かぬうちに計算の挙動がグローバルに上書きされてしまっていました。その事実に気づいたのは stylish が入ってから数年後で、その頃にはすでに mathn を前提とした計算がコードベースに深く浸透していたのでした。

基本的には誤差が無くなる方向に作用するものなので、混乱を招く以外に実害はありませんでした。また、広範囲に影響していることから、 mathn を取り除くコストとリスクを踏まえて対応を先送りしていました。しかし、Ruby 2.5 にアップデートするときに、この問題が顕在化したのです。

Ruby 2.5 では、前述の通り mathn は外部ライブラリとして切り出されています。そのためアップデートにあたっては、ひとまず Gemfile に(外部ライブラリとして切り出された) mathn を記述して回避しようと考えていました。しかし実際に Ruby 2.5 に上げてみると、 mathn が原因で segfault するケースがあることがわかりました。

$ ruby -r mathn 'Time.at(1.1).to_s'
-e:1: [BUG] Segmentation fault at 0x0000080000000001
...

mathn はすでに deprecated なライブラリで、修正は期待できません。つまり、Ruby 2.5 にアップデートするためには mathn から脱却しなければならないということです。アップデートしないという選択肢はもちろんありませんよね。

入り込んだ mathn を取り除くためにやったこと

mathn 前提の計算はコードベースに無数に存在し、影響範囲は検討がつきません。それでも mathn を取り除かないと Ruby をアップデートできないので、なんとか工夫を考えました。

mathn による動作変更の理解を深める

まずは mathn が何をしているのかをよく知る必要があるので、コードリーディングをしました。 Ruby 部分はシンプルな実装なので難なく把握できました。

一方で C 拡張部分はなかなか複雑で、ドキュメントに書いてあった動作と照らし合わせながら、仕組みと影響範囲を理解することに努めました。 canonicalization 変数の作用を起点に見て回っていた記憶があります。たとえば ここ とか ここ とかから掘っていきました。

結果として以下のメソッドに影響がありそうだということがわかりました(網羅できてはいないが、あたりを付けるところまでをゴールとした)。

  • Xxx#to_r
  • Date#-
  • Date#ajd
  • Date#amjd
  • Date#marshal_load
  • Date#day_fraction
  • DateTime#-

影響調査範囲を絞り込む

mathn の影響箇所を人力で漏れなく洗い出すのは現実的ではありません。アプリケーションコードだけではなく、依存するライブラリ内部の計算結果にも影響するので、そこまで考慮するのはより一層難しくなります。

なので、ある程度網羅性を緩めた条件を設定することにしました。以下のように、アプリケーションの「出口」のみに焦点を当てるような仮定を設けました。

  • ライブラリ内部では通常は mathn がないことが前提になっているので、精度を求める場面では明示的に考慮するはず
  • Rational/Complex の自動丸めがなくなることは精度を低下させるような影響はないので、処理の途中の計算では問題ない
  • 出口であるリクエストやレスポンスの生成で顕在化した差異をやっつければよい。例えば、自動丸めされなくなり Rational が String に変換されて 3/1  のようになると困る

以上を前提とすると、次の方針が立てられます。

  • Rational を返すメソッド呼び出しを洗い出し、アプリケーションの出口に影響するかを判断する
  • メソッド呼び出しの検知は TracePoint の  returnc_return  イベントを掴む
  • Rational が Integer になって困るケースを検知するため、 mathn 削除前に TracePoint を仕込んで CI で検証する
  • Integer に丸められていたのが Rational になって困るケースを検知するため、 mathn 削除後に TracePoint を仕込んで CI で検証する

TracePoint で Rational の存在を検知し、影響を判断する

以下のようなモジュール(厳密には使用したものとは違います)を用意して、アプリケーションコード中の Rational が返ってくるメソッド呼び出しを検知しました。

module RationalDetection
  class << self
    def enable
      return if @trace_point
      trace_point = create_trace
      trace_point.enable
      @trace_point = trace_point
      @oneshot_detections ||= {}
    end

    def disable
      return unless @trace_point
      @trace_point.disable
      @trace_point = nil
    end

    def save_oneshot_detections(version: nil)
      dir = Rails.root.join('tmp/artifacts')
      filename = dir.join(["rational_callers_#{mathn_enabled? ? 'with' : 'without'}_mathn", version].join('_') + '.txt')
      FileUtils.mkdir_p(dir)
      File.open(filename, 'w') { |f| f.puts(@oneshot_detections.keys) }
    end

    private

    def called_from_app?(path)
      ['app', 'lib', 'config'].any? { |dir| path.match?(Rails.root.join(dir).to_s) }
    end

    def rational_instance?(object)
      Kernel.instance_method(:class).bind(object).call == Rational
    end

    def mathn_enabled?
      $LOADED_FEATURES.grep(/mathn\.rb/).present?
    end

    def create_trace
      TracePoint.new(:return, :c_return) do |tp|
        next unless rational_instance?(tp.return_value)

        caller_index = tp.event == :c_return ? 0 : 1
        caller_path = caller[caller_index].rpartition(/:/).first
        next unless called_from_app?(caller_path)

        @oneshot_detections[caller_path] ||= true
      end
    end
  end
end
  • called_from_app? メソッドの呼び出しでは、ライブラリの内部での mathn の影響を前述の仮定のもとで無視しています
  • 一度だけ呼ばれていることを確認できれば良いので、 @oneshot_detections のハッシュでフラグ管理しています。そして CI の Artifact としてテスト終了後に書き出します
  • 戻り値が Rational かどうかの判定のために is_a? を使わずにわざわざ rational_instance? という怪しげなメソッドを定義しています。これは、BasicObject を継承していて method_missing が定義されているクラスは、 is_a? を呼び出した際に method_missing が呼ばれてしまい、その実装により誤検知もしくは例外を発生してしまうケースがあったためです。また、 defined? による判定も true になってしまうようでした( Ruby 2.7-preview1 で確認したときは false になっていたので、どこかで修正されたのかな)

このモジュールを RSpec の実行時に呼び出すようにして、

config.around(:example) do |example|
  RationalDetection.enable
  example.run
  RationalDetection.disable
end

config.after(:suite) do
  RationalDetection.save_oneshot_detections(version: Time.current.to_i.to_s)
end

CircleCI で mathn あり、なしの両方の条件で実行し、結果を Artifact として得ました。そして一つ一つ出口への影響有無を判断し、必要な修正を行いました。

TracePoint で検知できない範囲への対応

検知可能なのはテストで通る範囲のみとなるので、それ以外の部分についても影響がないことをできる限り確認したいところです。かといって TracePoint を本番で運用するのはパフォーマンスやリスク面で難しいものがあります。

そのため、事前の mathn のコードリーディングや TracePoint で検知した結果得られたパターンで再度 grep して横展開しました。

TracePoint 以外の選択肢?

  • AST だと評価結果が得られないので Rational を返すタイミングがわからない
  • ISeq は AST をコンパイルした命令列なので、やはりこれも評価結果を得られるわけじゃない

というわけで、戻り値を検出するという目的のためには TracePoint で実際の評価結果を知る必要がありそうでした(後学のため、他の方法があれば教えてもらえると助かります)。

まとめ

そんなこんなで mathn の脱却に成功し、 Ruby 2.5 へのアップデートも無事に完了したのでした! 🎊

あまり同じ事例に遭遇することはないとは思いますが、この事例から得られる教訓としてはこのあたりでしょうか。

  • gem を入れるときは、require したらどうなるかをしっかり確認する
    • 他の例として、過去の Whois gem がある。この gem は Rails の core_ext 部分をごっそりコピーしてきて自分で持っていたので、 Rails を新しくしても古いままのコードで動くことになっていた
    • https://github.com/weppos/whois/pull/317
    • (メンテナンスされていれば)バージョンを上げることで致命的な問題は解消することが多いので、そういった意味でも新しいバージョンに追従しておくのは大事
  • ライブラリを作るときはその影響範囲をできるだけ閉じておく。副作用がある場合、ドキュメントにわかりやすく記載する
  • テストをしっかり書いてあると救われる(なかったらと考えるとゾッとする 😂 )