DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

Ruby のバージョンを 3.1 系から 3.2 系にアップデートしたら Ruby on Rails アプリの動きが変わったのを解決した話

私たちは Ruby on Rails の主要なマルチテナントライブラリ apartment を使ってサービスを提供しています。 Ruby のバージョンを 3.1 系から 3.2 系に上げたときに CSV ファイルを処理する部分でこのテナントの切り替えが意図通りに動作しませんでした。 この事象が興味深かったので共有します。

現在はこの事象に対応済で、私たちの環境は Ruby3.2 系で動作しています。

apartment ではマルチテナント対応部分をほとんど吸収してくれるので、アプリケーションのコードのほうにはあまりマルチテナント特有の処理が出てこず、個別処理のコードに集中できるメリットがあります。 事象が発生したコードは以下のような形式でした。

CSV.parse(filename, headers: true, header_converters: ->(header) {
  current_tenant = Apartment::Tenant.current
  # current_tenant を利用したテナント別の処理が入る
})

この事象は、以下のトピックの複合的な組み合わせによって影響が顕在化しました。

  • CSV ライブラリに渡したオプションブロックを実行する土台の Fiber が 3.2.6 から切り替わっている
  • Ruby の 3.1 から 3.2 に上げると CSV ライブラリのバージョンも変わり、動作が切り替わる
  • Thread[]Thread[]= は、Thread ローカルではなくて Fiber ローカルな変数を扱っている
  • Rails がデフォルトで利用しているサーバー Puma ではThreadでリクエストを処理する
  • apartment はリクエストローカルな値を設定するつもりで Thread[]= を使ってしまっている
  • apartment は最近アップデートがなく、最新版を使っていてもこの問題にぶつかる

CSV ライブラリに渡したオプションブロックを実行する土台の Fiber が 3.2.6 から切り替わっている

私たちが作った Ruby プログラムを windows や linux で実行するとき、そのプログラムは Process の上にある Thread の上にある Fiber の上で実行されていると認識してよいでしょう。(異論があれば教えてください m(__)m) その実行の土台となる Fiber が 3.2.5 までと、3.2.6 からは切り替わっています。

CSV ライブラリには header_converters オプションでブロックを渡せます。 https://docs.ruby-lang.org/ja/3.2/method/CSV/s/new.html

Ruby 3.1 系では、実行開始時の Fiber と header_converter の Fiber は同じでした。 Ruby 3.2 系では、実行開始時の Fiber と header_converter の Fiber は異なっていました。

$ ruby -v
ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [arm64-darwin22]
$ ruby -r csv -e 'main_fiber = Fiber.current; CSV.new("id,name,age\n1,2,3\n", headers: true, header_converters: ->(h) { p Fiber.current == main_fiber }).to_a;'
true
true
true


$ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin22]
$ ruby -r csv -e 'main_fiber = Fiber.current; CSV.new("id,name,age\n1,2,3\n", headers: true, header_converters: ->(h) { p Fiber.current == main_fiber }).to_a;'
false
false
false

私はこの現象は特にバグというわけではないと思っています。 ただ、ユーザーは特に言及されていない限り同じ Fiber で実行されるという暗黙の期待を持ってしまいがちかなとも感じているので、ここのミスマッチはトラブルを引き起すことがありそうです。 気をつけたいですね。 ちなみに Fiber は Enumerator で利用されているので、直接は利用していなくても each を利用しているあたりでその影響を受けたりします。 今回の影響も each から next を使うよう変えたときに切り替わったようでした。

Ruby の 3.1 から 3.2 に上げると CSV ライブラリのバージョンも変わり、動作が切り替わる

当初、上記の事象は Ruby 3.1 系から Ruby 3.2 系にアップデートしたときに起きたため、Ruby の言語系で何か変更があったのかなと考えましたが、そうではありませんでした。 Ruby 標準添付ライブラリ csv のバージョンが 3.2.5 から 3.2.6 に上がるときに変わった動作でした。

ただし、この標準添付ライブラリ csv は 3.1 系が 3.2.5、3.2 系が 3.2.6 と切り替わっていました。 言い替えると、Ruby 3.1 系でも、利用する csv のバージョン 3.2.6 へと上げると同じ影響が出ます。

$ ruby -v
ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [arm64-darwin22]
$ ruby -r csv -e 'p CSV::VERSION'
"3.2.5"

$ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin22]
$ ruby -r csv -e 'p CSV::VERSION'
"3.2.6"
Ruby バージョン 標準添付の CSV ライブラリバージョン
3.1.4 3.2.5
3.2.6 3.2.6

Thread[]Thread[]= は、Thread ローカルではなくて Fiber ローカルな変数を扱っている

