プロダクト不具合がOSS貢献に繋がるまで:Fiberとスレッドローカル変数の落とし穴を乗り越えた技術奮闘記

はじめに

こんにちは!OPTiM Biz 開発チームの石原です。

本記事は OPTiM TECH BLOG Advent Calendar 2025 Day 5 の記事です。

ある日のSlackでの出来事。

T.K.

@T.I. gem 'csv'へのコントリビュートをお願いします。

T.I !?

こんなメッセージから、私のOSSコントリビュートへの挑戦が始まりました。

調査の発端は、Rails バージョンアップ作業中に見つかった「英語環境だけ CSV インポートが失敗する」という不可解な不具合。 最初は CSV gem が怪しいと思われていましたが、調査を進めると i18n gem の実装に問題があることが判明。 最終的に i18n gem への PR がマージされ、無事問題が解決しました。

この記事では、その原因究明から OSS への貢献に至るまでの流れをまとめます。

問題の発覚

バージョンアップで発覚した不具合

OPTiM Biz では、Rails のバージョン追従を継続的に行うために 「Railsバージョンアップチーム」 を組成しています。 このチームが国際化対応の試験を実施していた際、奇妙な現象に遭遇しました。英語表記でログインし、CSVを使った機器インポート機能を試すと、「No data existed in the CSV file.」というエラーが表示されます。しかし、日本語表記では問題なく動作する。

詳しく調査したところ、以下のことが分かりました:

  • CSV gem を v3.2.5 → v3.2.6 に更新すると現象が発生
  • 日本語環境では再現しない
  • 英語環境でのみ CSV のヘッダー認識に失敗している

問題を最小限のコードで再現してみました:

require "csv"
require "i18n"

I18n.available_locales = [:en, :ja]
I18n.default_locale = :ja
I18n.locale = :en

puts "[before] I18n.locale=#{I18n.locale}"

data = "name\nTaro\n"
CSV.parse(data,
  headers: true,
  header_converters: [
    ->(f, info) { puts "[lambda] I18n.locale=#{I18n.locale}" }
  ]
)

puts "[after] I18n.locale=#{I18n.locale}"

期待される動作:

[before] I18n.locale=en
[lambda] I18n.locale=en
[after] I18n.locale=en

実際の動作:

[before] I18n.locale=en
[lambda] I18n.locale=ja  # ← ここで予期せずjaに!
[after] I18n.locale=en

CSV.parseのheader_convertersに渡したlambdaの中だけ、I18n.locale:enから:ja(default_locale)に変わってしまっていました。 これが原因で、英語のヘッダー名が認識されず、インポートが失敗していたのです。

OSSコントリビュートへの挑戦

上司からの提案。

T.I 誰か挑戦してほしいんですが、希望者はおりませんか?私がメンターはやります

OSSへのコントリビュートには以前から興味がありました。そんな中での「メンターはやります」という言葉は心強く、思い切って手を挙げました。

自分 気になるのでやります!

issue作成のプロセス

まずは CSV gem への issue 作成からスタート。 しかし、英語でissueを書くのは想像以上に難しい。 「問題の説明が明確か」「再現手順は分かりやすいか」「技術的な背景は正確に伝わるか」。何度も書き直しながら、メンターから多数のフィードバックをもらいました。 フィードバックを反映しながら、何度も修正を重ねました。

そしてついにCSV gemにissueを投稿しました:ruby/csv#352 投稿後は不安でした。「ちゃんと伝わるだろうか」「もっと情報が必要だったかな」。でも、上司からは「完璧です!」と言ってもらえて、少し安心しました。

転機:真の原因発見

CSV gemメンテナからの指摘

驚いたことに、メンテナ(kou 氏)から即日返信が,, 早すぎる

Hmm. I think that i18n should use Thread#thread_variable_{get,set} instead of Thread#[]{,=}. Could you open an issue to https://github.com/ruby-i18n/i18n ?

つまり、問題の原因は i18n gem の スレッドローカル変数の使い方にあるのでは? という指摘でした。

ここから調査は一気に Ruby の深い仕様に踏み込みます。

根本原因:Fiber と Thread.current の挙動

