できる! 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 することにより以下の挙動が変化するようです。なかなかにダイナミックなモジュールですね。
いつの間にか 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 の
return
,c_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
- (メンテナンスされていれば)バージョンを上げることで致命的な問題は解消することが多いので、そういった意味でも新しいバージョンに追従しておくのは大事
- ライブラリを作るときはその影響範囲をできるだけ閉じておく。副作用がある場合、ドキュメントにわかりやすく記載する
- テストをしっかり書いてあると救われる(なかったらと考えるとゾッとする 😂 )