Ruby では、1 つの Thread 上では複数の Fiber が動きます。Ruby の Thread クラスは、OS が生成するスレッド(ネイティブスレッド)に対応していて、Fiber クラスは Ruby プログラムが生成するスレッド(ユーザレベルスレッド)に対応しています。

Thread[]= は、Thread オブジェクトへと設定するため Thread ごとに設定できる値(Thread ローカル)になると思ってしまいますが、そうではありません。 Fiber ごとに設定できる値(Fiber ローカル)です。 https://docs.ruby-lang.org/ja/3.2/method/Thread/i/=5b=5d=3d.html

以下のように、新しい Fiber を Fiber.new で作った中では Thread[]= で作った値が共有できていません。

irb
irb(main):001:0> Thread.current[:a] = "hello"
=> "hello"
irb(main):002:0> p Thread.current[:a]
"hello"
=> "hello"
irb(main):003:0> Fiber.new { p Thread.current[:a] }.resume
nil
=> nil

るりまの Thread[]= の説明にもある通り、Thread#thread_variable_setThread#thread_variable_get を使うことで Thread ローカルな変数を扱えます https://docs.ruby-lang.org/ja/3.2/method/Thread/i/thread_variable_set.html

irb
irb(main):001:0> Thread.current.thread_variable_set(:a, "hello")
=> "hello"
irb(main):002:0> p Thread.current.thread_variable_get(:a)
"hello"
=> "hello"
irb(main):003:0> Fiber.new { p Thread.current.thread_variable_get(:a) }.resume
"hello"
=> "hello"

Rails がデフォルトで利用しているサーバー Puma ではThreadでリクエストを処理する

https://github.com/puma/puma/blob/v6.3.0/docs/architecture.md#how-requests-work のアーキテクチャのとおり、Puma では 1 リクエストを処理するのに 1 スレッドが対応しています。そこでリクエストの処理を開始するときに、Thread ローカルな変数に値を格納しておけば、そのリクエストの処理が終わるまで同じ値を取得できて都合がよいです。言い替えるとリクエストローカルな変数を扱うのに、Thread ローカルがばっちり対応しているということです。

apartment ではリクエストの内容からどのテナントかを判別します。判別した結果をレスポンスを作る処理の中で共有できると都合がよいです。そこでリクエストローカルな変数にテナントの情報を格納します。

余談になりますが、リクエストを実行する単位が Fiber なアプリケーションサーバーもあるので、リクエストローカルな変数を Proces、Thread、Fiber、Ractor などのどれに設定するか切り替え可能にしておかないとうまく動かないという点は将来直面する課題かもしれません。Puma を使っているうちは問題ありませんので今回はよしとします。

apartment はリクエストローカルな値を設定するつもりで Thread[]= を使ってしまっている

apartment は本家と、本家での動きがみられなくなったあとに fork した rails-on-services 版があります。どちらもリクエストローカルな値にテナントの情報を格納するつもりで Thread[]= を使ってしまっています。このため、リクエストを処理している中で Fiber が切り替わるとテナントの情報が取得できなくなります。

https://github.com/influitive/apartment/blob/f266f73e58835f94e4ec7c16f28443fe5eada1ac/lib/apartment/tenant.rb#L26 https://github.com/rails-on-services/apartment/blob/7d626d1fd53259da7c193a1710495b384cad6481/lib/apartment/tenant.rb#L22

apartment は最近アップデートがなく、最新版を使っていてもこの問題にぶつかる

問題があれば、PR を送るなりして修正すればよいですね。現在 https://github.com/rails-on-services/apartment/pull/182/files でも PR が作られています。 私たちの問題もこの PR のコードを参考にしたモンキーパッチで解決していますので、コード自体には問題ないように見えます。

この PR は 2021 年の 12 月に作られたもので、もしこの PR が既に取り込まれていれば私たちが今回直面した問題も予防できていたでしょうし、他のユーザーもこの問題にあたらなくなり良い影響となりそうですが、今のところ取り込まれていません。

また、リポジトリのコード自体 2022 年から動きがない状況で、現状メンテナンスがうまくいかないのかなという印象です。

まとめ

マルチテナントライブラリ apartment と標準添付の csv を使い、csv で convert_header にブロックを取ってその中でテナントに応じた処理を切り替えている場合、Ruby3.1 から Ruby3.2 に上げると、動きが変わってしまうという複合的な問題でした。どれか一つでも条件を満たしていなければ顕在化しなかったもので、興味深いですね。

apartment を使っていなくても、Ruby on Rails のリクエスト毎に current_user などをリクエストローカルな変数に格納しておきたい場合は多いと思われます。 その部分に Thread[]= ではなく Thread#thread_variable_set を使えているかは再度点検しておくとトラブルを未然に防げそうです。 みなさんのお手元のコードが問題なく動いていても、それはたまたま利用しているライブラリ群が新しい Fiber を作っていないだけかもしれません。

niku がお送りしました。