なぜ locale が巻き戻るのか?

  1. CSV v3.2.6の変更: パフォーマンス改善のため、Fiberベースの遅延評価に変更(該当コミット
  2. i18nの実装: I18n.localeThread.current[:i18n_config]に保存
  3. Rubyの仕様: Thread.current[:key]はfiber-local(Fiberごとに独立)
  4. 結果: 新しいFiberではThread.current[:i18n_config]nilになり、default_localeが使われる

実際にFiberだけで再現できることを確認しました:

require "i18n"

I18n.available_locales = [:en, :ja]
I18n.default_locale = :ja
I18n.locale = :en

puts "[before] I18n.locale=#{I18n.locale}"

Fiber.new do
  puts "[fiber] I18n.locale=#{I18n.locale}"  # => :ja
end.resume

puts "[after] I18n.locale=#{I18n.locale}"

Thread.current vs Thread#thread_variable_get

問題の解決方法も見えてきました。Rubyには2種類のスレッドローカル変数があります:

方法 スコープ Fiberでの挙動
Thread.current[:key] fiber-local 新しいFiberではnilになる
Thread#thread_variable_get thread-local Fiber間で共有される

i18n gemでThread#thread_variable_{get,set}を使うようにすれば、Fiberが切り替わってもI18n.localeの値が保持されるはずです。 i18n側でissueを立てることにしました。

i18n gemへのコントリビュート

issue作成とPull Request

issueとPull Requestを投稿しました:

Pull Requestの内容はシンプルでした:

# 変更前
def config
  Thread.current[:i18n_config] ||= I18n::Config.new
end

# 変更後
def config
  Thread.current.thread_variable_get(:i18n_config) ||
    Thread.current.thread_variable_set(:i18n_config, I18n::Config.new)
end

テストも修正し、CIで確認しました。かなり根幹な部分に修正を入れていたので問題ないか不安でした

反応を待つ日々

Pull Requestを投げた後、しばらく反応がありませんでした。 メンテナにメンションを送ってみるが、、 それでもしばらく反応はなく、「誰も興味ないまたは書き方間違えていると思ってたので」と不安になりました。

マージ

そして11月11日、travisbell氏からコメントがありました:

Good catch @lee266 I am also now wondering about some of the particulars in and around this. We use Falcon and I am wondering if there's some edge cases where this could be triggered and we are ultimately sending the wrong locale to the end user.

Falcon(Fiberベースのwebサーバー)を使っている人からの反応で、実際の問題として認識してもらえたようです。すごく嬉しかった。

そして11月17日:Slackの投稿

実はマージされてる気がする!

本当にマージされていました!メンテナからは温かいコメントも:

This is some very deep Ruby lore. I read the documentation for both Thread#[] and Thread#thread_variable_get and now understand the differences between the two. Thank you for this PR!

かなりいきなりのことだったで驚きましたが、無事マージ

学びと振り返り

技術的な学び

今回のコントリビュートを通じて、多くのことを学びました。 まず、Rubyの深い仕様について。FiberとThread.currentの関係、fiber-localとthread-localの違い。普段の開発では意識しない部分でしたが、「This is some very deep Ruby lore」とメンテナが言うように、確かに深い知識でした。 OSSの問題追跡方法も学びました。再現コードの作り方、issueの書き方、原因分析の進め方。特に、「問題を最小限のコードで再現する」ことの重要性を実感しました。 英語でのコミュニケーションも大きな学びでした。技術的な内容を正確に、かつ分かりやすく伝えることの難しさ。でも、メンターからのフィードバックを受けながら、少しずつ改善できました。

メンター制度とチーム協働

「誰か挑戦してほしい、メンターはやります」という言葉があったから、手を挙げられました。 issueの書き方から英語表現まで、細かくレビューしてもらえました。「指摘ばっかりすみません」と言われましたが、むしろそのフィードバックがありがたかったです。 上司にメンターしてもらえるなんてありがたい

OSSコントリビュートの価値

今回のコントリビュートで、プロダクトの問題が解決されただけでなく、Fiber ベースの Web サーバーを使う他の開発者にも役立つものになるでしょう。 コミュニティへの還元の実感も得られました。 そして何より、技術力の向上と自信が得られました。

おわりに

この経験を通じて、OSSコントリビュートは特別なことではないと感じました。 プロダクト開発の中で見つけた問題を、丁寧に調査し、再現し、報告する。その積み重ねが OSS への貢献になります。 私自身も、今回の経験をきっかけにもっと OSS に関わっていきたいと思っています。 もし同じような問題に遭遇したら、あるいはOSSに興味があるなら、ぜひ一歩踏み出してみてください。そして、私たちと一緒に働く仲間も募集しています。

www.optim.co.jp

参考リンク: