Ruby on Rails PR Digest - 2026年 11月
このページは rails/rails リポジトリにマージされたPull Requestを自動的に収集し、AIで要約したものです。
#59658 Fix Array#index_with documentation
マージ日: 2026/11/5 | 作成者: @byroot
- 概要 (1-2文で)
Array#index_with のドキュメントから実装寄りの説明を取り除き、より API 利用者向けの内容に整理した PRです。機能や挙動には変更はなく、あくまでドキュメント改善のみです。
- 変更内容の詳細
対象:
activesupport/lib/active_support/core_ext/enumerable.rbのArray#index_withに関するドキュメントコメント
主な変更点:
- 「内部でどう実装しているか」「どんなリファクタリング経緯があるか」といった実装詳細・履歴に近い記述をドキュメントから削除
- 代わりに、
index_withを使う開発者が知るべき以下のような点にフォーカスした説明へ整理- どのような戻り値になるか(ハッシュを返す)
- ブロックあり/なしでの使い方
- 引数を与えた場合の挙動
index_with 自体の役割(典型例):
# ブロックあり: 各要素をキー、ブロックの戻り値を値にした Hash を返す
[1, 2, 3].index_with { |i| i * 2 }
# => { 1 => 2, 2 => 4, 3 => 6 }
# ブロックなし + 引数あり: 各要素をキー、与えた値をすべて共通の値として持つ Hash を返す
%w[a b c].index_with(0)
# => { "a" => 0, "b" => 0, "c" => 0 }
# ブロックなし + 引数なしの場合は Enumerator を返す(Enumerable 系メソッドと同様のパターン)
enum = [1, 2, 3].index_with
enum.each { |e| [e, e * 3] }.to_h
# => { 1 => 3, 2 => 6, 3 => 9 }今回の PR は、こうした「どう使うか」「どう振る舞うか」の説明をわかりやすく保ちつつ、
- 実装クラス・モジュールの詳細
- 最適化・内部実装のテクニックや履歴(「昔はこうだった」等)
といった、将来の実装変更で陳腐化しやすい情報をドキュメントから追い出す方向の変更です。
- 影響範囲・注意点
- 挙動・API 仕様・シグネチャに変更は一切なく、既存コードへの影響はありません。
- テスト追加・変更もなく、純粋なドキュメント修正です。
- 今後、
index_withの内部実装が変わっても、ドキュメントに矛盾が出にくくなります(実装詳細を記載しない方針にしたため)。 - Rails のガイドラインとして「実装詳細はドキュメントではなくコミットメッセージに書く」という方針がより明確になったと解釈できます。
- 参考情報 (あれば)
- 似た API:
Enumerable#index_withは Active Support により追加された拡張で、Arrayにも同様のメソッドが生えています。 - ドキュメントと実装の責務分離:
- ドキュメント: 公開 API の意図・使い方・挙動・制約を記述
- コミットメッセージ: なぜその実装にしたのか、内部構造、パフォーマンス上の工夫などを記述
という整理を Rails コアが徹底している一例と言えます。
#59651 Speed up pluralize and singularize by testing rules with match?
マージ日: 2026/11/5 | 作成者: @jhawthorn
- 概要 (1-2文で)
ActiveSupport::Inflector のpluralize/singularizeが、内部でのルール適用方法を見直すことで大幅に高速化された PR です。従来は各ルールごとにsub!を実行していたのを、まずmatch?/include?で「マッチするルールか」を判定してから、該当ルールに対してのみsub!を呼ぶように変更しています。
- 変更内容の詳細
これまでの挙動
ActiveSupport::Inflector.pluralize / singularize は、内部的には apply_inflections というメソッドで「単数・複数形変換ルール」を上から順に試しています。
ざっくりしたイメージ:
def apply_inflections(word, rules)
result = word.dup
rules.each do |rule, replacement|
# マッチしてもしなくても、毎回 gsub! / sub! を実行
if result.sub!(rule, replacement)
break
end
end
result
end問題は「ほとんどの単語は大半のルールにマッチしない」ことです。
例: "comment" は英語の 33 個ある複数形ルールのうち最後の 1 つにしかマッチしません。
つまり 32 回の失敗した sub! 呼び出しが行われており、これがコストになっていました。
今回の変更内容
この PR では、「まずマッチするかどうかだけを軽量に判定し、マッチが確定してから置換を行う」 という 2 ステップ方式に変えています。
おおよそのイメージ:
def apply_inflections(word, rules)
result = word.dup
rules.each do |rule, replacement|
if rule.is_a?(Regexp)
# まず正規表現でマッチ有無だけチェック(match? はマッチオブジェクトを作らないので軽い)
next unless result.match?(rule)
else
# String ルールの場合、sub! はリテラルマッチなので include? で代用可能
next unless result.include?(rule)
end
# 「このルールはマッチする」と分かったものだけ sub! を実行
result.sub!(rule, replacement)
break
end
result
endポイント:
- 非マッチ時の
sub!呼び出しを避ける- 正規表現ルール:
sub!の代わりに先にmatch?で判定 - 文字列ルール:
sub!が「単純な文字列置換」として使われている場合はinclude?で判定
- 正規表現ルール:
match?は=~やmatchと違い、マッチオブジェクトを作らないためオーバーヘッドが小さい- 1 つの単語に対して実際に
sub!が呼ばれるのは「最初にマッチする 1 ルール分だけ」で、残りはmatch?/include?の軽いチェックだけになる
ベンチマーク結果
"comment" / "comments" を対象に、いくつかの経路から pluralize/singularize を呼び出すベンチマークが実行されています。
Ruby 4.0.9:
Inflector.pluralize('comment'): 約 2.0 倍Inflector.singularize('comments'): 約 1.9 倍"comment".pluralize: 約 2.0 倍view.pluralize(2, 'comment'): 約 1.8 倍
Ruby 4.1.0dev (HEAD):
Inflector.pluralize('comment'): 約 1.25 倍Inflector.singularize('comments'): 約 1.21 倍"comment".pluralize: 約 1.27 倍view.pluralize(2, 'comment'): 約 1.3 倍
Ruby HEAD では非マッチの gsub! が元々高速化されているため、Ruby 4.0 ほどのインパクトではないものの、それでも 20–30% 程度の改善が出ています。
テスト追加
activesupport/test/inflector_test.rb に 13 行のテストが追加されています。
主な意図としては:
- 新しい判定ロジック(
match?/include?経由)の導入によって、従来の変換結果が変わっていないこと - 正規表現・文字列ルールの両方で期待通りの動作を維持していること
を確認する回帰テストです。挙動変更ではなく純粋な最適化であることを保証するためのものと考えられます。
- 影響範囲・注意点
対象範囲
ActiveSupport::Inflector.pluralizeActiveSupport::Inflector.singularize- それらを内部で利用している API(
String#pluralize,String#singularize,ActionView::Helpers::TextHelper#pluralizeなど)
互換性
- ルールの並び順や中身が同じであれば、「どのルールが最初にマッチするか」は従来と変わらない前提のため、変換結果は変わらない想定です。
- 文字列ルールでは
include?→sub!という流れですが、sub!も元々は単純なリテラルマッチとして動いていたため意味的には等価です。 - Inflection ルールをカスタマイズしているアプリでも、
ruleがRegexp/Stringであれば問題は起こりにくいですが、極端に副作用のあるto_s/=~実装を持つオブジェクトをルールにしている場合は注意が必要です(一般的ではない想定)。
性能的な影響
- 英語以外でも、ルール数が多く、かつ「多くが非マッチ」になる言語ほど恩恵が大きいです。
- 大量の文字列に対して pluralize/singularize を行うバッチ処理・ビューのレンダリングなどで、CPU 使用率の低下・レイテンシ改善が見込めます。
デバッグ・プロファイル観点
- プロファイラ上では
sub!よりもmatch?/include?が目立つようになる可能性がありますが、トータル時間は減る方向です。 - もしアプリ独自で Inflector のルールを monkey patch している場合、今回の変更により「ルールが適用されるかどうかを判定するタイミング」が
sub!実行前に一段増えるので、非常にニッチなケースで動作差が出ないかだけ確認すると安心です。
- プロファイラ上では
- 参考情報 (あれば)
- 対象コード:
activesupport/lib/active_support/inflector/methods.rbのapply_inflections近辺 - 関連 API:
ActiveSupport::Inflector.pluralize(word, locale = :en)ActiveSupport::Inflector.singularize(word, locale = :en)ActiveSupport::Inflector.inflections(locale)でのカスタムルール定義
- Ruby レベルの最適化との関係:
- Ruby 4.0 系では非マッチ
gsub!が比較的高コストだったため今回の最適化の効果が大きい - Ruby HEAD では VM 側の最適化(特に YJIT や
gsub!実装改善)により、もともと速くなっているが、それでもこの PR による上乗せの効果がある
- Ruby 4.0 系では非マッチ
#59570 Escape unsafe OutputBuffer appends without the super dispatch
マージ日: 2026/11/5 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails のテンプレートで<%= %>を評価した際の出力バッファ (ActionView::OutputBuffer#<<) におけるエスケープ処理を最適化し、super経由の動的ディスパッチを避けることで、インタプリタ・YJIT の両方で高速化した PR です。
これにより、非html_safeな値を<%= %>で出力する処理が 20~25% 程度高速化されています(PR 記載のベンチマーク値より)。
- 変更内容の詳細
何をしているか
ActionView::OutputBuffer#<<で、非html_safeな値をテンプレートに出力する際のエスケープ処理を変更。これまでの流れ(簡略化):
ruby# 実際には OutputBuffer#<< 内で # あくまでイメージ if !value.html_safe? ERB::Util.unwrapped_html_escape(value) else value endunwrapped_html_escapeの中では:ruby# ERB::Util (singleton) での流れ(イメージ) def unwrapped_html_escape(arg) arg = arg.to_s return arg if arg.html_safe? # ERB::Escape.html_escape への alias + super 経由で到達 html_escape(arg) # ← 実際には alias + super などを経由 endこの
html_escapeはERB::Escape.html_escapeにaliasとsuper経由でつながっており、to_sとhtml_safe?が二重に呼ばれてしまうsuperによる動的ディスパッチのため、YJIT が最適化しにくい
という問題がありました。
この PR では、テンプレート経由の append に限り、
ERB::Escape.html_escapeを直接呼び出すように変更しています。変更イメージ:
ruby# 以前 (イメージ) ERB::Util.unwrapped_html_escape(value) # 変更後 (イメージ) ERB::Escape.html_escape(value)OutputBuffer#<<側ですでにto_sとhtml_safe?を呼んでおり、値が非html_safeであることを判定済みのため、unwrapped_html_escape内で再度to_s/html_safe?を行う必要がない、という前提で最短経路にしています。
なぜ速くなるか
- YJIT は
super経由のメソッド呼び出しをうまく specialize(最適化)できないため、
すべての<%= %>append が「動的ディスパッチ」扱いになっていました。 ERB::Escape.html_escapeを直接呼ぶことで、- メソッド解決が単純になり JIT の最適化が効きやすい
to_s/html_safe?の重複呼び出しを削減
という 2 点から、インタプリタ・YJIT 双方で高速化しています。
ベンチマーク(PR 記載値)
- Ruby 4.0.7 /
<%= %>で非html_safeな String を出力する場合:- インタプリタ: 141 ns → 110 ns
- YJIT: 68 ns → 52 ns
- ruby-bench の erubi-rails:
- 1 イテレーションあたりの命令数が
- インタプリタ: 約 5.8% 減
- YJIT: 約 5–6% 減
- 1 イテレーションあたりの命令数が
- Fizzy / Lobsters などのアプリでは 1 ページあたり 130~920 回の
<%= %>append が発生するので、
ページレンダリング全体でそれなりに効いてくる最適化です。
- 影響範囲・注意点
影響範囲(挙動の変化)
- 標準的な Rails アプリで、
<%= %>の動作(エスケープ結果)は変わりません。 - 変わるのは「どのメソッドを経由してエスケープされるか」という呼び出し経路のみです。
互換性上の注意点
テンプレートからの append だけは、今後は
ERB::Utilの singleton class を経由しません。そのため、次のような“かなりトリッキーな”拡張をしている場合にだけ挙動が変わります:
ERB::Utilの singleton class にprependしたモジュールでunwrapped_html_escape- もしくは Active Support がロードされる前の
html_escape
をオーバーライドしているケース
具体例(こういうことをしていると効かなくなる):
rubymodule MyHtmlEscapeOverride def unwrapped_html_escape(arg) # 独自の escape 処理 super(custom_escape(arg)) end end ERB::Util.singleton_class.prepend(MyHtmlEscapeOverride)このような override は、テンプレート中の
<%= %>経由の出力にはもう適用されません。
ただし「テンプレート以外」からERB::Util.html_escape/unwrapped_html_escapeを呼ぶ場合は従来どおりそこを通ります。PR 説明にもあるとおり、
unwrapped_html_escapeは:nodoc:扱い- 過去の PR (#46573) では「
superを使うことで、そうしたprependを壊さない」方針を取っていた
が、今回はテンプレート append に限ってはその互換性を捨ててパフォーマンスを優先した、という位置付けです。
一般的なアプリケーション開発者が意識する必要はほぼなく、ERB::Util に対してかなり特殊なメタプログラミングをしている場合のみ注意が必要、というレベルです。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59570
- 関連 PR(
super採用の経緯があるものとして言及されている): https://github.com/rails/rails/pull/46573 - YJIT 最適化に関する背景(Ruby 本体側ドキュメント・記事など)を読むと、この PR の「
superを避ける」意図がより理解しやすくなります。
#59602 Show the template's line for syntax errors compiled through Herb
マージ日: 2026/11/5 | 作成者: @IslamElsayed
- 概要 (1-2文で)
Rails 8.2 でデフォルト HTML テンプレートコンパイラになった Herb 利用時に、テンプレートの構文エラーが「どのファイルの何行目か」を正しく表示できるようにする修正です。ERB ハンドラが Herb にテンプレートのファイル名を渡すようにしたことで、エラー画面で実際のテンプレートの該当行がハイライトされるようになります。
- 変更内容の詳細
何が問題だったか
- Rails 8.2 の
load_defaults 8.2では、HTML テンプレートは Herb 経由でコンパイルされる。 - Herb 自体は「どの行で構文エラーが起きたか」は特定できるが、
ActionView::Template::Handlers::ERB#callが Herb にファイル名(filename)を渡していなかった。 - そのため、Ruby の構文エラーは
[source]:4:6のように「仮のファイル名」で報告される:- Rails が
[source]というパスを解決できない。 SyntaxErrorProxyがテンプレートの実ソースを特定できず、Herb 自身の Ruby コード(例: "around line #412")がエラー画面に表示されてしまう。- 結果として、例えばテンプレートの 4 行目に余計な
<% end %>があっても、そのテンプレートではなく Herb の内部コードが表示され、デバッグしづらい状態になっていた。
- Rails が
何を変更したか
1. ERB ハンドラで filename を渡すようにした
actionview/lib/action_view/template/handlers/erb.rb にて:
- Herb へ渡すオプションに
filename: template.identifierを追加。 template.identifierはそのテンプレートの実ファイルパス(例:app/views/posts/index.html.erb)を表す。
イメージとしては以下のような変更です(擬似コード):
# 変更前(イメージ)
Herb.compile(source)
# 変更後(イメージ)
Herb.compile(source, filename: template.identifier)これにより:
- Ruby の構文エラーが
app/views/posts/index.html.erb:4:6のような形式で報告される。 ActionView::Template::Error::SyntaxErrorProxyが従来のロジックのまま、正しいテンプレートファイルを開いて該当行をハイライトできる。- 追加のパース処理などは発生せず、既存のエラー解析フローをそのまま活かしている。
2. テスト用 FakeTemplate に identifier を追加
actionview/test/template/dependency_tracker_test.rb:
- テスト内の
FakeTemplateにidentifierを生やした。 - このテスト用テンプレートも ERB ハンドラを通るため、
identifierが無いと今回の変更部分でエラーになる。 - 実運用を模した形で、
template.identifierを前提としたコードに追従した形。
3. エラー行表示のテストを追加
actionview/test/template/template_test.rbrailties/test/application/rendering_test.rb
で、以下を確認するようなテストが追加されている:
- Herb 経由でコンパイルしたテンプレートに構文エラー(例: 余分な
<% end %>)がある場合:- 例外メッセージに実ファイルパスが含まれる。
- エラー画面(テスト環境でのエラーレスポンス)に、該当テンプレートの該当行が表示される。
- Herb の内部ソースではなく、ユーザのテンプレートコードが「around line #x」として表示される。
- 影響範囲・注意点
影響範囲
- 対象:
load_defaults 8.2を使っていて、HTML テンプレートが Herb でコンパイルされているプロジェクト。- 開発環境(error pages)やテスト環境での構文エラー表示。
- 主な効果:
- HTML テンプレート内の構文ミス時に、エラー画面が「正しいテンプレートファイルの正しい行」を表示するようになる。
- デバッグ性が向上し、「Herb の内部行を見せられてもどこを直せばいいか分からない」という状況が解消される。
既知の制限・未対応部分
- Erubi には未対応:
- HTML 以外のフォーマット(例:
*.text.erb,*.js.erb)や、 load_defaults < 8.2(Herb がデフォルトになっていないアプリ) では Erubi が使われるが、そちらの構文エラー行ズレ問題は依然として残っている。
- HTML 以外のフォーマット(例:
- Erubi の問題の特徴:
- Ruby がエラーを検出するタイミングがテンプレート全体を包むメソッドの末尾付近になることが多く、
- 「ファイル末尾より後ろの行」として報告されるケースがある。
- この PR は #51092 の一部対応であり、Erubi 側まで含めた完全な解決には至っていないため「refs #51092」という扱いになっている。
バージョン・依存関係
- Ruby 3.4.7 + Prism + parse.y で動作確認済み。
- Herb は Gem として
0.10も許容しているが、この PR では0.11.0でテストされており、0.10 での動作は未検証。
- 参考情報 (あれば)
- この変更によって、Herb 利用時は「ERB テンプレートの構文エラー表示まわりの挙動」が Erubi 時代より自然なものになる。
- もし 8.2 以降にアップデートして「構文エラー時のスタックトレースに
[source]:x:yが出てくる / Herb の内部コードがエラー画面に出てくる」といった問題に遭遇していた場合、この PR がその不便さを解消している。 - まだ Erubi を使っている部分については、将来的な Herb 移行や Erubi 側の改善 PR (#51092 関連) を待つ必要がある。
#59582 Running require from a Ractor currently creates a new thread every time
マージ日: 2026/11/5 | 作成者: @luke-gruber
- 概要 (1-2文で)
このPRは、Ractor 内でrequireを呼ぶと毎回スレッドが新規作成されてしまう現状を踏まえ、不要なrequire呼び出しをスキップするための微修正を Active Support に入れたものです。実質的には 1 行の条件(または同等のロジック)変更で、Ractor 利用時のオーバーヘッドを軽減する意図があります。
- 変更内容の詳細
- 変更ファイル:
activesupport/lib/active_support/ractors.rb - 変更行数:
+1 / -1(1 行差し替え)
実際の差分は 1 行のみで、内容としては「Ractor 関連の set-up 時に、すでに環境が整っている/今は不要なケースでは require を実行しない」ような条件分岐の調整です。
PR の説明文:
This will probably be fixed in 4.1, but may as well skip the require if we can.
から推測される意図は以下の通りです。
- Ruby 本体側(4.1 想定)で「Ractor 内からの
requireが毎回新規スレッドを作る」という問題は将来的に修正される見込み。 - それまでの間、Rails 側では「Ractor 用の補助コードを
requireする必要があるかをより厳密に判定し、不要時はrequireを呼ばない」ようにして、余計なスレッド生成を避ける。 - 具体的には、
ActiveSupport::Ractors内で行っている Ractor 対応の初期化処理(例:require "fiber"的なものや Ractor 対応ユーティリティの読み込み)に対して、「すでにロード済みか」「そもそもこの文脈では不要か」といったチェックを追加/強化している、と考えられます。
擬似的なイメージコードは以下のようなものです(実際のコードとは異なる可能性がありますが、意図のイメージとして):
module ActiveSupport
module Ractors
# Before
# require "active_support/some_ractor_helper"
# After
require "active_support/some_ractor_helper" unless defined?(ActiveSupport::SomeRactorHelper)
end
endあるいは、Ractor 内でのみ発生する require を避ける形の条件分岐になっている可能性もあります。
- 影響範囲・注意点
- 対象:
- Ractor を利用している(あるいは利用可能な)Ruby / Rails 環境。
- 特に Ractor 内から Rails / Active Support の機能を呼び出すパターン。
- 効果:
- Ractor 内での
require呼び出しが減少することで、Ractor 実行時に不必要にスレッドが増える問題を軽減。 - その結果、Ractor を使った並列処理のパフォーマンスとリソース効率が多少改善される可能性があります。
- Ractor 内での
- 互換性:
- 行数的にも微小変更であり、「条件を満たさない限り
requireしない」だけなので、通常は後方互換性が保たれます。 - 唯一懸念があるとすれば、「実は
requireが必要なのに条件が厳しすぎてスキップされてしまう」場合ですが、そのようなバグがない前提でマージされています。 - Ractor を使っていない通常の Rails アプリでは、この変更の影響はほぼありません。
- 行数的にも微小変更であり、「条件を満たさない限り
運用上の注意:
- Ractor を本格的に使っているアプリでは、この変更により Ractor ワーカーのスレッド数が過剰に増える現象が緩和されるか確認すると良いです。
- ただし PR 説明の通り、Ruby 4.1 で根本的に修正される想定なので、Ruby 本体のバージョンアップ時には挙動が変わる(=Rails 側でのワークアラウンドの重要性が下がる)ことに留意してください。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59582
- 関連トピック:
- Ruby の Ractor と
requireの挙動(Ractor 内でのrequireがスレッド生成と絡む既知の問題) - 将来の Ruby 4.1 での修正予定(Ractor +
requireのパフォーマンス問題)
- Ruby の Ractor と
- コードの実態を確認したい場合は、対象コミットでの
activesupport/lib/active_support/ractors.rbの差分を参照すると、requireの条件付け/削除がどのように行われたかを正確に把握できます。
#59482 Map template frames in ExceptionWrapper without walking every resolver
マージ日: 2026/11/5 | 作成者: @linyiru
- 概要 (1-2文で)
Rails の例外ページなどで行っている「バックトレース中のテンプレートフレームのマッピング」を大幅に高速・省メモリ化する変更です。従来は全ての view resolver を総なめにしてテンプレートを探していましたが、Template::Errorが持つ情報とテンプレートメソッド名のパターンを利用して、必要最小限の探索に絞るようになりました。
- 変更内容の詳細
背景・課題
ActionDispatch::ExceptionWrapper#build_backtraceは、バックトレースの中に含まれる「コンパイル済みテンプレートのメソッド呼び出し」を元のテンプレートファイル (.html.erbなど) に対応付ける処理を行っている。- これを実現するために、全ての view resolver に対して
built_templatesを呼び出し、そのキャッシュをなめて対応するテンプレートを探すという実装になっていた。 FileSystemResolver#built_templatesは「一度でも lookup されたテンプレートパス毎のエントリ」を全て返すため、大量のテンプレートや多数の resolver を使う環境では非常に重い。
PR 説明の計測結果(5000テンプレート・16 resolver・production エラーページ):
| 種類 | 変更前 | 変更後 |
|---|---|---|
| Routing error (404) | 7.06 MB, 17.0 ms | 0.04 MB, 0.12 ms |
| テンプレート内でエラー発生 (500) | 13.52 MB, 31.4 ms | 0.24 MB, 0.73 ms |
404/500 両方でメモリ・時間ともに 1〜2桁オーダーで削減されています。
コアアイデア
Template::Errorはすでに「どのテンプレートを経由したか」を知っている- テンプレートで起きた例外は、
ActionView::Template::Errorでラップされる。 - このとき「どのテンプレートからどのテンプレートを呼んだか」というチェーンを
sub_template_ofを使って辿れるようにしていて、Template::Error#templatesでその一覧を取得できる。 - この PR では、その
Template::Error#templatesを積極的に利用し、「実際にバックトレースに現れるテンプレートだけ」をマッピング対象とする。
- テンプレートで起きた例外は、
「テンプレート由来のメソッドかどうか」をフォーマットで判定
- まだマッピングされていないバックトレースフレームが「コンパイル済みテンプレートメソッド」かどうかを、
ActionView::Template.method_name?で判定できるようになった。 Template#method_nameが生成するメソッド名のフォーマットと同期した判定ロジック・テストが追加されている。- これにより、「テンプレートとは無関係なメソッド」については resolver 探索をそもそも行わないで済む。
- まだマッピングされていないバックトレースフレームが「コンパイル済みテンプレートメソッド」かどうかを、
resolver を総なめするのは「まだマッピングされていないテンプレートメソッド」だけ
- バックトレースフレームが
- まだテンプレートに紐づけられていない
Template.method_name?がtrueを返す(テンプレートメソッドらしい)
- この場合に限って初めて resolver を歩いてテンプレートを探すように変わった。
- これにより、次が達成される:
- Routing error やプレーンな例外(
Template::Errorを経由しない) → resolver を一切歩かない。 - 通常のテンプレートエラー → ほぼ
Template::Error#templatesの情報だけで足りるので、resolver を歩くケースが激減。 - カスタム resolver で拡張子なし identifier を持つテンプレート → 以前の「ファイル拡張子ベースのスキップ」では取りこぼしていたが、メソッド名ベースの判定により引き続きマッピング可能。
- Routing error やプレーンな例外(
- バックトレースフレームが
具体的なコードレベルの変更点
※ ざっくりした役割・利用方法の解説です(実際の行単位までは省略)。
1. ActionDispatch::ExceptionWrapper の変更
対象: actionpack/lib/action_dispatch/middleware/exception_wrapper.rb
build_backtraceの処理が書き換えられ、以下の手順でフレームをマッピングするようになったと考えられます:- 例外チェーン(cause を遡る)から
ActionView::Template::Errorを集める。 - 各
Template::Errorに対してtemplatesを呼び、そこに含まれるテンプレートを「コンパイル済みメソッド名 → Template オブジェクト」のマップとして保持。 - バックトレースの各フレームについて、上記マップを用いて素早くテンプレートに紐づける。
- それでも見つからないフレームのうち、
Template.method_name?がtrueになるものだけについて、fallback として resolver を歩く。
- 例外チェーン(cause を遡る)から
- これにより「まず安い情報源(
Template::Error)を活用し、高コストな resolver 探索は最小限に抑える」構造になっている。
2. ActionView::Template の変更
対象: actionview/lib/action_view/template.rb
- 新たに
self.method_name?(name)のようなクラスメソッドが追加されている。Template#method_nameが生成するメソッド名のパターン(例:_app_views_users_index_html_erb__<hash>のような形式)にマッチするかどうかを判定する。- これに対するテストが
actionview/test/template/template_test.rbに追加され、「Template#method_nameの実装を変えたらmethod_name?のテストも落ちる」ように同期が保証されている。
3. ActionView::Template::Error の変更
対象: actionview/lib/action_view/template/error.rb
Template::Error#templatesがきちんとテンプレートの通過リストを返すよう強化された(もともと存在していたが、今回の最適化に耐えうるように整備された形)。- 例外をラップするときに
sub_template_ofチェーンが正しく構築されること、及びtemplatesが期待通りの順序・内容で返ることがtemplate_error_test.rbで検証されている。
4. 既存テストの修正
対象:
actionpack/test/dispatch/exception_wrapper_test.rbactionview/test/template/template_error_test.rbactionview/test/template/template_test.rb「ソースマップ(バックトレース → テンプレートファイル)のテスト」で、これまで使っていたメソッド名が実際には
Template#method_nameでは生成されない形式だった問題を修正。以後は「実際に
Template#method_nameが生成しうる形式」に基づいてテストすることで、method_nameとmethod_name?の同期を保証している。
- 影響範囲・注意点
影響範囲
- 影響を受ける主な箇所
- 開発・本番問わず、例外ページやログ出力に利用される「テンプレート付きバックトレース」の生成。
- 特に
ActionDispatch::DebugExceptionsを通じてレンダリングされるエラー画面(404/500 など)で顕著に改善。
- カスタム view resolver を使っているアプリ
Template::Errorによるラップが行われる限り、Template::Error#templates経由で正しくマッピングされる。- それ以外のケース(カスタム resolver のテンプレートから例外が投げられても
Template::Errorにならない場合)でも、メソッド名がTemplate.method_name?にマッチすれば従来どおり resolver を歩いてマッピングされる。
注意点・互換性
- API 互換性
- 既存のパブリック API を壊す変更ではなく、「内部的な例外可視化ロジックの最適化」が主眼。
Template#method_nameのフォーマットを大きく変える場合は、今後Template.method_name?とそれを前提としたExceptionWrapperのロジックに注意が必要。
Template::Errorの扱い- テンプレート内で発生した例外が
Template::Errorに包まれないような独自実装をしている場合、Template::Error#templatesによる高速パスは効かず、fallback(resolver 探索)側の挙動に頼ることになる。
- テンプレート内で発生した例外が
- パフォーマンス面
- 大量テンプレート・複数 resolver 環境での例外処理コストがかなり下がるため、本番環境でも直接的に恩恵を受ける変更。
- 例外発生頻度が高いシステムではメモリ使用量とレスポンス遅延の削減につながる可能性がある。
- 参考情報 (あれば)
- 対応 issue: #58887 – 例外ラップ時の
built_templates使用によるメモリ・パフォーマンス問題。 - Supersedes: #58890 – 拡張子ベースで resolver lookup をスキップする方式は、拡張子なし identifier のカスタム resolver テンプレートを取りこぼすため廃案となり、本 PR のメソッド名ベース判定に置き換え。
- 関連クラス:
ActionDispatch::ExceptionWrapper– 例外のラップとバックトレース整形を行うミドルウェアクラス。ActionView::Template– ビューのコンパイル・実行ロジックを持つクラス。method_nameおよびmethod_name?が今回のポイント。ActionView::Template::Error– テンプレート実行時例外をラップし、sub_template_of/templatesでテンプレートチェーンを提供するクラス。
#59641 Remove remaining Markdown directives
マージ日: 2026/11/5 | 作成者: @fxn
- 概要 (1-2文で)
Rails のドキュメント形式を RDoc から Markdown に完全移行する一環として、ソースコード中に残っていた# :markup: markdownディレクティブを削除し、プロジェクト全体のクリーンアップを完了した PR です。機能的な挙動や公開 API には一切変更がありません。
- 変更内容の詳細
背景
Rails は長年 RDoc を使っていましたが、ドキュメント生成のマークアップを Markdown に移行中でした。
移行期間中は、Markdown 化が終わったファイルには先頭付近に
ruby# :markup: markdownのようなディレクティブを付けて、「このファイルは Markdown として扱う」と RDoc に指示していました。
未移行ファイルはデフォルトの
rdocとして処理される、という状態で徐々に変換を進めていました。
その後、PR #59339 で 「デフォルトのマークアップを Markdown に変更」 したため、Markdown で書かれたファイル側にこれ以上 # :markup: markdown を書き続ける必要がなくなりました。
このPRでの具体的な変更
今回の PR は、その最終的な後片づけで、
以下の 6 ファイルから
:markup: markdown行だけを削除しています:actionview/lib/action_view/helpers/date_helper.rbactivemodel/lib/active_model.rbactivemodel/lib/active_model/validations/format.rbactivemodel/lib/active_model/validations/numericality.rbactiverecord/lib/active_record.rbactiverecord/test/cases/connection_adapters/ractor_connection_handling_test.rb
追加行は 0 行、削除行は合計 7 行で、コードロジックそのものは一切変更していません。
おそらく削除前は各ファイルの先頭近くに、例えば:
# :markup: markdown
#
# == Some documentation title
# ...のような行があり、それがきれいに取り除かれた状態です。
PR 本文中にある:
% rg ':markup: markdown'
%は、ripgrep (rg) で :markup: markdown を検索した結果、ヒットが 0 件であることを示しており、この PR で「Rails リポジトリ内の Markdown ディレクティブが完全になくなった」ことを象徴的に示しています。
- 影響範囲・注意点
ランタイム挙動・API への影響はなし
- 変更は Ruby コメント行の削除のみであり、メソッド、クラス定義、モジュール構成、バリデーションロジックなど実行コードは一切変わっていません。
ActiveRecord,ActiveModel,ActionView::Helpers::DateHelperなどの挙動はこの PR により変化しません。
ドキュメント生成の挙動
- 既にデフォルトが Markdown に切り替わっている前提なので、この PR によって新たに Markdown / RDoc の扱いが変化することもありません。
- 「一部の未移行ファイル」を除き、Rails 全体を Markdown 前提でドキュメント生成するのが自然な状態になります。
- 明示ディレクティブがなくても Markdown として処理されるため、今後のファイル追記・修正時に
# :markup: markdownを書く必要はありません。
サードパーティツール/内部ツールへの影響
- もし独自に
:markup:行をトリガーにして何かしているツール(例: 独自ドキュメントジェネレータ、スタイルチェッカ)があれば、ヒットしなくなる点に注意が必要です。 - ただし、Rails 本体はそのような内部依存をしていないため、公式には影響なしと見てよいです。
- もし独自に
- 参考情報 (あれば)
- デフォルトマークアップを Markdown に切り替えた PR:
- この PR (#59641) は、その後の「
# :markup: markdownを全削除してリポジトリをきれいにする」最終ステップにあたります。
#59595 Load associations when the owner has an attribute named method
マージ日: 2026/11/5 | 作成者: @anthonyfranco
- 概要 (1-2文で)
Active Record の関連読み込み時に、モデルにmethodという属性が存在するとArgumentErrorが発生して関連がロードできなくなる不具合を修正する PR です。owner.method(:read_attr)の利用をやめ、method属性と衝突しない形で属性値を読むように変更しています。
- 変更内容の詳細
問題の発生条件
Rails main ブランチ (#59120 以降) で、例えば次のようなモデルがあるとします:
class Payment < ApplicationRecord
# DB カラム: method (string)
# 例: 'cash', 'check', 'credit_card'
belongs_to :user
endpayments テーブルに method カラムがあり、それを Active Record が普通に属性として扱える状況で、
payment = Payment.first
payment.user # belongs_to のロードのように関連 (belongs_to / has_many 含むすべての association) をロードしようとすると、次のような例外が発生していました:
ArgumentError: wrong number of arguments (given 1, expected 0)
activemodel/lib/active_model/attribute_methods.rb:300:in 'GeneratedAttributeMethods#method'
activerecord/lib/active_record/associations/association.rb:286:in 'block in Association#find_target'原因は以下の通りです:
Association#find_target内で、ステートメントキャッシュに対してowner.method(:read_attr)が渡されるようになっていた (#59120 の変更)。- Active Record は
methodを「危険な属性名」として禁止していないため、attribute :methodやt.string :methodは有効。 - しかし
methodという属性があると、生成された属性メソッド (GeneratedAttributeMethods#method) がObject#methodをオーバーライドしてしまう。 - その結果、
owner.method(:read_attr)を呼び出そうとすると、本来はObject#method(1 引数) を呼ぶべきところで、「引数 0 個想定」の属性リーダ#methodが呼ばれてしまい、「1 引数渡された、でも 0 個しか取れない」というArgumentErrorが発生する。
具体的な修正内容
activerecord/lib/active_record/associations/association.rb の 1 行を置き換えています。
元:
# 概念的にはこんな形
bind_values = bind_map.bind(owner.method(:read_attr), owner, ...)修正後:
# owner.method(:read_attr) の代わりにラムダを渡す
bind_values = bind_map.bind(->(name) { owner.read_attr(name) }, owner, ...)ポイント:
StatementCache::BindMap#bindは、渡されたオブジェクトに対して[]を呼ぶだけのインターフェイスを期待している。- もともと
owner.method(:read_attr)もcallだけでなく[]で呼び出せるようになっていた想定。
- もともと
- そこで、「任意の属性名
nameからowner.read_attr(name)を呼ぶ」だけのラムダ->(name) { owner.read_attr(name) }を渡すように変更。 - このラムダは
#methodを一切経由しないため、method属性が存在していてもObject#methodの挙動と衝突しない。
テスト追加
activerecord/test/cases/associations/belongs_to_associations_test.rb に 18 行分のテストを追加:
attribute :methodを持つモデルを定義。- そのモデルに対して
belongs_tohas_many
- 双方の association をロードするテストを追加し、例外が出ずに読み込めることを確認。
このテストは、現行 main ブランチでは失敗し、この PR の修正を適用すると通るようになります。
- 影響範囲・注意点
- 影響範囲:
- main ブランチ限定の回帰バグであり、現時点のリリース版には入っていないため、既存のリリースユーザーには直接の影響はありません。
- association のロードパス全般 (
belongs_to,has_many,has_one,has_and_belongs_to_many等) が内部的にこの変更されたコード経由で動作します。
- 実務上の意味:
- モデルに
methodというカラム / 属性を持っていても、関連ロードでエラーにならなくなります。 - 特に
payments.methodのような実務的に自然なカラム名を使っているアプリが main に追従した際の不具合が解消されます。
- モデルに
- パフォーマンス・挙動:
- 置き換えは
Methodオブジェクトからシンプルなラムダへの置き換えのみで、クエリ結果やバインドのロジック自体は変わっていません。 - 実質的なパフォーマンス劣化もほぼ無視できるレベルと考えられます。
- 置き換えは
- 注意点:
activemodel/lib/active_model/validations/with.rbに同様のパターン (record.method(method_name).arity) が残っていますが、この PR では手を入れていません。- こちらは別 PR で扱う意図でスコープを絞っています。
- main を追っている人で、ActiveModel の
withオプションを使いつつmethod属性を持つケースでは、別ルートで同種の問題に当たる可能性があります(この PR では未対応)。
- 参考情報 (あれば)
- このバグは #59120 の変更がトリガーになっており、
owner.method(:read_attr)という書き方が、「メソッド名methodとカラム名methodの衝突」という Ruby/ActiveRecord 特有の落とし穴を踏んだ形です。 methodは「危険な属性名 (dangerous attribute methods)」のリストには含まれていないため、従来から普通に使えるカラム名でしたが、今回の回帰で association ロードという別の経路で問題が顕在化しました。- 回帰は main のみで、まだリリースには含まれていないため、CHANGELOG には追記していません。
#59640 Allow anonymous subclasses of cache stores with LocalCache
マージ日: 2026/11/5 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails のキャッシュストアにLocalCache戦略を使う際、無名クラス(匿名サブクラス)のキャッシュストアでもエラーなく利用できるようにした修正です。Rails.cacheを ractor 対応のために freeze する前に LocalCache のキーを生成する仕様変更によって発生した、匿名クラスでのNoMethodErrorを解消しています。
- 変更内容の詳細
問題の背景
以前の変更 (#58061) により、ActiveSupport::Cache::Strategy::LocalCache のレジストリキー(内部的に LocalCache を識別するためのキー)が initialize 内で計算されるようになりました。
元のコードでは、キー生成は概ね次のような形でした:
# 変更前のイメージ
key = :"_local_cache_#{self.class.name.underscore}"ここで self.class.name が nil になるケースがあります。それが 無名クラス(匿名クラス)でキャッシュストアを作った場合 です:
store = Class.new(ActiveSupport::Cache::MemoryStore).new
# self.class.name #=> nil
# nil.underscore を呼んで NoMethodError 発生Rails 8.1 では:
- ストア生成時はエラーにならない
- ただし
with_local_cacheに入ったタイミングで同様のNoMethodErrorが発生
一方、main ブランチ(変更後の実装)では、ストア生成時点でエラーが出るようになってしまっていました。
この無名キャッシュストアは、Sure という OSS アプリのテスト (TransactionsControllerTest) で使われており、リクエスト中に書き込まれたキャッシュキーを記録する用途で匿名 MemoryStore サブクラスが活用されています。
対応内容
PR では、キー生成時に name が nil であっても問題にならないように、常に to_s をかませるように変更しています。
変更点(要約):
# local_cache.rb (変更前のイメージ)
:"_local_cache_#{self.class.name.underscore}"
# local_cache.rb (変更後のイメージ)
:"_local_cache_#{self.class.name.to_s.underscore}"これにより:
通常の(名前付き)クラス
self.class.nameは"ActiveSupport::Cache::MemoryStore"のような文字列to_sしても変わらない- 従来どおり
"active_support/cache/memory_store"→:"_local_cache_active_support/cache/memory_store"のようなキーになる(細部は実装依存)
匿名クラスの場合
self.class.nameはnilnil.to_s #=> """"に対してunderscoreを呼ぶと""のまま- そのためキーは
:"_local_cache_"だと被りそうだが、実際には PR 説明にあるように「オブジェクト ID を含めて一意にする」形にしている実装になっている
(説明文中の例::_local_cache_1234など。実装としては、name.to_sを使いつつ、別の箇所でオブジェクト ID を含むキーになるよう組み合わせている)
PR の説明では「This uses name.to_s, so an anonymous store gets a key such as :_local_cache_1234」とあるため、name.to_s で落ちないようにしつつ、匿名ストアにはオブジェクト ID ベースのキーが付与される設計になっています。
つまり:
- 匿名ストア:
:_local_cache_<object_id>のようなユニークなキー - 名前付きストア: 従来どおりクラス名ベースのキー
テスト追加
activesupport/test/cache/stores/memory_store_test.rb にテストが追加されています(+9行)。内容は:
- 匿名サブクラス
Class.new(ActiveSupport::Cache::MemoryStore)を生成 - そのインスタンスで
with_local_cacheを実行 - 中でキャッシュの read/write が正しく動くことを検証
これにより、無名クラスのキャッシュストア + LocalCache の利用が回帰しないよう保証しています。
- 影響範囲・注意点
影響範囲
ActiveSupport::Cache::Strategy::LocalCacheを利用する全てのキャッシュストアに影響しますが、変更は キーの生成方法のみで、通常の利用では挙動はほぼ変わりません。- 特に、
Class.new(ActiveSupport::Cache::MemoryStore)のような 無名のキャッシュストアクラスを使っているアプリ・テストにとっては、これまで起きていたNoMethodErrorが解消されます。 Rails.cacheを ractor 関連で early-freeze している構成でも、匿名ストアを作成しても問題なく LocalCache を使えるようになります。
後方互換性
- 名前付きクラスのキーは従来と同様にクラス名由来であり、
name.to_sの追加による影響はありません(nameが文字列のままなので)。 - LocalCache のレジストリキーは内部利用のため、キー文字列のわずかな変更がアプリレベルで問題になるケースは通常ありません。
- もし
LocalCacheの内部レジストリキーに直接依存するようなメタプログラミングをしている場合は、挙動が変わっていないかを念のため確認した方が安全です(一般的にはそのような依存は推奨されません)。
- 名前付きクラスのキーは従来と同様にクラス名由来であり、
パフォーマンス
name.to_sの追加コストは極小であり、パフォーマンス上の影響は無視できるレベルです。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59640
- 関連 PR(問題の発端となった変更): https://github.com/rails/rails/pull/58061
- LocalCache 実装(キー生成部分):
https://github.com/rails/rails/blob/7a78da5a5576d7ab7549b10fb13156788478ba35/activesupport/lib/active_support/cache/strategy/local_cache.rb#L70 - 本件を発見した OSS アプリのテスト例(Sure プロジェクト):
https://github.com/we-promise/sure/blob/e658686d5ba7a55734ae9f42683618aa42342b74/test/controllers/transactions_controller_test.rb#L1799 - 発見経緯(Lowrail プロジェクトのレポート):
https://github.com/thomasklemm/lowrail/blob/main/docs/upstream/app-suites.md
#59634 Accept bind values in where! and having! again
マージ日: 2026/11/5 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails 8.1 → main の過程でwhere!/having!のシグネチャ変更により「バインド変数を渡せない」「既存のオーバーライドが壊れる」といった後方互換性問題が発生していたのを、元の可変長引数 (*rest) 形式に戻して修正する PR です。where/having/mergeからの呼び出しも 8.1 と同じく再びスプラット渡しに戻しつつ、#59579 で入った他のメモリアロケーション最適化は維持しています。
- 変更内容の詳細
背景: シグネチャ変更による問題
#59579 で where! と having! のメソッドシグネチャが以下のように変更されていました:
# 変更前 (8.1 相当)
def where!(opts = :chain, *rest)
...
end
# 変更後 (#59579)
def where!(opts = :chain, rest = FROZEN_EMPTY_ARRAY)
...
endこれにより、従来のような「SQL + プレースホルダ」の呼び出しや、オーバーライド時の *rest 前提の実装が壊れていました。
問題例(main ブランチ時点):
| 呼び出し | 挙動 |
|---|---|
where!("id = ?", 1) | NoMethodError(1 が想定外の位置に渡る) |
where!("id = :id", id: 1) | :id が展開されずそのまま SQL に残り、PostgreSQL が構文エラー |
where("id = ?", 1) をオーバーライドした場合 | rest が期待通りの配列ではなく [[1]] のようなネスト構造になる |
merge(where: [permitted_params]) | 引数整形の前提が崩れ ArgumentError |
where! / having! 自体は :nodoc: ですが、実際には以下のようにアプリや gem から直接使われたりオーバーライドされたりしています:
Discourse:
rubypermalinks.where!("url ILIKE :url OR external_url ILIKE :url", url: "%#{url}%")Canvas LMS(要約):
rubycourses.where!("(courses.workflow_state='deleted' AND courses.updated_at > ?) ...", 120.days.ago)crono_trigger:
rubyrel.where!("executed_at >= ?", Time.parse(params[:from]))mobility / active_record-acts_as:
rubydef where!(opts, *rest) ... end
これらはすべて「*rest でバインド値を受け取る」ことを前提としており、シグネチャ変更により壊れていました。
この PR の対応内容
where!/having!のシグネチャを元の*rest形式に戻す具体的には:
ruby# この PR で(概念的に)戻した形 def where!(opts = :chain, *rest) ... end def having!(opts = :chain, *rest) ... endこれにより、以下のような呼び出しが再び 8.1 と同じように動作します:
ruby# プレースホルダ Model.where!("id = ?", 1) # 名前付きバインド Model.where!("id = :id AND status = :status", id: 1, status: "active") # IN 句などの配列バインド Model.where!("id IN (?)", [1, 2, 3])where/having/mergeからwhere!/having!への引数の渡し方も 8.1 と同じくスプラットに戻すwhere/havingがwhere!/having!を呼ぶ際、rest引数を配列のままではなく*restで渡すように戻しています。merge(where: [permitted_params])のようなケースでも、内部的にwhere!を*args前提で呼び出す 8.1 の挙動に合わせています。
#59579 のパフォーマンス最適化は極力残す
FROZEN_EMPTY_ARRAYを利用した割り当て削減など、「引数シグネチャ以外」の部分で行われた最適化は維持されています。- つまり、「API 互換性は 8.1 に戻すが、内部の不要なオブジェクト生成を減らす工夫は残す」という方針です。
テスト追加
activerecord/test/cases/relation/where_test.rb/relations_test.rbにテストが追加され、以下をカバーしています:?プレースホルダのバインド- 名前付きバインド(
id: 1など) - 配列バインド
where!をオーバーライドした場合にsuperを正しく呼べることmerge(where: [permitted_params])がエラーにならず動くこと
- 影響範囲・注意点
影響を受ける可能性が高いコード
where!/having!を直接呼んでいるコード- 代表例として PR に挙がっている Discourse / Canvas / crono_trigger など。
- これらは Rails 8.1 時点の挙動に戻るため、「main で一時的に壊れていたものが直る」という方向の影響です。
where!/having!をオーバーライドしている gem・アプリrubydef where!(opts, *rest) # 何か独自処理 super endこういった実装は 8.1 を前提に書かれていることが多いため、今回の PR によって再び期待通り動くようになります。
merge(where: [params])のようにmergeで where 条件をマージしている箇所- strong parameters を渡すようなコード(例:
Model.merge(where: [permitted_params]))で main の時点ではArgumentErrorになっていたものが、8.1 と同様に動作するようになります。
- strong parameters を渡すようなコード(例:
注意点 / 移行上のポイント
- この PR はあくまで「誤って導入された互換性破壊を元に戻す」ものなので、Rails 8.1 以前から正しく動いていたアプリに対して新たな互換性問題を生む可能性は低いです。
- ただし、Rails main(#59579 適用後〜この PR マージ前)の短い期間に、変更後のシグネチャ(第二引数が配列前提など)に依存したコードを書いていた場合は、そのコードが再び壊れる可能性があります。通常のアプリや一般的な gem ではほぼ無視できるレベルだと思われます。
where!/having!は依然として:nodoc:であり、今後もシグネチャが変更される可能性はある内部 API である点には注意が必要です。- ライブラリ作者で、どうしても内部 API を使いたい場合は、今回のような互換性問題を検出できるテスト(Rails の複数バージョンでの CI)を用意しておくのが望ましいです。
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/59634
- 問題のきっかけとなった PR: https://github.com/rails/rails/pull/59579
- PR 説明中で挙げられている実際の呼び出し例:
- Discourse
where!使用箇所
https://github.com/discourse/discourse/blob/0f746ecc2eb1a601ba3bd1e5bcff3e7019ef509e/app/models/permalink.rb#L92 - Canvas LMS
where!使用箇所
https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/gems/plugins/account_reports/lib/account_reports/sis_exporter.rb#L382-L385 - crono_trigger
where!使用箇所
https://github.com/joker1007/crono_trigger/blob/ebd776373eb3ad2b9bbd4af81cf2f9b153071c50/lib/crono_trigger/web.rb#L178 - mobility / active_record-acts_as の
where!オーバーライド
- Discourse
- PR 作者による API 回帰検出プロジェクト Lowrail の記録:
https://github.com/thomasklemm/lowrail/blob/main/docs/upstream/api-regressions.md
#59638 Fix Active Record includes with merge of an order on another model
マージ日: 2026/11/5 | 作成者: @kehra
- 概要 (1-2文で)
includesした関連に対して、別モデル側のorder/reorderをmergeすると事前ロードが外れ、JOIN せずに外部テーブルのカラムを参照して SQL エラーになるリグレッションが #59590 以降発生しており、この PR はそのバグを修正します。merge時のソート情報の取り込み方法を変更し、適切に参照テーブルが記録されるようにしています。
- 変更内容の詳細
問題の具体例
#59590 適用後は、次のようなコードがエラーになります:
Post.includes(:author).merge(Author.order(:name)).to_a
# => ActiveRecord::StatementInvalid: no such column: authors.name期待される挙動は:
includes(:author)によりauthorsテーブルを JOIN しつつ eager loadmerge(Author.order(:name))によりauthors.nameで ORDER BY
ところが実際には:
authorsテーブルが参照テーブル (references) として記録されておらず- SQL が
FROM "posts"だけ、もしくは不完全な JOIN の状態でORDER BY "authors"."name"を発行 - その結果「authors.name カラムがない」というエラーが発生
同様の問題が、以下のケースでも起きます:
reorderをmergeした場合- マージ対象リレーションに含まれる Arel 属性 (
Author.arel_table[:name]など) - エイリアス付きテーブルを使うような高度なクエリ
バグの原因 ( #59590 での変更点との絡み )
#59590 では主に2つの振る舞いが変わっています:
「リレーション自身のテーブルに対する
order」は、もはやreferencesにそのテーブルを追加しない- 例:
Post.order(:created_at)はpostsテーブルを references に足さない - これは「自分自身のテーブルはそもそも FROM にあるので references は不要」という合理化
- 例:
mergeは、マージ対象リレーションのorder_valuesを「前処理済み」とみなして、そのまま受け取り側に代入するようになった- その代わり、
referencesは別経路で統合される想定
- その代わり、
問題は、この2つが組み合わさったことです。
mergeする側(例:Author.order(:name))では、「自分自身のテーブル (authors) に対する order なので references には authors を追加しない」というルールが既に適用済み- その
order_valuesが、そのままPost.includes(:author)側へコピーされる merge後の文脈では、「authors テーブル」は「外部テーブル」扱いになるが、authors は一度も references に記録されていない- 結果として、
includesが想定通り JOIN を張らず、authors.nameを参照する ORDER BY だけが残りエラーになる
具体的な修正内容
activerecord/lib/active_record/relation/merger.rb で、merge 時の order/reorder の取り扱いを変更しています。
ポイント:
- これまで:
- 他方のリレーションの
order_values/reorder_valuesを「前処理済み」とみなして、そのままコピー
- 他方のリレーションの
- これから:
- マージ時に
_order!/_reorder!を通して設定するように変更
- マージ時に
_order! / _reorder! の役割:
- 元々、リレーション内部で
order/reorderを設定する際の共通処理 - 特徴:
- 既に前処理された値であっても「再前処理」はしない
- しかし、「受け取り側リレーションの文脈」で
referencesを適切に記録する- 受け取り側自身のテーブルへの order なら references 不要
- 外部テーブルへの order なら、外部テーブルを references に追加
加えて:
_reorder!は引数をuniq!で破壊的変更する実装になっているため、- マージ元の
order_valuesが書き換わらないよう、merge側で値を複製してから渡すようにしている
- マージ元の
テスト (activerecord/test/cases/relation/merging_test.rb) では、少なくとも以下のようなケースがカバーされています (推測ベース):
includes(:author)+merge(Author.order(:name))がエラーにならず、かつ eager load されるreorderを含むパターン (merge(Author.reorder(:name)))- 恐らく Arel ベースの order / エイリアステーブルについても類似の確認
- 影響範囲・注意点
対象バージョン:
- バグ自体は「まだリリースされていない」変更 (#59590) に起因するため、現時点でリリース版 Rails を使っているアプリには直接の影響はありません。
- 今後 #59590 を含むバージョンがリリースされる場合、この PR によって、リリース前にリグレッションが潰された、という位置付けになります。
影響するコードパターン:
includes+mergeを組み合わせ、かつmerge先が「別モデルの order / reorder / Arel order / エイリアス付きテーブル」を持つような複合クエリ- この PR により、そうしたクエリが期待通り eager load + order されるようになります。
破壊的変更の有無:
- 安定版から見れば、「本来期待される挙動への修正」であり、互換性を壊す変更ではありません。
- #59590 のみを取り込んだ中途バージョンを使っていた場合は、「これまで通らなかった
includes + merge(order on other model)パターンが通るようになる」だけです。
パフォーマンス:
includesによる JOIN / サブクエリ生成が、正しく references を解釈して行われるようになるだけで、特別な負荷増は想定されていません。_order!/_reorder!経由になることで、ごくわずかなオーバーヘッドは増えますが、もともと内部ユーティリティであり誤差レベルです。
- 参考情報 (あれば)
- 元リグレッションの PR:
- その議論の中で合意された方向性(
merge時の references 取り扱いなど)に沿った修正: - 対象 PR:
#59635 Fix FrozenError loading the Active Job Rakefile on JRuby
マージ日: 2026/11/5 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
JRuby 環境で Active Job の Rakefile を読み込む際にFrozenErrorが発生していた不具合を修正する PR です。ACTIVEJOB_ADAPTERS定数の配列を「凍結(freeze)する前」に JRuby 専用の要素削除処理を行うように変更しています。
- 変更内容の詳細
問題の原因
activejob/Rakefile では、使用するアダプタ一覧を ACTIVEJOB_ADAPTERS として定数化し、Rubocop の Style/MutableConstant 対応として freeze していました。
イメージとしては以下のようなコードになっていたと考えられます(簡略化した例):
ACTIVEJOB_ADAPTERS = [
"async",
"inline",
"queue_classic",
"resque",
"sneakers",
"backburner",
"test"
].freeze
if defined?(JRUBY_VERSION)
ACTIVEJOB_ADAPTERS.delete("queue_classic")
endここで問題となるのは、
ACTIVEJOB_ADAPTERSがfreeze済みの配列である- その後に
deleteで配列の要素を変更しようとしている
という点です。Ruby では freeze されたオブジェクト(配列)を変更しようとすると FrozenError になります。
- CRuby では
queue_classic関連のタスクが JRuby 向けに分岐されており、実行経路としてこのdeleteが走らなかったため問題が顕在化していませんでした。 - JRuby では
defined?(JRUBY_VERSION)が真になるためdeleteが実行され、FrozenError: can't modify frozen Arrayで Rakefile のロードが失敗していました。
修正内容
修正はシンプルで、「JRuby の場合に queue_classic を配列から取り除く処理」を freeze 前に行うように順序を変更しています。
イメージコード:
adapters = [
"async",
"inline",
"queue_classic",
"resque",
"sneakers",
"backburner",
"test"
]
# JRuby では queue_classic を使わないのでここで削除
adapters.delete("queue_classic") if defined?(JRUBY_VERSION)
# ここで初めて定数に代入し、かつ freeze する
ACTIVEJOB_ADAPTERS = adapters.freezeポイント:
- JRuby では
queue_classicが配列から削除された状態でfreezeされるため、以降の処理で配列の変更は行われずFrozenErrorも起きない。 - CRuby では
defined?(JRUBY_VERSION)が偽のため、元の配列がそのままfreezeされ、従来どおりqueue_classicを含むアダプタ一覧が Rake タスクとして定義される。
作者は activejob ディレクトリで rake -T を実行して動作確認しています。
- CRuby 4.0.7:
- タスクリストは変更なし (
test:queue_classicを含む)
- タスクリストは変更なし (
- JRuby:
- Rakefile が正常にロードされ、
queue_classic以外のアダプタ用タスクが列挙される
- Rakefile が正常にロードされ、
- 両方で
ACTIVEJOB_ADAPTERSは凍結されていることも確認済み
- 影響範囲・注意点
- 影響範囲:
activejob/Rakefile経由で定義される Rake タスク (特にアダプタごとのテストタスク) に限定されます。- ランタイムの Active Job 本体の挙動には影響しません。
- JRuby 利用時:
- これまで Rakefile のロード時に
FrozenErrorでタスクが一切定義されなかった問題が解消されます。 queue_classic向けタスク (test:queue_classic等) は JRuby では定義されません(従来も実質的にサポートされていないので妥当な挙動)。
- これまで Rakefile のロード時に
- CRuby 利用時:
- タスク一覧・挙動は従来と変わりません。
- 注意点:
- この PR ではテストや CHANGELOG の追加は行われていません(軽微なバグ修正として扱われている)。
- 今後同様に「定数を freeze した後に環境に応じて中身を変える」ような実装を行うと、再度同じクラスの不具合を生む可能性があります。
- パターンとしては「一旦ローカル変数で構築 → 条件分岐で調整 → freeze して定数化」という流れが安全です。
- 参考情報 (あれば)
- 元の不具合内容:
FrozenError: can't modify frozen Array: ["async", "inline", "queue_classic", "resque", "sneakers", "backburner", "test"]- JRuby でのみ再現(
JRUBY_VERSIONによる条件分岐が CRuby では走らないため)
- 関連する過去コミット:
5700c17cb7:ACTIVEJOB_ADAPTERSをfreezeして Rubocop のStyle/MutableConstantに対応したコミット。
ここで「後から削る処理」が考慮されておらず、JRuby で問題が露呈した。
#59584 Skip the HTML entity gsub! in JSON::Coder output with nothing to escape
マージ日: 2026/11/5 | 作成者: @thomasklemm
- 概要 (1-2文で)
JSON 出力時に<,>,&をエスケープする処理について、「そもそもこれらの文字が含まれていない場合」はgsub!を実行しないようにして無駄な全体走査をスキップする最適化が行われました。出力結果の仕様は一切変えずに、特に JS セパレータ非エスケープ時のパフォーマンスを改善する変更です。
- 変更内容の詳細
背景
- Ruby 4.1 より前の環境で、Rails は
jsongem 2.15.2 以降のデフォルトエンコーダJSONGemCoderEncoderを使用。 - このエンコーダでは、XSS 対策などの目的で JSON 文字列内の
<,>,&を 1 回のgsub!(正規表現の Union)でエスケープしています。 gsub!は対象文字が 1 つも含まれていなくても文字列全体を走査するため、小さい JSON を大量に返す API などではオーバーヘッドになりうる、というのが問題意識です。
また、Rails 8.1 (load_defaults 8.1) 以降ではデフォルトで「JS セパレータ(U+2028, U+2029)をエスケープしない」設定になっています。この「JS セパレータ非エスケープモード」の場合に最適化が効くようになっています。
なにをしたか
- 「JS セパレータをエスケープしない設定」の場合に限り、
<,>,&が 1 つも含まれていないときはgsub!を呼ばないように変更しました。 - 実装方針:
- 以前の案: 文字ごとに
gsub!("<", ...),gsub!(">", ...),gsub!("&", ...)を個別に呼ぶ(文字がないとき速いが、あるときは String の走査回数が増えて遅い)。 - 採用された案: まず
include?で 3 文字の存在をチェックし、1 つでも含まれていれば従来どおり「正規表現 union による 1 回のgsub!」を行う。
→ 文字があるときのパフォーマンスを維持しつつ、何もないときの無駄な走査を省く。
- 以前の案: 文字ごとに
擬似コードイメージ(Rails 実装のイメージ表現):
def escape!(string)
if js_separators_escaped?
# ここは挙動変更なし
string.gsub!(JS_SEPARATORS_REGEX, JS_SEPARATORS_REPLACEMENT)
string.gsub!(HTML_ENTITY_REGEX, HTML_ENTITY_REPLACEMENT)
else
# ここが今回変わったところのイメージ
if string.include?('<') || string.include?('>') || string.include?('&')
string.gsub!(HTML_ENTITY_REGEX, HTML_ENTITY_REPLACEMENT)
end
end
endHTML_ENTITY_REGEX 自体(/[<>&]/ のような union)や置換内容は従来どおりです。
テストの追加
activesupport/test/json/encoding_test.rb に 16 行のテストが追加されており、代表的なケースで 従来と全く同じ JSON が出力されること を検証しています。
新しいテストでは、少なくとも以下のような点を確認していると考えられます(変更規模から推測される内容):
<,>,&を含まない JSON 文字列が、以前と同じ結果であること。<,>,&を含む場合に正しくエスケープされ、以前と同じ結果になること。- JS セパレータのエスケープ有無設定ごとに、挙動が変わらないこと。
- 影響範囲・注意点
影響範囲
- 対象:
- Rails の
ActiveSupport::JSONを用いた JSON シリアライズ処理。 - 特に Ruby 4.1 未満 +
json2.15.2 以降でJSONGemCoderEncoderが使われているパス。 config.load_defaults 8.1以降(または同等設定)で「JS セパレータ非エスケープ」が有効な環境でのメリットが大きい。
- Rails の
- 変更内容はあくまで「gsub! を実行するかどうかの事前チェック」だけなので、JSON 文字列の仕様上の出力は変わりません。
パフォーマンスへの影響
ベンチ結果(Ruby 4.0.9, JS セパレータ非エスケープ、ns/call):
| ケース | main | この PR |
|---|---|---|
{"id":1,"name":"x"} | 537 / 354 | 482 / 278 |
| 11 カラムのレコード | 949 / 759 | 547 / 328 |
&, <b>, U+2028, U+2029 を含む短いドキュメント | 2,744 / 2,316 | 2,742 / 2,550 |
<,>,&を含まない頻出ケースでは 10〜40% 程度の改善(YJIT 下では特に顕著)。- 文字を含むケースでは、インタプリタではほぼ同等、YJIT では ~10% 程度遅くなる計測結果。
- Callgrind 計測では、典型的な 11 カラムレコードで命令数が 36%(インタプリタ)〜48%(YJIT)減少。
開発者視点では:
- API サーバで小さめの JSON を非常に大量に返すようなケースでは、トータルで数 % 程度の CPU 削減になり得ます(実際、著者がいくつかの OSS アプリをプロファイルしたところ、escape 処理が 0.4〜0.6% のサンプルを占めていたとのこと)。
<,>,&を頻繁に含むドキュメントを大量に返す特殊なケースでは、YJIT 利用時に若干の悪化があり得ますが、実アプリのボトルネックにはなりにくいレベルです。
注意点
- JSON エスケープの仕様自体は変わっていないため、互換性上の注意はほぼ不要です。
- 「JS セパレータをエスケープする設定」の場合はロジックが変更されておらず、今回の最適化は効きません(挙動・性能とも従来どおり)。
- Ruby 4.1 以降で JSON 実装が変わった場合、この最適化の有効範囲も変わる可能性があります(この PR 自体は Ruby 4.1 未満向けのホットスポット最適化として説明されています)。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59584
- 著者によるプロファイル結果・文脈説明:
この PR は「JSON エンコード周りのホットパスを、挙動を変えずに軽くする」タイプのマイクロ最適化であり、API パフォーマンスチューニングをしている開発者にとっては地味ながら有益な改善です。
#59604 Close the stream when Action Cable's event loop fails to attach it
マージ日: 2026/11/4 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
Action Cable のStreamEventLoopでNIO::Selector#registerなどの失敗時にストリームが閉じられずクライアントがハングする問題を修正し、失敗時でも確実に IO をクローズして接続を終了するようにした PRです。あわせてこの挙動を保証するテストが追加され、Action Cable のイベントループの堅牢性が向上しています。
- 変更内容の詳細
問題の背景
Action Cable の StreamEventLoop は内部で NIO::Selector を使って IO を監視していますが、
selector.register(io, interest)が例外を投げた場合- その例外がタスク(lambda)外に漏れてイベントループ用スレッドが死ぬ
- しかしストリーム(IO)は閉じられないまま残る
- その結果
client_goneコールバックなどが呼ばれず、クライアント側から見ると「いつまでも切断されない」状態になる
という問題がありました。
特に JRuby + nio4r の組み合わせで UNIX ソケットを登録しようとすると register が失敗し、Action Cable のテストがハングするという実害が報告されています。
StreamEventLoop の挙動変更
1. attach の変更: register 失敗時にストリームをクローズ
StreamEventLoop#attach は新しい IO をイベントループに登録するときに呼ばれます。この PR では、
selector.register(io, ...)が例外を投げた場合- その IO(ストリーム)を必ず
closeする - 結果として接続が確実に teardown され、クライアントがハングしない
- その IO(ストリーム)を必ず
という動作に変更されています。
(疑似コードイメージ)
def attach(io, *args)
@todo << proc do
begin
selector.register(io, :r, args)
@map[io] = args
rescue => e
# 新しくなったポイント: register に失敗した IO を閉じる
io.close rescue nil
raise e
end
end
end※実際のコードは多少異なる可能性がありますが、趣旨としては「register に失敗したらその IO は必ず閉じる」です。
2. detach の変更: 登録済み IO だけを deregister し、必ず IO をクローズ
StreamEventLoop#detach はイベントループから IO を取り外す処理です。ここも不具合の原因になりうる部分がありました。
変更点:
@map.delete(io)で「その IO が本当に登録されているか」を確認してからselector.deregister(io)を呼ぶ- すでに登録されていない IO に対して
deregisterしない - CRuby の
nio4r実装では「登録されていない IO を deregister しても nil が返るだけ」なので、挙動はほぼ変わらないが、JRuby など他実装での安全性が向上
- すでに登録されていない IO に対して
deregisterが例外を投げても、IO 自体は「必ず閉じる」- これにより、イベントループスレッドは依然として例外で死ぬ可能性があるが
- IO が閉じられないまま残る、という最悪の状態は避けられる
- 次の wakeup 時にスレッドは再生成され、キューに溜まっていたタスクが処理される
疑似コードイメージ:
def detach(io)
@todo << proc do
begin
if @map.delete(io) # 登録されていた場合のみ deregister
selector.deregister(io)
end
ensure
# deregister が成功/失敗に関わらず必ず IO を閉じる
io.close rescue nil
end
end
end3. テストの追加・変更
actioncable/test/server/socket/client_socket_test.rb- 「hijacked IO ソケットを shutdown 時に閉じる」テストが、永遠に
event.waitし続けるのではなく、最大 5 秒まで待つように変更- イベントループが詰まった場合でもテスト全体が延々とハングしないようにするため
- 「hijacked IO ソケットを shutdown 時に閉じる」テストが、永遠に
actioncable/test/server/stream_event_loop_test.rbregisterが失敗するケースを再現するテストを新規追加- 例えば、テスト用に
selector.registerをモックして例外を投げさせ、それでも IO が閉じられることを確認する
- 例えば、テスト用に
deregisterが失敗するケースをカバーするテストも追加- 登録済み IO に対して
deregisterが例外を投げても IO は閉じられることを確認
- 登録済み IO に対して
4. CHANGELOG の更新
actioncable/CHANGELOG.md に今回の挙動変更が追記され、ユーザー/開発者に
- 「登録失敗時や取り外し失敗時にも IO をクローズするようになった」
という振る舞いの変化が明示されています。
- 影響範囲・注意点
Action Cable の接続まわりの堅牢性向上
- Selector への登録/解除が失敗しても、クライアントソケットが閉じられずにハングする、という状態は起きにくくなります。
- 特に JRuby + nio4r + UNIX ソケットのような特殊環境で「テストが固まる」「Action Cable が固まる」現象のリスクが減ります。
イベントループスレッドは依然として例外で死ぬ可能性がある
- PR 説明にもある通り:
- 登録済み IO に対する
deregisterが例外を投げた場合、イベントループのスレッド自体は以前と同様に死ぬ - ただし、IO は確実に閉じられ、次回の wakeup でスレッドは再生成され、キュー内の作業も処理される
- 登録済み IO に対する
- アプリ側で
StreamEventLoopを直接いじっている場合(通常はない)は、この挙動を前提にしないよう注意が必要です。
- PR 説明にもある通り:
独自実装の Selector / nio4r 拡張に対する影響
@map.delete(io)によって「登録されていない IO を deregister しない」ようになったため、- これまで「未登録 IO に対する deregister に依存していた」ような奇抜な実装があれば挙動が変わる可能性はありますが、通常は問題になりません。
- CRuby の標準的な
nio4rでは挙動は事実上従来と互換です。
JRuby での状況
- この PR により、JRuby 環境での Client Socket テストは「ハングせずに速やかに失敗する」ようになります。
- ただし、もともとの根本問題(JRuby の UNIX ソケットを nio4r が登録できない)は JRuby / nio4r 側の修正待ちであり、Action Cable 側だけではテストを完全に通すことはできません。
アプリケーションコードへの直接的な破壊的変更はほぼなし
- Action Cable の公的な API には変更はなく、「内部的にエラー時にソケットをより確実に閉じるようになった」という性質の修正です。
- ただし、いままで「バグによりいつまでも切断されなかった」ようなクライアントは、今後はきちんと切断されるようになります。その意味では、バグ依存の挙動に頼っていた場合は影響があります。
- 参考情報 (あれば)
- PR 本体:
https://github.com/rails/rails/pull/59604 - nio4r が JRuby の UNIX ソケットを登録できない問題:
https://github.com/socketry/nio4r/issues/67 - JRuby 側の関連 Issue:
https://github.com/jruby/jruby/issues/9874
今回の修正は、「Selector への登録・解除が失敗するようなエッジケースでも、Action Cable がクライアントを宙ぶらりんにしない」ことに主眼があります。特にマルチプラットフォーム対応や JRuby サポートを重視する場合は、この変更を把握しておくとよいです。
#59626 Skip nested values that serialize to an empty query in to_query
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails のto_query(クエリ文字列生成)で、ネストした配列・ハッシュが「結果として空文字列にしかならない場合」はクエリから完全にスキップするようにし、「?&page=2」のような余計なセパレータ(&)が出ないようにする修正です。
同時に、nil・false・空文字列はこれまで通り有効な値としてクエリに残るようにテストで明示されています。
- 変更内容の詳細
背景
関連 PR の関係は以下の通りです:
- #57500
- 「生成されるクエリ文字列が空なら、その要素をスキップする」という挙動に変更
- #57677
- その後、「要素の「型」によって」空配列・空ハッシュをスキップする実装に変更
- 副作用として、「自分自身は空でないが、中身が全部空コレクション」というケースが漏れる
問題となった例:
{ q: [[]], page: 2 }.to_query
# => "&page=2"q の値は q: [[]] なので
qは Array として「空ではない」- しかし中身は空配列だけで、シリアライズすると空文字列
- その結果、「
qに対するクエリ断片は空文字列」だがHash#to_queryはそれを保持してしまう
→ 先頭に余計な&がついた"&page=2"が生成される
→url_forなどで/search?&page=2という URL になってしまう
今回の修正方針
「型チェック」は維持しつつ、「シリアライズ結果が空文字列なら、その値をスキップする」ようにする
という折衷案になっています。
Array#to_queryHash#to_query
の両方で、「ある要素をクエリ文字列に変換してみて、その結果が空文字列だったら、その要素を無視する」ロジックが追加/強化されました。
※ 実際のコードは activesupport/lib/active_support/core_ext/object/to_query.rb の +4/-2 の変更で実装されていますが、要点は「ネストした要素も一度 to_query して、空なら採用しない」というフィルタリングの追加です。
テストで確認しているポイント
activesupport/test/core_ext/object/to_query_test.rb にテストが追加されています (+10)。
配列/ハッシュが「空コレクションのみ」を含むケースはクエリから消える
想定される例:
ruby{ q: [[]], page: 2 }.to_query # 修正後の期待値(イメージ): # => "page=2" もしくは "page=2&q=" のような余計な `&` が入らない形また、
[{}, []]といった「要素としてハッシュや配列を持つが、それらがすべて空」というケースでも、結果のクエリ断片は空とみなされてスキップされます。nil・false・空文字は引き続き維持される例として(テスト内容から推測される形):
ruby{ filters: [nil, false, ""] }.to_query # => "filters[]=&filters[]=false&filters[]=" # ※ 具体的な順序やエンコードは実装依存だが、 # 「要素が落ちない」ことを確認するテストが追加されているここで意図されているのは:
- 「コレクションが“空”かどうか」ではなく
- 「(再帰的に)シリアライズ結果が完全に空文字列かどうか」
を基準にスキップするため、
nilや空文字・falseは「値として意味がある」ものとして保持する、という区別です。
- 影響範囲・注意点
対象となる機能
Object#to_queryを通じて利用される全ての箇所- 特に
Hash#to_query/Array#to_queryを内部で使うurl_for・link_toなど URL 生成まわり
主な挙動の変化
- 全体として:
- 「ネストの奥にある“空コレクションしか持たない値”」が、クエリ文字列から完全に消えることがある
- 副作用として:
- これまでは
"&page=2"のようなクエリが生成されていた箇所が、"page=2"だけになる - つまり「余計な空パラメータ」が付かなくなる
- これまでは
- 全体として:
既存コードへの影響の可能性
- 正常系の URL 生成は改善される(
?&page=2→?page=2)ため、多くの場合バグ修正としてプラス - ただし、もし既存コードが「空パラメータの存在そのもの」に依存していた場合は挙動が変わる
- 例: 「クエリ文字列に
qというキーが“空でもいいから存在するか”をチェックしていた」ようなケース - ただし今回スキップされるのは「シリアライズ結果が完全に空になるネスト構造」に限られ、
nil・false・空文字は保持されるため、影響範囲はかなり限定的
- 例: 「クエリ文字列に
- 正常系の URL 生成は改善される(
デバッグ・確認ポイント
- 独自で
to_queryを直接使っている場合は、ネストした配列/ハッシュで、空コレクションしか入っていないケースの出力が変わっていないか確認すると安心です。 - API クライアントなどで「空の配列を明示的に送りたかった」ケースがある場合は、
- それが本当に空配列としてシリアライズされているか (
param[]=) - もしくは完全に削除されてしまっていないか
を確認してください。
- それが本当に空配列としてシリアライズされているか (
- 独自で
- 参考情報 (あれば)
- 元 PR 説明文に出てくる関連 PR:
- #57500: 「クエリ文字列が空ならスキップ」への変更
- #57677: 「型ベースで空配列・空ハッシュをスキップ」に修正した PR
- この PR は、
8-1-stableとmainの差分を自動生成入力で比較する Lowrail プロジェクトから発見されたバグ修正であり、
Rails 8.1 系と 8.2 (main) 間の挙動差分を埋める意味合いもあります。
#59625 Generate URLs for empty arguments with default URL options as before
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails 7系(?) で導入された「最適化された URL ヘルパー」が、空文字""を返すto_paramを持つレコードに対してUrlGenerationErrorを投げるようになっていた回 regress を修正する PRです。default_url_options(特にhost)が存在する場合でも、従来どおり URL を生成できるように、最適化パスが失敗したときに従来の一般パスにフォールバックするようになりました。
- 変更内容の詳細
問題となっていた挙動
以前のPR #59479 で、URL ヘルパーの「最適化版」が使われる条件が拡張され、
default_url_optionsが予約済みオプション(hostなど)だけを設定している場合にも、最適化版ヘルパーが使われるようになりました。しかし、この最適化版ヘルパーは、
- 引数が空文字
""のとき - もしくはレコードの
to_paramが空文字""を返すとき
にパス部分を組み立てられず、ActionController::UrlGenerationErrorを発生させます。
- 引数が空文字
一方、最適化が導入される「以前の一般的なルート生成パス」は、こうした場合でも URL を生成しており、例えば:
rubydefault_url_options[:host] = "example.com" # post.to_param #=> "" post_url(post) # 以前の挙動: "http://example.com/posts/" # regression 後の挙動: ActionController::UrlGenerationError が発生メーラー (
default_url_options[:host]を設定している) などで、この違いが顕在化していました。PR 説明文では Lobsters のリクエストスペックcheck_url_dupeが main で落ち、この修正で通るようになったと書かれています。
このPRが行った修正
ポイントは「最適化パスでパス生成に失敗したときに、従来の一般パスにフォールバックする」という挙動を、
default_url_optionsが適用されるケースでも行うようにしたことです。すでに「明示的なオプション(クエリパラメータ等)が渡された場合」は、最適化が失敗すれば一般パスにフォールバックする挙動がありましたが、
- 「
default_url_optionsのみが存在する」ケースでは- 最適化版が一度動いて失敗しても、そのまま例外で終わってしまい、フォールバックが行われていませんでした。
- 「
このPRでは
route_set.rbを修正し、default_url_optionsがあり- 最適化版ヘルパーがパス生成を諦めた(例外を投げる/生成できない)
場合には、従来の一般パスによる URL 生成に切り替えるようにしています。
テスト (
routing_test.rb) では以下を確認しています:default_url_optionsがない状態では、空の引数は今まで通りUrlGenerationErrorを発生させる(=挙動を変えない)。default_url_optionsが存在する場合に、空のto_paramを持つレコードで URL が生成されること。
挙動の整理
- 最適化ヘルパーが生成できる URL:
- これまで通り変わらない。既存の(正常に通っていた)ケースの出力は一切変化しないように配慮されています。
true,false,nilを引数に渡したとき:default_url_optionsの有無にかかわらず、従来と同じ挙動を維持。
- 空文字/空
to_param:default_url_optionsがない:- 従来と同じく
UrlGenerationError。
- 従来と同じく
default_url_optionsがある(特にhost等を設定している):- 最適化ヘルパーが一度試みて失敗 → 一般パスにフォールバック → 以前と同じ URL を生成 (
http://example.com/posts/など)。
- 最適化ヘルパーが一度試みて失敗 → 一般パスにフォールバック → 以前と同じ URL を生成 (
- 影響範囲・注意点
影響を受ける主なケース
default_url_options[:host]等を設定しており*_url/*_pathヘルパーに対して、to_paramが空文字を返すような AR モデル・リソースを渡しているコード。- 実務上は、メーラーや外部向けURL生成処理(
url_for/*_url)で影響する可能性が高いです。
既存コードへの影響
- 「以前のバージョンでは正常に動いていたが、#59479 以降で
UrlGenerationErrorが出るようになっていたコード」は、このPRで元通り動くようになるはずです。 - 逆に、「#59479 以降の挙動(空の
to_paramで必ず例外)を前提にした」ようなコードがもしあれば、その前提は崩れます。ただし、説明文からも分かる通りそれは regress 挙動と見做されており、依存しているほうが危険と考えられます。
- 「以前のバージョンでは正常に動いていたが、#59479 以降で
セマンティクス上の注意点
default_url_optionsの有無で、空文字・空to_paramの扱い(例外 vs URL 生成)が変わるため、URL が生成されるかどうかを厳密に検査したい場合には、その点を考慮する必要があります。- とはいえ、これは「以前の Rails から存在していた挙動」に回帰しているため、実運用上は問題を減らす方向の変更と見てよいです。
- 参考情報 (あれば)
- 関連PR:
- #59479: 「最適化された URL ヘルパー」の適用条件を広げた変更。このPRはその副作用(regression)を修正している。
- 実例:
- Lobsters プロジェクトの
check_url_dupeに関するリクエストスペックが、この regression により main ブランチで落ちており、本PRの修正により通るようになっていると説明されています。
- Lobsters プロジェクトの
- 実装箇所:
ActionDispatch::Routing::RouteSet内の URL 生成パス(最適化パス → 一般パスフォールバック)周りのロジック。ActionDispatch::Routingのテスト (routing_test.rb) に、default_url_optionsの有無での挙動差を検証するテストが追加。
#59624 Load author addresses with authors in FieldOrderedValuesTest
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
PostgreSQL 18.4以降で外部キー検証タイミングが変わったことにより、FieldOrderedValuesTestがauthorsフィクスチャだけを読み込んだ場合に外部キー制約エラーが出ていた問題を解消するPRです。authorsと外部キーで結びつくauthor_addressesフィクスチャも同時にロードするようにして、テストが単体実行されても安定して通るようにしています。
- 変更内容の詳細
何をしているPRか
- 対象ファイル:
activerecord/test/cases/relation/field_ordered_values_test.rb - 変更内容: フィクスチャのロード対象に
author_addressesを追加(あるいはauthors単体ロード →authors, author_addressesのように変更)しただけの 1 行変更です。
イメージとしては、以下のような変更です(実際の記法はテストの他ファイルと揃えてありますが、概念的にはこんな感じです):
# 変更前(例)
fixtures :authors
# 変更後(例)
fixtures :authors, :author_addressesなぜ必要なのか
- コミット
63cf73e451("Extract order references from the built Arel nodes") 以降、FieldOrderedValuesTestがauthorsフィクスチャを読み込むようになった。 authorsテーブルはauthor_addressesに対する外部キーを持っているが、テスト内ではauthor_addressesフィクスチャを読み込んでいなかった。- PostgreSQL 18.4+ ではフィクスチャ挿入時、
- 一旦外部キー制約を
NOT ENFORCEDで挿入し、 - その後
ENFORCEDに戻す際に既存行の整合性検証が走る という挙動になったため、「author_addressesが存在しない状態でauthorsを挿入 → 外部キー再有効化」のタイミングでPG::ForeignKeyViolationが発生するようになった。
- 一旦外部キー制約を
- そのため、このテストファイルが「他のテストより先に単独で」走ると、21件の外部キー違反エラーが出るケースが Buildkite で観測されていた(seed 14797)。
- PRでは、過去のPR #28991 が別テストに対して行ったのと同様に、
authorsを読み込むテストでauthor_addressesも一緒にロードするようにして、外部キーを満たすデータを用意した状態で検証が行われるようにしています。
- 影響範囲・注意点
- 影響範囲:
- Rails本体の動作ではなく、Active Record のテスト (
FieldOrderedValuesTest) のみ。 - 本番コードには影響しない、テスト安定性向上のための変更です。
- Rails本体の動作ではなく、Active Record のテスト (
- 確認状況:
- PostgreSQL 18.6 で
FieldOrderedValuesTestファイル単体実行時:- 変更前: 21件の
PG::ForeignKeyViolationで失敗 - 変更後: 正常にパス
- 変更前: 21件の
- PostgreSQL 15 および SQLite:
- 変更前後ともテストはパス(既存環境へのマイナス影響なし)。
- 他のテストファイル:
authorsを読み込む他のテストファイルは、author_addressesを空にした状態でも単体実行で通っており、このPRの対象はFieldOrderedValuesTestのみで十分と判断されています。
- PostgreSQL 18.6 で
- 注意点:
- 似たように外部キーを持つフィクスチャを不完全な組み合わせで読み込んでいるテストが他にないか、PostgreSQLの新しいバージョンを使う環境では注意が必要です。
- 今後フィクスチャを追加・変更する際は、
- 「外部キーを張っているテーブルのペアを同時にロードしているか」
- 「DBバージョン差で外部キー検証タイミングが変わらないか」 を意識しておくと同種のテスト不安定を防げます。
- 参考情報 (あれば)
- 関連コミット:
63cf73e451"Extract order references from the built Arel nodes"
→ このコミット以降、FieldOrderedValuesTestがauthorsフィクスチャを利用するようになり問題が顕在化。
- 関連PR:
- #28991: 他のテストに対して、同様に関連フィクスチャを合わせてロードする対応を行ったPR。
- DB挙動:
- PostgreSQL 18.4 以降の「外部キーを一旦
NOT ENFORCEDで挿入 → 後からENFORCEDで既存行を検証」という仕様変更が、従来見えていなかったフィクスチャ間の依存関係問題を炙り出している形です。
- PostgreSQL 18.4 以降の「外部キーを一旦
#59620 Don't build a Set of renderers on every render
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
コントローラにローカルな renderer 設定がない場合に、毎回 Set を新規生成していた処理を共通のRenderers.allをそのまま返すようにし、レンダリング時のオーバーヘッドを削減する最適化です。Ractor セーフな実装方針を維持しつつ、render呼び出しあたりの時間・オブジェクト数を削って高速化しています。
- 変更内容の詳細
何をしているか
- 対象ファイル:
actionpack/lib/action_controller/metal/renderers.rb - 変更点の要旨:
- これまで
_all_renderersは「ローカル renderers + グローバル renderers」の和集合を 毎回新しい Set として構築していました。 - この PR では、
- ローカル renderers を持たないコントローラのとき:
Renderers.all(グローバルな renderer セットの凍結済みスナップショット)を そのまま返す。- つまり
local_renderers.empty?のときは、local + globalという Set.union を行わず、共有 Set への参照だけにする。
use_renderersなどで ローカル renderers を持つコントローラ(およびサブクラス)のとき:- 従来どおり
local_renderers + Renderers.allの和集合を先に作り、その結果を返す(ただし内部実装は Ractor 対応のスナップショット方式)。
- 従来どおり
- ローカル renderers を持たないコントローラのとき:
- これまで
背景: #57830 と Ractor safety
- #57830 での変更点:
- renderer のレジストリは、
add/removeのたびに 新しい凍結済み Set を再生成する実装に変更された。 - これにより:
- イテレーション中の内容変更が起こらない(スナップショット性)。
- Ractor 間で共有しても安全(凍結されている)。
- renderer のレジストリは、
- 今回の PR はその方針に沿いつつ、
- 「毎回
_renderers + Renderers.allという同じ内容の Set を新規生成している」ケース(ローカル renderers なし)を最適化し、 - 共有されている凍結済みの Set (
Renderers.all) をそのまま返すようにしたものです。
- 「毎回
パフォーマンスの効果(Ruby 4.0.9)
コントローラにローカル renderers がないケースの 1 回あたり:
| 項目 | main | this(本PR) |
|---|---|---|
| テンプレート render 時の renderer チェック (インタプリタ / YJIT) | 1,354 / 1,165 ns | 801 / 552 ns |
render json: (インタプリタ / YJIT) | 3,928 / 2,658 ns | 3,274 / 2,001 ns |
オブジェクト数, チェック / render json: | 3 / 9 | 2 / 8 |
- セットの union をやめて共有 Set を使うことで、
- 時間: renderer チェックで ~40% 前後短縮
- オブジェクト数: 毎回 1 オブジェクト削減(3→2 / 9→8) といった削減になっています。
テスト
- 追加テストは以下をカバー:
ActionController::Base/APIコントローラでローカル renderers がない場合。- 親クラスで
use_renderersしているケースと、そのサブクラスの挙動。 - renderers が render の合間に
add/removeされるケース(スナップショット更新の整合性)。
- 影響範囲・注意点
API・挙動の変化(互換性の観点)
目に見える外部 API(render の挙動など)は基本的に変わりませんが、内部メソッドの契約が一部変わります。
- 変更された契約:
- 「ローカル renderers を持たないコントローラ」で
_all_renderersをオーバーライドし、superが返す Set を 破壊的に変更していた場合に影響があります。 - 具体例:ruby
class ApplicationController < ActionController::Base def _all_renderers super.delete(:json) # これが FrozenError になるようになる end end- これまでは
superは毎回新しい(mutable な)Set だったため、deleteしても動作していました。 - 今後は
superが共有の凍結 Set (Renderers.all) を返すパスがあり、deleteなどの破壊的操作を行うとFrozenErrorが発生します。
- これまでは
- 「ローカル renderers を持たないコントローラ」で
- Rails のコアコードでは
_all_renderersの結果はイテレートのみで利用しており、この変更による動作影響はありません。
想定される問題パターンと対策
もし既存アプリ・ライブラリで _all_renderers をカスタマイズしている場合は、以下を確認してください:
- アンチパターン例(NG):ruby
def _all_renderers super.delete(:json) super end - 推奨パターン(OK):rubyもしくは:
def _all_renderers super - [:json] # 非破壊的な演算で新しい Set を作る endrubydef _all_renderers super.dup.tap { |set| set.delete(:json) } end - ポイント:
- 「
superが返す Set は凍結されていてもよい(= 破壊的変更してはいけない)」という前提で扱うべきになった、という契約変更です。
- 「
影響範囲のまとめ
- 直接影響するのは renderers 周りを内部 API レベルでカスタマイズしている一部の高度なユーザ / ライブラリのみ。
- 通常の
render,respond_to,use_renderersの利用だけなら、挙動は同じでパフォーマンスだけ良くなります。 - Ractor / 並行実行環境でも、renderer レジストリのスナップショット性・凍結状態は維持されます。
- 参考情報 (あれば)
- 元 PR: #59620 – Don't build a Set of renderers on every render
- 関連 PR(Ractor 対応の前段): #57830
- ベンチマークと詳細な検証:
#59619 Join adjacent literals when building a route's path format
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1–2文で)
Rails のルーティング内部で使われるJourney::Formatの実装を改善し、ルート生成時に隣接するリテラル文字列を一度だけ結合するようにした PR です。これにより URL の実際の文字列出力は変えずに、ルートヘルパー呼び出し時のオブジェクト生成・命令数が削減され、軽微なパフォーマンス改善とメモリ節約が行われています。
- 変更内容の詳細
2-1. これまでの挙動と問題点
Rails のルーティングでは、/posts/:id(.:format) のようなパス定義を内部的に「リテラル」と「パラメータ」に分解して扱っています。
例: /posts/:id
従来は /, posts, / のように「隣接する文字列リテラル」がバラバラな要素として保持され、それぞれを順に連結する形で URL が構築されていました。
さらに、以下のような問題がありました:
隣接リテラルが複数パーツとして扱われる
"posts"の前後の"/"など、連続するリテラルが分割されたまま。- ルート生成時にそれらを順番に append するため、処理が冗長。
パラメータを「インデックス」で追跡
@paramsの内容がインデックスベースになっており、処理がわかりづらく、かつオブジェクト生成にも影響。
オプショングループ
(.:format)をスキップした際の不要なオブジェクト生成- グループがスキップされるたびに
eachブロックからの戻り値としてオブジェクトが確保されていた。 - 実質的に不要なアロケーションが発生していた。
- グループがスキップされるたびに
@childrenが未使用- 内部に
@childrenという要素が存在していたが、実際には使われておらず、削除対象。
- 内部に
2-2. 今回の変更点
1) 隣接リテラルの事前結合
Journey::Format がルートを構築する段階で、隣接するリテラル文字列をまとめて 1 回だけ結合するように変更されました。
具体的には:
- 以前:
/posts/:id生成時の内部的な appendrubypath << "/" path << "posts" path << "/" path << params[:id] - 変更後:ruby
path << "/posts/" path << params[:id]
ルートパスの解析結果(/ や .、もしくは元ルートパスのスライス)はそのまま使われますが、隣接リテラルの run をひとまとめにした文字列として保持します。
この「結合」はルート構築時に一度だけ行われるため、ルートヘルパー呼び出し時の処理は軽くなります。
2) パラメータの扱いをインデックスから「そのもの」に変更
Journey::Format 内部でパラメータを index で管理する代わりに、パラメータオブジェクト(もしくはその情報)自体を保持するように変更されました。
これに伴い、「このパラメータを使うかどうか」を判定するロジックでも、any? による簡潔なチェックを利用しています。
これにより:
- コードの見通しが良くなり、
- 不必要な中間オブジェクト生成を避けられるようになっています。
3) オプショングループ (.:format) スキップ時のアロケーション削減
(.:format) のようなオプショナルなルートパーツをスキップするパスで、each ブロックからの戻り値によるオブジェクト生成が起きていました。
この PR では:
- ループの書き方・チェック方法が見直され、
- オプショングループをスキップしても不要なオブジェクトを新規に生成しないようになりました。
4) 未使用の @children を削除
Journey::Format が内部的に持っていた @children は実際には使われておらず、今回のリファクタリングで削除されています。
5) Format.new を手書きした場合の挙動変化
内部表現として @parts が変更されたことで、ユーザーが自前で Journey::Format.new([...]) を直接呼んでいるケースでは挙動の違いに遭遇する可能性があります。
主に次のようなポイントです:
- 渡した配列をそのまま保持せず、内部で
@partsに正規化する- その後で元の配列をミューテート(破壊的変更)しても、
Format側には影響しない(以前と差異がある可能性)。
- その後で元の配列をミューテート(破壊的変更)しても、
- 異なるエンコーディングの文字列リテラルが混在している場合は、
Format構築時点でエラーになることがある。 - String のサブクラスは内部で通常の
Stringに正規化される。
通常の Rails ルーティング (resources, get, root など) から生成される形式では問題にならない想定ですが、
「Journey を直接触ってカスタムフォーマットを作っている」ような高度なメタプログラミングをしている場合は意識が必要です。
- 影響範囲・注意点
3-1. 一般的な Rails アプリへの影響
- URL の文字列出力は変わらない
- ASCII の
/と.、あるいはルートパスのスライスをそのまま利用しているため、生成される URL は以前と同じバイト列・同じエンコーディングになります。
- ASCII の
post_path(post),posts_path,root_pathなど、通常のルートヘルパーの戻り値は一切変わりません。- 動作互換性は基本的に保たれており、ほとんどのアプリケーションではコード修正なしでそのまま恩恵を受けられます。
3-2. パフォーマンス・メモリ
PR 説明中の計測(Ruby 4.0.9, Lowrail による測定):
post_path(post)1回あたり:- オブジェクト数: 7 → 6
- インタプリタ:
- 4,336 ns → 3,988 ns(約 −8.0%)
- 34,472 → 31,855 命令(約 −7.6%)
- YJIT:
- 2,005 ns → 1,880 ns(約 −6.2%)
- 15,571 → 14,840 命令(約 −4.7%)
posts_path: 命令削減率 ~6% 前後root_path: リテラル1つだけのため、ほぼ変化なしFormatの構築コスト:- ルート定義時に行われる処理は 約 +19% の命令数増加(1 ルートあたり +2 µs 程度)
- 一方で、各フォーマットインスタンスが保持するメモリは減少。
総じて:
- 「ルート定義時にほんの少しだけ余分なコストを払い、リクエストごと・パス生成ごとのコストを削減」する方向の最適化です。
- 大量のリクエストでルートヘルパーを多用するアプリケーションでは、わずかに CPU 命令数・GC 負荷が減ると期待できます。
3-3. 互換性上の注意点
- 手動で
Journey::Format.newを叩いているコードがある場合:- 渡す引数のエンコーディングが混在していないか、
- その配列を後からミューテートしていないか、
- String のサブクラスに依存した挙動を期待していないか
をチェックした方が安全です。
- 通常の Rails ルート DSL (
config/routes.rb) しか使っていない場合は、互換性問題は基本的に発生しません。
- 参考情報
- PR 本体:
- rails/rails #59619: “Join adjacent literals when building a route's path format”
- 作者の計測レポート(Lowrail):
- 変更ファイル:
actionpack/lib/action_dispatch/journey/visitors.rb- ルートフォーマット構築ロジックの改善・リファクタリング
actionpack/test/journey/route_test.rb- 隣接リテラル結合や新挙動を確認するテスト追加
#59614 Iterate Hash#extract! keys with each instead of each_with_object
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
Hash#extract!の実装をeach_with_objectベースから単純なeach+ ローカルHash生成に変更し、余分なオブジェクト生成と命令数を減らすマイクロ最適化のPRです。これによりstylesheet_link_tag/javascript_include_tagやActionController::Parameters#extract!経由の呼び出しがわずかに軽量化されます。
- 変更内容の詳細
何をしているか
- 変更ファイル:
activesupport/lib/active_support/core_ext/hash/slice.rb - 対象メソッド:
Hash#extract!
従来の実装は概ね次のような形でした(イメージ):
def extract!(*keys)
keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key, true)
each_with_object(self.class.new) do |(key, value), hash|
if keys.include?(key)
hash[key] = delete(key)
end
end
endここで each_with_object は Enumerable による汎用メソッドであり、ブロック実行ごとに内部的なコールバックオブジェクトを 1 つ余計に確保してしまう、というコストがありました。
このPRでは、deep_transform_keys (#59450) と同様に、より素朴な each ループ+ローカル変数の Hash を用いた実装に切り替えています。ざっくり言うと以下のような形です(イメージ・実際の細部は多少異なる可能性あり):
def extract!(*keys)
keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key, true)
extracted = self.class.new
each do |key, value|
if keys.include?(key)
extracted[key] = delete(key)
end
end
extracted
endポイント:
each_with_object({})をやめて、明示的にextracted = self.class.newを定義。eachで自分自身を走査しつつ、条件を満たすキーをdeleteしてextractedに格納。- ローカル変数で
Hashを保持することで、Enumerable#each_with_objectが作る内部オブジェクトを 1 つ減らせる。
挙動は変わるか?
PR説明によると:
With the standard
Hash,ArrayandEnumerableimplementations it makes the same calls in the same order; only the frames seen in backtraces or TracePoint change.
すなわち:
- 標準的な
Hash/Array/Enumerable実装を使っている限り、- 呼び出されるメソッド
- その呼び出し順
は以前と同じになるように実装されており、挙動互換性を保っています。
違いが出るのは:
- スタックトレースや
TracePointで観測されるフレーム構成(each_with_objectのフレームがなくなる) each_with_objectをフックした非常に特殊なメタプログラミングをしている場合(通常は考慮不要)
ベンチマーク結果
{"media" => "all"} に対して 4 つのキー ("protocol", "extname", "host", "skip_pipeline") を extract! するケースの 1 呼び出しあたり:
| 指標 | main | このPR |
|---|---|---|
| オブジェクト数 | 3 | 2 |
| 命令数(インタプリタ) | 7,694 | 6,250 (−18.8%) |
| 命令数(YJIT) | 6,404 | 3,600 (−43.8%) |
さらに stylesheet_link_tag("application") レベルの処理では:
- インタプリタ: 命令数 −1.6%
- YJIT: 命令数 −5.1%
といった程度の改善が観測されています。
ただし、リクエスト全体のベンチマーク (ruby-bench) ではノイズ範囲内のため、リクエスト単位で体感できるスピードアップがあるとは主張していません。
- 影響範囲・注意点
影響範囲
ActiveSupport の Hash#extract! を利用している全ての箇所に影響します。代表例:
ActionView:stylesheet_link_tagjavascript_include_tag
これらはオプション引数から「パス関連のオプション」を取り除くためにextract!を毎回呼んでいます。
ActionController:ActionController::Parameters#extract!がHash#extract!に委譲
そのため、
- ビュー内で大量に
stylesheet_link_tag/javascript_include_tagを呼ぶアプリ - パラメータ処理で
extract!を多用するAPI
などでは、微小ながらパフォーマンス上の恩恵が見込めます。
注意点
- 公開APIとしての
Hash#extract!の仕様(返り値や破壊的動作)は変わりません。 - Ruby の標準
Hash実装を前提に同じ呼び出しパターンが維持されているため、通常のアプリケーションコードに対して互換性問題はまず発生しません。 TracePointを使ってEnumerable#each_with_objectそのものをトレースしているような極めて特殊なコードでは、トレース対象メソッドが変わる可能性があります。
- 参考情報 (あれば)
- PR本体:
Iterate Hash#extract! keys with each instead of each_with_object(#59614) - 類似の最適化:
deep_transform_keysの実装変更 (#59450) - 著者の調査プロジェクト Lowrail:
extract!が実際の OSS Rails アプリ 20 個中 17 個でホットスポットとして観測された旨のノート
https://github.com/thomasklemm/lowrail/blob/860e9f4/docs/upstream/as-request-paths.md
extract!のベンチマーク用スクリプト(PR記載):
https://github.com/thomasklemm/lowrail/blob/860e9f4/bench/upstream/as_request_paths/micro.rb
#59618 Append the default star projection without a rest Array
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
build_selectがデフォルトのtable.*(星投影)をproject経由ではなく、SelectManagerのprojectionsに直接追加するように変更され、余計な配列割り当てが1つ減りました。Rails 標準のSelectManagerを使う限り、生成される SQL と挙動は従来どおりです。
- 変更内容の詳細 (サンプルコード含む)
これまでの挙動
ActiveRecord::Relation#build_select は、デフォルトのカラム指定(例: posts.*)を付与するときに project を呼んでいました。
擬似コードイメージ:
def build_select(arel_table)
# デフォルトの星投影を付ける
manager.project(arel_table[Arel.star])
endproject は可変長引数(splat)を受け取るため、内部で1要素だけを持つ配列(rest Array)が毎回生成されます:
def project(*projections)
@ctx.projections += projections
endこのため、デフォルトの table.* を足すたびに「1要素の配列」が1つずつ割り当てられていました。
変更後の挙動
今回の PR では、build_select が project を呼ばずに、直接 manager.projections にデフォルトの星投影を << するようになりました。
イメージ:
def build_select(arel_table)
# 直接 projections に push する
manager.projections << arel_table[Arel.star]
endポイント:
projectを経由しないため、1要素の splat 配列が作られなくなるprojectが行っていた「String や Symbol の変換」は、デフォルト星投影ではそもそも不要なため、挙動に差は出ない(table[Arel.star]は既に Arel ノード)- その結果、1回のデフォルト星投影あたり割り当てが1つ減る
ベンチマーク中の例:
Post.all.to_aで走るクエリ生成中に SELECT "posts".* FROM "posts" の posts.* を追加する箇所が、今回の変更の対象です。
- 影響範囲・注意点
影響範囲
- 対象:
build_selectがデフォルトでtable.*を付けているパス(典型的にはModel.all,Model.where(...)など、明示的にselectを指定していないケース)
- 変更点:
- デフォルトの星投影を
projectではなくprojections << ...で追加するようになった
- デフォルトの星投影を
互換性・挙動
- Rails 標準の
Arel::SelectManagerを使う限り:- 生成される SQL は変更前後で同一
projectionsの中身も論理的には同じ(projectが行うのは String / Symbol のラッピングであり、デフォルト星投影ではそれが関与しない)
- パフォーマンス面:
- デフォルト星投影 1回あたり配列割り当てが1つ減る
- PR の測定では:
Post.all.to_aの allocations: 291 → 290- ruby-bench のビルドでは 1,811 回/iteration このコードパスが呼ばれ、微小ながら積み重ねで効いてくる
- Callgrind 上の命令数は 0.2〜0.3% 程度の改善(誤差レベルだが一貫して減少)
注意点(カスタマイズしている場合)
以下に該当する場合のみ注意が必要です:
- 独自の
SelectManager実装を使っている - かつ、その
SelectManager#projectをオーバーライドして「デフォルトのtable.*も含めて」何らかの特別な処理をしていた
今回の変更により:
- デフォルト星投影は
project経由せず直接projectionsに積まれるようになったため、 - 独自
project実装では、もはや「デフォルト星投影」をフックできません
そのような場合は、例えば:
projectionsをラップする / 書き込みをフックする- あるいは
build_selectを上書きする
などの形で、意図した処理を移植する必要が出てくる可能性があります。
標準的な用途(ActiveRecord + 標準 Arel だけを利用)では、特に対応は不要です。
- 参考情報 (あれば)
- 関連 PR:
- #45864: 同じ行(
build_select周り)を書き換える別の変更 - #59579:
where!から同様の rest Array を除去したパフォーマンス改善 PR
- #45864: 同じ行(
- ベンチマーク環境:
- SQLite in-memory, GC 無効
Post.all.to_aで 20レコードを 500回ロード- Ruby 4.0.9 / ZJIT は Ruby master (9aa0898240)
- 低レベルな AR のパフォーマンス研究プロジェクト:
- Lowrail: https://github.com/thomasklemm/lowrail
- 対象行は 20個すべてのアプリの allocation profile に出現しているとのこと
#59616 Read the update timestamp only when it is written
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
record_update_timestampsが、実際にタイムスタンプを書き込む直前にだけ現在時刻を取得するように変更され、不要な時計アクセスと接続取得を減らすことで、update時のパフォーマンスと挙動の明瞭さが改善されました。updated_at/updated_onなどに書き込まれる値そのものは、従来の標準的なタイムスタンプ処理と同じです。
- 変更内容の詳細
2-1. 何が変わったか
これまでの record_update_timestamps の挙動:
- 「更新時にタイムスタンプを付与する」処理が、複数のパス(少なくとも 2 回)に分かれて行われるケースがある
- 1回目のパス: 通常の
updated_at/updated_onなど - 2回目のパス:
before_updateコールバック等により、再度「更新対象カラム」が増えた/変わった場合の追い書き
- 1回目のパス: 通常の
- 各パスの「開始時」に現在時刻を読む(
Time.current相当)。 - そのパスで結局何もタイムスタンプを書かなくても(スキップされても)、時計読み出しはすでに行われていた。
- 2回目のパスでも、実際にはほとんどのケースで何も書かないのに、
with_connectionを通ってアダプタのdefault_timezoneを取得しに行っていた。
今回の変更後:
- 「最初のタイムスタンプカラムに書き込む直前」にだけ時計を読むように変更。
- タイムスタンプを書かないなら、時計はそもそも読まない。
- タイムスタンプを複数カラム(
updated_atとupdated_onなど)に書く場合は、1 回だけ読んだ時間を共有して使う。
- 「スキップするかどうか」のチェック(タイムスタンプを書かなくていいかどうか)が済んでから時計を読むようになった。
- つまり、「どうせ使われない時計読み出し」が削減されている。
- 2回目のタイムスタンプパス(#47767 由来)において:
- 実際には値を書かないケースが多いにもかかわらず、これまでは
with_connectionを呼んでアダプタのdefault_timezoneにアクセスしていたが、 - 「書く必要がある」と判断されるまで、余計な
with_connection/default_timezone参照が走らないようになった。
- 実際には値を書かないケースが多いにもかかわらず、これまでは
2-2. テストで保証されている点
テスト追加内容から読み取れる保証:
- 「1回の update あたり時計は1回だけ読む」ことを確認。
updated_atとupdated_on等、同時に更新されるタイムスタンプカラムが、同じ時刻オブジェクト(共有の時間)で書かれることを確認。before_updateコールバックの中で属性を変更して、2回目のパスが「実際に書き込む」ケースでも、正しくタイムスタンプが付与されることを確認。
実装上の変更はとても小さく、activerecord/lib/active_record/timestamp.rb に +2/-1 行、テストファイルに +34 行という規模です。
2-3. パフォーマンス計測結果
作者のベンチマーク:
環境: SQLite in-memory, GC 無効, Ruby 4.0.9, ZJIT は Ruby master 9aa0898240
測定方法:
- 500 回の
post.update!に対する Callgrind の命令数を、0 回更新の run を差し引いて算出 - 2 回ずつ計測し、その中央値
- main ブランチ同士の比較で差が ±0.2% 以内に収まることも確認
- 500 回の
この PR による変化(main → この PR):
post.update!, Callgrind instructionsInterpreter YJIT ZJIT main → this PR −1.4% −0.9% −1.4% メモリアロケーション: このベンチマークで 190 → 189 allocations に減少。
つまり小さいながらも一貫した改善が見られており、不要な時計読み出し・接続コストが削れたことと整合的です。
- 影響範囲・注意点
3-1. 開発者から見た挙動の変化
通常の
updated_at/updated_onの値:- 「何時が入るか」という意味では、これまでと同じく「更新処理のタイミングの時間」が入るため、アプリケーションから目に見える違いはほぼありません。
- 1 回の更新中で複数のタイムスタンプカラムを更新する場合も、以前と同様に「同じ時間」が入ります(これをテストで保証)。
before_updateコールバックを駆使してupdated_at等の上書きや、2回目のパスでの特別な処理をしているようなレアケース:- 2回目のパスで書かれるタイムスタンプが、「2回目パス開始時刻」ではなく「実際に最初に書く直前の時刻」になっている点に注意が必要です。
- もっとも、従来も「パス開始時刻」だったこと自体を前提にしたコードはまず想定されていないはずで、互換性リスクは極小です。
3-2. パフォーマンス・負荷面
更新件数が多いアプリケーション、特に:
- タイムスタンプを付けるけれど、コールバック等の条件で「実際には書き込まれない」ようなパターンが多い場合
- DB 接続のコストが相対的に高い環境 では、ほんのわずかですが CPU 命令数やアロケーションの削減が見込めます。
ruby-bench 上の代表的な Rails アプリでは、測定ループ内でレコード更新が行われていないため、この変更による差はほぼ現れていません。
⇒ 「フレームワーク全体のベンチマークスコアが大きく動く」ような変更ではなく、Active Record 更新パスに絞った細かな最適化です。
3-3. 互換性
- 公開 API や設定オプションは一切変わっていないため、通常のアプリケーションはそのまま動作します。
- タイムスタンプの実際の値の違いも、あってもサブミリ秒レベル/パス内の順序の違いにすぎず、依存しているコードはほぼないと考えられます。
- 参考情報 (あれば)
- 該当 PR:
- Rails PR #59616 "Read the update timestamp only when it is written" (thomasklemm)
- 関連する以前の変更:
- PR #47767 — 更新時にタイムスタンプ処理が複数パスに分かれるきっかけになった変更(2回目パスの存在に関係)。
- 著者のベンチマーク・ノート:
総じて、「不要な時計読み出しと接続オーバーヘッドを削った、挙動互換性の高いマイクロ最適化」という位置づけの変更です。
#59615 Read the blob filename's extension once when choosing a variant format
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1–2文で)
Active Storage のblob.variant(...)がデフォルトの画像フォーマットを決める際に、ファイル名拡張子を何度も解析していた処理を1回だけにまとめ、オブジェクト生成と処理時間を削減するパフォーマンス改善です。機能的な挙動は変えずに、画像バリアント生成時のオーバーヘッドを削っています。
- 変更内容の詳細
対象: activestorage/app/models/active_storage/blob/representable.rb の Representable#format メソッドまわり。
背景:
blob.variant(resize_to_limit: [100, 100])等でバリアントを作るとき、Representable#formatが「この画像は PNG / JPEG / GIF など何として処理するか」を決定します。- この
#format内でfilename.extensionが最大3回呼ばれており、0e6a6afe3c以降、filename.extensionを呼ぶたびに新しいActiveStorage::Filenameオブジェクトを生成し、ファイル名全体を再サニタイズするようになった- その結果、同じ拡張子を読みたいだけなのに、無駄なオブジェクト生成&処理が積み重なっていた
今回の変更:
filename.extensionの結果をローカル変数に一度だけ読み取り、以降はそれを使い回すようにしました。- 既に
tempfile_extensionでも同様の最適化(ローカルに読み出し)が行われており、それと同じパターンを適用した形です。 - 拡張子は単に読んでいるだけで、Marcel や ActiveStorage 側の「拡張子の解釈ロジック」は変えていないため、フォーマット選択の挙動は従来と同じです。
イメージ的なコード差分(疑似コードレベル):
変更前(イメージ)
def format
if filename.extension == "png"
"png"
elsif filename.extension == "jpeg" || filename.extension == "jpg"
"jpeg"
elsif filename.extension == "gif"
"gif"
else
# 何らかのフォールバック
end
end変更後(イメージ)
def format
ext = filename.extension # ← 1回だけ生成して使い回す
if ext == "png"
"png"
elsif ext == "jpeg" || ext == "jpg"
"jpeg"
elsif ext == "gif"
"gif"
else
# 何らかのフォールバック
end
endベンチマーク結果(blob.variant(resize_to_limit: [100, 100]) on PNG, 1回あたり):
- オブジェクト数: 61 → 39
- 実行時間:
- 通常インタプリタ: 16.3 µs → 11.4 µs(約30%短縮)
- YJIT: 10.4 µs → 6.7 µs(約35%短縮)
実アプリ(Campfire, Fizzy, Writebook, Loomio, Chatwoot, Docuseal)でも:
- バリアント representation path の構築で命令数が 7–10% 減少
- 多くのワークロードで 22 個の割り当て削減(Fizzy のアバターは 9 個削減)
- 影響範囲・注意点
影響範囲:
- Active Storage で画像バリアント (
blob.variant(...)) を作成するときの内部処理(Representable#format)に限定されます。 - 対象となるのは主に Web 画像(PNG / JPEG / GIF)のデフォルト
format:決定ロジックです。 - API や外部から見える挙動(生成される画像形式・ファイル名・URL など)は変わりません。
期待される効果:
- アップロード済み画像の縮小・サムネイル生成などが多いアプリケーションで、CPU 使用量とオブジェクト割り当てがわずかに減少。
- 高トラフィック環境やバッチで大量にバリアントを生成する環境では、累積的に無視できないコスト削減につながる可能性があります。
注意点:
- 挙動は変わらない前提で書かれていますが、「ファイル名拡張子に依存した独自の挙動」をパッチ当てなどで
Representable#formatへ追加している場合、今回のローカル変数化を前提にしたコードとコンフリクトする可能性はあります(とはいえ通常の Rails アプリではまず問題になりません)。 - テスト結果として、Active Storage のモデル・コントローラテストのラン数は同じで、PDF プレビューア未インストールに起因するエラー件数も完全一致とのことで、既存のテスト観点では後方互換とみなせます。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59615
- 関連コミット:
0e6a6afe3c(ActiveStorage::Filenameの生成・サニタイズ周りの変更の起点) - ベンチスクリプト・調査ノート(作者の Lowrail プロジェクト)
この PR は「ファイル名拡張子を何度もパースしない」という、ごく小さいが筋のよいマイクロ最適化で、Active Storage を多用するアプリケーションには素直なメリットがあります。
#59613 Build notification handle groups with each instead of Hash#map
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
ActiveSupport::Notifications::Fanout#build_handle内で、通知ハンドルの生成にHash#map(実際はEnumerable#map)を使うのをやめ、each+<<に書き換えることで、余計なオブジェクト生成を減らし、特に SQL 実行など高頻度イベントでのオーバーヘッドを削減するパフォーマンス改善 PR です。挙動は原則として同じまま、呼び出しトレース上のフレーム位置のみ変わる可能性があります。
- 変更内容の詳細
背景
- Rails の通知システム (
ActiveSupport::Notifications) はFanoutを使ってイベントを購読者に配信しています。 - すべての計測対象イベント(
instrument呼び出し)は、少なくとも一つでもアクティブな subscriber が存在すると、Fanout#build_handleを通ります。 - 特に Active Record の runtime registry は常に購読しているため「すべての SQL クエリ」でこの処理が走ります。
- 既存実装では
groups_forが返すHashに対してgroups.map { |*args| Group.new(*args) }のような形でmapが使われていましたが、Hash独自のmapではなくEnumerable#mapが呼ばれ、内部で毎回コールバックオブジェクトが生成されていました。- これが高頻度イベントでは無視できないオーバーヘッドになっている、というのが問題意識です。
具体的な変更
対象ファイル: activesupport/lib/active_support/notifications/fanout.rb
行数レベルでは +3/-2 と小さな変更ですが、ロジックとしては:
Before(イメージ):
def build_handle(groups)
# groups は Hash
handles = groups.map do |group_klass, args|
group_klass.new(*args)
end
handles.empty? ? NullHandle : SomeHandleClass.new(handles)
endHash#map(実体は Enumerable#map)により以下が発生:
map内部のブロック呼び出しに伴うコールバックオブジェクト- 中間
Array(handles)の生成
After(イメージ):
def build_handle(groups)
handles = []
groups.each do |group_klass, args|
handles << group_klass.new(*args)
end
handles.empty? ? NullHandle : SomeHandleClass.new(handles)
endポイント:
Enumerable#mapをやめて、Hash#eachとArray#<<の組み合わせでハンドル群を構築。- これにより:
mapが内部で作るコールバックオブジェクトが不要になる- 実質的にやっていることは同じだが、Ruby VM 的にシンプルなパターンになり JIT などでも最適化されやすい
挙動の保持について
groups_forが返すHashの順序に対して:- 通常の Ruby の
Hashは挿入順を保持するので、mapからeachに変えても、生成される group オブジェクトの順序は同じです。
- 通常の Ruby の
- コンストラクタへの引数も同じ形で渡されます。
groupsが空の場合にNullHandleを返す挙動も維持されています。- 違いが出る可能性があるのは「group のコンストラクタ内部から
caller_locationsやTracePointでスタックを覗いた場合に、見えるフレーム構造が僅かに変わる」点だけ、と明示されています。
- 影響範囲・注意点
影響範囲
- 影響を受けるのは
ActiveSupport::Notifications::Fanoutを通るすべてのイベント:- Active Record の SQL ログ(runtime registry が購読)
- 独自に
ActiveSupport::Notifications.instrumentを使っている箇所 - 標準/サードパーティ gem が Notifications にフックしている場合 など
- ただし挙動は互換であり、外部 API 観点での破壊的変更はありません。
性能への影響(作者の計測結果)
代表的なマイクロベンチマーク(「単一の monotonic subscriber」「payload 再利用」)で:
- オブジェクト数: 4 → 3
- 実行時間/呼び出し:
- インタプリタ: 1,907 ns → 1,711 ns(約 -10.3%)
- YJIT: 1,076 ns → 802 ns(約 -25.5%)
- 命令数/呼び出し:
- インタプリタ: -5.1%
- YJIT: -10.8%
- ZJIT: -11.3%
Post.find(1)(in-memory SQLite)程度の実アプリ寄りケースでは:
- 命令数は 0.3%〜0.7% 減少(インタプリタ / YJIT / ZJIT)
- ただし request 単位のベンチマーク(railsbench, shipit, lobsters など)では「ノイズの範囲」であり、体感できるリクエストレベルの高速化までは主張していません。
計測環境:
- Ruby 4.0.9 (2026-10-23 revision 3e93b23afc) でインタプリタと YJIT
- Ruby master
9aa0898240で ZJIT - GC 無効・単一 CPU 固定・15 回の中央値、Callgrind で命令数計測
- Ruby 3.3/3.4 では未計測
注意点
- 通知ハンドル生成周りで
caller_locationsやTracePointを使い、スタックフレーム位置に依存したロジックを書いている場合は、挙動の変化がないか確認した方が安全です。- 通常の Rails アプリではまず問題にならないケースです。
Fanout#build_handleの API シグネチャや返り値の意味は変わっていないため、自前のFanout実装や monkey patch がなければ基本的にそのまま使えます。
- 参考情報 (あれば)
- PR本文で挙げられているスタンドアロン計測スクリプト(
objects per callとns per callを出力):
# frozen_string_literal: true
require "active_support"
require "active_support/notifications"
require "benchmark"
fanout = ActiveSupport::Notifications::Fanout.new
module Runtime
def self.call(name, start, finish, id, payload) = nil
end
fanout.subscribe("event", Runtime, monotonic: true)
instrumenter = ActiveSupport::Notifications::Instrumenter.new(fanout)
payload = {}
run = proc { instrumenter.instrument("event", payload) { } }
10_000.times(&run)
before = GC.stat(:total_allocated_objects)
100_000.times(&run)
puts "objects per call: #{(GC.stat(:total_allocated_objects) - before) / 100_000.0}"
puts "ns per call: #{(Benchmark.realtime { 1_000_000.times(&run) } * 1000).round}"groups.mapが 20 個の OSS Rails アプリ(Rails main)すべてでホットスポットとして現れ、インタプリタで実行された命令全体の 0.12% を消費していた、という調査に基づく最適化です。- 著者のリサーチプロジェクト「Lowrail」ドキュメント(PR で参照されているノート):
https://github.com/thomasklemm/lowrail/blob/860e9f4/docs/upstream/as-request-paths.md
#59608 Skip the format union in prepend_formats when the format already leads
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
ActionView::TemplateRenderer#render内で毎回行われていたprepend_formatsの不要な配列ユニオン処理を、既にフォーマットが先頭にある場合はスキップする最適化です。これにより、特にリクエスト外 (ApplicationController.renderなど) のレンダリングで命令数・オブジェクト数が削減され、若干のパフォーマンス向上が得られます。
- 変更内容の詳細
背景: これまでの prepend_formats の挙動
TemplateRenderer#render はレンダリングごとに、テンプレートのフォーマットを優先するため prepend_formats(template.format) を呼んでいます。
ここで prepend_formats はざっくり言うと:
def prepend_formats(format)
formats = Array(format) # 説明用の擬似コード
self.formats = formats | @lookup_context.formats
endのように、渡したフォーマット配列と @lookup_context.formats(現在のフォーマット一覧)の「和集合」を取ります。
- リクエスト中の典型例:
@lookup_context.formats == [:html]など - リクエスト外(
ApplicationController.renderなど): 36個のデフォルトフォーマットを全部持った配列(例:[:html, :js, :json, ...])
問題は、デフォルトフォーマットをフルで持っている状況で、すでにテンプレートのフォーマットが先頭にある場合でも毎回 formats | @lookup_context.formats を実行している点です。
例:
@lookup_context.formats # => [:html, :js, :json, ...] (36個)
template.format # => :html
formats = [:html]
formats | @lookup_context.formats
# => [:html, :js, :json, ...] # つまり同じ配列を再構成しているだけこのユニオン計算は、
- 37要素の配列ユニオンを毎回行う
- 結果として新しい配列・中間オブジェクトが発生
- 実質的には元と同じフォーマットリスト
となっていて、コストだけかかって効果がないケースが多い、というのが今回の指摘です。
このPRの変更点
このPRでは、「すでに lookup_context の formats の先頭が、prepend しようとしている format と同じ場合」はユニオン計算をスキップします。
イメージとしては:
def prepend_formats(format)
formats = Array(format)
if @lookup_context.formats.first == formats.first
# すでに format が先頭なら、そのまま現在の formats を formats= に渡す
self.formats = @lookup_context.formats
else
# 従来どおりユニオンを取る
self.formats = formats | @lookup_context.formats
end
end※上は説明用の擬似コードです。実際の実装は abstract_renderer.rb に8行追加・1行削除という小さな差分で実現されています。
重要なのは、この「ショートカット」をしても、formats= に渡す配列に対しては従来通り以下が行われる点です:
*/*(any)フォーマットへの展開- フォーマット重複の除去
- 不正なフォーマットのバリデーション
- JS → HTML フォールバック
- lookup details キー(キャッシュキー)の維持
つまり、
formats=のロジックは全く変えない- 変わるのは
formats | @lookup_context.formatsを「やるか / やらないか」だけ
という形で、安全側に寄せた最適化になっています。
ベンチマーク結果
prepend_formats(:html) をデフォルトフォーマット(36個)と一緒に使った場合:
| 環境 | main | このPR |
|---|---|---|
| interpreter | 4,667 ns | 3,217 ns |
| YJIT | 4,226 ns | 2,631 ns |
ruby-bench の erubi-rails ベンチでは:
- インタプリタ: 命令数 2.2% 減
- YJIT: 命令数 4.1% 減
(Ruby master ではそれぞれ 1.5%, 2.5% 減)
さらに、1レンダリングあたりオブジェクトを2つ少なく確保するようになっています。
- 影響範囲・注意点
影響があるケース
説明文より、挙動が変わる(=最適化の恩恵を受ける)のは以下のケースのみです:
- リクエスト外のレンダリング
- 例: ジョブ内から
ApplicationController.render(:show)を呼ぶ場合
- 例: ジョブ内から
- かつ
formats:オプションを明示的に渡さない場合 - かつ
lookup_contextが 36 個のデフォルトフォーマットを持っていて、 - かつ「テンプレートの format がすでに
lookup_context.formatsの先頭」である場合
この条件が揃う場合、これまで無駄に行っていた配列ユニオンをスキップするだけなので、機能的な挙動は変わらず、パフォーマンスのみが向上します。
変わらないケース
以下は挙動が変わりません:
- 通常の HTTP リクエストでのレンダリング
- 例: ブラウザからの
text/htmlリクエストでのコントローラアクション
- 例: ブラウザからの
- パーシャルレンダリング(テンプレート内の
render) formats:オプションを明示的に指定したレンダリング- 例: Turbo Stream の broadcast などで
formats: [:turbo_stream]を渡す場合
- 例: Turbo Stream の broadcast などで
理由: これらのケースでは lookup_context.formats がデフォルト36個フルの状態ではなく、すでにリクエストのフォーマット([:html] など)に絞り込まれていることが多く、今回の「すでに format が先頭か?」のチェックに当てはまらないためです。
また、formats: を明示的に指定する場合は prepend_formats の呼び出し方や formats= への引き渡し方が異なり、この最適化パスは通りません。
互換性上の注意点
formats=メソッドの意味や挙動自体は変わっていないため、アプリケーションコードでlookup_context.formatsを直接参照しているような場合も、従来と同じ結果が得られます。TemplateRenderer/AbstractRendererをモンキーパッチしているコードがある場合は、prepend_formatsまわりの実装差分に注意が必要です。- フォーマット解決の仕様(
*/*展開や JS→HTML フォールバック)が変わるものではないため、レンダリング結果の互換性に影響はほぼありません。
- 参考情報 (あれば)
- 変更ファイル:
actionview/lib/action_view/renderer/abstract_renderer.rbprepend_formatsまわりに8行追加・1行削除の小変更
actionview/test/template/lookup_context_test.rb- 新しい最適化パスをカバーするテストコードを14行追加
- パフォーマンス効果は小さいものの、レンダリングが多いジョブ処理(メール・バッチ・バックグラウンドレンダリング)などでは積み重なって効くタイプの最適化です。
#59612 Avoid reprocessing with values when merging relations
マージ日: 2026/11/4 | 作成者: @kamipo
- 概要 (1-2文で)
Relation#merge時にwithで指定した値を二重に処理していた問題を解消し、既に処理済みのwith_valuesをそのままマージするようにした PR です。これにより不要なオブジェクト生成が減り、withを多用したクエリ合成でメモリアロケーションが削減されます。
- 変更内容の詳細
背景: with と Relation#merge
Active Record の with は、CTE (Common Table Expression) などの「追加のクエリ情報」を Relation に付与するためのメソッドで、内部的には with_values という配列/ハッシュのような構造に加工されています。
Post.with(a: Post.all, b: Post.all)のような呼び出しでは、process_with_args によって引数が内部表現に変換され、relation.with_values として保持されます。
一方、Relation#merge は
Post.all.merge(Post.with(a: Post.all, b: Post.all))のように 2 つの Relation を合成する API ですが、このとき with_values の扱いに問題がありました。
問題点: 既に処理済みの値に process_with_args を再適用していた
従来の Merger は、with_values をマージする際に、次のような流れになっていました:
- マージ対象の relation (
Post.with(...)側) では、既にprocess_with_argsを通してwith_valuesに加工済み Mergerがwith_valuesをマージする際、再度with!を呼び出しwith!の中でまたprocess_with_argsが走る
つまり、一度処理済みの内部表現に対して、再び「引数としての処理」をしようとしており、以下のような無駄が発生していました。
- 余計なオブジェクト生成(メモリアロケーション)
- 不要な変換処理によるオーバーヘッド
PR の説明にあるとおり、次のコードのアロケーション数が 17 から 8 に減っています。
Post.all.merge(Post.with(a: Post.all, b: Post.all))修正方針: order_values と同様に「そのまま代入」する
order_values などの他のクエリ要素は、Merger 内で「既に処理済みの内部値」として扱い、素直に配列結合/代入をしています。
この PR では、with_values もそれと同じ方針に変更しました。
- これまで:
with_valuesをマージする際にwith!を呼んでしまい、その中でprocess_with_argsが再実行される - これから:
with_valuesを 加工済み内部値として直接マージ する(process_with_argsは呼ばない)
コード差分自体は小さく、
Mergerのwith_valuesマージロジックを、order_values的な「直接統合」に変更query_methods.rb側では、それに合わせてwith!/process_with_argsの使い方を簡素化
という形になっています(行数としては +6 / -7 の微調整)。
- 影響範囲・注意点
- 対象:
- ActiveRecord::Relation に対して
withを使い、それを他の Relation とmergeしている箇所 - 特に、
withに別の Relation (Post.allなど) を渡しているケースで効果が出やすい
- ActiveRecord::Relation に対して
- 期待できる効果:
- 不要なオブジェクト生成の削減(アロケーション数減少)
- 軽微なパフォーマンス向上(特に
withを多用した複雑なクエリ合成で)
- 互換性:
- 外部から見える API (
with,merge) の挙動は変えておらず、あくまで内部実装の最適化 - すでに
withを使っているアプリケーションで、挙動上の非互換は基本的に発生しない想定
- 外部から見える API (
- 注意点:
- もしアプリ側で
with_valuesを直接触っているようなメタプログラミングをしている場合は、念のためテスト確認推奨(ただし、このような使い方自体が内部実装依存で非推奨)
- もしアプリ側で
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59612
- 関連する内部 API:
ActiveRecord::Relation#withActiveRecord::Relation::MergerActiveRecord::QueryMethods#process_with_args
- 同様のパターン:
order_valuesやgroup_valuesなど、Relation が持つ他の *_values 系フィールドのマージ戦略(「内部表現をそのまま結合する」方針)
#59607 Resolve attribute aliases with a fetch default instead of a block
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
Active Record がカラム型を取得する際に使うtype_for_attribute内の属性エイリアス解決ロジックを、ブロック付きHash#fetchから「デフォルト値付きfetch」に変更し、Symbol#to_proc呼び出しのオーバーヘッドを削減する最適化です。これによりtype_for_attribute呼び出しが 10〜20% 程度高速化され、whereの条件評価などでの累積コストを下げています。
- 変更内容の詳細
背景となる処理
Active Record は、where 句の評価や関連読み込み・作成・更新時に、カラムの型情報を type_for_attribute("column_name") で何度も参照します。
例: Post.where(title:, published:).to_a の中で type_for_attribute が 8 回呼ばれる。
type_for_attribute は内部で属性エイリアス(alias_attribute :nick, :nickname のようなもの)を解決するために、概ね以下のようなコードパスを通っています:
attribute_aliases.fetch(super, &:itself)superは「エイリアスを除いた元のメソッド」が返す属性名(例::titleのようなシンボル)。attribute_aliasesは{ "別名" => "本来のカラム名" }のような Hash。fetch(key, &block)は、キーが存在しなければブロックを呼び出し、その戻り値を返す。
ここで &:itself は Symbol#to_proc を使って proc { |x| x } を作るのと同等であり、
「エイリアスが無い通常の属性名」のケース(=ほとんどのケース)では、わざわざブロックを起動する余計なコストが発生していました。ベンチマーク上、この Symbol#to_proc のコストが、実際の Hash lookup のコストより高くなっています。
実際の変更内容
この PR では、ブロック付き fetch をやめて「第2引数にデフォルト値を渡す形」に書き換えています。
変更前(イメージ):
def type_for_attribute(name)
name = attribute_aliases.fetch(super, &:itself)
# ...
end変更後(PR の要点):
def type_for_attribute(name)
name = attribute_aliases.fetch(name = super, name)
# ...
endポイント:
name = superで、まず元のtype_for_attributeが解決した属性名をローカル変数nameに入れる。attribute_aliases.fetch(name, name)で、attribute_aliases[name]が存在すればその値(エイリアス先の本来のカラム名)を返す。- 存在しなければ第2引数の
name(= さっきsuperが返した元の属性名)をそのまま返す。
- ブロックを使っていないため、
Symbol#to_procを経由せず、ブロック呼び出しコストが完全になくなる。
Hash#fetch の仕様に従い、以下は従来通りの挙動を維持します:
- ハッシュのデフォルト値があればそれが使われる。
- キーが存在し、値として
nilが入っている場合も、そのnilがそのまま返される。
パフォーマンス結果(要点)
Post.type_for_attribute("title") 1回あたりの計測:
- インタプリタ:
- 変更前: 641 ns
- 変更後: 561 ns (−12.5%)
- YJIT:
- 変更前: 384 ns
- 変更後: 293 ns (−23.7%)
- 命令数(Callgrind)も interpreter / YJIT / ZJIT でそれぞれ約 12〜29% 減少。
Post.where(title:, published:).to_a のような二条件 where では、
- インタプリタで ~0.47% 命令数削減
- YJIT で ~1.4% 命令数削減
と、1呼び出しあたりは小さい最適化でも、頻繁に呼ばれるためトータルでは一定の効果が確認されています。
- 影響範囲・注意点
影響範囲:
- Active Record の
type_for_attributeを経由する全てのコードパス:where条件の型変換- association の読み込み / 作成 / 更新
- その他、型情報を参照する内部処理
- 属性エイリアス (
alias_attribute) を利用する場合/しない場合どちらも対象だが、特に「エイリアスを使っていない通常の属性名」で効いてくる最適化です。
- Active Record の
挙動面の互換性:
Hash#fetchの使い方を「ブロック付き」から「デフォルト値付き」に変えただけであり、- エイリアスが存在する場合 → その解決先を返す
- エイリアスが存在しない場合 → 元の属性名をそのまま使う というロジックは変わっていません。
- デフォルト値や
nilの扱いもfetchの既存仕様通りで、以前と同じです。 - したがって、アプリ側で
attribute_aliasesを独自にいじっていない限り、挙動の変更は発生しないはずです。
パフォーマンス観点での注意:
- 単体機能としては非常に小さな最適化ですが、
type_for_attributeは CPU プロファイル上で 0.18〜0.39% 程度を占めうる箇所であり、大規模アプリや高トラフィック環境ではじわじわ効くタイプの改善です。 - 実アプリ全体のスループットが体感で変わるほどではないものの、ボトルネック分析において「
type_for_attributeが無駄に重い」という状況は緩和されます。
- 単体機能としては非常に小さな最適化ですが、
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59607
- 著者の調査・ベンチスクリプト:
- lowrail プロジェクト: https://github.com/thomasklemm/lowrail
- micro ベンチスクリプト:
https://github.com/thomasklemm/lowrail/blob/main/bench/upstream/common_paths_2/micro.rb - Rails における共通ホットパス分析メモ:
https://github.com/thomasklemm/lowrail/blob/main/docs/upstream/rails-common-paths-2.md
#59606 Restore three ActionView::Template handler methods removed in #57874
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で) Rails 8.1 までは存在していた
ActionView::Templateの3つのハンドラ関連メソッドが、#57874 で削除されたことによる互換性問題を解消するため、それらをActionView::Template::Handlersへの委譲として復活させた PR です。既存のテンプレートハンドラ(例: maildown の Markdown ハンドラ)が Rails main で動かなくなっていた問題を、8.1 と同等の挙動に戻す形で修正しています。
- 変更内容の詳細
復活した3つのメソッド
#57874 で削除されていた以下のメソッドが、ActionView::Template 上に再導入されました。
registered_template_handlertemplate_handler_extensionsregister_default_template_handler
実体としては ActionView::Template::Handlers に移されたレジストリへの「委譲メソッド」として実装されており、Rails 8.1 で受け付けていた拡張子に対しては同じ結果を返すようになっています。
1) ActionView::Template.registered_template_handler(extension)
目的: 拡張子に対応するテンプレートハンドラを取得するためのメソッド。
復活後の挙動:
- 8.1 と同様に、引数の
extensionに対して一度だけto_symを呼ぶ - 内部的には
ActionView::Template::Handlersのレジストリを参照する
これにより、以下のようなコードが Rails main でも再び動作するようになります。
handler = ActionView::Template.registered_template_handler(:erb)
# or
handler = ActionView::Template.registered_template_handler("erb") # to_sym されるmaildown 3.3.1 の Markdown ハンドラはこの API を使っており、main では undefined method 'registered_template_handler' によってコンパイル時エラーが出ていましたが、それが解消されます。
2) ActionView::Template.template_handler_extensions
目的: 登録されているテンプレートハンドラの拡張子一覧を返すメソッド。
復活後の挙動:
- 8.1 同様に
Handlersの情報をそのまま返す - これを直接使っているアプリ/gem は少ないはずですが、古いコードとの互換性確保のために復活
3) ActionView::Template.register_default_template_handler(extension, handler)
目的: 「デフォルト」のテンプレートハンドラ(拡張子ベースの標準ハンドラ)を登録するためのメソッド。
復活後の挙動のポイント:
- 引数
extensionに対して 8.1 と同様に一度だけto_symを行う unshareable_proc_actionが有効な場合、Procを渡すときは- その Proc を「shareable copy」に変換して保存
- その shareable なハンドラを戻り値として返す
ActionView::Template.register_default_template_handler(
:md,
->(template, source) { MyMarkdownRenderer.render(source) }
)unshareable_proc_action フラグは、プロセス間で共有できない Proc をそのまま保持しないための内部的な仕組みで、register_template_handler ではすでに同様の処理が行われていました。この PR によって、register_default_template_handler も同じ安全性・一貫性を持つように修正された形です。
変えないままにしている2点(設計上の差分を維持)
#57874 で導入された以下の変更点は、互換性にほぼ影響しないためこの PR ではそのまま残されています。
handler_for_extensionがregistered_template_handlerを呼ばなくなった- 以前は
handler_for_extension→registered_template_handlerという委譲があったが、今は直接Handlersを見る形になっている。 - これに依存して
handler_for_extension/registered_template_handlerをオーバーライドしているような高度なメタプログラミングコードがない限り影響はない。
- 以前は
register_default_template_handlerがregister_template_handlerを呼ばなくなった- 内部実装上の違いであり、通常ユース (拡張子と Proc/クラスを登録する) の挙動は維持される。
- ただし、これらのメソッドをオーバーライドしているフレームワーク拡張などがある場合は注意が必要。
- 影響範囲・注意点
主な影響範囲
ActionView::Template.registered_template_handlerを直接呼んでいるライブラリ/アプリ- 例: maildown 3.3.1 の Markdown ハンドラ
ActionView::Template.template_handler_extensionsやActionView::Template.register_default_template_handlerを使っているコード
これらは Rails 8.1 で動いていたコードが Rails main で壊れていた問題を解消し、8.1 と同等の挙動に戻します。
互換性と注意点
- このメソッド削除はまだリリース版に入っていないため、CHANGELOG には記載されていません(Breaking Change がリリース前に取り消された形)。
handler_for_extension/register_default_template_handler/registered_template_handlerを「オーバーライド」しているような高度な拡張コードは、- 以前との呼び出し順序の違いに注意(上記 2 点)
- とくに「メソッド同士をお互いに呼び合う」ような実装をしている場合は挙動確認推奨。
register_default_template_handlerに Proc を渡している場合:unshareable_proc_actionが有効な環境では、shareable copy が保存・返却されるようになります。- これは
register_template_handlerと同じ挙動への揃えなので、通常はプラスの変更ですが、返り値のオブジェクト同一性を厳密に見ているテストなどがあれば影響の可能性があります。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59606
- 関連 PR(メソッド削除元): https://github.com/rails/rails/pull/57874
- 研究プロジェクト Lowrail(main に対して OSS Rails アプリを動かして互換性を検証):
https://github.com/thomasklemm/lowrail
この PR 自体はファイル変更も 2 件・追加のみで、挙動も 8.1 への回帰+Proc の取り扱いの微修正にとどまるため、基本的には「安心して main を試しやすくする互換性改善」と考えてよいです。
#59605 Look up the SQLite3 type map once per result
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
SQLite3 アダプタがクエリ結果を組み立てる際に、これまでカラムごとに呼び出していたtype_mapを「結果セットごとに1回だけ」取得するようにし、CPU命令数を削減してパフォーマンスを改善する変更です。挙動は基本的に従来と同じですが、type_mapをオーバーライドしているサブクラスのみ呼び出し頻度が変わります。
- 変更内容の詳細
以前の動作
ActiveRecord の SQLite3 アダプタでは、クエリ結果を構築する際にカラムごとに type_map.lookup を呼び出していました。このとき type_map は単純なインスタンス変数のリーダではなく、呼ばれるたびに以下の処理が行われます。
extended_type_map_keyの呼び出しself.class::TYPE_MAPの読み取り- DB設定に
default_timezoneがある場合:{ default_timezone: }という Hash を都度構築- それをキーに
Concurrent::Mapを参照して type map を取得
この type_map 呼び出しは「引数なし・接続確立時の設定にのみ依存・毎回同じマップを返す」という性質ですが、結果セットのカラム数分だけ繰り返し実行されていました。
今回の変更
PRの要点は「結果セットを処理するときに type_map を一度だけ呼んで、その戻り値をローカル変数にキャッシュする」ことです。
擬似コードで表すと:
# 変更前(イメージ)
columns.each do |column|
map = type_map # カラムごとに type_map を計算
type = map.lookup(column)
# ...
end
# 変更後(イメージ)
map = type_map # 結果セットごとに1回だけ取得
columns.each do |column|
type = map.lookup(column)
# ...
endポイント:
type_mapは引数を取らず、接続時の設定 (default_timezoneなど) にのみ依存
→ 同じ結果セット内では常に同じマップになるため、振る舞いは変わらない。- 従来は「カラムごと」に行っていた
extended_type_map_keyや Hash / Concurrent::Map 参照が「結果セットごと1回」になる。
パフォーマンス計測結果
Post.find(id)(カラム9列のテーブル)に対する Callgrind 計測:
- 通常設定:
- インタプリタ: 285,315 → 279,005 (-2.2%)
- YJIT: 139,477 → 136,912 (-1.8%)
- ZJIT: 142,740 → 140,449 (-1.6%)
- DB設定で
default_timezone: "utc"を指定した場合:- インタプリタ: 335,274 → 284,558 (-15%)
- YJIT: 186,974 → 142,887 (-24%)
- ZJIT: 188,758 → 145,588 (-23%)
default_timezone 有効時のオーバーヘッド削減効果が特に大きく、1カラムごとに Hash 作成+Concurrent::Map 参照を繰り返していたコストが減っていることが分かります。
別途 ruby-bench でも、shipit / lobsters など複数ワークロードにおいて 0.1〜0.4% 程度の命令数削減が確認されており、ノイズを超える改善になっています。
- 影響範囲・注意点
機能的挙動
type_mapの戻り値が接続設定にのみ依存するという前提のもとでの最適化なので、通常の使い方ではクエリ結果の型変換挙動は変わりません。- カラムごとに
type_mapが再評価されなくなったため、「同じ結果セットの中で、カラムごとに異なるtype_mapを返したい」といった非標準なカスタマイズがもしあれば、そこは動作が変わります(ただし、そのような前提で書くのはもともと危険な設計と言えます)。
type_mapをオーバーライドしているアダプタ・サブクラス- 変更前: カラムごとに
type_mapが呼ばれていた - 変更後: 結果セットごとに1回だけ呼ばれる
- もし
type_mapを副作用付きでオーバーライド(例: 呼ばれた回数をカウントしたり、呼び出しごとに状態を変える)している場合は、呼び出し回数の減少によって観測される挙動が変わる可能性があります。 - 正しくは、
type_mapは純粋関数的(設定のみ依存・副作用なし)に実装すべきで、その前提に沿っていれば影響はありません。
- 変更前: カラムごとに
対象
- ActiveRecord SQLite3 アダプタにのみ影響(PostgreSQL/MySQL など他アダプタは無関係)。
- 特に SQLite +
default_timezoneを設定しているアプリでメリットが大きいです。
- 参考情報 (あれば)
- 該当PR: https://github.com/rails/rails/pull/59605
- 変更ファイル:
activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb(+2 / -1 行の軽微な変更)
- パフォーマンス計測スクリプト・ノート(作者の Lowrail プロジェクト):
#59603 Check for String tag attribute values first
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
tag.*,content_tag,link_toなどが HTML 属性を組み立てる際の内部処理で、「属性値が String であるケース」を最優先で判定するようにして、典型的なパスを高速化する最適化 PR です。主に文字列属性を大量にレンダリングするビューで、わずかながら CPU 命令数と実行時間が削減されます。
- 変更内容の詳細
変更対象
- ファイル:
actionview/lib/action_view/helpers/tag_helper.rb - クラス:
ActionView::Helpers::TagHelper::TagBuilder - メソッド:
append_tag_option(推定:PR 説明より)
Rails の以下のようなヘルパーで使われる「属性の組み立て処理」に影響します。
tag.*(tag.input,tag.divなど)content_taglink_to- 各種 form ヘルパー (
form_with,form_for,text_fieldなど)
これらのヘルパーは、最終的に TagBuilder#append_tag_option によって、HTML 属性のキー・値を文字列化し、エスケープしながら class="..." や data-* 属性などを生成します。
もともとの処理フロー
append_tag_option 内では、属性値 value に対してだいたい次のような case 文で型を見ています(簡略イメージ):
case value
when Array
# 配列の場合: 空要素を除外して結合、など
when Hash
# ハッシュの場合: data 属性などの展開
when Regexp
# 正規表現の場合: 文字列化して使う、など
when Symbol
# シンボルの場合: 文字列化
else
# その他 (主に String 想定) をそのままエスケープして使う
end実際の OSS アプリの計測では、「属性値の 90–98% が String」であることが分かっており、String は上記の when には全くマッチしないため、毎回 Array → Hash → Regexp → Symbol と 4 回クラスチェックされた後に else に到達していました。
この PR の変更
この PR では、この case に
when String
# else と同じ処理というブランチを「最初に」追加しています。イメージとしては:
case value
when String
# 以前の else と同じ処理
when Array
# ...
when Hash
# ...
when Regexp
# ...
when Symbol
# ...
else
# String 以外の残り (Integer, true/false など)
endポイント:
- String の処理内容は以前の
elseと完全に同じ
→ エスケープのされ方や HTML 出力は一切変わりません。 - 「String は Array/Hash/Regexp/Symbol には一致し得ない」ため、ロジックの意味上の変更はなく、クラスチェックの順番だけが変わっています。
- String 以外の値(Symbol, Integer,
true, Array, Hash, Regexp 等)は、when Stringが先頭に増えたため、1 回だけ追加の型チェックを払うことになります。
パフォーマンス計測結果
代表的なケースの計測(Ruby 4.0.9, Rails main 付近):
文字列属性だけのケース
tag.input(type: "text", name: "q", value: "rails")- インタプリタでの命令数: 約 6.2% 減
- YJIT での命令数: 約 2.2% 減
- ZJIT での命令数: 約 2.0% 減
- 実行時間(インタプリタ): 約 4.7% 短縮
- 実行時間(YJIT): 約 2.4% 短縮
Symbol 属性を含むケース
tag.div("x", dir: :ltr)- インタプリタ命令数: +1.2%
- YJIT 命令数: +0.5%
- 実行時間も +1〜2% 程度の増加
ただし全体として非常に小さい差であり、ベンチマーク全体では「ノイズの範囲」という評価です。
- 影響範囲・注意点
影響範囲
- 影響を受けるのは、
tag.*content_taglink_to- form ヘルパー類
などが内部で HTML 属性を組み立てるタイミングです。
- 対象となる属性値は「String を含むすべての型」ですが、ロジックは以下のように変わっています:
- String: 早い段階で処理されるようになっただけで、出力結果は一切変わらない。
- Array/Hash/Regexp/Symbol: これまでどおり各分岐に入るので、振る舞いは変わらない。
- それ以外(Integer, Float,
true/falseなど):elseを通る点も従来どおりで、違いは「String チェックが 1 回増える」だけ。
互換性・バグのリスク
caseのマッチ順序に依存していたようなコードパスは、ここでは存在しません。- String は他の
when(Array,Hash,Regexp,Symbol) のいずれも満たさないため、以前も必ずelseルートに行っていました。 - 今回はこの「以前の else ルート」を明示的な
when Stringに切り出しただけです。
- String は他の
- したがって、ユーザーコードの互換性問題や HTML 出力の変化は原理的に発生しないと考えられます。
パフォーマンス上の意味合い
- String 属性値が多い一般的なアプリでは:
- ページ全体で見ると、レンダリング時間の 0.1% 程度の改善が期待できる、という評価になっています。
- 例: Fizzy や Lobsters で、約 1350 個の String 属性を描画するページをインタプリタでレンダリングした場合、60µs 程度の削減(44–63ms のうちの 0.1%)。
- ruby-bench の railsbench のようなマクロベンチでは、「測定ノイズの範囲内」であり、大きな違いは観測されていません。
- まとめると、
- ホットパスのマイクロ最適化であり、
- 影響は非常に小さいが、よく出るコードパスの効率が少し良くなった、 という位置づけです。
開発者が意識すべき点
- アプリケーションコード側で 特別な対応や修正は不要です。
- タグヘルパー周りの挙動が変わったわけではないので、「この PR によってテストが壊れる・HTML が変わる」といったことは想定されません。
- パフォーマンス計測環境で Rails main / Rails 9(想定)を追っている場合は、微妙な数値変化の原因としてこの変更を知っておくとよい、という程度です。
- 参考情報 (あれば)
- PR 本文で引用されているリポジトリ・資料:
- Lowrail (CPU センサス/計測プロジェクト):
https://github.com/thomasklemm/lowrail - この変更の詳細な計測ノート:
https://github.com/thomasklemm/lowrail/blob/main/docs/upstream/actionview-render-c10.md - ベンチマークスクリプト:
https://github.com/thomasklemm/lowrail/tree/main/bench/upstream/actionview_c10
- Lowrail (CPU センサス/計測プロジェクト):
- Ruby JIT 実装:
- YJIT, ZJIT を使ったプロファイル結果が PR 内で報告されています。Rails のタグレンダリング周りで JIT との相性を意識した最適化が進んでいる文脈の一部と捉えられます。
#59405 Index existing attachments once when attaching to a collection, 1.6x faster alone and 63x with #58679
マージ日: 2026/11/4 | 作成者: @czj
- 概要 (1-2文で)
has_many_attachedに対して既に大量の添付ファイルがあるレコードにさらに attach する際の計算量の悪さ(O(N²))を解消し、添付処理を実質線形時間に近づけるパフォーマンス改善 PR です。既存の添付レコードを一度だけインデックス化して再利用することで、単体でも約 1.6x、高速化 PR #58679 と併用すると最大 63x 程度の高速化が報告されています。
- 変更内容の詳細
背景となる問題
has_many_attached で既存添付が N 件あるレコードに対して record.attach(new_blob) する処理が、N に対して二乗時間かかっていました(#50848 が指摘)。原因は:
Attached::Many#attach→CreateManyに「すでに attach 済みの blob + 新規 blob」をすべて渡す- 各 blob を処理する
CreateOneOfMany#find_attachmentが「そのレコードの添付一覧」をdetectで線形走査して該当する attachment を探す - これを既存添付ごとに繰り返すため、全体として O(N²)
という構造になっていたためです。
変更の要点
この PR では CreateMany に「既存 attachment を blob_id で引けるインデックス」を一度だけ構築させ、以降は CreateOneOfMany がそのインデックスに問い合わせるように変更しています。
1. CreateMany にインデックスを追加
activestorage/lib/active_storage/attached/changes/create_many.rb への主な変更:
- 既存の attachments を
blob_idをキーとするハッシュに変換し、最初に見つかった attachment を値として保持 - インデックスは「初めて永続化済み blob の attachment が必要になったタイミング」で遅延生成(lazy)する
- これにより、「新規アップロードだけを attach するケース」では attachments 連想をそもそもロードしない
擬似コードイメージ:
class CreateMany
def attachment_for_blob_id(blob_id)
# 初回アクセス時だけインデックスを構築
@attachments_by_blob_id ||= existing_attachments.index_by(&:blob_id)
@attachments_by_blob_id[blob_id]
end
endここで existing_attachments は、そのレコードにすでに紐づいている ActiveStorage::Attachment のコレクションです。index_by(&:blob_id) により、内部的には { blob_id => attachment } の形で一回だけハッシュを組み立てます。
detect との互換性としては、「各 blob について最初に見つかった attachment だけを採用する」という従来挙動を維持しています(index_by は同一キーに対して最初の要素を残すため)。
2. CreateOneOfMany はインデックス経由で参照
activestorage/lib/active_storage/attached/changes/create_one_of_many.rb の変更では、従来 find_attachment 内で自前で attachments.detect { … } していた処理をやめ、親の CreateMany から取得するようにしています。
イメージ:
class CreateOneOfMany
def find_attachment
create_many.attachment_for_blob_id(blob.id)
end
endこれにより、添付レコードのスキャンは「一度インデックスを作るとき」だけになり、それ以降はハッシュ lookup(O(1))で済むようになります。
3. テストの追加
activestorage/test/models/attached/many_test.rb に 2 つのテストが追加されています。どちらも「挙動は変えずに速度だけ上げる」ことを保証するものです。
「複数添付のうち同じ blob を再割り当てしたときに、正しい attachment を保持する」テスト
- 複数の attachment が同じ blob を共有している場合でも、従来と同じ attachment が選ばれること(最初のもの)が保証されています。
「新規アップロードのみを割り当てる場合には、既存 attachments 連想をロードしない」テスト
- この挙動は main でも成立しており、今回の最適化でも壊してはいけない前提条件です。
- インデックス構築を eager にやるとここが壊れるため、「インデックスは lazy に構築する」という実装方針をテストで固定しています。
- 影響範囲・注意点
影響範囲
- 対象: Active Storage の
has_many_attachedに対するattach処理(Attached::Many#attach経由) - 変更点の性質: パフォーマンス最適化のみで、外部 API や見える挙動は変えていない
- 主な効果:
- 既存添付が多いレコードに対し、単一 blob を追加で attach する操作が O(N²) → ほぼ O(N) に改善
- #58679(
CollectionAssociation#replace_common_records_in_memoryの別の二乗ステップ解消)と併用することで、全体として線形時間への近似が顕著になる
ベンチマーク結果(SQLite, Ruby 4.0.9 arm64, N 個の既存添付があるレコードに対して 1 回 attach の中央値):
| N | main | この PR 単体 | #58679 単体 | 両方適用 |
|---|---|---|---|---|
| 1K | 247 ms | 161 ms | 105 ms | 18.6 ms |
| 5K | 5,796 ms | 3,672 ms | 2,220 ms | 91.6 ms |
N=5,000 でおおよそ 5.8s → 92ms 程度まで短縮されており、「添付数に対して線形に増える」挙動になっていることが示唆されます。
注意点・互換性
挙動の互換性
- 既存添付からどの attachment を採用するか(同じ blob が複数回 attach されている場合)は従来と同様「最初の attachment」です。
- テストでこの挙動を固定しているため、アプリ側での期待値が変わることはありません。
遅延インデックス構築による副作用
- 「新規 blob のみを attach するケース」では、引き続き attachments のアソシエーションをロードしません。これは、N が大きい場合に余計なクエリやメモリを消費しないために重要です。
- インデックスは「永続化済み blob に対して既存 attachment を探す必要が生じたとき」にだけ構築されるため、通常のユースケースでは以前より余計にレコードがロードされることはありません。
パフォーマンスを前提としたコード
- 独自パッチや monkey patch で
CreateMany/CreateOneOfManyの内部に依存している場合は注意が必要です。特にfind_attachmentの実装に直接依存している場合は差分を確認した方がよいです。 - ただし、public API レベル(
record.attach/has_many_attached)では非互換はありません。
- 独自パッチや monkey patch で
- 参考情報 (あれば)
- 元 issue: #50848 —
has_many_attachedでの二乗時間問題の指摘 - 関連 PR: #58679 —
CollectionAssociation#replace_common_records_in_memoryの別の二乗ステップ解消。今回の PR とは独立だが、併用すると大幅な高速化。 - ベンチマークスクリプト: PR 本文に掲載(
activestorageディレクトリからBUNDLE_WITHOUT=db bundle exec ruby collection_replace_attach.rb 1000 5000で実行可能)
この PR は Active Storage の内部実装レベルの変更にとどまり、「大きな has_many_attached コレクションに繰り返し添付する」ワークロードを持つアプリ(例: ユーザー毎に多数の画像・ハイライト・ログを蓄積するようなケース)で特に恩恵があります。
#59598 Anchor URI_REGEXP once instead of per alternative
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails のasset_pathなどで使われるURI_REGEXPの正規表現を、「オルタネーションごとに^を付ける」形から「先頭に一度だけ^を付ける」形に変更し、特に相対パス(例:"application.css")に対するmatch?のコストを削減するパフォーマンス改善です。
マッチ結果は(広範なテストで確認された範囲で)完全に互換であり、挙動を変えずに 10〜15% 程度asset_path呼び出しの命令数・時間を削減します。
- 変更内容の詳細
何をしているか
asset_path は引数が「すでに完全な URL であるか」を判定するために URI_REGEXP を match? しています。
この PR は、その URI_REGEXP の「アンカー(^)」の付け方だけを変更しています。
元のイメージ(簡略化):
URI_REGEXP = /^https?:|^\/\/|^data:/i
# 実際にはもっと複雑ですが、趣旨としてはこういう形変更後のイメージ:
URI_REGEXP = /^(?:https?:|\/\/|data:)/i違いは:
- 変更前: オルタネーションのそれぞれに
^が付いている^https?:|^\/\/|^data:
- 変更後: 先頭に一度だけ
^を付け、その後を非キャプチャグループ(?:...)でまとめている^(?:https?:|\/\/|data:)
なぜ速くなるか
- 正規表現エンジンは「オルタネーションごとに
^を評価」する必要がなくなる - パターンは依然としてケースインセンシティブ (
/i) だが、アンカー評価の回数が減るため、マッチ失敗(例:"application.css"のような相対パス)が高速化 - ベンチマーク結果(Ruby 4.0.9, interpreter):
asset_path("application.css")のmatch?:- 変更前: 9,580 命令
- 変更後: 5,943 命令
asset_path("application.css")全体:- 時間: 約 −12〜15%
- 命令数: 約 −10〜14%
- 正規表現の論理は等価であり、「同じ文字列に、同じ位置でマッチする」ことが保証される形の変形です。
互換性検証
作者は以下のような強めの検証を行っています:
- 6文字以下の全文字列(改行を含むアルファベット): 約 810 万通り
- 30万以上のランダム文字列:
- 複数行を含む文字列
- Unicode のケースフォールディングが効く文字
- 不正な UTF-8
- 他エンコーディング
- 旧・新パターンで比較:
match?の真偽- マッチ開始/終了オフセット
- 例外発生の有無
いずれも差分は見つからず、正規表現としては完全互換とみなせる、という報告です。
実際に使われている箇所
URI_REGEXP を使うのは以下の 3 か所のみで、いずれも match? を呼んでいます:
asset_pathcompute_asset_host- Action Text:
RemoteImage.remote_url?
このため、MatchData への依存やグルーピングの変化による影響はありません(グループも非キャプチャ (?:...) のみ)。
- 影響範囲・注意点
影響範囲
- 直接の変更ファイルは 1 個のみ:
actionview/lib/action_view/helpers/asset_url_helper.rb
- 影響する高レベルヘルパー:
asset_pathimage_tagstylesheet_link_tagjavascript_include_tagasset_url- (および
compute_asset_host,ActionText::RemoteImage.remote_url?)
これらは「引数が絶対 URL なら書き換えず、そのまま返す」判定に URI_REGEXP を使っているため、そこに関わるパフォーマンスが改善されます。
実アプリの CPU プロファイル調査結果:
asset_path内の該当行は、ある種のリクエストにおいて「全命令数の約 0.23%」を占める hotspot だった- この PR 適用後、その行の命令数はリクエストあたり 35–47% 減少
- リクエスト全体では約 0.1% の命令数削減
- 個別リクエスト単位では「誤差レベル」だが、全体としての改善はポジティブ
- ただし他の要因に埋もれる程度であり、劇的な速度向上ではない
挙動の変化・後方互換性
- 正規表現の意味は等価であり、
match?の結果は変わらない前提 - グループは非キャプチャ (
?:) なのでMatchData#[]等にも影響なし - 使用箇所はいずれも
match?の真偽しか見ていないため、実際の Rails API の挙動は変更されません - 絶対 URL に対するコストはほぼ変化なし(インタプリタで 2,163 命令/回と報告)
実測値上、stylesheet_link_tag("application") 等では「命令数は減っているが、実行時間の差は測定ノイズレベル」とされており、体感的な変化はほぼないと考えて良いです。
注意点
- 独自に
ActionView::Helpers::AssetUrlHelper::URI_REGEXPを参照しているアプリや gem が、match?以外にmatchしてMatchDataに依存している場合:- グループ番号やキャプチャの有無に依存していなければ影響なし
- もともとアンカー位置しか変えていないので、通常は影響しません
- レガシーコードで
URI_REGEXP.sourceをパースしているような「メタ」な使い方をしていない限り、挙動の差異は出ないはずです。
- 参考情報 (あれば)
- PR 本文での測定条件:
- Ruby 4.0.9 / Rails main (コミット: d795dac950)
- x86-64 Linux VM
- 各ケース:
- 20,500 回ウォームアップ + 200,000 回計測(GC 有効)
- 最良値(best of 7 rounds)を採用
- 命令数:
- Callgrind で 20,000 回実行時と 0 回実行時の差分を 20,000 で割った値
- GC 無効
- 作者のプロファイル/ベンチ環境:
- Research プロジェクト「Lowrail」:
- Rails main に対し、20 OSS アプリ・251 ワークロードで、各行ごとの命令数を計測
- 対象行が 17 アプリで実際に使われていることを確認
- ベンチスクリプト:
- CPU census レポート:
- Research プロジェクト「Lowrail」:
実務的には、「挙動は変えずに asset_path 周辺のコストを少し削ったマイクロ最適化」であり、アップグレード時に特別な対応は不要ですが、大量のアセットを扱うアプリでは「こうした細かい積み重ね」が全体のレスポンス時間・CPU コスト削減につながる、という位置づけの変更です。
#59597 Build allow_browser's named version sets once
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
allow_browser versions: :modernなどの「名前付きバージョンセット」を、これまでリクエストごとにHashWithIndifferentAccessへ変換していた処理をクラスロード時に一度だけ行うようにし、ブラウザチェックのオーバーヘッドを削減した PR です。アプリ側で渡す任意のバージョン Hash の挙動は変えず、生成アプリの共通リクエストパスをわずかに高速化しています。
- 変更内容の詳細
背景
- 新規生成された Rails アプリ(
--apiでない通常アプリ)のApplicationControllerはデフォルトでrubyを呼びます。allow_browser versions: :modern - 各リクエストの User-Agent を検査するたびに、
:modernのような「名前付きセット」が内部でHashWithIndifferentAccessに変換されていました。 - この変換は「毎回同じ結果」になるにもかかわらず、リクエストごとに繰り返し実行されていたため、無駄なオブジェクト生成・命令実行が発生していました。
何をしたか
1) 名前付きバージョンセットの事前展開(クラスロード時に一度だけ)
- これまで:
- 内部定数
SETSに、例えば:modern用のバージョン情報が Symbol キーの Hash として定義されている。 allow_browser versions: :modernの実行時(ブラウザチェックごと)に、:modern→HashWithIndifferentAccessへの変換を毎回実行していた。
- 内部定数
- この PR では:
- クラスロード時に
SETSの「名前付きセット」を一括変換し、EXPANDED_SETSのような新しい定数に格納して凍結(freeze)・Ractor 共有可能な形にしています。 - 実行時には、
:modernなどの名前から直接EXPANDED_SETSを参照するだけになるため、変換コストがゼロになります。 - 元の
SETSはそのまま保持され、Symbol キーの Hash を前提としたコード(設定など)には影響しません。
- クラスロード時に
イメージ(疑似コード):
# 既存: Symbol キーの Hash 群
SETS = {
modern: {
chrome: 120,
firefox: 132,
# ...
},
# ...
}
# 追加: クラスロード時に一度だけ IndifferentAccess に変換し凍結
EXPANDED_SETS = SETS.transform_values { |h|
HashWithIndifferentAccess.new(h).freeze
}.freezeブラウザ判定時のロジックは:
def versions_for_check(versions)
case versions
when Symbol
# :modern など → 事前に展開済みの EXPANDED_SETS から取得
EXPANDED_SETS.fetch(versions)
when Hash
# アプリ側が直接 Hash を渡した場合 → 毎回変換(後述)
HashWithIndifferentAccess.new(versions)
else
# 何らかのエラー処理 etc.
end
end2) アプリ側が渡すカスタム Hash の挙動は維持
- アプリケーションコードが以下のように Hash を直渡しするケース:ruby
allow_browser versions: { chrome: 120, firefox: 132 } - この場合は、従来どおりチェックのたびに
HashWithIndifferentAccessに変換します。- 理由: アプリケーション側でこの Hash を後から書き換えた場合に、その変更が直ちに反映される現行の挙動を壊さないためです。
- よって、「名前付きセット」は一度だけ展開、「その場で渡した Hash」は都度変換、という住み分けになります。
3) HashWithIndifferentAccess の require タイミングを明示
- クラスロード時に
HashWithIndifferentAccessを使うようになったため、actionpack/lib/action_controller/metal/allow_browser.rb内でrubyを明示的に追加しています。require "active_support/core_ext/hash/indifferent_access" - コメント上の説明によると、
require "action_controller"全体としては、以前と同じファイルのロード順になるよう配慮されています。
4) テストの追加
actionpack/test/controller/allow_browser_test.rbに 4 行のテストが追加され、- 名前付きセットの利用時に正しく事前展開された Hash が使われること
- 挙動の後方互換性
を検証しています(差分から見るに、そのあたりの確認用と思われます)。
- 影響範囲・注意点
性能面の影響
- ベンチマーク対象:
BrowserBlocker#blocked?を、毎回新しいモックリクエストに対して呼び出すケース(User-Agent あり、versions: :modern使用)。 - 測定条件:
- Ruby 4.0.9(インタプリタ / YJIT)、ZJIT は Ruby master (9aa0898240)
- GC 無効、同一 CPU 上で 15 回ずつローテーション測定
- Callgrind で 3,000 回実行時の命令数差分を計測
- 結果(
:modern+ Firefox 132 のケース):- インタプリタ:
- 実行時間: 43.9 µs → 40.4 µs(約 8.0% 減)
- 命令数: 312,242 → 294,313(約 5.7% 減)
- YJIT:
- 実行時間: 23.5 µs → 22.5 µs(約 4.4% 減)
- 命令数: 224,199 → 216,408(約 3.5% 減)
- ZJIT:
- 命令数: 225,413 → 215,280(約 4.5% 減)
- インタプリタ:
- まとめると、名前付きセットを使う場合のブラウザ判定が数 % 単位で軽くなる変更です。
- 一方、カスタム Hash 渡しの場合は A/A テスト範囲内で差分なしと報告されており、性能特性は事実上変わりません。
機能面・後方互換性
allow_browserの外部インターフェイス(DSL、オプション指定方法)は変更されていません。SETS自体は従来どおり Symbol キーの Hash で保持されており、ActionController::Metal::AllowBrowser内部の定数を参照しているコード(あれば)は壊れません。- 名前付きセット (
:modern等) の内容をランタイムに書き換えるようなメタプログラミングをしていた場合は、変換後に参照されるのがEXPANDED_SETS側であるため、期待どおりに反映されない可能性があります。- ただし、通常の利用ではそのようなパターンは想定されていません。
- カスタムバージョン Hash の後からの変更は、従来どおり「次のチェックから即反映される」挙動が維持されています。
スレッド / Ractor 安全性
- 事前展開された
EXPANDED_SETSは凍結されており、Ractor 間で共有可能なデータとして扱えるようになっています。 - これにより、Ractor 利用時にも余計なコピーや変換が減り、性能に寄与します。
その他
- ruby-bench 上の 3 つの Rails アプリのうち、
allow_browserを利用しているのは shipit のみですが、shipit は User-Agent を送らないため、この最適化はそこでの計測結果にはほぼ現れません。 - 実際には、新規生成アプリのリクエストパスを Lowrail という研究用ベンチで測定する中でこのボトルネックが発見されています。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59597
- 変更ファイル:
actionpack/lib/action_controller/metal/allow_browser.rbactionpack/test/controller/allow_browser_test.rb
- ベンチマークスクリプト:
- 開発者のメモ(Rails の共通リクエストパス調査):
#59596 Freeze a cached statement's SQL once instead of copying it per execution
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
find,find_by, association 読み出しなどで使われる ステートメントキャッシュの SQL 文字列を「キャッシュ時に一度だけ freeze」することで、実行ごとの文字列コピー・freeze を削減し、割り当てオブジェクト数と命令数をわずかに減らす変更です。
その結果、prepared statement 利用時にpayload[:sql]が毎回別インスタンスではなく 同じ frozen String になり、SQL をインプレースで書き換えるような独自拡張はFrozenErrorとなる可能性があります。
- 変更内容の詳細
何をしているか
- 対象:
- Active Record のステートメントキャッシュ経由で実行されるクエリ
Model.find(id)Model.find_by(...)- association の読み出し (
user.posts,user.posts.to_aなど)
- Active Record のステートメントキャッシュ経由で実行されるクエリ
- これらは内部的にキャッシュされた SQL を使って
find_by_sqlを呼び出します。 - これまで
- prepared statements 利用時 (SQLite / PostgreSQL のデフォルト) では、キャッシュに保持されている SQL String は「非 frozen」。
to_sql_and_bindsは、渡された SQL String を毎回dupしてfreezeしてから利用していた。- つまり、クエリ 1 回ごとに「SQL のコピー」と「そのコピーを freeze したオブジェクト」の 2 つを生成していた。
- この PR で
- ステートメントをキャッシュするときに SQL を 1 度だけ freeze するように変更。
- その結果、
to_sql_and_bindsは既に frozen な SQL を受け取るので、毎回のdup/freezeが不要になり、実行ごとのオブジェクト生成が減る。
PR の説明にあるように、Arel からコンパイルされた SQL は既に to_sql_and_binds 内で freeze されていたため、「キャッシュ経由の SQL も同様に最初から freeze しておく」ことで処理の一貫性を持たせています。
変更箇所 (概念的なイメージ)
実際の差分は 1 行だけですが、概念的には以下のようなことをしていると考えられます:
# 以前: キャッシュには非 frozen の SQL が入る
statement_cache[key] = sql # String(非 frozen)
# 実行時: 毎回 dup & freeze
def to_sql_and_binds(sql)
frozen_sql = sql.dup.freeze
[frozen_sql, binds]
end
# 変更後: キャッシュ時に freeze しておく
statement_cache[key] = sql.freeze
# 実行時: 既に frozen なので dup 不要(実際の実装は to_sql_and_binds 側)
def to_sql_and_binds(sql)
# sql は既に frozen
[sql, binds]
end実際の変更は activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb の 1 行入れ替え程度で、テスト (statement_cache_test) に「キャッシュされた SQL が frozen であること」を確認するテストが追加されています。
ベンチマーク結果
Post.find(id) 1 回あたり:
- オブジェクト数: 61 → 59
- 命令数: ~0.4〜0.6% 減少 (インタプリタ / YJIT / ZJIT いずれも)
- 時間: インタプリタで ~2.3% 減、JIT では差は誤差範囲レベル
大幅な改善ではありませんが、「頻繁に呼ばれる共通パスの細かな無駄を削る」タイプのマイクロ最適化です。
- 影響範囲・注意点
挙動が変わるケース
prepared statements を使っている場合のみ影響
- SQLite, PostgreSQL ではデフォルトで prepared statements 有効。
- それ以外や、prepared statements を無効にしている場合は挙動は変わりません。
find_by_sql/select_allをオーバーライドして SQL をインプレースで書き換えているコード- 例:
payload[:sql] << " /* tracing */"のようなコード - この PR 以降:
- ステートメントキャッシュ経由で実行されたクエリの
payload[:sql]は 常に同じ frozen String インスタンス になります。 - そのため、
<<やgsub!などの破壊的メソッドを使うとFrozenErrorが発生します。
- ステートメントキャッシュ経由で実行されたクエリの
- 以前の main では:
- キャッシュされた生の SQL をインプレース変更していたため、「クエリを実行するたびにキャッシュ内の SQL に追記されていき、文字列が肥大化する」というバグを起こし得ました。
- さらに、元々 frozen な String を渡された場合にはすでに
FrozenErrorが出ていたため、「今度はキャッシュ経由でも同じようにエラーになる」形で一貫性が改善されたとも言えます。
- 例:
ベストプラクティス
find_by_sql,select_allをフックしたい場合、SQL 文字列を破壊的に変更しないことが推奨されます。- どうしても変更したい場合は
sql = sql.dupのように明示的にコピーしてから編集し、コピーを使って実行するようにします。
- どうしても変更したい場合は
- ActiveSupport::Notifications (
sql.active_record) のpayload[:sql]を見てログやメトリクスを取っているだけなら、この PR による問題はありません(読み取り専用であれば OK)。
互換性のまとめ
- 公開 API のインターフェースは変わっていない。
- 「破壊的に SQL を書き換える非推奨パターン」に対して、以前はバグった動作をしていたのが、明示的に
FrozenErrorとして表面化するようになった、という性質の変更です。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59596
- ベンチマーク・背景説明:
- Lowrail のスクリプトとノートで、Active Record の共通パスの割り当てトレースやベンチが示されています。
- 関連キーワード:
- Statement Cache
- Prepared Statements
to_sql_and_binds- ActiveSupport::Notifications (
sql.active_recordのpayload[:sql])
#59590 Extract order references from the built Arel nodes
マージ日: 2026/11/4 | 作成者: @kamipo
- 概要 (1–2文で)
order系メソッドが、生の引数からではなく Arel で組み立てたノードから参照テーブル情報(references)を抽出するように変更された PR です。- これにより、
order/reorder/default_orderの前処理タイミングがspawn前に移り、副作用が減って、merge時の余計な再処理もなくなり、オブジェクト割り当て数が減少しています。
- 変更内容の詳細
2-1. これまでの問題点 / 背景
従来の挙動:
orderに渡された「生の引数」からcolumn_referencesがテーブル参照を抽出していた- さらに
order_columnが内部でarel_column_with_tableを呼び出す際に、副作用としてreferencesにテーブル名を追加していた - そのため:
order引数の前処理を「spawn 済みの relation」で行う必要があった- 同じ
order_valuesを別 relation にマージするときにも、order!/reorder!経由で再度前処理が走っていた
問題点:
- テーブル参照収集の経路が複数あり(生引数+Arel 生成時の副作用)、挙動が分かりづらい
referencesの更新に副作用が絡むため、spawnタイミングやmergeの実装が複雑- 不要な処理・オブジェクト生成が多く、割り当て数が多い
2-2. 新しいアプローチ
Arel ノードから参照を抽出するように変更
- テーブル参照(
references)は、order引数の生情報ではなく、「ビルド済みの Arel ノード」から抽出するようになりました。order/reorder/default_orderは、まず Arel ノードを組み立て、そのノードから参照テーブルを解析します。
- これに伴い、
arel_column_with_table呼び出し時にreferencesを更新するような副作用は廃止されています。
効果:
- 引数の加工と
referencesの更新ロジックが統一され、見通しがよくなる - 「Arel ノードが真実」になるため、raw 引数の形式 (
:id/:"posts.id"など) に煩わされずに済む
order / reorder / default_order の前処理タイミング変更
- これら 3 つのメソッドは、
spawnを呼ぶ前に引数を前処理するようになりました。- 以前は「spawn 済み relation」に対して副作用的に
referencesを更新していたため、spawn 後処理が前提だった。 - 今回からは、新しい relation を生成する前に、必要な Arel ノードと参照情報を完成させてから spawn する形になっています。
- 以前は「spawn 済み relation」に対して副作用的に
これにより:
- relation インスタンスの状態変化の場所・順序が明確になる
spawn後の relation がより「完成された値」を持つようになる
relation 自身のテーブルは references から除外
order引数として:idや:"posts.id"を渡した場合の扱いが整理されています:rubyPost.order(:id) Post.order(:"posts.id")これらはどちらも最終的に
posts.idという Arel ノードになりますが、- relation 自身のテーブル(ここでは
posts)はreferencesに追加されなくなりました。
- relation 自身のテーブル(ここでは
もともと eager loading(
includesなど)では relation 自身のテーブル参照は無視しており、意味を持たなかったため、その無駄を取り除いた変更です。
Merger が order_values を直接コピー
- これまで
Relation#mergeは、マージ対象 relation のorder_valuesをorder!/reorder!を通して自分に適用- → その過程で
orderの前処理やreferences周りの処理が再度実行されていた
- この PR では、
Mergerが相手のorder_valuesをそのまま代入するようになりました:- すでに Arel ノードとして完成しており参照抽出も済んでいる前提なので、再前処理は不要
- 結果として、
merge時の余分なオブジェクト生成・処理が削減されています
2-3. パフォーマンス改善の具体例
PR 説明にある例:
Post.order(:"posts.id") # 39 -> 19 allocations
Post.all.merge(Post.order(:id, title: :desc)) # 26 -> 8 allocationsPost.order(:"posts.id")- 1 クエリ発行あたりのオブジェクト割り当てが 39 から 19 に削減
Post.all.merge(Post.order(:id, title: :desc))- 26 から 8 に削減
order 引数の解析や merge 時の二重処理が削減された効果が確認できます。
- 影響範囲・注意点
3-1. 公開 API レベルではほぼ後方互換
Post.order(:id)/Post.order(:"posts.id")などのユーザー向け API の結果 SQL は、原則として従来どおりです。- eager load の解決ロジックも、もともと relation 自身のテーブル参照を無視していたため、挙動差はほぼありません。
3-2. 内部的に references や Arel ノードに依存しているコードへの影響
ライブラリ・アプリ側で以下のような「Rails 内部構造依存」をしている場合は、影響しうるので注意してください:
Relation#references_valuesなどを直接読んでいて、- そこに relation 自身のテーブル(例:
posts)が含まれることを前提にしていた
- そこに relation 自身のテーブル(例:
order!/reorder!呼び出しにより- 「生の引数」状態の
order_valuesを期待していたり、 - 副作用的に
referencesが更新されることを前提にしていた
- 「生の引数」状態の
Mergerの挙動を書き換える monkey patch に依存している
今回の変更で:
references:- relation 自身のテーブルは含まれなくなりやすい(
order経由では入らない) - Arel ノード解析後のテーブル名に基づく形に統一
- relation 自身のテーブルは含まれなくなりやすい(
order_values:- より早い段階でArel化・前処理済みであることが前提になり、
Mergerはそれをそのまま引き継ぐ
そのため ActiveRecord::Relation の内部配列構造に依存する実装は、挙動確認・修正が必要になるケースがあります。
3-3. カスタム Arel / order 拡張との相性
- 自前で Arel ノードを組み立てて
order(Arel.sql(...))やorder(my_arel_node)のように渡している場合:- 今回は「生成済み Arel ノードから参照を抽出する」方向に統一されているので、むしろ相性は良くなっています。
- 一方で、「
orderの生引数解析にフックするような metaprogramming」をしている場合は、- 解析位置・タイミングの変更(
spawn前)が影響する可能性があります。
- 解析位置・タイミングの変更(
- 参考情報 (あれば)
- PR 番号: #59590
- タイトル: Extract order references from the built Arel nodes
- 主なポイント:
column_referencesは raw 引数ではなく 完成した Arel ノードを元にするarel_column_with_tableによるreferences更新の副作用を廃止order/reorder/default_orderの前処理をspawn前に移動- relation 自身のテーブルは
referencesに含めない(もともと eager load では無視されていた) Mergerはorder_valuesを直接代入し、order!/reorder!による再処理をしないorder/merge周りで allocations が減り、パフォーマンスが改善されている
#59589 Remove outdated JRuby branch from test_should_impose_childless_html_tags_in_html
マージ日: 2026/11/4 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
JRuby 向けにだけ存在していたコントローラテストの分岐(JRuby + NekoHTML 由来の挙動差対応)を削除し、CRuby/JRuby を問わず同じ期待値でテストするようにした PR です。Nokogiri が JRuby で使用する HTML パーサを切り替えたことで挙動差が解消され、JRuby 専用アサーションがむしろ不正になったため、その対応を整理しています。
- 変更内容の詳細
背景となる挙動差
対象テストは actionpack/test/controller/test_case_test.rb 内のtest_should_impose_childless_html_tags_in_html です。
このテストは、子要素を持たないはずの HTML タグ(例: <area>)に対して、Rails 側が適切に HTML を補正(自動クローズ)しているかを検証するものです。
過去の状況:
- JRuby では Nokogiri が NekoHTML を使っており、
- NekoHTML は auto-close された
<area>を<map>タグでラップする挙動を持っていた
(参考: sparklemotion/nokogiri#1653)。 - そのため「CRuby(libxml2)系」と「JRuby(NekoHTML)」で DOM 構造が変わるため、
Rails のテスト内で JRuby 専用の期待値分岐が入っていた。
具体例(PR 説明中のスクリプトの結果):
| 環境 | パース結果 (body) | root > area + p | root > map > area + p |
|---|---|---|---|
| 1.13.10(JRuby+NekoHTML) | <root><map><area><p>content</p></map></root> | 0 | 1 |
| 1.14.0 以降 | <root><area><p>content</p></root> | 1 | 0 |
Nokogiri 1.14.0 以降、JRuby で使うパーサが neko-htmlunit に切り替わり、JRuby 側も libxml2 と同じ DOM 構造を返すようになりました。その結果、NekoHTML 時代に合わせた JRuby 専用の期待値(root > map > area + p)が失敗するようになっています。
この PR の具体的変更
対象ファイル:actionpack/test/controller/test_case_test.rb
変更量: 1行追加 / 7行削除
やっていることはシンプルで、
jruby?チェックなど JRuby 固有の分岐ロジックを削除- 「全プラットフォーム共通の DOM 構造」を前提に、単一のアサーションだけを残す
という形でテストコードを整理しています。
疑似コードで表すと:
# 変更前(イメージ)
if jruby?
assert_select "root > map > area + p"
else
assert_select "root > area + p"
end
# 変更後(イメージ)
assert_select "root > area + p"実際には条件分岐全体ごと削除され、共通のセレクタによるアサーションのみが残る形です。
他テストへの影響
同様に NekoHTML 由来の挙動差に対応していた箇所として、actionview/test/template/sanitize_helper_test.rb 内の分岐はまだ残っています。
PR 説明によると、そのテストは「現在の JRuby + Nokogiri の挙動と一致しており、まだ必要」と判断されています。
つまり今回の PR は、問題になっている特定テスト(test_case_test.rb 内の 1 件)のみを対象とした最小限の整理です。
- 影響範囲・注意点
- 影響範囲は
test/controller/test_case_test.rbの 1 テストメソッドに限定されており、本体コード(Action Pack や Action Controller の実装)には一切影響しません。 - JRuby 環境でのテスト挙動が、CRuby 環境と完全に一致する方向に揃えられます。
- JRuby + nokogiri-java 1.19.4
- CRuby 4.0.7 + nokogiri 1.19.1
でテストが通ることが確認されています。
- もし古い Nokogiri 1.13 系(JRuby + NekoHTML)を使って Rails のテストを走らせる場合、
DOM 構造が過去のまま変わっていないと、このテストは失敗する可能性があります。
つまり、この PR は「JRuby でも新しい Nokogiri を前提にする」方向のテスト更新といえます。 - 逆に言えば、JRuby で Rails を開発/利用する場合、
Nokogiri を十分新しいバージョン(少なくとも 1.14.0 以降、推奨は 1.19.x)にしておくことが望まれます。
- 参考情報 (あれば)
- 関連 Nokogiri Issue/PR:
- NekoHTML による
<area>ラップの問題:
https://github.com/sparklemotion/nokogiri/issues/1653 - JRuby を neko-htmlunit に切り替えた PR:
https://github.com/sparklemotion/nokogiri/pull/2496
- NekoHTML による
- 今回テストが前提とする Nokogiri バージョン周辺:
- JRuby: nokogiri-java 1.19.4
- CRuby: nokogiri 1.19.1
この PR は、パーサ実装の統一に伴う過去の互換性ハックを取り除く「テストの正常化・簡素化」であり、今後 JRuby を含めた Rails の挙動検証がシンプルになる方向の変更です。
#59585 Freeze TimeZoneConverter and EnumType when what they wrap is frozen
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
Active Record のTimeZoneConverterとEnumTypeについて、内部で包んでいるオブジェクトがすでに freeze 可能な場合は、それらラッパー自体も freeze するようにした PRです。これにより、モデルのデフォルト属性が Ractor 間で共有されやすくなり、attribute_types取得やレコード生成・読み込みのパフォーマンスが改善されます。
- 変更内容の詳細
背景: schema context と Ractor-shareable なデフォルト属性
Rails では、モデルのデフォルト属性(_default_attributes)は「Ractor で共有可能 (Ractor-shareable)」な場合にだけ schema context を通じて共有されます。
共有できない場合は、各 Ractor ローカルなストレージから attribute_types / attributes_builder / _default_attributes を都度引くパスを通るため、処理が重くなります。
問題は以下の2つです。
time_zone_aware_attributesによって datetime カラムに付くTimeZoneConverter- enum に使われる
EnumType
が「常に freeze されていない」ため、ほとんどのモデルのデフォルト属性が Ractor-shareable にならず、結果として Ractor ローカル lookup パスを通ってしまっていた点です。
Rails 本体では、カラム型(type_map が返す型オブジェクト)はすでに freeze 済み (#54228, #55606) ですが、
それを包む TimeZoneConverter / EnumType が non-frozen だったため、最終的に shareable になりきれていませんでした。
変更1: TimeZoneConverter を必要に応じて freeze
対象ファイル:activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb (+4/-1)
主なポイント:
TimeZoneConverterが wrap している「サブタイプ」がすでに frozen で Ractor-shareable な場合は、TimeZoneConverterインスタンス自体も freeze するように変更。- ただし サブタイプが frozen でない場合は freeze しない。
理由:Delegatorを freeze すると、その中身(ラップ対象)も一緒に freeze されてしまうため、意図せずミュータブルな型オブジェクトを凍結することになるのを避けるため。 TimeZoneConverterのサブクラスは freeze 対象外。
サブクラスが独自の状態を持つ可能性があり、それを一律で freeze すると破壊的な影響が出るため。
結果として、標準的な Rails アプリで使われる「型マップが返す frozen な型 + デフォルトの TimeZoneConverter」というケースでは、TimeZoneConverter も frozen になり、
その属性型が Ractor-shareable として扱えるようになります。
テスト追加:activerecord/test/cases/attribute_methods/time_zone_converter_test.rb (+31)
- frozen な subtype を包んだときに converter 自体も frozen されること
- unfrozen subtype やサブクラスを使った場合に挙動が変わらないこと
などを確認するテストが追加されています。
変更2: EnumType を必要に応じて freeze
対象ファイル:activerecord/lib/active_record/enum.rb (+2/-1)
主なポイント:
EnumTypeの内部で持つ- enum の mapping(
{ "draft" => 0, "published" => 1 }のようなハッシュ) - 下層の subtype(おそらく整数型や文字列型など) の両方が frozen である場合に、
EnumType自体も freeze するように変更。
- enum の mapping(
サンプルイメージ(概念的なもの):
mapping = { "draft" => 0, "published" => 1 }.freeze
subtype = ActiveRecord::Type::Integer.new.freeze
enum_type = ActiveRecord::Enum::EnumType.new(mapping, subtype)
enum_type.frozen? # => true (この PR 後)- mapping か subtype のどちらか一方でも unfrozen であれば、
EnumTypeは freeze しません。 - こちらも同様に、freeze により Ractor-shareable になり、schema context で共有されやすくなります。
テスト追加:activerecord/test/cases/enum_test.rb (+10)
- mapping と subtype が frozen のときに
EnumTypeが frozen になること - 条件を満たさないときは frozen にならないこと
などの挙動がテストで確認されています。
パフォーマンス面での効果
PR 説明に挙がっている代表的な数字(Ruby 4.0.7):
Post.attribute_types- インタプリタ: 2,790 → 1,672 命令 (−40%), 295 → 144 ns
- YJIT: 1,411 → 781 命令 (−45%), 128 → 94 ns
Post.new- インタプリタ: 25,905 → 23,679 命令 (−8.6%), 3,737 → 3,348 ns
- YJIT: 8,950 → 7,688 命令 (−14%)
- 10 行ロード
- インタプリタ: 298,287 → 285,985 命令 (−4.1%), 42.0 → 38.7 µs
- YJIT: 135,330 → 128,709 命令 (−4.9%), 20.5 → 19.0 µs
wall time はノイズが大きいものの、命令数ベースでは確実に削減できていると判断されています。
ベンチマーク全体としても:
- ruby-bench: インタプリタで 0.13〜0.23% 命令数削減
- ZJIT では
attribute_types,Post.newが 7〜10% 命令数削減 - 実アプリベンチ(lobsters, railsbench, shipit など)でも、ほぼ全体として命令数減少
- 影響範囲・注意点
影響範囲
- すべての
time_zone_aware_attributesを持つ datetime カラム- デフォルト設定の Rails アプリならほぼ全モデルが対象
- すべての
enum定義
に対して、内部の型オブジェクトが freeze 可能な範囲で、より積極的に freeze されるようになります。
PR 作者による調査では、11 個の OSS アプリで 312 モデル中 294 モデルが schema-context 読み時に Ractor ローカルパスを通っており、この変更で 223 モデルが共有パスに移動したとのことです。
互換性上の注意点
- 通常利用 (
enum/datetime/time_zone_aware_attributesを素直に使う) の範囲では、freeze の有無を意識する必要はほぼありません。 - ただし、以下のようなケースでは注意が必要です:
TimeZoneConverter/EnumType/ それらの subtype を直接触り、後からミューテーションするようなメタプログラミングをしている場合TimeZoneConverterのサブクラスで独自の state を持ち、それを runtime に書き換えようとしている場合- サブクラス自体は freeze されないよう配慮されていますが、他の型オブジェクトを巻き込んで freeze しないようにする判断がなされています。
- Rails が返す type object を「ミュータブルな設定オブジェクト」として後から書き換えているコードがあると、
以前から freeze によって壊れる可能性は増えていました(#54228, #55606 を含めた方針として「型オブジェクトは基本的に immutable」になってきている)ため、
その流れがTimeZoneConverter/EnumTypeにも及んだと理解しておくとよいです。
ZJIT 特有の注意点
- ZJIT では、一部のモデルだけがなお Ractor ローカルパスに残り、他の多くのモデルが共有パスに移るような状況で、
- その「置き去りモデル」の
attribute_types読みなどが 1 回あたり最大 +4.6% 命令増になるケースがあったとの報告があります(ZJIT がその分岐をうまく profile しないため)。
- その「置き去りモデル」の
- ただし、アプリ全体の命令数としては依然としてマイナスになっており(railsbench, lobsters など)、
アプリ全体としてはほぼ確実にプラス とみてよい変更です。
- 参考情報 (あれば)
- PR 本文中の調査・ベンチ:
- 型オブジェクト freeze 化の関連 PR:
- #54228
- #55606
この PR の実質的なメッセージは「Active Record の型まわりをさらに immutability / Ractor-shareable 化して、schema context をちゃんと活かす」というもので、
enum や datetime を多用する普通のアプリでも、静的な設定を保ったまま自動的に小さな性能改善が見込める変更です。
#59588 Fix flaky ServerTest#test_restart_rails_server_with_custom_pid_file_path
マージ日: 2026/11/4 | 作成者: @yahonda
- 概要 (1-2文で)
ServerTest#test_restart_rails_server_with_custom_pid_file_pathが CI でときどき失敗していた問題を、待ち条件を変更することで解消した PR です。Puma の起動ログのどのタイミングを待つかを見直し、サーバ再起動テストのフレークを減らしています。
- 変更内容の詳細
対象テスト:railties/test/application/server_test.rb の ServerTest#test_restart_rails_server_with_custom_pid_file_path
やっていることは 1 行の変更で、Rails サーバ起動時に どのログが出るまで待つか を変えています。
元の挙動(問題点):
- テストは、Rails サーバを起動し、Puma のログに
"Listening"が出たら、すぐにrails restartを叩いていました。 - Puma の挙動概要:
- バインド処理中に
"Listening"をログ出力 tmp_restartプラグインのポーリングスレッドを起動- サーバを生成して実際にリクエスト受付を開始
- バインド処理中に
tmp_restartプラグインは:- 2秒間隔でファイル変更を監視し、
- 変更を一度検知すると
restartを試みたあとポーリングを止める
このため、次のようなレースコンディションが発生していました:
"Listening"は出たが、まだサーバ本体は走っていない状態tmp_restartが「再起動すべき変更」を検知 →restartを呼ぶが、サーバがまだ実行されていないので何も起こらない- ポーリングは停止するので、以後変更があっても再起動されない
- テストが
rails restartを実行しても、再起動が行われず"Restarting"ログが出ない - その結果、テストが
"Restarting"を待ち続けて失敗(flaky failure)
変更後の挙動:
"Listening"ではなく、"Use Ctrl-C to stop"がログに出るまで待ってからrails restartを実行するように修正しています。"Use Ctrl-C to stop"はサーバ本体が起動し、実際にリクエストを処理できる状態になってから出力されるログです。- これにより:
tmp_restartプラグインが監視・検知・再起動を行う前提となる「サーバが実行中である」という条件が満たされたあとに、テスト側からrails restartを実行できる- プラグインが「空振りリスタート」してポーリングを止めてしまうタイミングと、テストからの
rails restart実行タイミングがかぶる可能性を下げられる
コードイメージ(擬似差分):
# 変更前(イメージ)
wait_for_server_output("Listening")
run_rails_command("restart")
# 変更後(イメージ)
wait_for_server_output("Use Ctrl-C to stop")
run_rails_command("restart")実際の差分は 1 行の文字列変更のみです。
- 影響範囲・注意点
- 影響範囲:
- Rails 本体のランタイム挙動や Puma の挙動には一切影響せず、テストコードのみの変更です。
ServerTest#test_restart_rails_server_with_custom_pid_file_pathの安定性が向上し、CI のフレークが減ります。
- 注意点:
"Use Ctrl-C to stop"というログ文言に依存しているため、将来 Puma 側や Rails のサーバ起動メッセージが変更された場合、このテストが再度壊れる可能性があります。- ログベースの同期(待ち)は本質的に brittle なので、今後テストの設計を見直す際には、より構造化されたシグナル(ポートオープン確認など)での待機に置き換える余地があります。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59588
- 失敗ログ例 (CI):
- 関連知識:
- Puma
tmp_restartプラグインは、ファイル変更検知 → 一度だけ再起動を試みる、というシンプルなポーリングベースの仕組みであるため、今回のような「サーバがまだ動いていない」状態との競合に注意が必要です。
- Puma
#59583 Read the through foreign key without wrapping it in an Array
マージ日: 2026/11/4 | 作成者: @thomasklemm
- 概要 (1-2文で)
has_one :through/has_many :throughがbelongs_toを経由する関連を読み出す際のstale_state計算を最適化し、単一カラムの外部キー読み出しで不要なArray生成などを避けてオブジェクト数・命令数を削減する PR です。挙動(いつ関連をリロードするか)は変えずに、Ruby インタプリタ / YJIT / ZJIT すべてで実行コストを大きく削減しています。
- 変更内容の詳細
背景: stale_state とは何か
has_one :through / has_many :through が「既にロード済みの関連が古くなっていないか」を判定するために、所有者(owner)の外部キー値を stale_state としてキャッシュしています。
- owner 側の外部キーが変化したら
stale_stateが変わる → 関連を再ロード - 変化していなければ → 既存の関連オブジェクトを再利用
このとき、belongs_to の外部キーを読む実装が、単一カラムの場合でも配列を経由する形になっており、オブジェクト生成やメソッド呼び出しが多くなっていました。
従来のイメージ(単一カラムキーでもだいたいこんな感じ):
# 疑似コード
cols = Array(reflection.foreign_key) # ["post_id"]
values = cols.map { |col| owner.read_attr(col) } # [123]
state = values.presence # [123] or nilここで、
Array(...)→ 新しい配列を生成map→ また別の配列を生成presence→nil/[]判定のために呼び出し
というコストが、belongs_to ベースの has_one :through / has_many :through アクセスごとに発生していました。
今回の変更点
「外部キーがプレーンな String 1カラムの場合」に限って、より軽量な読み方をするようにしたのがこの PR です。
新しい分岐の方針
- 外部キーが:
- 単一カラム & キー名がプレーンな
String→ 新しい軽量パスを使う - 複合キー or
Stringのサブクラス → 既存ロジックをそのまま使う
- 単一カラム & キー名がプレーンな
つまり、挙動が変わるのは「ごく一般的な belongs_to :post → post_id(String)1カラム」のパターンだけで、Composite keys 対応や特殊なキー名型は安全のため従来通り。
単一カラム String キーの新実装(概念図)
# 旧:
cols = Array(foreign_key) # ["post_id"]
values = cols.map { |col| owner.read_attr(col) } # [123]
state = values.presence # [123] or nil
# 新:
value = owner.read_attr(foreign_key) # 123 or nil/false
state =
if value.nil? || value == false
nil # 従来通り「キーが nil/false なら stale_state は nil」
else
[value] # 単一要素の配列で表現
endポイント:
- 読み出し (
read_attr) は 1回だけ(以前も結果的に1回だったので変わらない) - キーが
nil/falseのときは 従来通りstale_stateはnilになる
→ 「外部キーが未設定なら関連を持たない」という意味合いは一切変わらない - それ以外では
[value]という単一要素配列で従来と同じフォーマットを維持
→stale_stateを使う他のコードへの互換性を保っている
つまり、「計算手順だけ軽くしたが、stale_state の値の意味・再ロード判定の条件は変えていない」という実装です。
追加テスト
has_one_through_associations_test.rb にテストが追加されています。目的は:
- 単一カラムの外部キーで、
nil/false/ 有効値それぞれについて:stale_stateがこれまで通りの値になる- 外部キー変更時に関連が再ロードされること
- 新しい実装によって「キャッシュが壊れる / リロードされない」等が起きないことの検証
テストレベルで「挙動非互換は起きていない」ことを担保しています。
- 影響範囲・注意点
影響範囲
- 対象:
has_one :through/has_many :throughが- 中間に
belongs_toを持ち - その
belongs_toの外部キーが「単一カラムのプレーンなString」である場合
- 実質的には「普通の
belongs_to外部キー(例:user_id)」を通じたhas_one :through/has_many :throughがほぼ全部対象になります。
期待される効果
PR 説明にある計測結果(comment.post_author のようなアクセスを 5,000 回):
| パターン | Array オブジェクト数 | 命令数(インタプリタ) | YJIT 命令数 | ZJIT 命令数 |
|---|---|---|---|---|
| main | 3 | 15,447 | 8,283 | 7,879 |
| この PR | 1 | 12,076 (−22%) | 4,983 (−40%) | 4,571 (−42%) |
- 1呼び出しあたりのオブジェクト数 (Array) が 3 → 1 個に削減
- 命令数が:
- インタプリタ: 約 22% 減
- YJIT: 約 40% 減
- ZJIT: 約 42% 減
Lowrail による 20 アプリのプロファイルでは、5 つのアプリ・16 ケースのリクエスト / ジョブ / メーラーで、1 リクエストあたり数百オブジェクト単位の削減が報告されています(例: Discourse のトピックリスト 1 リクエストで約 250 オブジェクト削減)。
大量の関連を読む画面(N+1 をきちんと避けていても「キャッシュを読む回数」が多いケース)では、GC 負荷と CPU 命令数がわずかに下がることが期待されます。
互換性・注意点
- API の挙動変更はなし
has_one :through/has_many :throughのリロード条件は変わらないstale_stateがnilか否か、配列の形式などは従来と同じ意味
- 特殊なケースへの配慮:
- 複合キー(外部キーが配列など)
Stringのサブクラスをキー名に使っているようなメタなコード
これらは旧実装パスに残されているため、今回の最適化の影響を受けません。
そのため、この PR によってアプリケーションコード側で何か修正が必要になるケースは基本的にありません。
- 参考情報 (あれば)
PR 本文で参照されているリソース:
- ベンチマークスクリプト:
https://github.com/thomasklemm/lowrail/blob/main/bench/upstream/r7/cpu_r7.rb - 20 OSS アプリでのアロケーション調査(census):
https://github.com/thomasklemm/lowrail/blob/main/docs/upstream/rails-r7-queue.md
- ベンチマークスクリプト:
実務的なポイント:
has_one :through/has_many :throughを多用している大規模アプリでは、Rails をこのバージョン以降に上げるだけで微小な性能改善(CPU・GC の両面)が見込める変更です。- 既存アプリでこのあたりの関連キャッシュ挙動に依存したハックをしていない限り、マイグレーション時の互換性問題はほぼ考えなくてよい内容です。
#59411 Retry ActiveRecord::SerializationFailure errors on safely-retryable queries
マージ日: 2026/11/3 | 作成者: @adrianna-chang-shopify
- 概要 (1-2文で)
ActiveRecord::SerializationFailure(主にDBのトランザクション分離レベルに起因するシリアライゼーションエラー)発生時に、「安全にリトライできるクエリ」であれば自動リトライするようにしたPRです。すでに行っているActiveRecord::Deadlockedの自動リトライと同じ扱いを、トランザクション外のシリアライゼーションエラーにも適用します。
- 変更内容の詳細
背景・モチベーション
- シリアライゼーションエラー(
SerializationFailure)は、発生したステートメントを含むトランザクション全体をロールバックします。 - ただし、そのステートメントが「トランザクション外」(autocommitモード)で実行されていた場合、接続の状態は正常なままで、その同じステートメントを同じ接続上で安全に再実行できます。
- これは既に
ActiveRecord::Deadlockedに対して行っている戦略(一定回数まで自動リトライ)と同じであり、同様にSerializationFailureも扱える、という判断です。
実際の変更点
1) retryable_query_error? に SerializationError を追加
activerecord/lib/active_record/connection_adapters/abstract_adapter.rb で、クエリエラーが「リトライ可能かどうか」を判定する retryable_query_error? に SerializationError を含めました。
イメージとしては以下のような変更です(簡略化):
def retryable_query_error?(error)
case error
when Deadlocked,
SerializationFailure # ← これが追加された
true
else
false
end
endこれにより、ActiveRecordが内部でクエリを実行する際に、SerializationFailure が起きた場合も「リトライ対象」と見なされます。
2) トランザクション中のステートメントはリトライしない
重要なのは、「トランザクション中のクエリは相変わらずリトライしない」という点です。
説明文にある通り:
- 多くのアダプタでは
savepoint_errors_invalidate_transactions?がtrueの場合、シリアライゼーションエラーが発生するとトランザクション全体が無効化されます。 - PostgreSQLアダプタはさらに厳密で、接続が「アボートされたトランザクション状態」である場合には
retryable_query_error?自体がfalseを返すようにしています。
PostgreSQLアダプタ側では #retryable_query_error がオーバーライドされていて、概ね以下のようなロジックになっています(PR説明文より):
def retryable_query_error?(error)
in_transaction = begin
@raw_connection&.transaction_status == ::PG::PQTRANS_INERROR
rescue PG::ConnectionBad
false
end
# トランザクションエラー状態ではリトライしない
!in_transaction && super
endこれによって:
- トランザクション外:
SerializationFailureはDeadlockedと同様に自動リトライ対象 - トランザクション内(
PQTRANS_INERROR): 自動リトライしない(アプリ側でトランザクション全体をリトライすべき)
という挙動が保証されます。
3) テストの追加
主なテスト追加:
activerecord/test/cases/adapter_test.rb- 抽象アダプタレベルで、
SerializationFailureがリトライ可能として扱われることを確認するテストを追加。 - 既存の
Deadlockedと同様のパターンで、リトライロジックが意図通り動作するかを検証。
- 抽象アダプタレベルで、
activerecord/test/cases/adapters/postgresql/transaction_test.rb- PostgreSQLアダプタにおいて、トランザクション中に
SerializationFailureが発生した場合にはリトライされないことを確認するテストを追加。 - 接続の
transaction_statusがPQTRANS_INERRORの時にretryable_query_error?がfalseになることを検証。
- PostgreSQLアダプタにおいて、トランザクション中に
4) CHANGELOGの更新
activerecord/CHANGELOG.mdに、この挙動変更が記載されました。- これにより、ユーザー視点でも「
SerializationFailureが特定条件で自動リトライされる」という仕様変更が明示されています。
- 影響範囲・注意点
影響範囲
対象:
ActiveRecordを利用していて、- トランザクション外(autocommit)のクエリで、
- DBのトランザクション分離レベルなどにより
ActiveRecord::SerializationFailureが発生しうるアプリケーション
これらのケースでは、今後は
ActiveRecord::Deadlockedと同様に、ActiveRecord側で自動的に一定回数(アダプタ実装依存)リトライされる可能性があります。
期待されるメリット
- 一時的な競合により発生した
SerializationFailureに対して、アプリケーションコードを書き換えずに耐障害性が向上します。 - これまで
Deadlockedのみ自動リトライに頼っていたコードでも、同種の一時的エラーであるSerializationFailureを吸収できるようになります。
注意点
トランザクション内ではリトライされない
- トランザクションを手動で張っているコード(
transaction do ... endなど)でのSerializationFailureは、これまで通りエラーとしてアプリケーション側に返されます。 - その場合は、アプリケーション側で「トランザクション全体をリトライする」ロジックを実装する必要があります。
- トランザクションを手動で張っているコード(
idempotent(副作用が安全)なクエリ設計が前提
- 自動リトライの対象になるクエリは、再実行しても整合性が崩れないように設計する必要があります。
- 通常、単純なSELECTや、同じ結果になるように設計されたUPDATE/INSERT(UPSERT的な操作など)は問題になりにくいですが、副作用のある処理や外部サービス連携がクエリ実行と密に結合している場合は設計を見直す必要があります。
パフォーマンス・レイテンシ
- 競合が頻発する環境では、シリアライゼーションエラー → リトライ → 再度エラー…という形でレイテンシが伸びる可能性があります。
- ただしこれは既存の
Deadlockedリトライと同様の懸念であり、SerializationFailureが追加されたことでその対象が少し広がるイメージです。
- 参考情報 (あれば)
- エラー種別:
ActiveRecord::Deadlocked: DBロック競合由来のデッドロックActiveRecord::SerializationFailure(DBアダプタによって名前は多少異なる場合あり): 高い分離レベル(例:SERIALIZABLE)での並行トランザクション競合により、トランザクションが完了できない場合に発生
- PostgreSQLにおける
transaction_status:PG::PQTRANS_IDLE: トランザクション外PG::PQTRANS_INTRANS: トランザクション中PG::PQTRANS_INERROR: アボートされたトランザクション(以降のクエリはROLLBACKするまでエラー)
- 「
savepoint_errors_invalidate_transactions?」がtrueなアダプタでは、セーブポイント内のエラーがトランザクション全体を無効化するので、部分的なリトライより「トランザクション全体のやり直し」が必要になります。
#59586 Fix in_order_of modifying the receiver relation
マージ日: 2026/11/3 | 作成者: @kamipo
- 概要 (1-2文で)
in_order_ofが既存の Relation(特に既にロード済みのもの)を不正に破壊的変更してしまい、ActiveRecord::UnmodifiableRelationを発生させていたバグを修正した PR です。
他のクエリメソッドと同様に、in_order_ofの処理を spawn 済みの Relation 上で行うことで、元の Relation を変更しないようにしています。
- 変更内容の詳細
問題点
in_order_of が実装上、spawn する前に self に対してカラム解決を行っていたため、
column_referencesorder_column(内部でarel_column_with_tableを経由)
といった処理が、受け取り側の Relation (self) に対して references_values を追加してしまう状態になっていました。
この結果、以下のようなケースで例外が発生します:
posts = Post.all.load # ここで Relation がロード済みになる
posts.in_order_of("posts.id", [3, 4, 1])
# => ActiveRecord::UnmodifiableRelation が発生load 済みの Relation は基本的に「不変(変更不可)」として扱われるため、後から references_values などを変更しようとすると ActiveRecord::UnmodifiableRelation が投げられます。
しかし従来の in_order_of 実装では、この不変 Relation 自体を直接いじってしまっていた、というのがバグの本質です。
修正方針
他のクエリメソッド(where, order, limit など)と同様に、まず Relation を spawn し、その spawn した Relation に対してのみ変更を加える形にそろえています。
Rails では慣例的に:
- 非破壊的メソッド本体(例:
in_order_of)
→ 内部でspawnしてから*_in_order_of!のような破壊的実体メソッドを呼ぶ構造になっていることが多いです。
今回の変更により、_in_order_of! で行う処理が spawn された Relation 上でのみ実行されるようになりました。
修正の要点:
in_order_ofがself(元の Relation)に対してカラム解決やreferences_values追加を行わないように変更_in_order_of!の処理対象を、spawnによって複製された Relation に限定- テスト (
field_ordered_values_test) に、ロード済み Relation に対してin_order_ofを呼び出しても例外が出ないことを検証するケースを追加
(疑似的なイメージ)
# 修正後のイメージ(概念)
def in_order_of(column, values)
spawn._in_order_of!(column, values)
end
def _in_order_of!(column, values)
# ここでカラム解決 / references_values の追加などを行う
endCHANGELOG
activerecord/CHANGELOG.md に、in_order_of が受け手の Relation を変更していた問題を修正した旨が追記されています。
Rails のバージョンアップ時に、この挙動修正が含まれていることが明示されます。
- 影響範囲・注意点
既存コードの挙動への影響
- 正常系でのクエリ結果(返ってくるレコードの順序)は変わりません。
- 過去に「ロード済み Relation に対して
in_order_ofを呼ぶと例外が出る」状態だった箇所は、例外が出なくなり、意図した順序付けが行われるようになります。 - これまで「バグを前提にしたワークアラウンド」(例: わざと
load前にin_order_ofを呼ぶ、to_aに逃がす等)を書いていた場合、そのような回避コードは不要になります。
パフォーマンスやクエリ内容
spawnを使うのは他のクエリメソッドと同じパターンで、追加コストは他のメソッドと同程度です。- 生成される SQL の構造や
ORDER BYの内容に大きな変更はありません(目的は副作用の排除のみ)。
Relation の不変性モデルとの一貫性
- これにより、
in_order_ofも他のクエリビルダメソッド同様、「元の Relation を変更せずに、新しい Relation を返す」という Rails 的な一貫した挙動になります。 - 「ロード済み Relation は変更不可」というルールに完全に従うようになりました。
- これにより、
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59586
- 関連するクラス/メソッド:
ActiveRecord::Relation#in_order_ofActiveRecord::Relation#spawnActiveRecord::UnmodifiableRelation
- テスト:
activerecord/test/cases/relation/field_ordered_values_test.rbにin_order_of用の追加テストあり
#59580 Fix merge dropping RECURSIVE from a relation built with with_recursive
マージ日: 2026/11/3 | 作成者: @kamipo
- 概要 (1-2文で)
with_recursiveを使った ActiveRecord::Relation をmergeすると、生成される SQL からRECURSIVEが抜け落ちるバグを修正した PRです。with_recursiveのフラグ管理を通常の relation 値として扱うように変更し、merge・spawn・only・orなどの演算で正しく伝播するようになりました。
- 変更内容の詳細
問題の内容
これまで with_recursive は内部的に以下のような状態になっていました。
- CTE 自体は
@values[:with]に格納 - 「これは再帰的 CTE である」というフラグは
@with_is_recursiveというインスタンス変数で別管理
一方、Relation::Merger は @values だけを見てマージする実装のため、merge 時に CTE 本体は引き継がれるものの、「再帰的である」というフラグは失われていました。
そのため、例えば:
Post.all.merge(Post.with_recursive(r: Post.where(id: 1))).to_sql
# => "WITH \"r\" AS (...) SELECT ..." # 本来必要な "RECURSIVE" が消えるという不正な SQL が生成されていました。
修正方針
with_recursiveの「再帰フラグ」を@with_is_recursiveのような別変数ではなく、@valuesに格納される「通常の single value」として管理するように変更。- これにより、
spawn・merge・only・orなど、@valuesに基づいて動くメソッドが、with_recursiveフラグも他の値と同様に扱えるようになりました。
推測されるイメージとしては、以下のような状態になっています(実装イメージ):
# 従来 (イメージ)
@values[:with] #=> CTE の一覧
@with_is_recursive #=> true / false
# 変更後 (イメージ)
@values[:with] #=> CTE の一覧
@values[:with_recursive] #=> true / false (single value として管理)各メソッドに対する扱いの変更点
spawn,merge,only,or- これらは
@valuesをベースに relation をコピー・合成する仕組みなので、with_recursiveも他の値同様に自動的に伝播するように。
- これらは
except(:with),only(:with),unscope(:with)withに対する操作をするときは、with_recursiveも「with の一部」としてまとめて扱うように変更。- 例:
relation.except(:with)を呼ぶと、CTE だけでなくRECURSIVEフラグもリセットされる。
or- 片方の relation にだけ再帰 CTE があるような場合には、「マルチバリューの扱いと同じく」緩める(relax)ようなロジックが入っています。
- つまり
rel1.or(rel2)でrel1だけが再帰的 CTE を持っている時、全体でどうRECURSIVEを付与するかについて、安全側に倒す形で調整されています(PR 説明上の挙動)。
テストの追加・修正
activerecord/test/cases/relation/with_test.rbにwith_recursive周りのテストが 41 行追加され、mergeなどでRECURSIVEが落ちないことが確認されるようになりました。relation/mutation_test.rbにもテストを追加し、spawn等による relation の複製・変異操作でwith_recursiveが正しく保持されることを検証しています。
- 影響範囲・注意点
- 影響を受けるのは、PostgreSQL の WITH RECURSIVE を ActiveRecord の
with_recursiveで使っているコードです。 - これまで:
mergeやorを挟むと、生成される SQL からRECURSIVEが抜け落ち、意図通りの再帰クエリになっていなかった可能性があります。
- この PR 適用後:
merge,spawn,only,orを経由しても、RECURSIVEが正しく付与されるようになります。- 逆に言うと、「今までたまたま RECURSIVE が外れていたせいで動いていたバグ」が表面化する可能性はあります(ただし本来の仕様からすれば今回の挙動が正しい)。
except(:with),only(:with),unscope(:with)がwith_recursiveフラグも同時に消すようになったため、CTE だけでなくRECURSIVEの有無にも依存しているようなメタ的な処理を書いていた場合は挙動を確認した方がよいです(通常のアプリではまず問題にならないレベル)。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59580
- 関連クラス/モジュール:
ActiveRecord::RelationActiveRecord::Relation::MergerActiveRecord::QueryMethods(with,with_recursiveなど)ActiveRecord::SpawnMethods
- 実際に影響があるかを確認するには、
with_recursiveを使った relation に対してmerge/orを使ったクエリの.to_sqlを、PR 適用前後で比較するとわかりやすいです。
#59562 Don't slice the event name on every ActiveSupport::Subscriber#call
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
ActiveSupport::Subscriber がイベントを受け取るたびに行っていた文字列スライスをやめ、あらかじめ計算したハンドラ名マッピングを使うことで、オブジェクト生成を削減しパフォーマンスを改善する変更です。通常の「attach_toでクラスを紐付けるタイプ」のサブスクライバで特に恩恵があり、既存のインターフェースは維持されています。
- 変更内容の詳細
これまでの挙動
ActiveSupport::Subscriber#call は通知イベントを受け取ると、event.name(例: "sql.active_record")から「最初のドットまで」を取り出して、
対応するインスタンスメソッド(ハンドラ)名を決めていました。
擬似コードで表すとイメージはこんな感じです:
def call(event)
# "sql.active_record" → "sql"
handler_name = event.name.slice(0, event.name.index("."))
# "sql" メソッドが定義されていればそれを呼ぶ
send(handler_name, event) if respond_to?(handler_name, true)
end問題は、この slice によって「イベント 1 回ごとに新たな String オブジェクト」が生成される点です。
ログ用/structured events 用など、同じイベントが多数の subscriber に配信されるケースでは「イベント数 × サブスクライバ数」分だけ短命オブジェクトが発生します。
この PR の変更点
この PR では「どのイベント名パターンがどのハンドラメソッドに紐づくか」を、
サブスクライバクラスが attach される段階であらかじめ計算・保存するように変えています。
1. クラスレベルでのハンドラマップを導入
Subscriber クラスに、以下のような「イベントパターン → ハンドラ名」の Hash を持たせます(実装は :nodoc: なクラスリーダーで公開):
# イメージ:実際のコードは多少異なる可能性あり
class ActiveSupport::Subscriber
class << self
# :nodoc:
attr_reader :handler_names
end
endこの handler_names は:
- キー:
"sql","render_template"など「ドット前の部分」 - 値:
:sql,:render_templateといったメソッドシンボル
という形の 凍結 (.freeze) された Hash として保持されます。
2. attach_to/detach_from でマップを更新
attach_to / detach_from が呼ばれるタイミングで、この handler_names Hash を組み立て / 差し替えます。
- attach される際に、その subscriber クラスにあるハンドラメソッドを調べ、
- 例えば
"sql.active_record"に対応する"sql"→:sqlをマップに登録
- 例えば
- 複数のイベントパターンがあればそれぞれ登録
detach_from時には対象に応じてこのマップを更新
これにより、「どのイベント名(パターン)がどのメソッドを呼ぶか」があらかじめ決まっているので、call 内で毎回 String#slice を行う必要がなくなります。
3. call の実装変更
call は、受け取った event.name から「ドット前」をスライスする代わりに、
そのまま handler_names から lookup します。
擬似コードで表すと:
def call(event)
# "sql.active_record" / "sql.active_record.custom" などを
# attach 時に用意した handler_names で解決
handler = self.class.handler_names[event.name] ||
self.class.handler_names[event.name.split(".").first]
send(handler, event) if handler
end※実装詳細は多少異なりますが、要点は「スライスして新しい文字列を作らないで、既存のマップを使った lookup に置き換えた」ことです。
4. 特別扱い: Regexp など「素の Subscriber」ケース
説明文にもある通り、以下のような「素の/特殊な subscription」 は従来のスライスロジックを維持します:
Regexpによるサブスクライブ- 個別にインスタンスを作って
subscribeしたもの(attach_toを通さない)
これらは attach 時にパターンを静的に解決できないため、
この PR の最適化対象外とされ、以前同様に String スライスが行われます。
5. API への影響
- 追加されたのは
:nodoc:のクラスリーダーだけ (handler_names的なもの) - 公開 API やインスタンス側から見える挙動は変わらない想定
- つまり、通常の利用者から見えるインターフェースはそのままです
- 影響範囲・注意点
影響範囲
- 主に影響するのは
ActiveSupport::NotificationsをベースにしたActiveSupport::Subscriberのサブクラスおよびそれをattach_toしているコードです。- 例: ActiveRecord の
"sql.active_record"、 ActionView の"render_template.action_view"など
- 例: ActiveRecord の
- Rails 本体のログ・計測系 subscriber に広く適用されるため、 Rails アプリ全般で恩恵があります。
ベンチマーク結果(PR 説明より):
Subscriber#call, per call | main | この PR |
|---|---|---|
| objects | 1 | 0 |
| ns (interpreter / YJIT / ZJIT) | 412 / 244 / 322 | 247 / 93 / 182 |
- 1 回の
callあたり 1 オブジェクト → 0 オブジェクト - 実行時間も interpreter / YJIT / ZJIT いずれも短縮
9 つの OSS アプリ・97 ワークロード中、75 ワークロードでオブジェクト数削減(中央値 −3、Fizzy では −29)と報告されています。
注意点
- 公開 API は変えていないため、通常の Rails アプリ開発者が意識して対応すべきことはほぼありません。
- ただし、以下のようなケースでは挙動差が出ないか気にしておく価値があります:
ActiveSupport::Subscriberを継承し、独自にattach_to/detach_fromをラップ・上書きしている場合- Subscriber クラスのメソッド探索ロジックに依存したメタプログラミングをしている場合
- PR 内で
:nodoc:と明示されているクラスリーダーに依存したコードを書くのは推奨されません- 将来の互換性が保証されないため
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59562
- 関連 PR(補完関係とされているもの): https://github.com/rails/rails/pull/58957
- こちらも ActiveSupport::Notifications/Subscriber 周りのパフォーマンス改善である可能性が高いです。
- ActiveSupport::Notifications/Subscriber のドキュメント:
#59576 Compile a single-predicate where clause without a nodes Array
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
where句に単一の述語しか無い場合に、内部で不要なArrayを生成しないようにして、Active Record のクエリコンパイル時のオブジェクト生成数を削減する最適化です。クエリの意味や生成される SQL/AST は変えずに、メモリ効率とわずかなパフォーマンス向上を狙った変更です。
- 変更内容の詳細
背景: これまでの挙動
ActiveRecord::Relation::WhereClause#ast は、where(...) で指定された述語(predicates)を AST ノードの配列として組み立てています。
内部的には:
- 述語ごとに AST ノードを生成
- それらを新しい
Arrayに詰める - 述語が 1 個しかない場合は「配列の先頭要素だけ」を返す
という流れになっており、例えば:
Post.where(id: 1)のように述語が1つしかないケースでも、毎回一旦 Array を作ってから array.first だけを返していました。この配列は最終的に使われないにもかかわらず、オブジェクトとしては毎回確保されてしまいます。
今回の変更点
- 述語が 1つだけ の
WhereClauseかつ、 - その述語が 空文字列ではない(
""ではない)
という条件を満たす場合には、配列を作らず、その単一述語だけを直接ラップして返すように実装を変更しています。
それ以外のケース:
- 述語が複数個ある
- 述語が1つだが、それが空文字列である(
where("")のようなケース)
これらは従来通りのコードパス(配列を作る処理)を通すようにしてあり、AST の構造や動作の互換性を維持しています。
擬似コードイメージ
以前:
def ast
nodes = []
predicates.each do |predicate|
nodes << wrap_predicate(predicate)
end
nodes.one? ? nodes.first : nodes
end今回:
def ast
if predicates.one? && !empty_string_predicate?(predicates.first)
wrap_predicate(predicates.first) # Array を作らず直接返す
else
# 従来通り Array を作る
predicates.map { |p| wrap_predicate(p) }.yield_self { |nodes|
nodes.one? ? nodes.first : nodes
}
end
end※ 実際の実装はもう少し細かいですが、意図としてはこのような形です。
テスト
activerecord/test/cases/relation/where_clause_test.rb で新挙動に対するテストが追加されています。
主に以下を検証していると考えられます:
- 単一述語(非空文字列)の
whereで AST が期待通り構築されること - 複数述語や空文字列を含むケースでは従来通りの挙動であること
- 影響範囲・注意点
パフォーマンスへの影響
PR で示されている計測結果:
Post.where(id: 1).to_a- 変更前: 92 オブジェクト
- 変更後: 91 オブジェクト
author.posts.order(:id).to_a- 変更前: 158 オブジェクト
- 変更後: 157 オブジェクト
Rails main を使った 20 個の OSS アプリの 251 ワークロード(リクエスト/ジョブ/メール処理)では:
- 187 ワークロードでオブジェクト数が減少(中央値で -4 オブジェクト/ワークロード)
と、1クエリあたりではごく小さい削減ですが、回数が多いと積み重なるタイプの最適化です。
挙動面(互換性)
- AST の形そのもの(単一述語のときに何が返るか)は、意図としては「従来と同じ」となるように調整されています。
- 複数述語や空文字列述語を含む特殊ケースは、従来の処理経路を維持
- 外部から
WhereClause#astを直接叩いて独自の前提を置いているようなメタプログラミングをしていない限り、アプリケーションレベルでの挙動変更は基本的にありません。
注意が必要なケースとしては:
- ライブラリ/ミドルウェア等で
WhereClause#astの戻り値のクラス/内部構造を厳密に期待している場合- 例: 「単一述語でも必ず
Arrayが返るはず」という前提を置いているようなコードがあれば、挙動が変わる可能性があります
- 例: 「単一述語でも必ず
- 一般的なアプリケーションコードでは
WhereClause#astに触れることはまずないため、多くの利用者には影響ありません。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59576
- 対象クラス:
ActiveRecord::Relation::WhereClause - 主な目的: AST 組み立て時の不要な
Array生成を減らし、オブジェクト数と GC 負荷をわずかに削減するマイクロ最適化 - パフォーマンス系 PR として、類似の「1オブジェクト削減」レベルの変更は Rails コアでも継続的に行われており、これもその一環と考えられます。
#59579 Avoid extra array allocations when forwarding query method arguments
マージ日: 2026/11/3 | 作成者: @kamipo
- 概要 (1-2文で)
Rails のActiveRecord::Relationのクエリメソッド (order!,where!,having!など) が引数配列を二重に生成していた問題を解消し、不要な配列アロケーションを削減してパフォーマンスを改善する変更です。結果として、一般的なクエリチェーンでのオブジェクト生成数が減ります。
- 変更内容の詳細
背景となっていた問題
spawn.order!(*args) のように「メソッドに配列をスプラットで渡し、その受け側メソッドも *args で受ける」構造だと、Ruby は内部的に配列を再構築するため、余分な配列アロケーションが発生します。
例(イメージ):
def order!(*args)
# args はここで新しく配列として組み立てられる
end
spawn.order!(*args_from_caller)
# args_from_caller -> 新しい args 配列 にコピーされるこの PR は、この「二重配列アロケーション」を避けるのが主目的です。
_xxx! メソッドの追加とラッパー化
order! を直接 *args で実装するのをやめて、配列をそのまま受け取る「内部用メソッド」を追加し、order! 自体はラッパーにしています。
イメージコード:
# 外部インターフェイス(従来どおり *args を受ける)
def order!(*args) = _order!(args) # :nodoc:
# 内部実装(配列をそのまま受け取る)
def _order!(values) # values は Array 前提
# ここで配列 values を加工・利用する
end効果:
- 呼び出し側が既に配列を持っている場合に、その配列をそのまま
_order!に渡せる _order!の内部では*args展開が発生しないため、余計な配列生成が行われないorder!を外部 API としては互換維持しつつ、内部経路では効率の良い形で引数を伝搬できる
同様のパターンが他のクエリ系 bang メソッドにも導入されています(_where!, _having! など)。
where! / having! の引数処理の最適化
where! や having! にも配列アロケーション削減が入っています。
以前のイメージ
def where!(*rest)
# 引数がなくても rest = [] の新しい配列が毎回できる
end変更後のイメージ
FROZEN_EMPTY_ARRAY = [].freeze
def where!(predicates = nil, rest = FROZEN_EMPTY_ARRAY)
# 第二引数を省略した場合、毎回新しい [] を作らず
# 共有の FROZEN_EMPTY_ARRAY を参照する
endポイント:
- 引数が 0 個や 1 個のケースで、不要な空配列の生成をしない
FROZEN_EMPTY_ARRAYを使うことで、「読み取り専用の共有空配列」を再利用する
効果の例
PR 説明にあるサンプル:
Post.where(title: "x").order(:id).includes(:comments)
.joins(:comments).select(:id).limit(10)このクエリを一度実行するまでに生成されるオブジェクト数:
- 変更前: 52 allocations
- 変更後: 48 allocations
クエリビルド時の配列割り当てが削減されていることが分かります。
1 回あたりは小さな差ですが、リクエストあたり大量のクエリ生成が行われるアプリや、バックグラウンドジョブなどで効いてきます。
- 影響範囲・注意点
対象クラス/モジュール
ActiveRecord::Relation::QueryMethodsActiveRecord::Relation::Merger
公開 API の挙動は原則として非変更
order!,where!,having!などの public なインターフェイス (引数の渡し方・返り値) は変わっていない想定です。- パフォーマンス改善が目的であり、通常のアプリケーションコードから見た動作は互換になります。
影響が出る可能性のあるケース
ActiveRecord::Relationの内部実装 (特にQueryMethodsの bang メソッド) を monkey patch しているコードorder!,where!,having!に対して arity チェックやmethod(:order!).source_locationのようなメタプログラミングをしている場合- 内部用
_order!,_where!,_having!などに直接依存しているコード (本来非推奨)
この PR で新設された _xxx! メソッドは :nodoc: 付きでドキュメント非公開であり、将来の互換性は保証されません。
アプリ側から直接利用しない方が安全です。
- パフォーマンスの性質
- GC 負荷の軽減: 小さな短命オブジェクト (配列) が減ることで GC 回数・時間がわずかに改善される可能性があります。
- 一回一回は微小だが、トラフィックが多いアプリやバッチ処理で蓄積効果が出るタイプの最適化です。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59579
- 関連しそうなトピック:
- Ruby における
*args/ スプラット演算子と配列アロケーションのコスト - Rails 内部の frozen な空配列 (
FROZEN_EMPTY_ARRAY) パターンによるマイクロ最適化 ActiveRecord::Relationのクエリビルドチェーンの実装 (QueryMethods,Merger)
- Ruby における
#59571 Define parameterless scope methods for lambda bodies without parameters
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails のscope定義で、引数を取らない lambda を使っている場合に限り、そのスコープ用メソッドを「引数なしメソッド」として定義するようにして、余計な配列・ハッシュの生成を減らすパフォーマンス改善が行われました。挙動(引数を渡したときにArgumentErrorが出ることなど)は維持しつつ、1リクエストあたりのオブジェクト生成数を削減しています。
- 変更内容の詳細
これまでの挙動
class Post < ApplicationRecord
scope :published, -> { where(published: true) }
end上記のようなスコープ定義では、内部的に以下のようなメソッドが定義されていました(イメージ):
def self.published(*args, **kwargs)
scope_lambda.call(*args, **kwargs)
end- そのため 呼び出しごとに空の配列 (
[]) と空のハッシュ ({}) が割り当てられる。 Post.publishedもauthor.posts.published(関連経由の delegator)も同様。- 実際には多くのスコープは引数を取らない lambda で書かれているにもかかわらず、常に可変長引数用のオブジェクトが生成されていました。
今回の変更
「引数のない lambda をスコープ本体として渡した場合」に限り、定義されるスコープメソッドが「引数なし」になります。
具体的には:
- 対象:
-> { ... }のように パラメータを取らない lambda
- 対象外(従来通り
*args, **kwargsな汎用メソッドのまま):- 引数つき lambda(
->(n) { ... },->(**options) { ... }など) Proc(lambda ではないブロック)- その他の callable オブジェクト
- 引数つき lambda(
例
class Post < ApplicationRecord
# 今回の変更の対象
scope :published, -> { where(published: true) }
# 従来どおり (*args, **kwargs) なメソッドとして定義される
scope :min_n, ->(n) { where("n >= ?", n) }
scope :with_options, ->(**opts) { where(opts) }
scope :using_proc, Proc.new { where(active: true) }
endpublished スコープについては、内部的には以下のようなイメージになります:
# 変更前(イメージ)
def self.published(*args, **kwargs)
PUBLISHED_SCOPE.call(*args, **kwargs)
end
# 変更後(イメージ)
def self.published
PUBLISHED_SCOPE.call
endこれにより:
Post.published呼び出し時に余計な[]と{}が生成されない。author.posts.publishedのような関連経由の delegator からの呼び出しでも同様に削減される。- もし誤って
Post.published(1)といった呼び出しをした場合:- これまでは
lambda側が引数不一致でArgumentErrorを投げる。 - 今回の変更後も、メソッド自体は引数を取らないので Ruby が
ArgumentErrorを投げ、メッセージも含めて同等になるよう配慮されている(PR 説明より)。
- これまでは
arity / parameters の違い
PR で明示されている唯一の「見える違い」はメソッドシグネチャ関連です。
- 対象スコープ(引数なし lambda)のメソッド:
Post.method(:published).arityが0になる。Post.method(:published).parametersも空配列になる。
- それ以外のスコープは従来通り:
Post.method(:min_n).arityは可変長(-1)扱い。parametersに[:rest, :args],[:keyrest, :kwargs]などが残る。
オブジェクト数の削減効果(PR による計測)
| 呼び出し | 変更前 | 変更後 |
|---|---|---|
Post.published | 19 | 17 |
Post.where(n: 1).published | 20 | 18 |
Post.min_n(1) | 16 | 16 |
また、Rails チームが計測した 21 種類の構成のうち 20 でこの種のスコープ呼び出しがあり、そのうちの中央値では 1 リクエストあたり 13 オブジェクト減 という結果が出たとのことです。
- 影響範囲・注意点
- 大半のアプリでは互換性上の問題はほぼない想定
- 引数を取らない lambda を
scope本体にしているだけなら、挙動は同じです。 - 「引数を渡すと
ArgumentErrorになる」という性質も維持されます。
- 引数を取らない lambda を
- メソッドシグネチャに依存しているメタプログラミングは影響しうる
method(:published).arityやparametersを使って挙動を変えているコードがある場合、挙動が変わる可能性があります。
- Proc ベースや引数つき lambda のスコープは影響なし
- これらはこれまで通り
*args, **kwargsを取るメソッドとして定義されます。
- これらはこれまで通り
- パフォーマンス上の効果
- スコープを多用しているコードパス(特に関連経由でのチェイン)で、GC 負荷やオブジェクト生成数がわずかに減る可能性があります。
- 効果自体は 1 呼び出しあたり数オブジェクトの削減ですが、大規模トラフィック下では積み上がります。
挙動確認の観点で見ると:
- 以下を行うと差分を実感できます(コンソールなどで):
Post.method(:published).arityPost.method(:published).parameters
- 逆に、スコープを普通に呼んでいるだけのアプリケーションコードは、そのまま動作する想定です。
- 参考情報 (あれば)
- 対象ファイル:
activerecord/lib/active_record/scoping/named.rbscope定義時のメソッド生成ロジックに変更。
activerecord/test/cases/scoping/named_scoping_test.rb- 追加された振る舞い(引数なし lambda の扱い、arity/parameters 含む)を保証するテストが追加。
- PR 番号: #59571
- タイトル: Define parameterless scope methods for lambda bodies without parameters
- マージ日時: 2026-11-03T13:59:13Z
#59578 Fix flaky test_preload_has_many_through_with_source_type_ignores_other_types_in_loaded_through_records
マージ日: 2026/11/3 | 作成者: @yahonda
- 概要 (1-2文で)
PostgreSQLでときどき落ちていたEagerAssociationTest#test_preload_has_many_through_with_source_type_ignores_other_types_in_loaded_through_recordsを、レコードの順序に依存しないように修正したPRです。テストの本質である「型のフィルタリング」を確認しつつ、ORDER BY のないクエリに起因する順序の違いで失敗しないようにしています。
- 変更内容の詳細
問題となっていたテストは「has_many :through + source_type を使った関連の eager load が、別タイプのレコードを無視していること」を検証するものです。
失敗のパターンとしては、期待値と実際の値の“順序”だけが違っていました:
- [#<SpecialPost id: 2, ...>, #<Post id: 1, ...>]
+ #<ActiveRecord::Associations::CollectionProxy [#<Post id: 1, ...>, #<SpecialPost id: 2, ...>]>PR説明から分かるように:
期待値側は次のクエリで取得 (ORDER BY なし)
sqlSELECT "posts".* FROM "posts" INNER JOIN "taggings" ON "posts"."id" = "taggings"."taggable_id" WHERE "taggings"."tag_id" = $1 AND "taggings"."taggable_type" = $2実際値側はプリロード経由で取得 (どちらのクエリも ORDER BY なし)
sqlSELECT "taggings".* FROM "taggings" WHERE "taggings"."tag_id" = $1; SELECT "posts".* FROM "posts" WHERE "posts"."id" IN ($1, $2);
PostgreSQL では ORDER BY がない場合、実行計画や環境によって返却順序が変わる可能性があります。そのため、同じ集合でも「[SpecialPost, Post]」と「[Post, SpecialPost]」のように並びが変わり、テストがたまに失敗していました。
このPRでは、「テストが担保したいのは“他の型のレコードが含まれていないこと”であって、“順序”ではない」ことから、両者を id でソートした上で比較するように変更しています。
変更ファイル: activerecord/test/cases/associations/eager_test.rb (+2 / -2)
実際の修正イメージとしてはおおよそ次のような形になります (擬似コード):
# 変更前(順序も含めてそのまま比較)
assert_equal expected_records, post.tags
# 変更後(id でソートして集合比較)
assert_equal expected_records.sort_by(&:id), post.tags.sort_by(&:id)実際には、左辺(通常ロード)と右辺(プリロード済み関連・CollectionProxy)の両方を sort_by(&:id) する形で比較しています。
- 影響範囲・注意点
影響範囲
- 変更はテスト (
eager_test.rb) のみであり、本体の Active Record の動作・API には影響しません。 - CI 上での flakiness(ときどき落ちるテスト)を解消することが主目的です。
- 変更はテスト (
注意点
- このテストに限らず、ORDER BY を伴わないクエリ結果の順序に依存したテストは、DB 実装や実行計画の違いで不安定になりうるため、同様に「id等による明示的なソート」か「集合としての比較」にすべき、という示唆があります。
- 実運用コード側に ORDER BY が不要であるという前提 (順序を保証しない仕様) はこのPRで変わっていません。必要であればアプリケーション側・ライブラリ側で明示的に ORDER BY を付与する必要があります。
- 参考情報 (あれば)
- 問題のテスト:
EagerAssociationTest#test_preload_has_many_through_with_source_type_ignores_other_types_in_loaded_through_records - ビルド失敗ログ(PostgreSQLでのフレークテスト):
https://buildkite.com/rails/rails/builds/135932#01a19d83-c8b9-4b67-8cb6-3a6028679221 - テストを導入した元コミット:
9ae08c3ee0
#59575 Use count(:all) for one? and many?
マージ日: 2026/11/3 | 作成者: @kamipo
- 概要 (1-2文で)
ActiveRecord::Relation#one?/many?がselect句付きのクエリでActiveRecord::StatementInvalidを起こすケースがあり、それを内部でcount(:all)を使うようにして修正したPRです。レコード数判定のためのカウント処理を「SELECT リスト」ではなく「行数ベース」に統一し、distinct付きクエリでも正しく動くようにしています。
- 変更内容の詳細
問題の背景
元々 one? と many? は、内部的に limited_count → count を呼んで件数を判定していましたが、count の呼び出しが「引数なし」だったため、以下のような振る舞いになっていました。
countに引数なし:selectが指定されていない場合:COUNT(*)相当で問題なしselectが指定されている場合:COUNT(SELECT で指定した式)になろうとする
select に複数のカラムや集約関数を含めている場合、COUNT(...) の中身として不正な式が生成され、DB から StatementInvalid が返されていました。
PRの説明にあるような例:
# 集約を含んだ select
Post
.select("posts.*, COUNT(comments.id) AS n")
.joins(:comments)
.group(:id)
.many?
# 複数の select 要素を含む例
Post
.select("posts.*, 1 AS one")
.many?これらは、many? が内部で count を呼ぶ際に、COUNT(posts.*, COUNT(comments.id) AS n) のような不正な SQL を組み立ててしまい、ActiveRecord::StatementInvalid が発生していました。
しかし one? / many? が本当に知りたいのは「この Relation が返すレコード数」だけであり、select に何を書いているか(カラムや集約式)は本質的には関係ありません。
このPRでの修正内容
limited_countがcountを呼ぶ際に、引数なしではなくcount(:all)を使うように変更
→COUNT(*)相当のクエリになるため、selectで何を指定していても安全に件数を数えられるようになります。
変更ファイル:
activerecord/lib/active_record/relation.rblimited_count内のcount呼び出しがcount(:all)に置き換えられた (実質 1 行の変更)
activerecord/test/cases/relations_test.rb- 上記のような
select付きの Relation に対してone?/many?が正常に動作することを確認するテストが追加された(約 29 行)
- 上記のような
distinct 付き Relation への対応
説明文に
distinctrelations are still counted correctly, via a subquery.
とある通り、distinct が指定されている Relation に対しても、既存の limited_count ロジック(内部でサブクエリを用いたカウント)により正しく件数が取得されます。
count(:all) にしても、distinct の扱いは ActiveRecord 内部の count 実装側でケアされるため:
Model.distinct.one?
Model.select(:name).distinct.many?といったクエリも引き続き期待通りに動作します。
- 影響範囲・注意点
影響を受けるメソッド:
ActiveRecord::Relation#one?ActiveRecord::Relation#many?- (内部的には
limited_count→count(:all)への変更)
想定される影響:
- これまで
select句に集約関数や複数カラムを含む Relation に対してone?/many?を呼ぶとActiveRecord::StatementInvalidが発生していたケースが、正常に件数判定できるようになります。 - カウント対象が
COUNT(任意の式)からCOUNT(*)相当(count(:all))に変わったため、「select内容に依存した特殊な count の挙動」を期待していたコードがあれば挙動が変わる可能性はありますが、one?/many?の責務を考えると、本来そうした使い方は想定外であり、ほとんどのコードベースでは問題にならないはずです。
- これまで
パフォーマンス面:
count(:all)は典型的なCOUNT(*)カウントと同等であり、一般的な RDBMS では高速に処理されます。distinct+countの場合はサブクエリが使われるため、以前と同様に、巨大な結果セットではコストがそれなりにかかる可能性はあります(このPRで悪化したわけではない)。
アプリケーションコード側の変更有無:
- 通常は何も変更不要です。
- 過去にこのバグをワークアラウンドするために
to_a.one?などに書き換えていた箇所があれば、元のシンプルなrelation.one?/relation.many?に戻せる可能性があります。
- 参考情報 (あれば)
- 該当PR: rails/rails #59575 – Use
count(:all)forone?andmany? - フォローアップ元PR: #59559(
limited_count周りの先行変更) - 関連メソッドのドキュメント:
#59559 Fix one? and many? on grouped relations
マージ日: 2026/11/3 | 作成者: @b84330808
- 概要 (1-2文で)
Grouped Relation(groupを使ったクエリ)に対してone?/many?が正しく動作していなかったバグを修正し、グループごとの件数に基づいてone?/many?を判定できるようにした PR です。countがハッシュを返す場合でも、追加のクエリをほぼ増やさずに正しいブール値が返るようになっています。
- 変更内容の詳細
背景となる問題
group を含む Relation に対して one? / many? を呼ぶと、以下のような不整合・エラーが起きていました。
relation = Post.joins(:comments).group(:id)
relation.one? # 本来は「コメントを持つ Post が1件だけか?」を判定したい
relation.many? # TypeError: no implicit conversion of Integer into Hash原因は、one? / many? が内部で利用している limited_count の実装にあります。
one?/many?は「最大2件まで数えるクエリ(LIMIT 2)」で件数を取得し、その値で判定している- しかし
groupを使うと、countは「グループごとの件数ハッシュ」を返す
relation = Post.joins(:comments).group(:id)
relation.count
# => { 1 => 3, 5 => 2 } # 例: post_id=1 のコメント3件, post_id=5 のコメント2件このとき、limited_count は「整数」を期待して == 1 などの比較をしていたため、
one?の場合: 比較が常に false になる(正しく 1 グループだけなのに false)many?の場合: Integer を期待したところに Hash が来て TypeError になる
という問題が起きていました。
修正内容
ActiveRecord::Relation#limited_count の戻り値の扱いを変更し、「count がハッシュを返した場合は、その“グループ数”を件数とみなす」ようにしています。
変更の要点:
- これまで:
limited_countはcountの戻り値(通常は Integer)をそのまま用いていた - これから:
countの戻り値が Integer → 従来どおりそのままcountの戻り値が Hash(groupを使った場合) →hash.size(グループ数)を件数として扱う
これにより、例えば以下のような挙動になります。
relation = Post.joins(:comments).group(:id)
# グループ(= Post)ごとのカウント
relation.count
# => {1 => 3} # コメントがある Post が1件だけの場合
relation.one?
# 内部的には、LIMIT 2 の count を実行し、ハッシュ.size == 1 なので true
relation.many?
# LIMIT 2 の count を実行し、ハッシュ.size > 1 かどうかで判定この挙動は relation.load.one? / relation.load.many?(実レコードをロードして Ruby 側で判定)と一致しつつも、SQL 的には「最大2グループだけ数える」単一のカウントクエリで済むようになっています。
テスト・ドキュメント
activerecord/test/cases/relations_test.rbに、groupを含む relation でのone?/many?の挙動を確認するテストが追加activerecord/CHANGELOG.mdにこの挙動修正が記載
- 影響範囲・注意点
- 影響対象:
groupを含む ActiveRecord Relation でone?/many?を使っているコード- 特に
joins(...).group(...)や集計系のクエリでの利用箇所
- 互換性:
- これまで「常に false を返していた」「例外を投げていた」ケースが、正しく true/false を返すようになります。
- 正しい動作への修正なので「仕様として依存していた」ケースはほぼないはずですが、
- これまで rescue していた TypeError が発生しなくなる
- 「group 付き relation では one?/many? は壊れている」と想定して、独自ロジックを組んでいた場合は挙動が変わる可能性があります。
- パフォーマンス:
- これまでと同様、
one?/many?はLIMIT 2の count クエリのみで判定するため、性能面の悪化はほぼありません。 - グループ数を数えるだけなので、大量レコードでも負荷は従来同等です。
- これまでと同様、
- 参考情報 (あれば)
- 元 Issue: #41870 – Grouped relation に対する
one?/many?の不具合報告 - 同様のアプローチで過去に出ていた PR(stale でクローズ済み):
- #41871 (@mnacos)
- #42639
- 変更対象コード(概念的なイメージ):
ActiveRecord::Relation#limited_countがcount結果の型を見て、Hash ならsizeを返すようになっている。
#59573 Keep the local cache consistent with batched writes and deletes
マージ日: 2026/11/3 | 作成者: @kamipo
- 概要 (1-2文で)
Rails のローカルキャッシュ (Strategy::LocalCache) が、バッチ書き込み・削除 (write_multi,delete_multi) を使った場合でも一貫性を保つように修正された PR です。あわせてMemCacheStoreのオプション処理(unless_exist,compress,race_condition_ttl)まわりの重複と漏れも整理・修正されています。
- 変更内容の詳細
2-1. LocalCache がバッチ API を直接フックするように変更
従来の Strategy::LocalCache は、以下の単一キー向けのメソッドにフックすることでローカルキャッシュの整合性を保っていました。
write_serialized_entrydelete_entry
一方で、Rails の CacheStore には以下の複数キー向けのバッチメソッドがあります。
write_multi→ 内部的にwrite_multi_entriesdelete_multi→ 内部的にdelete_multi_entries
デフォルト実装では write_multi_entries / delete_multi_entries が単一キーのプリミティブ(write_serialized_entry, delete_entry)を呼ぶため問題ありませんでしたが、MemCacheStore は Dalli の set_multi, delete_multi を直接呼ぶ実装に差し替えており、その経路では LocalCache がフックしている単一キーのメソッドが使われていませんでした。
この結果、以下のように with_local_cache ブロック内で delete_multi を呼んでもローカルキャッシュ上は値が残り続けるという不整合が起こっていました:
cache.with_local_cache do
cache.write("k", "v")
cache.delete_multi(["k"])
cache.read("k") # => "v" (本来は nil になってほしい)
end今回の修正
Strategy::LocalCache 側で直接 write_multi_entries と delete_multi_entries をオーバーライドし、スーパークラスに処理を渡す前にローカルキャッシュを更新/削除するようにしました。
ポイント:
write_multi_entries- ローカルキャッシュをバッチ書き込み分で更新し、その後
superを呼んで実ストアに書き込む。
- ローカルキャッシュをバッチ書き込み分で更新し、その後
delete_multi_entries- 削除対象キーをローカルキャッシュから先に削除してから
superを呼ぶ。
- 削除対象キーをローカルキャッシュから先に削除してから
これにより、下記のようなコードでも期待通りに動きます:
cache.with_local_cache do
cache.write("k", "v")
cache.delete_multi(["k"])
cache.read("k") # => nil
endまた、「どのストア実装がどういうバッチ実装をしているか」に依存せず、LocalCache 層だけで一貫性が担保されるようになりました。そのため、以前 MemCacheStore 側に入れていたローカルキャッシュ削除処理(#59566 で追加されたもの)は不要になり削除されています。
2-2. MemCacheStore のバッチ書き込みのオプション処理整理
MemCacheStore#write_multi_entries が、単一キーの write_serialized_entry とほぼ同じオプション処理を内部で重複実装しており、その過程で以下のオプションが正しく扱われていませんでした (#59486 由来):
:unless_exist:compress
今回の変更で以下のように整理されています。
dalli_write_options の抽出
MemCacheStore内にdalli_write_optionsというヘルパーメソッドを新設。- ここで以下を一括で処理するように変更:
race_condition_ttlを使った有効期限の調整:compressオプションの取り扱い(および不要なオプション削除):unless_existなどの Dalli に渡すべきオプション整理
write_serialized_entry と write_multi_entries はどちらもこの dalli_write_options を通して Dalli にオプションを渡すようになり、単一書き込みと複数書き込みのオプション処理が統一されました。
これにより:
write_multiでunless_exist: trueを指定したときも単一キーと同様に動作write_multiでもcompress: trueが正しく効くrace_condition_ttl指定時の expire 計算も単一/複数で同じルールで動作
となります。
2-3. テストの追加・整理
以下のテスト追加・移動が行われています。
activesupport/test/cache/behaviors/cache_store_behavior.rbwrite_multi+:unless_existの挙動を、共通のCacheStoreBehaviorに移動。- これにより全てのキャッシュストア実装がこの期待値を満たしているか検証される。
activesupport/test/cache/behaviors/local_cache_behavior.rbdelete_multiが LocalCache と整合性を保てているかを検証するテストを追加。with_local_cacheブロックでwrite→delete_multi→readのパターンをカバー。
activesupport/test/cache/stores/mem_cache_store_test.rbMemCacheStoreに対して、以下のパスをテストするケースを追加:write_multi+compressが効くかwrite_multi+race_condition_ttlの期限計算が正しいか
- 既存のテストのうち、共通化されたものは
CacheStoreBehavior側に移行。
- 影響範囲・注意点
with_local_cache 使用時の挙動変化
- これまで
delete_multi後もローカルキャッシュから値が読み出せていた(=バグ)ケースが、今回の修正で正しくnilになるようになります。 - バグに依存した挙動を前提にしているコードがある場合は注意が必要ですが、通常はバグ修正として歓迎される変更です。
- これまで
MemCacheStore + Dalli のバッチ書き込み周り
write_multiでunless_exist,compress,race_condition_ttlを使っている場合、以前は単一書き込みと異なる・あるいは効いていなかった可能性があります。- 今回の修正で仕様通りに揃えられているため、
- バグが直ることで期待通り動作するようになる
- 逆に「誤った挙動に依存していた」場合には動作が変わる
- という影響が考えられます。
カスタム CacheStore 実装への示唆
write_multi/delete_multiを独自実装しているカスタムストアでも、LocalCache と組み合わせる場合は「LocalCache がwrite_multi_entries/delete_multi_entriesをオーバーライドする」前提で設計されることになります。- 今回 LocalCache 側で多くを吸収しているため、通常はストア側に特別な対応は不要ですが、「さらに独自レイヤーで wrap している」ような高度な実装では呼び出し順に注意してください。
- 参考情報 (あれば)
- この PR で言及されている関連 PR / Issue:
- #58066: MemCacheStore の
write_multiに Dalli のset_multiを導入 - #58071: MemCacheStore の
delete_multiを Dalli ベースに - #59566: MemCacheStore 内で
write_multi時に LocalCache を削除するワークアラウンド - #59486:
write_multiで:unless_existや:compressが効かない問題
- #58066: MemCacheStore の
- キャッシュ戦略:
ActiveSupport::Cache::Strategy::LocalCacheは、with_local_cacheブロック内で「プロセスローカルな一時キャッシュ」を保持し、同一リクエスト中のキャッシュヒット率向上や N+1 キャッシュアクセスの軽減などを目的として使われます。- 今回の修正で、バッチ API を多用するコードでも、単一キー API と同様に LocalCache の一貫性が保証されるようになりました。
#59572 Skip merging a relation's values into themselves
マージ日: 2026/11/3 | 作成者: @kamipo
- 概要 (1-2文で)
ActiveRecord::Relation 同士のマージ処理で、同じ Relation を自分自身に対して merge!/Merger した場合にクエリ条件が失われうる不具合が修正されました。自分自身をマージ対象とした場合は、何も変更せず同じ Relation をそのまま返すようにしています。
- 変更内容の詳細
背景: Merger と merge / merge! の挙動
ActiveRecord には以下の2つのマージ API があります。
relation.merge(other)- 内部的に
relation.spawnした新しい Relation にotherをマージ - 元の Relation は変更されない(非破壊)
- 内部的に
relation.merge!(other)(private)- 呼び出し側 Relation (
self) にotherの条件等を直接書き込む(破壊的)
- 呼び出し側 Relation (
merge! は内部的に ActiveRecord::Relation::Merger を使って、@relation に @other の各種値(where, order, joins など)をコピー・統合しています。
ここで問題となっていたのは、「relation.merge!(relation) のように、同じ Relation を self と other の両方に渡した場合」です。
問題点: self を other にマージすると条件が消える
PR 説明にあるように、次のようなコードで不具合が発生していました。
relation = Post.unscope(:order, :where).order(:id).where(title: "a")
relation.merge(relation) # => WHERE "posts"."title" = 'a' ORDER BY "posts"."id" ASC
relation.merge!(relation) # => WHERE も ORDER BY もなくなる(バグ)理由の要点は以下の通りです。
Mergerはマージ時にunscope!を使う関係で、「値を読みつつ削除する」ような動きをする- そのため、「読み出し時に値を退避する」ためのコピー処理があるが、
NORMAL_VALUESのループ(通常の値群)についてはコピーする- 一方で
order,select,joins, 各種 clause などはother経由で読み出しており、ここはコピーされていなかった
- self と other が同一オブジェクトの場合、
unscope!などの副作用で 同じオブジェクトが書き換わり、結果的に WHERE/ORDER BY などが消えてしまう
つまり、「Relation を自分自身にマージすると、何も変えないはずなのに内部処理の副作用で条件が落ちる」という状態でした。
今回の修正内容
今回の PR では、Merger に「self と other が同一 Relation の場合の早期リターン」を追加しています。
イメージとしては以下のようなチェックが追加されています(厳密なコードは多少異なる可能性あり):
def merge
if relation.equal?(other)
# 自分自身へのマージは何もしない
return relation
end
# 通常のマージ処理
# ...
end要点:
- 「同じ Relation 同士のマージは、論理的には何も変化を生まない」
→ であれば、わざわざ値をコピーしたりunscope!などを走らせる必要はない - この条件を追加することで、上記の副作用による条件消失を防ぐ
さらに、これにより以下の一貫性が保たれます。
merge(public, 非破壊)- 常に
spawn済みの別インスタンスを返す relation.merge(relation)しても意味的には「自分自身を複製した Relation」が返るだけ
- 常に
merge!(private, 破壊的)- 通常は self を変更して self を返すが、
- 「self を self にマージする」という操作は no-op と見なし、実質的に何もしない
テスト (activerecord/test/cases/relation/merging_test.rb) も追加されており、merge! で self をマージした場合に WHERE/ORDER BY などが消えないことが検証されています。
- 影響範囲・注意点
- 対象:
- ActiveRecord::Relation の
merge!(およびそれを利用する内部処理) - 特に、同一の Relation を self/other に渡しているケースのみが挙動変更の対象
- ActiveRecord::Relation の
- 挙動の変化:
- 以前:
relation.merge!(relation)で where/order などが失われることがあった(バグ) - 現在:
relation.merge!(relation)は何も変更せず、relationをそのまま返す
- 以前:
- 後方互換性:
- 期待される正しい挙動に修正されただけなので、多くのアプリケーションにとっては「バグ修正」であり、意図的にこのバグを利用しているコードでもない限り問題は起こりにくいです。
- 注意点:
- ライブラリやメタプログラミング的なコードで
merge!相当を直接呼び出している場合(あるいは Relation 内部構造に依存している場合)、
「self を other にそのまま渡したら条件がリセットされる」といった誤った前提を置いていると挙動が変わります。 - ただし
merge!は public API ではなく内部向けメソッドのため、通常のアプリケーションコードへの影響はほぼありません。
- ライブラリやメタプログラミング的なコードで
- 参考情報 (あれば)
- 対応 PR: https://github.com/rails/rails/pull/59572
- 関連概念:
ActiveRecord::Relation#mergeActiveRecord::Relation#spawnActiveRecord::QueryMethods#unscope/unscope!
- 類似のバグパターン:
- 同一オブジェクトを「入力」と「出力」に同時に使う合成処理における、副作用による状態破壊(in-place 処理と共有オブジェクトの組み合わせ起因)
#59568 Read attribute_types once when loading records
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
Active Record が SQL 結果からレコードをロードする際に、attribute_typesを毎カラムごとに何度も呼んでいた箇所を、事前に一度だけ読み出すようにして無駄なメソッド呼び出しを削減し、レコード生成処理のパフォーマンスを向上させる変更です。
機能的な挙動は変えずに、主に SQLite / PostgreSQL 利用時のクエリ結果インスタンス化コストを数 %〜最大 20% 程度削減しています(命令数ベース)。
- 変更内容の詳細
背景
Active Record の
_load_from_sqlでは、DB から返ってきたResult#column_typesを元にモデルの属性型を決定しています。その際、「モデルが既に持っている
attribute_typesと重複するカラム型」を除外するために以下のような処理をしていました(擬似コード):ruby# column_types: Result#column_types が返す { "id" => ..., 0 => ..., ... } のような Hash column_types.reject { |k, _| attribute_types.key?(k) }Result#column_typesは「カラム名」と「カラムインデックス」の両方でカラム型を保持するため、1 カラムにつき 2 エントリあります。rejectのブロック内でattribute_typesを呼ぶため、- 1 カラムあたり 2 回(名前・インデックス分)
- カラム数 × 2 回
という頻度でattribute_typesが呼ばれていました。
さらに、PR #58437 により
attribute_typesの取得経路が- 単純なインスタンス変数参照ではなく
schema_context→schema_loaded?→ スキーマオブジェクトの属性 とたどるようになり、呼び出しコストが以前より高くなっています。
その結果、「レコードをインスタンス化するたびに余計な
attribute_types呼び出しが大量に発生する」状態になっていました。
今回の変更
対策として、
reject呼び出しの前に一度だけattribute_typesをローカル変数に読み出しておき、そのローカル変数を使ってkey?を判定するように変更しています。具体的なイメージ(実際のコードに近い形の擬似コード):
ruby# 変更前(イメージ) column_types.reject { |k, _| attribute_types.key?(k) } # 変更後(イメージ) attr_types = attribute_types column_types.reject { |k, _| attr_types.key?(k) }これにより:
attribute_types呼び出しは 1 回だけ- ブロック内ではローカル変数
attr_typesへの Hash 参照のみ
となり、1 クエリあたりの命令数が削減されます。
ブロック内部では
attribute_types(モデルの属性定義)を変更していないため、attribute_typesを事前に 1 回評価してキャッシュしても結果の Hash は変わりません(純粋なパフォーマンス改善)。
計測結果(PR 説明より)
12 カラムのモデルに対して:
| ケース | Before (interp / YJIT) | After (interp / YJIT) | 改善率 |
|---|---|---|---|
Post.find(1) | 442k / 222k instructions | 413k / 213k | −6.6% / −3.8% |
Post.where(...).limit(5).to_a | 747k / 360k | 718k / 351k | −3.9% / −2.5% |
| 1 行インスタンス化だけ | 143k / 84k | 114k / 75k | −20% / −10% |
ruby-bench 上のベンチでは(インタプリタ実行時):
- railsbench: −1.0%
- shipit: −4.1%
- lobsters: −1.0%
YJIT 有効時:
- railsbench: −0.7%
- shipit: −5.3%
- lobsters: −0.6%
と、特に shipit ベンチマークで顕著な改善が見られています。
実際の差分
- 変更ファイル:
activerecord/lib/active_record/querying.rb - 行数: +1 / -0
コード上は「attribute_types をローカル変数に束縛する 1 行の追加」のような極小変更です。
- 影響範囲・注意点
影響範囲
- 対象: Active Record が SQL 結果 (
_load_from_sql) からレコードをインスタンス化する全てのパス- 主に SQLite / PostgreSQL 利用時に関係(型情報を
Result#column_typesから取得する DB アダプタ) Model.find,where(...).to_a,pluck絡みなど、通常のクエリによるレコード生成全般
- 主に SQLite / PostgreSQL 利用時に関係(型情報を
- 影響内容:
- 機能的な挙動(返ってくるオブジェクト、型付けの結果)は変わりません。
- レコードインスタンス生成時の CPU 使用量・命令数が削減され、全体としてクエリ処理のスループットとレイテンシがわずかに改善されます。
注意点
- 互換性:
- 挙動を変える変更ではなく、「同じ Hash をより効率よく計算する」だけなので、アプリケーションコードへの互換性リスクはほぼありません。
- 他 PR との関係:
- PR 説明にもある通り、#59141 でこの
rejectブロック自体が削除される予定であり、そちらが先にマージされていれば今回の最適化は不要(moot)になる設計です。 - すでにこの PR (#59568) はマージ済みなので、今後 #59141 がマージされれば、さらに当該コードパスごと簡素化される見込みです。
- PR 説明にもある通り、#59141 でこの
attribute_typesのキャッシュ前提:- ブロック内で
attribute_typesが変更されない(=モデルのスキーマ定義が途中で変化しない)ことを前提にしていますが、通常の Rails アプリケーションでは要求される前提条件であり、一般利用では問題ありません。
- ブロック内で
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59568
- 関連 PR:
- #58437:
attribute_typesの取得経路をschema_contextベースに変更したため、attribute_types呼び出しコストが増えた。 - #59141:
_load_from_sql内でのcolumn_typesフィルタリングロジック自体を削除予定の PR。
- #58437:
- 関連コード:
ActiveRecord::Querying- ActiveRecord が内部で利用する
_load_from_sql/ レコードインスタンス化ロジック(通常はアプリから直接呼ばない内部 API)
#59563 Serialize Active Job Hash arguments without pair Arrays
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
Active Job でジョブ引数の Hash/キーワード引数をシリアライズする処理から、不要な[key, value]の一時配列生成を取り除き、オブジェクト生成数と実行時間を削減した変更です。動作仕様(キー・値・順序・_aj_*マーカー)は一切変えずに、内部実装のみを高速化しています。
- 変更内容の詳細
何を変えたか
対象メソッドは ActiveJob::Arguments#serialize_hash です。
従来は次のような「each_with_object + ブロック引数の多重代入」で Hash を組み立てていました:
def serialize_hash(hash)
hash.each_with_object({}) do |(key, value), result|
result[serialize(key)] = serialize(value)
end
endこの書き方だと、hash の各要素に対して一度 [key, value] の配列を生成し、それを (key, value) に展開していました。この「要素ごとの一時配列」が無駄なオブジェクト生成になっています。
PR では、Hash を素直に each で回して、結果 Hash を直接更新する形に変更しています(イメージ):
def serialize_hash(hash)
result = {}
hash.each do |key, value|
result[serialize(key)] = serialize(value)
end
result
end実際の差分は 3 行追加・1 行削除程度で、ロジック自体はほぼ同じですが、(key, value) という配列→多重代入のパターンを避けるのがポイントです。
この書き方は他の最適化 PR (#59236) と同じパターンで、Active Job 内部のシリアライズ処理を一貫して高速化する流れの一部です。
ベンチマーク結果
PR 説明の表を整理すると:
- 対象メソッド:
serialize(の中でserialize_hashが呼ばれるパス) - 環境: Ruby 4.0.7 / インタプリタ / YJIT
| ケース | 指標 | main (変更前) | このPR (変更後) |
|---|---|---|---|
| 典型的なキーワード引数 | 生成オブジェクト数 | 13 | 6 |
| 実行時間 | 5.1 / 3.1 µs | 4.2 / 2.4 µs | |
| ネストしたイベント Hash | 生成オブジェクト数 | 37 | 15 |
| 実行時間 | 14.3 / 7.9 µs | 11.8 / 5.6 µs |
※時間は「Interpreter / YJIT」の 2 列をまとめた形だと思われます。
Chatwoot での実利用例では、「ステータス切り替え時に複数ジョブを enqueue する処理」で、238 個のオブジェクト削減(約 1.3% 減) が確認されています。
- 影響範囲・注意点
影響する箇所
- Active Job 経由でジョブを enqueue する際に:
- 引数に Hash を渡した場合
- キーワード引数として渡した場合
これらを内部でシリアライズする処理のパフォーマンスが向上します。
仕様面の影響
- Hash のキー・値・挿入順序はこれまで通り保持されます。
- Active Job が内部的に使用する
_aj_*系のマーカーもこれまで通り付与されます。 - 引数のシリアライズ結果の形式(JSON などに落としたときの形)は変わりません。
つまり、アプリケーション側での互換性問題は基本的にありません。
テストを書き換える必要も、通常は発生しない想定です。
注意点(とはいえ)
- 非公開 API(
ActiveJob::Argumentsの内部実装)に依存しているようなメタプログラミングをしている場合は、挙動を念のため確認してください。 - 大量にジョブを enqueue するバッチやイベント駆動処理では、パフォーマンス改善の影響が出やすいので、メモリ・スループットをウォッチしておくと良いです(良い意味で変化するはずです)。
- 参考情報 (あれば)
- この PR: Serialize Active Job Hash arguments without pair Arrays (#59563)
- 類似の最適化 PR:
- #59236: 同様の「pair Array を避ける」パターンを他所で適用したもの
- #55544: 今回と同じコードパスを過去に高速化した PR
- 関連クラス:
ActiveJob::ArgumentsActiveJob::Base(ジョブの引数はここからArgumentsに渡される)
#59566 Fix stale reads from the local cache after ActiveSupport::Cache::MemCacheStore#write_multi
マージ日: 2026/11/3 | 作成者: @kehra
- 概要 (1-2文で)
ActiveSupport::Cache::MemCacheStore#write_multi実行後に、同一リクエスト内のローカルキャッシュから古い値が読まれてしまう不具合を修正するPRです。set_multi経由の書き込み時にローカルキャッシュを適切に無効化することで、一貫した読み取り結果を保証します。
- 変更内容の詳細
問題の背景
Rails 8.2 以降(#58066 以降)、MemCacheStore#write_multi は Dalli の set_multi を利用するようになりましたが、この経路では write と異なりローカルキャッシュ(with_local_cache)が更新されていませんでした。
その結果、同一リクエスト内で次のようなコードを書くと:
cache.with_local_cache do
cache.write("k", "old")
cache.write_multi("k" => "new")
cache.read("k") # 実際には "old" が返ってしまう
end- memcached には
"new"が書かれる - しかしローカルキャッシュには
"old"が残り続ける
という状態になり、readがローカルキャッシュを優先するため、古い値が返る「古い読み取り(stale read)」 が発生していました。
Rails 8.1 まではwrite_multiが各キーをwrite_entry経由で書いていたため、ローカルキャッシュも更新され、この問題は起きていませんでした。
変更のポイント
MemCacheStore#write_multi_entries のうち、Dalli の set_multi を使う経路に変更が入っています。
set_multiを呼ぶ前に、今回書き込もうとしているキーをローカルキャッシュから削除する ようにした- これにより、次回の
readはローカルキャッシュにヒットせず memcached にフォールバックする - memcached から取得した値が改めてローカルキャッシュに保存されるため、以降の読み取りは新しい値になる
疑似コードイメージ:
def write_multi_entries(hash, **options)
if can_use_set_multi?
keys = hash.keys
# 追加された処理: ローカルキャッシュの該当キーを削除
delete_matched_keys_from_local_cache(keys)
# その後で memcached にバッチ書き込み
dalli.set_multi(hash, options)
else
# 従来通り、write_entry 経由で1件ずつ処理
end
endなぜ「削除」だけで「書き込み」はしないのか:
- Dalli の
set_multiは 各キーごとの成否結果を返さない - そのため、どのキーが実際に書き込まれたかを Rails 側で知ることができない
- もしローカルキャッシュに無条件で新しい値を書いてしまうと、「memcached への書き込みに失敗したがローカルキャッシュだけ新しい値になっている」という一貫性の問題が起こり得る
- そこで、「ローカルキャッシュは削除のみ」にとどめ、次回の read で memcached の実際の状態に同期させる という方針になっています
また、例外や部分的な書き込み失敗のケースにおいても:
set_multi呼出し前にローカルキャッシュを消しているため- 失敗したとしても「古い値がローカルキャッシュに残る」ことはなくなります
- 一時的にミスヒット(nil)になる可能性はありますが、「古い値を返し続ける」よりは安全です
テスト
activesupport/test/cache/behaviors/local_cache_behavior.rb にテストが追加されています。
内容としては、おおまかに次の動作を検証していると考えられます(推測に基づく要約):
with_local_cacheブロック内で- 事前に
writeした値がローカルキャッシュに載る - その後
write_multiで同じキーに別の値を書き込む - 直後の
readで新しい値が返る(= stale read にならない)
- 事前に
- 影響範囲・注意点
対象:
ActiveSupport::Cache::MemCacheStoreを利用していて- Dalli の
set_multiが有効な環境 - かつ
with_local_cache(もしくは:local_cacheを有効にしたミドルウェア環境)を使っているケース
期待される挙動の変化:
- これまで:
- 上記の条件下で、
write_multi後のreadが古い値を返すことがあった
- 上記の条件下で、
- 変更後:
write_multi対象キーはローカルキャッシュから一度消える- 次の
readは memcached を読みに行き、その値でローカルキャッシュが再構築される - 従って、同じリクエスト中の read でも、実際の memcached の状態に追随する
- これまで:
トレードオフ:
set_multi経路を通ったキーについては、- それまでローカルキャッシュにあった場合でも、一度キャッシュミス扱いになる
- 次回
readで memcached へのネットワークアクセスが発生する
- パフォーマンスよりも整合性(stale read を起こさない)を優先した設計です
互換性:
- API は変わっていないため、アプリケーションコードの変更は不要
- 動作としては Rails 8.1 までの「
write_multi後にすぐ新しい値が見える」挙動に近づく形になります
- 参考情報 (あれば)
- 関連PR: #58066 —
MemCacheStore#write_multiを Dalli のset_multi経由に変更したPR - 修正対象クラス:
ActiveSupport::Cache::MemCacheStore - 関連機能:
ActiveSupport::Cache::Strategy::LocalCache(with_local_cacheブロックや Rack ミドルウェアで利用されるローカルスレッドキャッシュ)
#59531 Fix -n ClassName#test_name filter in the test runner
マージ日: 2026/11/3 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
Rails のテストランナーでbin/rails test -n ClassName#test_name形式のフィルタが動作しなくなっていたバグを修正し、Minitest 本来のクラス名+テスト名フィルタが正しく動くようにした PR です。これにより、ActiveSupport::Testing::Isolationが-nを使ってサブプロセス実行するケース(JRuby / Windows など fork 非対応環境)でもテストが正しく実行されます。
- 変更内容の詳細
不具合の内容
背景:
- Minitest は
-n/--nameオプションで以下のようなフィルタをサポートしています:-n test_foo(テストメソッド名)-n ClassName#test_foo(クラス名 + メソッド名)
- Rails の
bin/rails testラッパは、文字列フィルタでtest_から始まっていないものに強制的にtest_プレフィックスを付与していました。
問題:
- ユーザーが
bin/rails test -n PostTest#test_fooとすると、Rails 側でtest_PostTest#test_fooに書き換えられてしまい、Minitest 側で何もマッチせず「0件実行」になる。 ActiveSupport::Testing::Isolation::Subprocessは、fork できない環境で各テストを別プロセスに渡す際に-nClassName#test_nameを使っているため、同じ問題で「子プロセスが何も実行しない → すべてSubprocessCrashed」という状態になっていた。
PR 説明にある再現例:
cd activemodel && NO_FORK=1 bin/test test/cases/railtie_test.rb
# 5 runs, 0 assertions, 0 failures, 5 errors本来 5 テストが動くべきところ、何も実行されずエラー扱いになっていた。
修正方針
rails/test_unit/runner.rb のフィルタ処理を以下のように修正:
これまで:
「test_で始まらない文字列フィルタには一律でtest_を付ける」これから:
以下の場合は プレフィックスを付けない:- フィルタが「テストクラス名 +
#test_」で始まっている場合- 例:
PostTest#test_foo,User::AdminTest#test_barなど
- 例:
- フィルタが「テストクラス名 +
それ以外(例えば宣言的なテスト名
"issue #123"など)には従来通りtest_を付与:test "issue #123"→ Rails 側では"test_issue #123"のような名前になるので、従来のプレフィックスロジックが必要
イメージとしては擬似コードでこんな条件分岐が追加された形です(実装はもう少し具体的な正規表現等ですが):
if name.start_with?("#{test_class.name}#test_")
# Minitest の ClassName#test_name 形式 → そのまま渡す
filter = name
else
# それ以外は今まで通り `test_` を補う
filter = name.start_with?("test_") ? name : "test_#{name}"
endテスト追加
railties/test/application/test_runner_test.rb に 26 行分のテストが追加され、
bin/rails test -n PostTest#test_fooのような指定で該当テストだけが実行されること- これまで動いていた
-n test_foo/ 宣言的テスト名のフィルタが壊れていないこと
などが検証されています。
CHANGELOG
railties/CHANGELOG.md に、テストランナーの -n ClassName#test_name フィルタが再び動くようになった旨が追記されています。
- 影響範囲・注意点
影響範囲:
- Rails のテスト実行:
bin/rails test -n ClassName#test_namebin/test -n ClassName#test_name
- fork 非対応環境(JRuby, Windows, または
NO_FORK=1)でActiveSupport::Testing::Isolation::Subprocessを用いるテスト:- これまで isolated テストが
SubprocessCrashedになっていたケースが正常に動作するようになります。
- これまで isolated テストが
互換性・注意点:
- 既存の
-n test_fooフィルタや、test "..."/it "..."など宣言的テスト名に依存したフィルタは挙動維持のままです。 Isolation::Subprocessは引き続き-nを子プロセスに渡しますが、Minitest 6 では「-n/--nameではなく-i/--includeを使ってほしい」という警告が出るとのことです。- この PR では Minitest 5 との互換性を優先して
-iへの移行はしていません。 - 将来的に Minitest 6 前提になるときに
-iへの切り替えが別途必要になる可能性があります。
- この PR では Minitest 5 との互換性を優先して
運用面でのポイント:
- これまで
bin/rails test -n ClassName#test_nameが動かずに諦めていた場合、今後は積極的にクラス単位+メソッド指定のフィルタを利用できます。 - JRuby / Windows 環境で isolation を使うテスト(ActiveModel, ActiveRecord の一部など)が一斉にグリーンになる可能性が高いので、CI の挙動が変わる (「5 errors → 5 passed」など) 点に注意してください。
- 参考情報 (あれば)
- この不具合は Rails 7.1 で導入された宣言的フィルタサポート(コミット
da651a21e1)以降に発生した回 regress です。 - Minitest のフィルタ仕様:
-n/--nameは「テスト名にマッチする正規表現」または「ClassName#test_name」形式を受け付ける。- Minitest 6 では
-i/--includeを推奨しており、-n使用時に警告メッセージが表示される。
#59561 Don't build the key String on every thread_mattr_accessor call
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
thread_mattr_accessorがスレッド/ファイバごとの状態管理に使うキー文字列を、毎回動的に生成するのをやめ、モジュールごとに1回だけ生成した凍結リテラルを使い回す最適化です。これにより、多くのリクエストで発生していた不要な文字列オブジェクトの生成が削減され、パフォーマンスが向上します。
- 変更内容の詳細
背景: thread_mattr_accessor とキー文字列
thread_mattr_accessor は、モジュール/クラスに対して「スレッド(および Fiber)ごとに独立したクラス属性」を定義するためのヘルパーです。内部的には IsolatedExecutionState に対して、
"attr_#{name}_#{object_id}"という文字列をキーにして値を格納・取得しています。
従来の実装では、読み取り・書き込みのたびにこのキー文字列を組み立てていたため、呼び出し1回あたり 2 個の String オブジェクトが生成されていました(1回のアクセスで reader / writer ともに同様の仕組みを使う場合など)。
この PR では、このキー文字列を「定義元モジュール単位で一度だけ」生成し、その後は凍結されたリテラルとしてメソッドに埋め込むように変更しています。
具体的な変更イメージ
(実際のコードは簡略化していますが、概念的には以下のような違いです)
従来:
def thread_attr
key = "attr_#{:thread_attr}_#{self.object_id}"
IsolatedExecutionState[key]
end
def thread_attr=(value)
key = "attr_#{:thread_attr}_#{self.object_id}"
IsolatedExecutionState[key] = value
endPR 後(定義時に一度だけキーを生成し、リテラルとして埋め込み):
# attr 定義時に module (または class) 固有の key を作る
key = "attr_#{:thread_attr}_#{self.object_id}".freeze
# 生成されるメソッドは、この key をそのまま使う
def thread_attr
IsolatedExecutionState[KEY_LITERAL] # 実際は key の値が直接埋め込まれる
end
def thread_attr=(value)
IsolatedExecutionState[KEY_LITERAL] = value
endつまり、
- 「どのモジュール・クラスがその属性を定義したか」で key が一意に決まる
- そのモジュール・クラスの
thread_mattr_accessor経由の read/write は、常に同じ key を使う - その key は定義時に1回だけ生成し、文字列リテラルとして(かつ
freezeされた状態で)メソッド内に閉じ込める
という方針になっています。
サブクラスの扱い
説明文にある通り:
For the module that defines the attribute the key never changes, so the generated methods use it as a frozen literal. Subclasses keep their own values and still build their key.
- 親側(
thread_mattr_accessorを定義したモジュール/クラス)- キー文字列は1回だけ生成し、以降は凍結リテラルを使う
- サブクラス側
- 「親クラスとは別の値を持つ」挙動は維持する必要がある
- そのため、サブクラスはサブクラス固有の key を引き続き生成する(=サブクラスでは key の構築が続く)
この設計により、
- 親クラスとサブクラスで値が独立している、というこれまでの挙動は維持
- より頻繁に使われる「定義元クラスの accessor 呼び出し」におけるオブジェクト生成コストだけを削減
という形で後方互換性と最適化が両立されています。
ベンチマーク結果
PR 説明にある通り、reader 1 回あたりのコストは:
| Reader, per call | main | this PR |
|---|---|---|
| objects | 2 | 0 |
| ns (interpreter / YJIT) | 364 / 299 | 198 / 109 |
- オブジェクト数: 2 → 0 (String の一時オブジェクトを生成しない)
- 実行時間: インタプリタ / YJIT ともに短縮
さらに、9 つの OSS アプリでの 97 workload 中 83 で、1 リクエストあたりのオブジェクト数が削減され(中央値 −11/req)、実アプリでも効果が確認されています。
- 影響範囲・注意点
影響範囲
- 対象機能:
ActiveSupport::CoreExt::Module::AttributeAccessorsPerThreadのthread_mattr_accessor - 利用箇所の代表例:
- Turbo の request id 管理
- Action Text
- Active Record の query log tags
- それ以外にも
thread_mattr_accessorを直接使っているアプリ/gem 全般
どれもリクエストごと・クエリごとに呼ばれやすい箇所であり、ここでのオブジェクト削減は GC 負荷・レイテンシの両方に影響し得ます。
挙動上の互換性・リスク
- スレッド/ファイバごとの値の分離は従来通りで、意味的な挙動は変わりません
- 親クラスとサブクラスで異なる値を保持する挙動も、説明文の通り維持されています
IsolatedExecutionStateのキーとして使う文字列が「実質的に定数化」されますが、もともとアプリケーションレベルからキー文字列に依存することは想定されていない内部仕様のため、通常の利用法では影響はありません- マルチスレッド環境でも、キーの生成は定義時に行われるだけで、その後は読み取り専用の frozen String を使用するため、スレッドセーフです
注意点
- 非推奨ですが、もしアプリや gem が内部実装に依存して
IsolatedExecutionStateのキー列挙やキー文字列そのものを参照している場合、キーの生成タイミングやオブジェクト ID の取り扱いに関する挙動が細かく変わる可能性があります(通常はしてはいけない使い方です)。 - サブクラス側では key を引き続き構築するため、「サブクラスだけを大量に生成して
thread_mattr_accessorを乱用する」ような極端なケースでは、最適化効果が限定的になる可能性があります。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59561
- 対象コード:
activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb - 関連概念:
IsolatedExecutionState: Rails がスレッド/ファイバごとの状態を安全に扱うための内部 APIthread_mattr_accessor:mattr_accessorの「スレッド/ファイバごとに分離された」版で、フレームワーク内部でも広く使用される仕組み
#59560 Reuse type map cache keys for PostgreSQL result columns
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
PostgreSQL のクエリ結果の型解決で、毎カラムごとに新しい配列オブジェクトを生成していたキャッシュキーを再利用することで、オブジェクト生成数を削減する最適化です。型解決ロジックの挙動(どの型になるか)は変えずに、メモリ・GC 負荷を下げる変更になっています。
- 変更内容の詳細
これまでの問題点
PostgreSQL アダプタは、クエリ結果の各カラムについて、次のように型を引いています:
type_map.fetch(oid, fmod, sql_type)ここで *args([oid, fmod, sql_type])がハッシュのキーになっており、
- 各カラムごとに
Arrayが1つ生成される - キャッシュヒットしても、毎回新しい配列オブジェクトが生成される
という状態でした。
さらに、結果セットのカラムに関しては
sql_typeは常に空文字列""- 実際のキーは
[oid, fmod, ""]相当
となるため、かなりパターンが限られています。それにもかかわらず、都度 Array を生成していました。
この PR の主なアイデア
「結果カラム用の type_map 参照では、[fmod, ""] のキーオブジェクトを使い回す」ことで、配列の生成数を削減しています。
大きく分けて 2 点の変更があります:
HashLookupTypeMap#fetch_result_typeの追加- PostgreSQL アダプタ側で結果セット用に
fetch_result_typeを使うように変更
1. HashLookupTypeMap に結果専用の fetch を追加
activerecord/lib/active_record/type/hash_lookup_type_map.rb に、新たに「結果カラム用」のフェッチメソッドが追加されています(名前は fetch_result_type)。
役割:
- 結果カラムで使うキーオブジェクトを、
fmodごとに 1 つだけ保持・再利用する - キーは凍結(
freeze)して不変オブジェクトとして使う register_typeやclearでキャッシュを破棄する際に、これらのキーもまとめて破棄する- 失敗した(該当する型がなかった)場合には何も保持しない
イメージ的には次のような動きです(擬似コード):
class HashLookupTypeMap
def fetch_result_type(oid, fmod)
# fmod ごとに 1 回だけキー配列を生成してキャッシュ
key = (@result_keys_by_fmod[fmod] ||= [fmod, ""].freeze)
# 内部的にはいつも通り lookup(型解決)は行う
# (実際には oid も効くような構成になっている)
@mapping[key]
end
def register_type(*)
# 型が更新されたらキーキャッシュもクリア
@result_keys_by_fmod.clear
super
end
def clear
# 完全クリア時にもキーキャッシュをクリア
@result_keys_by_fmod.clear
super
end
endポイント:
- 「型の見つけ方」自体は今まで通り
HashLookupTypeMapの仕組みを利用している - 変わったのは「そのためのキーオブジェクトの生成の仕方」のみ
2. PostgreSQL アダプタで結果セットに fetch_result_type を利用
activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb では、クエリ結果のカラム型の解決に fetch_result_type を使うようにしています。
- スキーマ情報(カラム定義など)を読むときの
type_map.fetchは従来通り - 実際のクエリ結果(
SELECTの結果)に対するキャストではfetch_result_typeを利用
と用途をきっちり分けています。
効果
PR 説明にあるとおり:
| main | this PR | |
|---|---|---|
cast_result, 10-column select_all | 27 objects | 17 objects |
10カラムの select_all で、1 回の結果キャストにおけるオブジェクト生成数が 27 → 17 に減少(約 37% 減)。
さらに 9 個の OSS PostgreSQL アプリの測定では、
- 124 件のワークロード中 120 件でオブジェクト生成数が減少
- 中央値では −171 個(−2.4%)のオブジェクト削減
と報告されています。
テストの追加
activerecord/test/cases/adapters/postgresql/type_map_registration_test.rbactiverecord/test/cases/type/type_map_test.rb
で次のような点を検証しています:
fetch_result_typeによる型解決が、従来のfetchと同じ結果になることregister_type/clearを行った場合に、結果用キーキャッシュも正しく破棄されること- 失敗した lookup(見つからなかった型)の場合に余計なキーが残らないこと
- 影響範囲・注意点
影響範囲
- 対象: ActiveRecord の PostgreSQL アダプタでの「クエリ結果の型キャスト」
select_all,pluck,where,find_byなど、結果のカラムを Ruby オブジェクトに変換するところ全般
- スキーマ読み取り(
columnsやマイグレーション関連)は現行どおりfetchを利用しており、挙動は変わりません。
互換性・挙動の変更有無
- この PR は「キーオブジェクトの扱いを変えただけ」で、どの OID/fmod/sql_type がどの型にマップされるかという型の解決ロジック自体は変えていません。
- 公開 API の挙動変更やシグネチャ変更はありません(
HashLookupTypeMapにメソッドが増えただけ)。 HashLookupTypeMapを独自拡張しているようなレアケースで、内部実装に強く依存していた場合は、fetch_result_typeの存在を前提にしていないコードとの相互作用を一応確認したほうが安全です。
パフォーマンス面
- メインのメリットはオブジェクト生成数の削減 → GC 負荷の削減で、CPU 時間もある程度改善が見込めます。
- 特に高トラフィックかつ「SELECT が多い」「1クエリあたりのカラム数が多い」アプリケーションでは、効果が出やすい変更です。
- スキーマの登録・更新 (
register_type,clear) パスに少しだけ追加処理がありますが、頻度が低いため通常は無視できるオーバーヘッドです。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59560
- 関連箇所:
ActiveRecord::ConnectionAdapters::PostgreSQLAdapterActiveRecord::Type::HashLookupTypeMap
- 類似の最適化パターン:
- 頻出するハッシュキー・配列・文字列を「凍結・共有」して GC 対象オブジェクトを減らすテクニック
- ex.
Symbolの利用、"".freeze、FrozenRecord/ frozen hash の活用 など
- ex.
- 頻出するハッシュキー・配列・文字列を「凍結・共有」して GC 対象オブジェクトを減らすテクニック
#59558 Avoid scratch Arrays when casting PostgreSQL results
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
PostgreSQL 用アダプタで、クエリ結果をキャストする際に毎回作成していた一時的な Array(field_typesとmissing_oids)の生成を避け、オブジェクト割り当て数を削減する最適化です。ほぼすべてのカラム型が既知という前提で、実行時の動作は維持しつつメモリアロケーションを減らしています。
- 変更内容の詳細
背景: 既存コードの問題点
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#cast_result(あるいはそれに準じたメソッド)では、PostgreSQL の PG::Result から Ruby オブジェクトへ変換するときに、以下のようなことをしていました:
- 各クエリ結果について:
- 全カラム分の OID を
field_typesArray に一度まとめて読み込む - 未知の OID を集めるための
missing_oidsArray を作る - その後、型テーブルを引きながら変換を行う
- 全カラム分の OID を
しかし、
result.ftype(index)はPG::Result上の非常に軽い Integer アクセサである- ほとんどのアプリケーションでは、全ての OID が事前に登録済み(=未知の OID が無いケースが「ほぼ常に」)
という事情から、「わざわざ全カラム分の OID を一括で Array に取り出す」コストと、「未知の OID がないのに毎回 missing_oids Array を作る」コストが無駄になっていました。
今回の変更のポイント
field_typesArray の削除- 各カラムの型を調べるとき、
- 以前: 一度
field_types = (0...nfields).map { |i| result.ftype(i) }のように Array を作成してからそれを参照 - 変更後: 各ループ内で都度
result.ftype(i)を呼び出す
- 以前: 一度
- これにより、クエリ結果ごとに 1 つ作られていた
field_typesArray の割り当てが不要になります。
- 各カラムの型を調べるとき、
missing_oidsArray の遅延生成- 以前: 「未知の型があるかどうか」に関わらず、毎回
missing_oids = []を用意してからループ - 変更後:
- まず
missing_oidsをnilのままにしておき、 - ループ中に「未知の OID に遭遇したタイミング」で初めて
missing_oids = []を生成して push する
- まず
- そのため、全カラム型が既知の場合は
missing_oidsArray 自体が一切作られません。
- 以前: 「未知の型があるかどうか」に関わらず、毎回
load_additional_typesへの引き渡しの挙動維持- 未知 OID が見つかった場合に、
load_additional_types(missing_oids)のようなメソッドを呼んで追加ロードする処理があります。 - このメソッドに渡される OID リストについて:
- 並び順は従来と同じ
- 重複もこれまで通り許容(仕様を変えない)
- 追加ロード後に依然として不明な型は、従来通り「still-missing types」として処理・登録されます。
- 未知 OID が見つかった場合に、
コードイメージ(擬似的な Before / After)
※ 実際のコードは多少異なりますが、意図が分かるレベルの擬似コードです。
Before(イメージ):
def cast_result(result)
nfields = result.nfields
field_types = Array.new(nfields) { |i| result.ftype(i) }
missing_oids = []
nfields.times do |i|
oid = field_types[i]
type = @type_map[oid]
unless type
missing_oids << oid
next
end
# type を使って値をキャスト
end
load_additional_types(missing_oids) if missing_oids.any?
endAfter(イメージ):
def cast_result(result)
nfields = result.nfields
missing_oids = nil
nfields.times do |i|
oid = result.ftype(i)
type = @type_map[oid]
unless type
(missing_oids ||= []) << oid
next
end
# type を使って値をキャスト
end
load_additional_types(missing_oids) if missing_oids
end変更差分は実質このロジックの簡素化で、該当ファイルの変更は +5 / -7 行と非常に小さくまとまっています。
- 影響範囲・注意点
影響範囲
- 対象: PostgreSQL アダプタ (
activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb) の、クエリ結果を Ruby オブジェクトへキャストする処理全般。 - 利点:
- 既知の型のみで結果が返るケースがほとんどのアプリケーションでは、クエリ結果ごとに 2 個(
field_typesとmissing_oids)の Array アロケーションが減少- 未知 OID が存在するケースでも、少なくとも
field_types1 個分は削減
- 未知 OID が存在するケースでも、少なくとも
- PR の計測では、9 つの PostgreSQL アプリ(Mastodon, Discourse, Docuseal, Lago, Loomio, Sure, CASA, CodeTriage, PingCRM)と 124 の workload(リクエスト、ジョブ、メーラー)において、121 workload でオブジェクト割り当て数が減少(中央値 −23 個)。
- 計測はオブジェクト数のみで、パフォーマンスの時間的な改善を直接主張してはいないものの、GC 負荷軽減などの面でメリットが見込まれます。
- 既知の型のみで結果が返るケースがほとんどのアプリケーションでは、クエリ結果ごとに 2 個(
挙動互換性・注意点
- 機能的な挙動(型解決の結果、未知 OID の扱い、
load_additional_typesの呼び出しタイミングなど)は従来と同じになるよう配慮されています。 - OID リストの順序・重複も既存と同じで、これに依存したコードへの影響はありません。
result.ftype(index)をループ内で繰り返し呼ぶ形になりましたが、PG::Result上では軽量な整数アクセサであり、かつfield_typesの Array 構築コストが無くなるため、実質的には総コスト減の方向です。- #57517 でこの周辺がロックでラップされているため、片方の PR がマージされた後、もう一方は trivial なリベースが必要になる(=コード位置が近い)ことが明記されています。
→ 既に #59558 がマージ済みなので、#57517 側で小さなコンフリクト解消が必要になる可能性があります。
- 参考情報 (あれば)
- 該当ファイル:
activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb - 関連 PR:
- #57517: 同じ
cast_result付近のコードをロックで保護する変更(競合する可能性)
- #57517: 同じ
- 計測対象アプリケーション:
Mastodon, Discourse, Docuseal, Lago, Loomio, Sure, CASA, CodeTriage, PingCRM
(prepared_statements: falseのアプリケーションも含む) - 性能評価:
- オブジェクト割り当て数の削減に基づく評価のみで、処理時間の改善は主張していない。
- しかし、Array 削減という性質上、GC 負荷低減による間接的な恩恵は期待できる変更。
#59556 Read the other relation's values without a copy when merging
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
Relation#merge実行時に、マージ元のRelationが持つvaluesハッシュを不要にコピーしていたのをやめ、読み取り専用で直接参照するようにして、オブジェクト生成数を削減する最適化です。unscope!の影響を受け得る「自分自身の values」を扱う場合のみ、これまで通りコピーします。
- 変更内容の詳細
なにを改善したか
従来の挙動:
# Relation#merge 内部イメージ
def merge(other)
Merger.new(self, other).merge
end
class ActiveRecord::Relation::Merger
def initialize(relation, other)
@relation = relation
@other = other
# ここで other.values を dup していた
@values = other.values.dup
end
endRelation#mergeは、other側のvalues(where, joins, includes などの内部状態が詰まった Hash)をdupしてからマージしていました。- しかしマージ処理自体は「読むだけ」で、
otherのvaluesを変更しないにもかかわらず、毎回 Hash オブジェクトを余分に生成していました。 - この
mergeは、アソシエーション読み出し (post.author,author.posts, スコープ付き / through アソシエーション) の内部でも常に使われるため、頻度が高い。
今回の変更:
Mergerがotherのvaluesにアクセスするための新しい読み取り専用メソッドvalues_for_merge(:nodoc:)がRelationに追加されました。Mergerはother.valuesをdupするのではなく、原則としてother.values_for_mergeを「そのまま読む」だけに変更されました。- ただし「受け側 relation の values を参照している場合」に限り、これまで通りコピーします。これは
unscope!が内部で@valuesからキーを破壊的に削除するため、読み取り中に状態が変わるのを避けるためです。
擬似コードイメージ:
# activerecord/lib/active_record/relation.rb
class ActiveRecord::Relation
# 新規: マージ用の読み取り専用 API(:nodoc:)
def values_for_merge
@values
end
end
# activerecord/lib/active_record/relation/merger.rb
class ActiveRecord::Relation::Merger
def initialize(relation, other)
@relation = relation
@other = other
# other が @relation 自身(または結果的に同じ @values を共有するケース)の場合のみ dup
if other.equal?(relation) || other.values_for_merge.equal?(relation.values_for_merge)
@values = other.values_for_merge.dup
else
@values = other.values_for_merge
end
end
end
# activerecord/lib/active_record/associations/collection_proxy.rb
class ActiveRecord::Associations::CollectionProxy
# `values` と同様に `values_for_merge` も delegte
delegate :values_for_merge, to: :@association
endテスト
activerecord/test/cases/relation/merging_test.rb にテストが追加され、以下をカバーしています。
- relation を自分自身に対して
mergeするケース - スコープを
CollectionProxyに対してmergeするケース(author.posts.merge(Post.published)のようなもの) - アソシエーションスコープ (
has_many :articles, -> { published }など) をマージするケース
これらのケースで、unscope! による破壊的変更が安全に動きつつ、不要なオブジェクト生成が減ることを確認しています。
- 影響範囲・注意点
影響範囲
- 対象となるのは
ActiveRecord::Relation#mergeと、その内部で使われるActiveRecord::Relation::Merger。 - フレームワークレベルで
mergeを利用している以下のような一般的なコードパスはすべて対象です:- 単純なアソシエーション取得:
post.author,author.posts - スコープ付きアソシエーション:
has_many :posts, -> { published } - through アソシエーション:
has_many :comments, through: :posts - ユーザーコードでの
merge:Post.published.merge(Post.recent)
- 単純なアソシエーション取得:
動作上の互換性
- 公開 API (
Relation#merge,CollectionProxy#merge) の挙動は変わりません。変更は内部実装と割り当てオブジェクト数の削減のみです。 - ただし、内部 API (
Relation#values_for_merge) が追加されていますが:nodoc:です。これに依存すべきではありません。 unscope!とmergeを組み合わせた複雑なメタプログラミング・ハックをしていない限り、既存アプリに挙動の変化はまずありません。
パフォーマンスへの影響
- 典型的なオブジェクト生成削減例(PR 説明より):
post.author一回あたり 223 → 221 オブジェクトauthor.posts.to_a一回あたり 239 → 237 オブジェクト- スコープ付き
has_many302 → 299 count呼び出し 100 → 98
- 9 つの Rails main ベースのアプリ(SQLite/PostgreSQL/MySQL)での測定では、97 パターンのリクエスト・ジョブ・メイラーのうち 80 で割り当てオブジェクト数が減少(中央値 -6.8)。A/A テストの揺れ(17)と比較しても有意な改善といえます。
- この PR は割り当て数の改善のみを主張しており、実行時間向上については主張していません(ただし GC 負荷減により、間接的な恩恵はあり得ます)。
注意点
- 独自に
Relation#valuesやCollectionProxy#valuesを直接いじっているような内部依存コードがある場合(通常は非推奨)、values_for_mergeの存在に触発されて変な最適化をしないよう注意してください。values/values_for_mergeは完全に内部用です。 Relationを継承・拡張しているライブラリで、initializeやmergeの実装を差し替えている場合は、この PR と干渉しないか一応確認した方が安全です(とはいえ、ほとんどのアプリには無関係な領域です)。
- 参考情報 (あれば)
- この PR は #59212 とは独立に動作し、#59212 適用後でも依然として多くのワークロードで割り当て削減効果が残るとされています(97 のうち 68 がまだ改善)。
- 関連ファイル:
activerecord/lib/active_record/relation.rbvalues_for_mergeの追加。activerecord/lib/active_record/relation/merger.rbvaluesの扱いをdup前提から「条件付きdup+ 読み取り専用参照」に変更。activerecord/lib/active_record/associations/collection_proxy.rbvalues_for_mergeを委譲する 1 行の変更。activerecord/test/cases/relation/merging_test.rb
利用シナリオをカバーするテストが 27 行追加。
#59550 Compare Trix attachment HTML with assert_dom_equal
マージ日: 2026/11/3 | 作成者: @yahonda
- 概要 (1-2文で)
Action Text の Trix 添付ファイル関連テストが Nokogiri 1.19.5 以降で落ちていた問題に対し、HTML 比較方法を文字列比較からassert_dom_equalに変更して、Nokogiri のシリアライズ仕様変更に依存しないようにした PR です。これにより、Nokogiri のバージョン差異に関係なく同じテストが通るようになります。
- 変更内容の詳細
背景となる問題
- 失敗していたテスト:
ActionText::ContentTest#test_sanitizes_attachment_markup_for_TrixActionText::AttachmentTest#test_to_trix_html_sanitizes_action-text_HTML_content_attachment
- Nokogiri 1.19.5 以降の HTML5 シリアライズ仕様変更により、属性値中の
<と>が</>にエスケープされるようになった(WHATWG HTML仕様に準拠した変更)。 - 既存テストは、
data-trix-attachment属性を「シリアライズ後の文字列そのもの」として比較していたため、Nokogiri のバージョンごとに生成される文字列が異なり、CI(Nokogiri 1.19.5+)ではテストが落ちていた。
対応内容
テストの HTML 比較方法を、文字列の完全一致から DOM ベースの比較に変更しています。
具体的には、対象テストで:
変更前(イメージコード):
rubyassert_equal expected_html, actual_htmlあるいは、
data-trix-attachmentなどを文字列としてそのまま比較していた。変更後:
rubyassert_dom_equal expected_html, actual_html
assert_dom_equal は内部的に HTML をパースし、DOM として構造・属性値を比較するため、
</>が文字列中で</>としてシリアライズされていても、- 同じノード構造・属性値であれば「同じ DOM」とみなしてパス
するようになります。
これにより、以下の両方でテストが通る:
- 古い Nokogiri(例:
Gemfile.lockに入っている 1.19.1) - 新しい Nokogiri(CI でインストールされる 1.19.5 以降)
対象ファイル
actiontext/test/unit/attachment_test.rbactiontext/test/unit/content_test.rb
の 2ファイルで、それぞれ関連テストに対して assert_dom_equal を使うように +3/-1 行程度の小変更が行われています。
- 影響範囲・注意点
影響範囲
- 実際の Action Text 機能・本番コードには変更なし。
- 影響するのは Trix 添付ファイル(
data-trix-attachment)に関するユニットテストのみ。 - これにより、Rails の Action Text テストスイートが Nokogiri バージョンに依存せず安定する。
注意点 / 開発者向け示唆
- HTML/DOM をテストする際、Nokogiri などの HTML シリアライザの仕様変更に影響されやすい文字列比較 (
assert_equalでの raw HTML 比較) は脆くなりがち。 - 今回のように:
- DOM 構造や属性値の論理的な同一性を検証したい場合は
assert_dom_equal - 単純文字列比較が本当に必要な場合は、ライブラリのシリアライズ仕様に追従する覚悟が必要
- DOM 構造や属性値の論理的な同一性を検証したい場合は
- 自前のテストでも、属性値中の
<,>,&などが関わる場合、assert_dom_equalや属性値の個別取得 + 比較を検討した方がよいです。
- HTML/DOM をテストする際、Nokogiri などの HTML シリアライザの仕様変更に影響されやすい文字列比較 (
- 参考情報 (あれば)
- Nokogiri の仕様変更 PR:
- WHATWG HTML の関連変更:
- 対象テストが落ちていた nightly CI ビルドログ:
#59552 Use the scopes as constraints of a direct association reflection
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
belongs_to/has_one/has_manyのような「直接の関連(through を介さないもの)」について、関連スコープ構築時に余分な配列オブジェクトを作らないようにして、オブジェクトアロケーションを減らす最適化が入っています。挙動は変えずに、メモリ使用量を少し改善する内部的なパフォーマンスチューニングです。
- 変更内容の詳細
背景: constraints と scopes
Active Record では、関連スコープを組み立てる際に「リフレクション(関連定義)」のチェーンを辿り、それぞれの constraints を集めてスコープを構築します。
- チェーン:
chain - 各リフレクションの
constraints: 通常はchain.flat_map(&:scopes)を通じて取得 scopes: その関連に紐づくスコープ情報(プロックや Arel 条件など)
belongs_to / has_one / has_many のような「through を使わない直接の関連」の場合、チェーンは 1 要素だけになります:
chain # => [self] (self はその関連の Reflection)このとき constraints を求めるために
chain.flat_map(&:scopes)を実行すると、実際には self.scopes を取り出すだけで足りるのに、毎回 2つの配列が内部的に生成されます。
chain用の配列 (既にある)flat_map内部で使用される中間配列scopesをコピーした結果の配列 … など、不要なアロケーションが発生
この PR の変更点
この PR では、以下のポリシーに変更されています:
- 「直接の関連 (through で別関連を経由しない
belongs_to/has_one/has_many)」constraintsメソッドで、単にscopesをそのまま返すように変更- これにより
chain.flat_map(&:scopes)による不要な配列生成を避ける
- 「through 関連 (
has_many :throughなど)」および「ポリモーフィック関連」- これまでどおり自前の
constraints実装を維持し、挙動に変更はない
- これまでどおり自前の
ファイル変更は activerecord/lib/active_record/reflection.rb のみで、行数も +5 行と小さな変更です。
コードイメージとしては以下のような形になります(擬似コードレベルのイメージ):
# もともと: constraints は共通的に chain.flat_map(&:scopes) を使うパスがあった
def constraints
chain.flat_map(&:scopes)
end
# 今回: 直接の関連では、余計な flat_map を使わず、そのまま scopes を返す
class AssociationReflection
def constraints
# through を介さない belongs_to / has_one / has_many 用の実装で、
# そのまま scopes を返すようにした
scopes
end
end
class ThroughReflection
def constraints
# これまでどおり、自分専用の constraints ロジックを保持
...
end
end
class PolymorphicReflection
def constraints
# これまでどおり
...
end
end効果 (オブジェクト数の削減)
作者による計測:
post.comments.sizeのような素のhas_many- オブジェクトアロケーション: 249 → 246 (−2配列/1呼び出し程度)
- スコープ付き
has_many- 302 → 299
has_many :through- 292 → 286(こちらは through 側の処理も絡むので変動がやや大きめ)
9つのアプリ・97種類のワークロードで計測したところ:
- 74/97 のワークロードでオブジェクトアロケーション数が減少(中央値 −7)
- 実行時間については「改善を主張するには小さすぎる」ということで、タイムの主張は明示的にしていない
- 影響範囲・注意点
- 挙動の互換性
- 公開 API レベルでの挙動変更は意図しておらず、返されるスコープロジック自体は従来どおりです。
constraintsが返す配列インスタンスそのものに依存しているようなメタプログラミング(例:object_idや破壊的変更)をしていなければ、通常のアプリケーションには影響ありません。
- 対象となる関連
belongs_tohas_onehas_many- ただし「through を介さない直接関連」のみ。
has_many :throughやポリモーフィック関連は従来のロジック継続。
- パフォーマンス観点
- 1呼び出しあたりの削減は小さいですが、アプリ全体のトラフィックや複雑な関連を多用するケースでは、GC 負荷の軽減にわずかに寄与する可能性があります。
- 計測上、多くのワークロードでオブジェクト数が減っているが、劇的な差ではないため、性能改善目的でこれ単体に依存するのは現実的ではありません。
- 参考情報 (あれば)
- この変更は PR #59212 とは独立に機能します。
- #59212 適用後でも、今回の最適化の効果は残り、97 ワークロード中 63 で依然オブジェクト数が減っているとのこと。
- 実装箇所:
activerecord/lib/active_record/reflection.rb- Reflection 周りを拡張・オーバーライドしている場合は、
constraintsとscopesの関係がより直接的になったことを踏まえてコードを確認するとよいです。
#59555 Index what each yields in Enumerable#index_with again
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails 8.1 以降でEnumerable#index_withがHashやStructに対して挙動退行していた問題を修正し、「eachが yield する値」をインデックス対象として再び使うように戻した PRです。Arrayに対してはパフォーマンス改善のためのto_h利用を維持しつつ、Enumerable全体では従来どおりeachに基づいた挙動を保証します。
- 変更内容の詳細
何が問題だったか
もともと Enumerable#index_with は、each が yield する値をキーとしてハッシュを作っていました。
# Rails 8.1 以前のイメージ
enum.index_with { |elem| ... } # elem は each が yield するものしかし、コミット 2b88dc1995 で「事前に配列サイズを見積もれる」という理由から内部実装を to_h { ... } ベースに変更した結果、
to_hがeachと同じ形で yield しないEnumerableで挙動が変わってしまった
具体例:
Hashの場合
# 元々(Rails 8.1 の挙動)
{ a: 1 }.each.to_a
# => [[:a, 1]]
{ a: 1 }.index_with { |pair| pair }
# => { [:a, 1] => [:a, 1] } # キーは [:a, 1]
# 問題が発生した実装(to_h ベース)
{ a: 1 }.to_h { |elem| ... }
# Hash#to_h はブロックに key, value の 2 引数を渡す
{ a: 1 }.index_with { |k, v| v }
# |k, v| に 2 引数渡されるため、一見正しそうに見えるが、
# 「index_with のキーに何を使うか」が to_h 実装依存になってしまう
# 説明文中の問題例(ブロック引数 1 個想定の書き方で使うと)
{ a: 1 }.index_with { |k, v| ... }
# 本来は k = [:a, 1], v = nil という形で「ペア 1 つ」を受ける想定だったのに、
# Hash#to_h 側の 2 引数の仕様が紛れ込んでしまう要するに、
Enumerable#index_withは「eachが yield するもの」を前提にした API だったのに- 実装を
to_hベースに変えたことで、「to_hがどう yield するか」に依存してしまった
という仕様的なねじれが発生していました。
Structの場合
Struct#to_h は「メンバ名と値のペア」ではなく「メンバ名をキーにしたハッシュ」として扱われる関係で、これも each とは異なる形で yield します。そのため、Struct#index_with でも従来と違うキーが使われるようになってしまっていました。
何を直したか
PR では以下のように整理し直しています。
Enumerable#index_withは再びeachを使う- 内部実装を
to_h { ... }ベースから、従来どおりのeachループベースに戻した - これにより、
Hash・Struct・HashWithIndifferentAccessなど、to_hがeachと違う振る舞いをするクラスでも、「eachが yield する値をインデックスにする」という本来の意味を回復
イメージ:
rubymodule Enumerable def index_with # 擬似コード result = {} each do |elem| result[elem] = yield(elem) end result end end- 内部実装を
Array#index_withだけはto_hを使う最適化を維持Arrayに対するindex_withについては、to_hを使ってもeachと yield される値が等価Array#eachもArray#to_h { ... }も、ブロックには要素 1 つずつが渡される形になる
- そのため、パフォーマンス最適化(配列サイズに応じた事前割り当てなど)を維持する意図で、
Array#index_withに限定してto_hベースの実装を残している - あわせて、Ruby 4.1 での
#59203(埋め込みハッシュに関する挙動)の対応も、このArray#index_withベースのto_hで問題なく機能するよう配慮されている旨が説明されています
テストの追加
activesupport/test/core_ext/enumerable_test.rb に回帰テストが追加され、以下のケースで「以前と同じ挙動」であることが確認されます。
Hash- ブロック引数 1 つのケース
- ブロック引数 2 つのケース
- ブロック省略など「デフォルトの形」のケース
HashWithIndifferentAccessStruct
PR 説明では「このテストは main ブランチでは失敗する(= 回帰を検出できる)」と述べられています。
- 影響範囲・注意点
対象バージョン
- 回帰を引き起こした変更は Rails 8.1 以降に入ったが、まだリリース前の状態だったため、この PR も同じく「未リリースの挙動」を修正する扱いです
- そのため CHANGELOG には記載なし(ユーザーがすでにリリースで影響を受けていたわけではない)
影響があるクラス
Enumerable#index_withを、Hash・Struct・HashWithIndifferentAccessなどで利用しているコード- 「
eachが yield する単位(要素・ペア・構造体インスタンスなど)」をキーにしている前提のコードは、この PR によって従来どおりの意図した挙動に戻る Arrayについてはindex_withの挙動は(表面上)変わらず、内部実装のみ最適化されている形です
注意点(開発者視点)
- 今後
Enumerable#index_with相当の処理を自前で実装する場合も、
「to_hではなくeachが yield するものを前提にする」というこの PR の設計意図を意識しておくと、HashやStructでの思わぬ挙動差を避けられます index_withをHashやStructに対して使う際、rubyのenum.index_with { |x| ... }xは、「eachで見える単位」と同じであることが仕様として再確認された、と理解しておくとよいです
- 今後
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59555
- 関連コミット:
2b88dc1995(index_withをto_hベースに変更したコミット) - 関連 Issue/PR: #59203(Ruby 4.1 での埋め込みハッシュ対応に関連)
#59551 Skip extending! in Relation#merge when there are no extensions
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
Relation#merge内で常に呼ばれていたextending!を、「拡張モジュールが存在する場合だけ」呼ぶようにし、余分な配列オブジェクトの生成を避けてメモリ割り当てをわずかに削減する変更です。動作仕様は変えずに、Active Record の関連操作(author.posts.countやmerge(Post.published)など)のオブジェクト数を微減させるマイクロ最適化です。
- 変更内容の詳細
何が問題だったか
ActiveRecord::Relation#merge は、最終的に以下のような処理をしていました(概念的なイメージ):
# 擬似コードイメージ
def merge(other)
# ... いろいろなマージ処理 ...
relation.extending!(*other.extensions)
endother.extensionsは、Relation#extendingなどで付与された「拡張モジュール」のリストです。- 多くの場合、この拡張は使われず
other.extensionsは空の配列です。 extending!自体は「引数がなければすぐ return」する軽いメソッドですが、*other.extensionsのスプラット展開のために毎回「配列を 1 個」確保するオーバーヘッドが発生していました。
このため Relation#merge を呼ぶたびに、ほぼ無駄な Array オブジェクトが 1 つずつ増える形になっていました。
今回の変更点
extensions が空のときには extending! 自体を呼ばないようにしただけの 1 行変更です。イメージとしては次のような条件分岐が追加された形です:
def merge(other)
# ... マージ処理 ...
relation.extending!(*other.extensions) if other.extensions.any?
end実際の差分も「+1 / -1 行」の小さな変更で、ロジックはほぼこの意図と同じです。
効果の具体例
PR では実測として、例えば以下のようなケースでオブジェクト数が減ったと報告されています:
author.posts.count- 100 → 98 オブジェクト
Post.where(...).merge(Post.published).to_a- 123 → 122 オブジェクト
- YJIT 有効時でも同様の傾向
9 つのアプリケーション・97 個のワークロード(リクエスト、ジョブ、メイラー)での計測では:
- 76 / 97 のワークロードで割り当てオブジェクト数が減少
- 減少の中央値は −6 オブジェクト
パフォーマンスの時間(スループットやレイテンシ)改善を主張するものではなく、割り当てオブジェクト数の削減にフォーカスした変更だと明示されています。
また、この変更は別 PR #59212 とは独立で、#59212 を適用した状態でも 97 中 63 ワークロードでなおオブジェクト数に変化があるとのことです。
- 影響範囲・注意点
影響範囲
- 対象:
ActiveRecord::Relation#mergeを通るすべてのパス- 関連経由の読み取り:
author.posts,author.posts.where(...),author.posts.count,author.posts.exists?など - Relation のマージ:
Post.where(...).merge(Post.published),User.active.merge(User.admin)など
- 関連経由の読み取り:
- 機能的な挙動は変わらず、「拡張があるときのみ
extending!でそれを反映する」点は従来どおり維持されています。 - 拡張モジュールを実際に利用している場合(
Post.extending(SomeModule)や association にextendingを付けているケースなど)でも、extensionsが空でない限りこれまでどおりextending!が呼ばれるため、動作上の差異はありません。
後方互換性・注意点
Relation#extensionsが空である場合に限りextending!呼び出しがスキップされるため、以下のような前提に依存しているコードがあると理論上は影響します:- 「
extending!が毎回必ず呼ばれること」を副作用として当てにしているコード
(例えばextending!を monkey patch してログを取る、呼ばれた回数で何かをするなど)
- 「
- ただし、そのような使い方は通常想定されておらず、実質的には後方互換性への影響はほぼゼロと見なしてよい変更です。
実務的には「Active Record の関連や merge を多用するアプリで、GC 負荷やオブジェクト割り当てをとにかく減らしたい場合に、ほんの少しだけ効く」レベルのマイクロ最適化です。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59551
- 関連 PR: https://github.com/rails/rails/pull/59212
- 関連 API ドキュメント:
ActiveRecord::Relation#mergeActiveRecord::Relation#extending/#extending!/#extensions
#59554 Don't build bang method names when merging relation values
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
ActiveRecord::Relation#mergeが内部でlimit!やoffset!などのメソッド名を毎回動的に生成していたのを、事前に定義したシンボルを使うように変更し、オブジェクトアロケーション数をわずかに削減するパフォーマンス改善です。挙動や公開APIは変えずに、Relation マージ時の文字列生成コストを減らしています。
- 変更内容の詳細
何をしていたか
Relation#merge は、マージ対象の Relation が持つ各種値(limit, offset, distinct, readonly など)を、自身に反映するために以下のようなことを行っています。
- 値の種類ごとに対応する「破壊的メソッド」(
limit!,offset!,distinct!,readonly!など)を呼び出す - これまではそのメソッド名を毎回
:"#{name}!"のようにして動的に組み立てていました
疑似コードで表現すると、以前はだいたいこんなイメージです:
MERGEABLE_VALUES.each do |name|
value = other_relation_values[name]
next if value.nil?
bang_method = :"#{name}!" # ここで毎回 String → Symbol が発生
send(bang_method, value)
endこの "#{name}!" によって、マージされる値ごとに新しい String が作られていました。
何に変えたか
この PR では、NORMAL_VALUES と同様の位置に「limit! などの bang メソッド名の一覧」をあらかじめ定義し、それを使ってメソッドを呼び出すようにしました。
イメージとしては、例えば以下のような形です(実際の定義名や構造は多少異なる可能性がありますが、本質は同じです):
# 事前に bang メソッド名を紐づけておく
BANG_METHODS = {
limit: :limit!,
offset: :offset!,
distinct: :distinct!,
readonly: :readonly!,
# …
}
BANG_METHODS.each do |name, bang_method|
value = other_relation_values[name]
next if value.nil?
send(bang_method, value) # ここでは既存の Symbol を使うだけ
endポイント:
"#{name}!"といった文字列結合はもう行わない- メソッド名はシンボルとして1回だけ定義して共有
- ループの順番(イテレーションオーダー)は変更していない(
NORMAL_VALUESに揃えている)
効果(測定結果)
PR 説明で示されている具体的な効果:
- 1つのマージ対象値につき 1つ String アロケーションが減る
post.author(単数関連読み出し): 223 → 222 個Post.published.merge(Post.recent): 178 → 177 個
- 9つのアプリ(DB: SQLite / PostgreSQL / MySQL)の 97 種類のワークロード(リクエスト・ジョブ・メール送信)中:
- 66件でオブジェクトアロケーション数が減少(中央値 -3)
- あくまで「アロケーション数」の改善であって、時間的な速度向上を直接主張しているわけではない
さらに、関連 PR #59212(plain な association reader が毎回 scope を組み立てるのをやめ、limit(1) の merge 自体をなくす変更)を適用した後も:
- 依然として「マージされる他の値」に対する String 削減効果は残る
- 97ワークロード中 51件でアロケーション減少(中央値 -1)
- YJIT 下ではノイズレベル(効果が埋もれる程度)との観測
2つの PR (#59554 と #59212) はコンフリクトしないように設計されています。
- 影響範囲・注意点
- 対象:
ActiveRecord::Relation#mergeの内部実装のみlimit,offset,distinct,readonlyなどのマージ処理- 特に、関連読み出し(
post.authorなどの単数関連)や、スコープ同士の merge (Post.published.merge(Post.recent)) など、Relation をマージするあらゆるケース
- 挙動の変化:
- 公開 API のシグネチャ・返り値・SQL などの機能的挙動は変わらない設計
- イテレーション順序も維持されていると明言されており、
merge結果の優先度や上書きルールに変更はない前提
- 互換性:
Relation#mergeの内部でsend(:"#{name}!")をフックしているようなメタプログラミングをしていない限り、互換性問題はほぼ起こり得ません- 通常のアプリコードでは、この内部実装差分を意識する必要はありません
- パフォーマンス:
- 効果はかなり小さく、オブジェクトアロケーション数の微減レベル
- 高頻度で Relation マージが発生する大規模アプリや tight loop では、GC 圧のごくわずかな低減が期待できる程度
- 参考情報 (あれば)
- 該当 PR:
- 関連 PR(association reader 側の
limit(1)マージをなくす改善): - 背景となる一般的な話:
- Ruby では
:"#{name}!"のように毎回文字列補間を行うと、その String が都度生成され GC 対象になるため、ホットパスでは事前定義したシンボルを使うのが定石 - Rails 本体はこの種の「小さなアロケーション削減」を継続的に行うことで、トータルの GC 負荷を下げる方針をとっている
- Ruby では
#59553 Return a frozen empty Array from default_extensions
マージ日: 2026/11/3 | 作成者: @thomasklemm
- 概要 (1-2文で)
ActiveRecord::Scoping::Namedのdefault_extensionsが、毎回新しい空配列を返すのではなく、共有された frozen な空配列を返すように変更されました。これにより、関連オブジェクトの生成時のオブジェクト割り当てがわずかに減り、メモリアロケーションが最適化されています。
- 変更内容の詳細 (サンプルコード含む)
背景
各コレクション関連 (
author.postsなど) のプロキシは、内部でAssociation#extensionsを呼び出します。Association#extensionsは、以下のようなイメージで- モデルクラス側の
klass.default_extensions - リフレクション (
has_many ... do ... endなどで指定した extension)
を union(和集合)して使っています。
擬似コードイメージ:
rubydef extensions klass.default_extensions | reflection.extensions end- モデルクラス側の
「デフォルトスコープを持たないモデル」では、
klass.default_extensionsは「何もない」ので、実際には空配列を返していましたが、そのたびに 新しい Array オブジェクト が生成されていました。
変更点
default_extensionsが「空の場合」に、毎回[](新規配列)を返すのではなく、共有された frozen な空配列を返すように変更されました。変更前(イメージ):
rubydef default_extensions [] end変更後(イメージ):
rubyEMPTY_EXTENSIONS = [].freeze def default_extensions EMPTY_EXTENSIONS end実測として、
author.posts.to_aの実行で割り当てられる配列が「239 → 238」と、関連ごとに Array 1つ分だけ減っています。Rails チームが 9 個のアプリケーション・97 個のワークロードで計測したところ、
- 58 ワークロードでオブジェクト割り当て数が減少(中央値 −1.6)
- 処理時間についての有意な改善までは主張していない(“No timing claim”)。
- 影響範囲・注意点
影響範囲
- 対象は
ActiveRecordの「名前付きスコープ/関連スコープ」に関わる内部実装 (ActiveRecord::Scoping::Named) です。 - 全ての関連 (
has_many,has_one,has_and_belongs_to_manyなど) の コレクション関連プロキシ が恩恵を受けます。 - 特に「デフォルトスコープを定義していないモデル」での関連アクセス(
user.posts,author.booksなど)において、オブジェクト割り当てが微減します。
互換性面の注意点
default_extensionsが返す配列が frozen になったことにより、もしアプリケーションコードや gem がdefault_extensionsの戻り値を「破壊的に変更」している場合はFrozenErrorが発生する可能性があります。例: NG な使い方
ruby# こういうコードがどこかにあると壊れる可能性がある SomeModel.default_extensions << MyExtensionModuleただし、
default_extensionsは内部的な用途がメインであり、通常のアプリコードで直接いじることは稀と考えられます。正しい使い方(拡張モジュールを関連に追加したい場合)は、通常どおり association 定義時にブロックや
extend:オプションを使う形になります:rubyclass Author < ApplicationRecord has_many :posts do def published where(published: true) end end endもしくは:
rubymodule PostsExtensions def published where(published: true) end end class Author < ApplicationRecord has_many :posts, extend: PostsExtensions end内部的には「共有された frozen 配列 + union 演算」で扱うため、機能的な挙動(関連に定義された extension が呼び出せるかどうか)は変わりません。
- 参考情報 (あれば)
- 対象ファイル:
activerecord/lib/active_record/scoping/named.rb(+2 / -2 の非常に小さな変更) - PR 番号: #59553
タイトル: "Return a frozen empty Array fromdefault_extensions" - パフォーマンス効果:
- 「オブジェクト割り当て削減」による 軽微なメモリ効率改善 が主目的
- 実行時間については「改善を主張しない」と明記されているため、マイクロ最適化レベルの変更と見なすのが妥当です。
#59544 Only extract Ranges in ArrayHandler when there are any
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
where(column: array)などで使用されるArrayHandlerが、配列内にRangeオブジェクトが含まれない場合には、Range 抽出用の一時配列を生成しないよう最適化されました。SQL やバインドパラメータの内容・挙動は変えずに、不要なオブジェクト割り当てをわずかに削減する変更です。
- 変更内容の詳細
何をしているクラスか
activerecord/lib/active_record/relation/predicate_builder/array_handler.rb は、以下のようなクエリで使われる
Model.where(column: array)
# => column IN (...)のような配列を使った条件 (IN 句や範囲指定) を、SQL とバインド値に変換するためのハンドラです。
配列に Range オブジェクトが含まれていると、BETWEEN などに展開する処理があります。
変更のポイント
元の実装では、配列の中に Range が含まれているかどうかに関わらず、毎回「Range だけを集めた新しい配列」を作成していました。その結果、
where(id: [1, 2, 3])のように Range を一切含まない配列でも
毎回空の配列を割り当てて捨てる、という無駄なオブジェクト生成が発生していました。
今回の変更では:
- まず
array.any? { |v| v.is_a?(Range) }のような形で、配列にRangeが含まれているかを事前チェック - Range が1つも無ければ、Range 抽出処理そのものをスキップ
(=新しい配列を作らない) - Range が含まれている場合のみ、従来通り Range を抽出した配列を作成し、SQL に反映する
というフローになっています。
コードイメージ(概念的な擬似コード):
def call(column, values)
# 変更後: まず Range が含まれているかを確認
if values.any? { |value| value.is_a?(Range) }
ranges = values.select { |value| value.is_a?(Range) }
# Range を使った BETWEEN などの条件を組み立てる
end
# 残りは従来通り: IN (...) 用の条件などを組み立てる
end実際の変更は +2/-2 行程度で、is_a?(Range) 相当のチェックを、
「抽出処理の中」だけでなく「抽出処理をするかどうかの分岐」にも使うようになった、という非常に小さな修正です。
パフォーマンスへの効果
作者の測定結果:
Post.where(id: ids).to_a
オブジェクト割り当て数: 172 → 171Post.preload(:author, :comments)
オブジェクト割り当て数: 691 → 689- 9つのアプリ・97ワークロード中、60ワークロードでオブジェクト割り当てが減少
あくまで「割り当て数の削減」の話であり、PR では 処理時間の改善を主張していない 点が明示されています。
- 影響範囲・注意点
- 機能・挙動の変更はなし
生成される SQL 文およびバインド値は「完全に従来通り」であり、アプリケーションから見える振る舞いは変わりません。 - 影響範囲は
ArrayHandlerを通る場面全てですが、実質的には:Model.where(column: array)形式のクエリincludes/preloadなどが内部で発行するINクエリ における内部実装のメモリアロケーションがわずかに軽くなっただけです。
- Range を含むクエリ(
where(id: 1..10)や[1, 2..5, 10]など)の動作・SQL 生成ロジックも変わっていません。チェック条件も以前と同じis_a?(Range)ベースなので、ここでの判定仕様にも変更はありません。 - したがって、この PR によって既存コードが壊れるリスクは非常に低く、通常のアプリ開発者が特別な対応をする必要はありません。
- 参考情報 (あれば)
- 対象ファイル:
activerecord/lib/active_record/relation/predicate_builder/array_handler.rb - 関連する Active Record 機能:
where(column: array)によるIN句- Range を含む配列 (
[1..3, 5]など) の SQL 展開 includes/preload/eager_loadが内部で生成するバッチINクエリ
- パフォーマンス観点:
- オブジェクト割り当て数削減による GC プレッシャーの微減
- ただし PR 自身が示す通り、実行時間の短縮は測定・主張されていないため、期待するなら自前でベンチマークを取るのがよいです。
#59543 Avoid copying relation values in except and only
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
ActiveRecord::Relation のexcept/onlyが内部で余計な Hash のコピーを作らないようにし、オブジェクト生成数をわずかに削減する変更です。挙動は変えずにメモリアロケーションを少し最適化するマイクロパフォーマンス改善です。
- 変更内容の詳細
これまでの実装
Relation#except / Relation#only は内部状態を持つ @values(クエリ条件などを保持する Hash)に対して、いったん values メソッド経由で dup 済み Hash を取り出し、それに対して except / slice を呼んでいました。
イメージとしては次のような形です(実際のコードを簡略化):
def except(*skips)
relation = spawn
relation.values = values.except(*skips) # ← values が @values.dup を返す
relation
end
def only(*onlies)
relation = spawn
relation.values = values.slice(*onlies) # ← 同様に dup を経由
relation
endこの場合、
valuesメソッドが@valuesをdupして新しい Hash を返す- さらに
Hash#except/Hash#sliceが また 新しい Hash を返す
という二重コピーになっていました。
つまり 1 回の except / only 呼び出しで、不要な Hash が 1 個多く生成されている状態です。
今回の変更
values 経由ではなく、インスタンス変数 @values を直接参照するように変更しました。
擬似コードで表すと次のような違いです:
- relation.values = values.except(*skips)
+ relation.values = @values.except(*skips)
- relation.values = values.slice(*onlies)
+ relation.values = @values.slice(*onlies)ここでのポイント:
Hash#except/Hash#sliceはもともとレシーバを破壊せず、新しい Hash を返す メソッド- そのため、
@valuesそのものは変更されず、ミュータビリティの安全性は維持される - わざわざ
dup済みのvaluesを経由する意味がない
結果として、「1 回の except / only 呼び出しあたり Hash の生成が 1 個減る」ようになります。
オブジェクト生成数の差分(PR 内の計測)
PR では以下のように、代表的なケースで 1 オブジェクト分の削減になることが示されています:
Post.where(...).exists?- 変更前: 94 個
- 変更後: 93 個
author.posts.exists?(関連経由)- 変更前: 255 個
- 変更後: 254 個
また、9 つのアプリ/97 ワークロードのうち、62 ワークロードでオブジェクト生成が減少しているとのことです。
なお、この PR は 割り当て数のみ を根拠にしており、「パフォーマンスが必ず速くなる」という時間計測上の保証をしているわけではありません。
- 影響範囲・注意点
影響範囲
- 対象メソッド:
ActiveRecord::Relation#exceptActiveRecord::Relation#only
- それらを経由するメソッド:
Relation#exists?Relation#count(スコープ上)- association(関連)スコープの構築処理
- つまり、普通にスコープを組み立てて
exists?/countなどを呼ぶケース全般で、内部的には今回の変更の経路を通ります。
挙動の互換性について
- 通常の Rails アプリケーションでは挙動の変化はありません。
Hash#except/Hash#sliceが新しい Hash を返す仕様は従来どおりであり、@values自体は変更されません。- 以前も、新しい Relation に対して
relation.values = ...していた構造は同じで、「元の Relation の状態が破壊されない」性質は維持されます。
カスタムな Relation サブクラスを使っている場合の注意点
PR の説明にもある通り、Relation を継承して values メソッドをオーバーライドしている場合 の挙動が変わる可能性があります。
- 以前:
except/onlyはvaluesメソッドを経由していたため、- サブクラスで
valuesをオーバーライドしていれば、そのロジックが反映されていました。
- 今回:
except/onlyは@valuesを直接参照するようになったため、valuesのオーバーライドはexcept/onlyには影響しません。
Rails 本体に含まれるクラスで values をオーバーライドしているものはないため、フレームワークとしては安全な変更ですが、
独自実装で Relation を拡張し、values に特別な処理(遅延初期化・フィルタリングなど)を入れているケースでは挙動の違いが出る可能性があります。
そのような場合は、以下のような対応が必要になるかもしれません:
valuesをオーバーライドする代わりに、@valuesの初期化タイミングを見直す- あるいは、
except/only自体をサブクラスでオーバーライドし、従来どおりvaluesを経由する形に戻す
- 参考情報 (あれば)
PR 本文での主なポイント:
Hash#except/#sliceはレシーバを変更しないので、事前のdupは不要- メリットはあくまで オブジェクト生成数の削減 であり、ベンチマーク時間についての主張はしていない
- Rails 本体には、
valuesをオーバーライドする Relation サブクラスは存在しない
関連する ActiveRecord の内部 API:
ActiveRecord::Relation#spawn: 既存の Relation をベースに新しい Relation を生成する内部メソッドActiveRecord::Relation#values/@values: SELECT, WHERE, ORDER など、クエリ構築に関する内部状態を保持する Hash
#59542 Skip finalizing intents on checkin when none are pending
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
このPRは、接続チェックイン時(リクエストやジョブ終了時など)に「保留中の intent」が何もない場合は処理を早期リターンし、不要な配列オブジェクトの割り当てを避ける最適化です。機能的な挙動は変えずに、オブジェクトアロケーションをわずかに削減します。
- 変更内容の詳細
対象ファイル:
activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb(+2行)
背景となる挙動:
- Active Record の接続は、
- 各リクエストの終わり
- 各ジョブの終わり
- (デフォルトの per-block leasing の場合)リースを保持していないクエリブロック終了時
などのタイミングで「checkin」の処理が走ります。
- この checkin 処理の一環として、「pending intents(保留中の intent)」を保持している配列を、毎回「新しい空配列」に置き換えていました。
- たとえ既に空の配列であっても、新たな空配列オブジェクトを毎回割り当てていたため、無駄なオブジェクトアロケーションが発生していました。
今回の変更:
- 「保留中の intent が1つもない(配列が空)の場合は、そこで早期リターンする」という条件分岐が追加されました。
- これにより、
- 保留中の intent がある場合: 従来通り intent を finalize し、配列をリセット
- 保留中の intent がない場合: finalize 処理も配列の再割り当ても行わず、そのまま抜ける
という挙動になります。
擬似コードイメージ(あくまで説明用):
def finalize_pending_intents
# 【今回追加】空なら何もしない
return if @pending_intents.empty?
# ここから下は従来通り
@pending_intents.each(&:finalize!)
@pending_intents = []
endPRの説明にある通り、
Post.firstを(リース非保持で)実行するケースで、checkin あたりの Array アロケーションが118 → 117に減少- 9つのアプリ、97のワークロードを計測したところ、82ワークロードでアロケーション数が減少(中央値 -7個/ワークロード)
という効果が報告されています。
なお、あくまで「オブジェクト割り当て数」だけの改善であり、「パフォーマンス時間の改善」を主張するものではないと明記されています。
- 影響範囲・注意点
機能面:
- intent の finalize 処理がスキップされるのは「そもそも pending intent が1件もない場合」に限られます。
- pending intent が存在する場合の処理フローは従来と変わりません。
- そのため、アプリケーションの観測可能な挙動(クエリ結果、トランザクション挙動など)は基本的に変わりません。
パフォーマンス面:
- 1回あたり配列1個分のアロケーション削減のような、かなり細かいレベルの最適化です。
- リクエスト数・ジョブ数が多い環境では、総和として GC 負荷軽減にごく僅かに寄与する可能性がありますが、すぐに体感できるレベルではないと想定されます。
- Ruby インタプリタ本体、YJIT いずれでも同様のアロケーション削減が確認されています。
互換性/マイグレーション:
- 破壊的変更は含まれていません。
- API 仕様変更や設定変更の必要もありません。
- 既存のコード、特に ActiveRecord::Base / コネクションプール / トランザクションまわりの呼び出し側に修正は不要です。
- 参考情報 (あれば)
- PR 番号: #59542
- タイトル: Skip finalizing intents on checkin when none are pending
- 変更規模: 1ファイル、2行追加(削除なし)
- 指標:
Post.first実行時の checkin あたり Array アロケーション: 118 → 117- 9アプリ・97ワークロード中、82でアロケーション減少(中央値 -7)
- 明示されていること:
- 計測しているのは「オブジェクトアロケーション数のみ」であり、「速度改善」については主張していない。
#59541 Skip unneeded Arrays when merging into an empty WhereClause
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
ActiveRecord::Relation::WhereClause#mergeが、空のWhereClauseに他の条件をマージする際の不要な配列生成や分割処理をスキップする最適化が入りました。結果として、関連の読み取り(post.author,author.posts, スコープ付きhas_manyなど)でオブジェクトアロケーション数が減少します。
- 変更内容の詳細
何が問題だったか
関連を読むたびに、内部的には「関連に紐づく where 条件などを既存の relation にマージする」処理が走ります。WhereClause#merge は、このマージ処理で次のような無駄をしていました:
- 受け側(self)の
WhereClauseに まだ1つも predicate がない 場合でも、- 相手側の
WhereClauseの属性情報を一旦Arrayに展開 except_predicates内でさらに2つのArrayに分割- しかし、実際には「フィルタされる predicate が 0 件」なので分割した意味がない
- 相手側の
このため、「空の WhereClause に条件をくっつけるだけ」のケースでも、配列生成と分割というコストのかかる処理が毎回発生していました。
今回の最適化内容
説明文から読み取れるロジック上の変更点は2つです。
self に predicate が無いときは配列展開をスキップ
- 「元が空」の場合はフィルタ処理の余地がなく、相手の条件をそのまま使えばよいので、
- 相手側の属性を配列化して
except_predicatesでさらに分けて… といった処理を丸ごと飛ばします。
- 「元が空」の場合はフィルタ処理の余地がなく、相手の条件をそのまま使えばよいので、
すべてのカラムが attribute のときは、2 回目の分割をスキップ
except_predicatesは「どのカラムの predicate を除外するか」に応じて、predicate を2つのグループに分けるような処理をしていると考えられます。- しかし、「対象となる predicate がすべて attribute 由来で、それ以外を混ぜる必要がない」ケースでは、二段階の分割処理は無駄になるため、この二度目の分割をスキップします。
この変更により、論理的な結果(predicates, order, binds の中身)は一切変えずに、「中間的な Array オブジェクトの生成・分割」を避けています。
効果の具体例
PR では、1回の呼び出しあたりのアロケーション削減例が示されています(数値は生成されるオブジェクト数):
post.author- 235 → 228 オブジェクト
author.posts.to_a- 252 → 245
- スコープ付き
has_many- 319 → 311
また、Rails main を使う9つのアプリ(SQLite / PostgreSQL / MySQL)での計測では:
- 97個のリクエスト/ジョブ/メイラーワークロード中、82個でアロケーション数が減少
- 対象ワークロードの中央値は 19 オブジェクトの削減
いずれも「オブジェクトアロケーション数のみの測定」であり、「速度向上を数値として保証するものではない」と明確にされている点もポイントです。
- 影響範囲・注意点
影響範囲
- 影響を受けるのは
ActiveRecord::Relation::WhereClause#mergeの内部処理です。 - 実際の利用コードの代表例:
- 関連の読み込み:
post.author,author.posts,user.comments,scoped_has_many_association.recordsなど - 関連付きのスコープ (
has_many :posts, -> { where(published: true) }など) - それらに対してさらに
whereを重ねるような複合的なクエリ
- 関連の読み込み:
振る舞いの互換性
- PR で明言されている通り:
- 「マージ後の
WhereClause(predicates,order,binds)は 以前と同じ」です。
- 「マージ後の
- つまり、生成される SQL や検索結果は変わらない 前提の変更です。
- あくまで内部で行っていた無駄な配列操作を削減しただけなので、通常のアプリケーションコードのレベルでは挙動の変化はないはずです。
注意点
- 独自に
WhereClauseやその周辺の内部 API を直接操作しているようなメタプログラミング・パッチがある場合は、万一に備えてテストを通しておくと安心です。 - ただし、ファイル変更は
where_clause.rbの数行の差分のみであり、公開 API ではなく内部ロジックの最適化なので、互換性リスクはかなり低いと考えられます。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59541
- 該当ファイル:
activerecord/lib/active_record/relation/where_clause.rb- 変更量: 4行追加 / 4行削除(合計1ファイル)
- ベンチマークは Ruby インタプリタと YJIT の両方で実行されており、どちらでも同様にオブジェクトアロケーションが減っているとされています。
#59534 Map Arel project and order arguments in place
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
Rails の Arel において、SelectManager#project/#orderとWindow#order/#partitionの可変長引数の扱いを「その場変換」に変えることで、余分な Array の生成を減らし、オブジェクトアロケーションを削減する最適化が行われました。機能的な挙動は変えずに、SELECT クエリのコンパイル時に発生するメモリアロケーションを抑える変更です。
- 変更内容の詳細
変更の背景
対象メソッドは以下の4つです:
Arel::SelectManager#projectArel::SelectManager#orderArel::Nodes::Window#orderArel::Nodes::Window#partition
これらは Ruby の可変長引数 (*args) を受け取り、既存の内部配列に結合する処理を行っています。
以前の実装では、以下のような構造になっていたと考えられます(擬似コード):
def project(*projections)
# 例: @ctx.projections は既存の配列
@ctx.projections += projections.map { |p| some_conversion(p) }
endこのとき問題になるのは:
*projectionsは呼び出し元が Array を渡していても、必ず新しい Array としてローカルに生成される- 例:
project(*frozen_array)でもprojections自体は新しい Array
- 例:
- さらに
.mapによって もう1つ別の Array を作ってから+=で結合している
結果として、呼び出し 1 回あたり最低 1 つ余計な Array が生成されていました。
今回の変更のポイント
PR では、この「2つ目の Array 生成」をなくすために、
- 可変長引数で受け取った Array を その場で変換 (in-place) し
- それを既存の配列に結合する
形に変更しています。
擬似コードにすると:
def project(*projections)
# projections はすでに新しい Array (rest parameter) なので、
# それを再利用してその場変換する
projections.map! { |p| some_conversion(p) }
@ctx.projections.concat(projections)
endポイント:
- rest parameter (
*projections) は「呼び出しごとに必ず新しい Array」なので、map!のような破壊的操作をしても 呼び出し元の配列は書き換わらない - PR の説明にもある通り「Callers' arrays and elements are not touched.」なので API としての安全性は維持
.map++=ではなく、map!+concatのような形になっていると考えられます(実際のコードは行数の変化が +2/-2 程度)
効果 (オブジェクト削減数の例)
PR の説明にある計測結果:
- Fizzy (board/cards/notifications):
- それぞれ 126 / 39 / 13 個のオブジェクトが リクエストあたり削減
- Writebook:
- 1リクエストあたり 6〜21 個削減
この計測は:
- YJIT + 通常のインタプリタの両方で同様の傾向
- 単にアロケーション回数を見ているだけで、「明確な速度向上」を主張しているわけではない(no timing claim)
- 影響範囲・注意点
影響範囲
- 対象:
- Arel の SELECT ビルド処理 (
SelectManager#project,#order) - ウィンドウ関数の ORDER / PARTITION 指定 (
Window#order,#partition)
- Arel の SELECT ビルド処理 (
- 実質的には、Active Record が内部で SQL をコンパイルする場面すべてで、この最適化の恩恵を受けます
- 例:
Model.select(...),order(...),window(...)を多用するクエリ
- 例:
挙動の互換性
- 外部 API としての挙動は変わらない設計になっています:
- rest parameter の in-place 変換のみで、元の引数配列・要素は変更しない
- Active Record SQLite スイートは テスト結果が完全に同一("passes unchanged")
- 破壊的操作はあくまでメソッド内部で生成された配列に対してのみ行われているため、
SelectManager#project・#order・Window#order・#partitionに Array を渡しても、 呼び出し元の配列が変わることはありません
注意点 (主にライブラリ開発者向け)
- Arel のこれらのメソッドを override / monkey patch している場合:
- 実装が
Array#mapの戻り値に依存している前提や、引数が複製されることに依存していると、挙動差が出る可能性はあります - といっても今回は元々 rest parameter によって新しい Array が生成されていたため、通常の利用では問題になりにくい変更です
- 実装が
- パフォーマンス計測:
- あくまでオブジェクト数削減による GC 負荷軽減が主眼であり、「N% 高速化」といった数値は提示されていません
- 高トラフィック・大量クエリ発行環境では GC 回数やヒープの断片化に対して良い影響が見込めます
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59534
- 関連クラス:
Arel::SelectManager- 主に Active Record のクエリビルダーが内部で使用
Arel::Nodes::WindowOVER (PARTITION BY ... ORDER BY ...)等のウィンドウ関数構築に使用
- 計測環境の一例:
- Basecamp のアプリケーション群:
- Fizzy: board/cards/notifications におけるアロケーション削減数
- Writebook: 1リクエストあたり 6〜21 オブジェクト削減
- Basecamp のアプリケーション群:
#59539 Use drop instead of an endless Range for the join chain tail
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
joins/left_joins/eager_load(および JOIN に変換されるincludes)で内部的に使っている「関連リフレクションのチェーン処理」において、endless Range (array[index..]) の生成をやめてdrop(index)に置き換えることで、オブジェクト割り当てを 1 個だけ削減するマイクロ最適化です。
振る舞いは一切変わらず、内部実装のメモリアロケーション削減のみを目的とした変更です。
- 変更内容の詳細
変更の意図
- Active Record が
joins,left_joins,eager_load(および JOIN に変換されるincludes)の SQL を組み立てる際、- 各関連(association)の「リフレクションチェーン」をたどる処理で
reflection_chain[index..]のような endless Range を使ったスライスを行っていた。
- このとき、
index..という endless Range オブジェクトが毎回生成されるため、- チェーンの要素ごとに 1つオブジェクトが余分に割り当てられていた。
- そこで
reflection_chain[index..]をreflection_chain.drop(index)に変更し、- Range オブジェクトを作らずに
- 同じ結果の配列を得るようにした。
PR 説明で挙げられている例:
Post.joins(:author).loadこの 1 回の呼び出しにおけるオブジェクト割り当て数が:
- 変更前: 153
- 変更後: 152
と、joined reflection 1 つにつき 1 オブジェクト削減されています(through association が多いと、より多く削減される可能性あり)。
実際のコード変更イメージ
※PR本文からの推定ですが、変更は 1 行のみで、ほぼ次のような置き換えです:
# 変更前(例)
reflection_chain[index..]
# 変更後
reflection_chain.drop(index)Ruby の挙動としては:
ary = [1, 2, 3, 4]
ary[2..] # => [3, 4] # endless Range を生成してからスライス
ary.drop(2) # => [3, 4] # Range 不要で同じ結果となり、返る配列の内容は同じでありつつ、Range オブジェクトの生成だけがなくなります。
- 影響範囲・注意点
対象機能
- Active Record の:
joinsleft_joinseager_loadincludes(JOIN ベースに最適化されるケース)
- これらが内部で使う
JoinDependency::JoinAssociation周りのみ。
- Active Record の:
互換性 / 振る舞い
array[index..]とarray.drop(index)はこの文脈では同等の配列を返すため、
SQL の内容や返ってくるレコードが変わることはありません。- 公開 API の挙動変更はなく、完全に後方互換な内部最適化と見なせます。
パフォーマンスへの影響
- 「オブジェクト割り当て数の削減」のみを主張しており、実行時間の改善は主張していない点に注意が必要です。
- ただし、大量の join・ネストした through association を多用する大規模クエリ生成では、 GC プレッシャーがごくわずかに減る可能性はあります。
Ruby バージョン依存性
dropは古くからある Array メソッドなので、サポート Ruby バージョン間での互換性問題は事実上ありません。- endless Range (
index..) は Ruby 2.6 以降の構文ですが、Rails 本体はすでにそれを利用しており、 今回はむしろそれを避ける方向の変更なので、互換性は向上方向といえます。
- 参考情報 (あれば)
- Endless Range と
dropの違い:ary[index..]はindex..という Range オブジェクトを生成し、それをキーにスライスします。ary.drop(index)は Range を作らず、内部ループでindex個を読み飛ばした新しい配列を返します。
- Rails の join 関連内部クラス:
ActiveRecord::Associations::JoinDependency::JoinAssociationは、複数テーブルを結合する際に、 各関連のリフレクション情報から実際の JOIN ツリーを構築するための内部クラスで、 includes/joins/eager_load がすべてここを通ります。- この種のマイクロ最適化は、アプリ全体で膨大な回数呼び出されるパスではジワジワ効いてくる可能性があります。
#59538 Pass frozen empty defaults when loading a relation
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
Relation をロードするときに毎回新しく空配列を生成していた箇所を、共通のFROZEN_EMPTY_ARRAYを渡すように変更し、オブジェクトアロケーションを削減したパフォーマンス微改善の PR です。SQL 実行時のbindsや association のjoins用デフォルト値として、内容も変更されない空配列であることを明示しています。
- 変更内容の詳細
何をしているか
変更対象: activerecord/lib/active_record/relation.rb のみ(+2 / -2 行)
Relation#load がクエリを実行する際に、以下のような場面で都度 [](新しい空の配列)を生成していました。
exec_main_queryに渡すbinds(プレースホルダーに対するバインド値)- association の scope 構築時に
alias_trackerが使うjoinsのデフォルト値
これらは:
- 中身を読むこともなく
- 変更(push など)されることもない
という完全に読み取り専用の用途で使われているため、新しい空配列を毎回生成するのではなく、既に定義済みの共有オブジェクト FROZEN_EMPTY_ARRAY を渡すように変更しています。
疑似的には以下のような変化です(実際のコードはもう少し込み入っている想定):
# 変更前(イメージ)
def exec_main_query
binds = []
# ...
@klass.connection.exec_query(sql, "SQL", binds)
end
# 変更後(イメージ)
def exec_main_query
binds = FROZEN_EMPTY_ARRAY
# ...
@klass.connection.exec_query(sql, "SQL", binds)
end同様に alias_tracker の joins 引数なども [] → FROZEN_EMPTY_ARRAY に変更されています。
ログ上の挙動の変化
prepared_statements: false(MySQL のデフォルト設定)の場合、sql.active_record の通知ペイロードに含まれる binds が、これまでは「毎回新しい空配列」だったものが「共有の frozen な空配列 (FROZEN_EMPTY_ARRAY)」になります。
使う側からは「中身が空である」という意味では変わりませんが、object_id が固定される・frozen? が true になる、という違いがあります。
効果
PR 本文いわく:
- 各 relation load ごとに 1 オブジェクト分のアロケーション削減
- 各 association load(例:
post.authorやauthor.posts)ごとに 2 オブジェクト分削減- 例:
post.authorのオブジェクトアロケーション数 231 → 229
- 例:
- CRuby インタプリタ/YJIT いずれでも同じアロケーション削減効果を確認
- あくまで「アロケーション数」の話であり、時間的な高速化については主張しない(計測してもいない)と明記
- 影響範囲・注意点
影響範囲
関連する箇所:
Relation#load経由で実行されるクエリ全般to_a,each,firstなど Relation を実体化する呼び出し- association reader:
post.author,author.postsなど
alias_trackerが内部的に使うjoinsのデフォルト値
ユーザーコードから見える変化:
- 通常のクエリ結果や挙動には一切影響しない想定
- ActiveSupport の
instrumentationでsql.active_recordのpayload[:binds]を検査している場合、bindsが[](都度生成)→FROZEN_EMPTY_ARRAY(共有オブジェクト)に変わる- その結果として
object_idやfrozen?の結果が変わる
注意点
bindsや association のjoinsに対して「破壊的変更」を加えるような(本来想定されていない)コードを書いていた場合、FROZEN_EMPTY_ARRAYになったことでFrozenErrorが発生する可能性があります。- 例:
payload[:binds] << somethingなどは、今後はエラーになり得ます。
- 例:
- 公式の API としては
bindsを外部から変更することは想定されていないため、そのようなコードを書いている場合は修正が必要です。 binds.empty?やbinds.any?のように読み取り専用であれば、挙動は変わりません。
- 参考情報 (あれば)
- この変更は ActiveRecord の内部実装レベルでの小さなメモリアロケーション削減であり、機能的変更(SQL の内容やクエリ結果)ではありません。
- Rails 本体では
FROZEN_EMPTY_ARRAY/FROZEN_EMPTY_HASHなどの定数が複数箇所で使われており、「読み取り専用のデフォルト値には共有の frozen オブジェクトを使う」という方針に沿った一貫性のある変更です。
#59537 Take tag_string options before the optional content
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1–2文で)
Rails のActionView::Helpers::TagHelper内部で使われるTagBuilder#tag_stringの引数順を見直し、「任意引数の後ろに必須引数が来る」状態を解消して、毎回発生していた不要な配列オブジェクトの割り当てを削減するパフォーマンス改善です。外部 API の挙動(タグ出力・エスケープ・ブロックの扱い・エラー内容)は変わりません。
- 変更内容の詳細
問題となっていた点
TagBuilder#tag_string は内部的に次のようなシグネチャ(イメージ)になっていました:
# 旧イメージ(説明用)
def tag_string(name, content = nil, options, escape = true)
...
endRuby では「デフォルト値を持つ引数(任意引数)」の後ろに「必須引数」があると、呼び出し時に引数の並びを合わせるために配列の割り当て等が発生します。tag.div, tag.span のようなヘルパー呼び出しはすべてこの tag_string を経由しており、タグ生成のたびに不要なオブジェクトが 1 個多く生成される状態になっていました。
さらに、method_missing を使ったカスタムタグ (tag.my_widget) ではこの tag_string 呼び出しが内部的に 3 回走るため、余計な割り当てコストが 3 倍かかっていました。
PR の説明にある通り、代表的なケースでは以下のように割り当てオブジェクト数が減っています(YJIT でも同様):
tag.span("x", class: "a")- 変更前: 5 オブジェクト
- 変更後: 4 オブジェクト
tag.my_widget("x")(method_missing経由)- 変更前: 9 オブジェクト
- 変更後: 6 オブジェクト
ここで言う「オブジェクト数」は 割り当て数のカウントのみ であり、PR 自体は処理時間の短縮を直接主張しているわけではありませんが、一般には割り当て削減は GC 負荷低減につながるため、高頻度にタグを生成するビューではわずかなパフォーマンス改善が期待できます。
具体的な変更内容
- プライベートメソッド
TagBuilder#tag_stringの引数の順番が変更され、デフォルト値付きの引数が最後に来るようにしました。- 例(あくまでイメージ):ruby
# 変更前(説明用) def tag_string(name, content = nil, options, escape = true) end # 変更後(説明用) def tag_string(name, options, content = nil, escape = true) end
- 例(あくまでイメージ):
tag_stringを呼び出している 3 箇所(tag.div,tag.span,method_missingなどから最終的に到達する呼び出し元)も、変更後の引数順に合わせて呼び出し順序を修正。- 実際に生成される HTML 文字列、エスケープの有無、ブロックの扱い、引数個数がおかしい場合のエラー(arity error)は従来通りになるように配慮されています。
表面的な API(tag.div, tag.span, tag.li, tag.custom_element など)の呼び出し方や挙動は一切変わっていません。
利用イメージ(外部からの呼び出しは従来どおり)
tag.span("x", class: "a")
# => <span class="a">x</span>
tag.my_widget("x", data: { foo: "bar" })
# => <my-widget data-foo="bar">x</my-widget>(例)これらの呼び出しインターフェイス・出力結果は変更されておらず、内部的に tag_string への渡し方だけが変わっています。
- 影響範囲・注意点
- 影響範囲:
- Rails の
ActionView::Helpers::TagHelper::TagBuilderを内部的に利用しているタグ生成ロジック全体。- 標準の
tag.div,tag.span,tag.liなど method_missingによるカスタムタグ (tag.my_widgetなど)
- 標準の
- Rails の
- 公開 API のシグネチャは変わっていないため、通常のアプリケーションコードは修正不要です。
- ただし、
TagBuilder#tag_stringを直接呼び出している独自拡張コード(プライベート API への依存)は引数順変更の影響を受けます。- 例: モンキーパッチや、自前の
TagBuilderサブクラスでsuper呼び出ししている場合など。 - このようなコードは基本的に非推奨の使い方ですが、もし行っている場合は新しいシグネチャに合わせて修正が必要になります。
- 例: モンキーパッチや、自前の
- PR 説明にもあるように、
#54042も同じtag_stringを触っているため、この PR をマージすると #54042 は trivial な rebase が必要です。Rails コア開発者やパッチ投稿者はコンフリクトに注意する必要があります。
- 参考情報 (あれば)
- PR 本文の主なポイント:
- 任意引数の前に必須引数があるせいで Ruby が毎回配列を割り当てて引数を並べ替えていた。
- 引数順を入れ替えることで割り当てを減らした。
- 出力・エスケープ・ブロック処理・arity エラーの挙動は変わらない。
- 測定しているのは「割り当て数」だけであり、処理時間については直接の主張はしていない。
- 対象ファイル:
actionview/lib/action_view/helpers/tag_helper.rb(+4 / -4)
1 メソッドのシグネチャおよびその呼び出し 3 箇所のみの軽微な変更。
#59536 Share an empty warnings Array when a query has none
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
このPRは、クエリ実行時に「警告がひとつも無い場合」に毎回新しいArrayオブジェクトを生成していた処理を、共有の「凍結された空配列」を返すように変更し、オブジェクトアロケーションを1つ減らす最適化です。Rails本体内の挙動は変えずに、SQLite/PostgreSQL/MySQLで「警告なし」のケースのメモリ効率を改善しています。
- 変更内容の詳細
背景
Active Record では、あらゆるクエリ実行時(find, pluck, 書き込み系クエリ、関連ロードなど)に collect_warnings が呼ばれます。
- これまでの挙動:
- 抽象アダプタ (Abstract Adapter):
collect_warningsで毎回新しい空配列[]を返す - PostgreSQL アダプタ: 通知が無くても毎回新しい
Arrayに差し替え - MySQL アダプタ: 早期リターン時に新しい
[]を返す
- 抽象アダプタ (Abstract Adapter):
どのケースでも「警告が何も無い」のに、毎回新しい配列オブジェクトが生成されており、単純なクエリでもオブジェクト数が 1 つ余分に増えていました。
- PRの説明中の例:
Post.find(1)実行時のオブジェクト数が 62 → 61 に減少(警告なしの場合)。
具体的な変更点
3ファイルに対して、合計7行追加・2行削除の小さな変更です。
1) 抽象アダプタ (database_statements.rb)
- これまで:
collect_warningsは[]のように、毎回「新しい配列インスタンス」を返していた。
- 変更後:
- 「警告がない」場合は、「共有の凍結された空配列」を返すように変更。
イメージとしてはこんな感じの変更です(実際のコードは多少異なるかもしれませんが、意図として):
EMPTY_WARNINGS = [].freeze
def collect_warnings
# 以前は毎回 [] を返していた
EMPTY_WARNINGS
endこれにより、「警告なし」のときに毎回新しい配列をアロケートせずに済みます。
2) PostgreSQL アダプタ (postgresql/database_statements.rb)
- これまで:
- PostgreSQLではサーバーからの notice を配列に集めますが、
- notice がひとつもない場合でも、新しい
Arrayを生成していました。
- 変更後:
- notice が取得されなかった場合、上記の抽象アダプタと同様に「共有の凍結された空配列」を返すように。
つまり、「通知ありのときだけ新規の配列を使い、通知なしのときは共有の空配列を使う」ような方針に統一されています。
3) MySQL アダプタ (abstract_mysql_adapter.rb)
- これまで:
- 「警告が待機していない / 警告が無効化されている」場合でも、新しい
[]を返していました。
- 「警告が待機していない / 警告が無効化されている」場合でも、新しい
- 変更後:
- そのような「警告なし」の早期リターンでは、共有の凍結された空配列を返すように変更。
- 影響範囲・注意点
性能面の影響
- メリット:
- クエリ1回あたり、警告が無い場合のオブジェクトアロケーションが1つ減少します。
- SQLite, PostgreSQL (notice なし), MySQL (警告なし or 警告無効) に適用。
- PR本文でも、YJIT/インタプリタ双方で「アロケーション数の減少」が確認されています。
- 注意:
- PRは「アロケーション数のみの話」であり、「パフォーマンス(時間)」については明示的に「主張しない」とされています。ただし、GC頻度低減などの観点で微小な改善が見込めます。
互換性・破壊的変更の可能性
Rails本体のコードは「警告配列を読むだけ」であり、返ってきた配列に対して << したり push するようなコードは存在していません。そのため、Rails本体の挙動は変わりません。
ただし、サードパーティアダプタで collect_warnings をオーバーライドしている場合に注意点があります。
- 想定外に影響を受けるパターン:
superを呼び、- 返ってきた配列に対して 破壊的操作(例:
<<,push,concatなど) を行っている場合。
このPRにより、「警告なし」のときに返ってくる配列は凍結されているため、以下のようなコードは FrozenError を起こします:
def collect_warnings(...)
warnings = super
# 以前: warnings は毎回新しい配列インスタンスで、破壊的変更も可能だった
# 今回の変更後: 警告なしの場合は warnings が凍結されており、以下は FrozenError に
warnings << "some custom warning"
warnings
endPR本文では「Rails本体内にはそのようなコードは存在しない」と明言されていますが、独自アダプタや monkey patch で同様のパターンを取っている場合には、今後の Rails バージョン更新時にエラーが発生する可能性があります。
- 対策の例:
superの戻り値は「読み取り専用」と見なす変更が必要であれば自前で配列を生成する:
rubydef collect_warnings(...) super_warnings = super warnings = super_warnings.dup # 凍結されていても dup で可変配列に warnings << "some custom warning" warnings endもしくは常に新しい配列に詰め直す:
rubydef collect_warnings(...) [*super, "some custom warning"] end
- 参考情報 (あれば)
- PR番号: #59536
- タイトル: Share an empty warnings Array when a query has none
- ポイント:
- クエリごとのオブジェクトアロケーション1つ削減(SQLite, PostgreSQL(no notice), MySQL(no warnings))
- 「警告なし」のときに共有の
[].freezeを返す設計に統一 - Rails 内部は「配列を読むだけ」のため振る舞いは従来通り
- サードパーティアダプタが
collect_warningsの戻り値を破壊的に変更している場合のみ注意が必要
#59535 Avoid a rest Array in HashWithIndifferentAccess#fetch
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
HashWithIndifferentAccess#fetchのシグネチャを見直し、毎回余計な配列オブジェクトが確保されていたのをやめて、paramsや enum などでのメモリアロケーションを削減する最適化です。機能的な挙動は(例外内容やブロック優先などを含めて)Hash#fetchと同等を維持しつつ、パフォーマンス改善のみを行っています。
- 変更内容の詳細
これまでの問題点
従来の定義は概ね以下のようになっていました(PR説明からの推測):
def fetch(key, *extras, &block)
indifferent_access.fetch(convert_key(key), *extras, &block)
endこのように可変長引数(*extras)を使っていたため、
fetch(key)と第二引数なしで呼んでもfetch(key, default)と第二引数ありで呼んでも
毎回 Ruby VM によって extras 用の配列が 1 つ確保されていました。HashWithIndifferentAccess は params の実体として広く使われているため、
params.fetch(:user)params.fetch(:page, nil)- enum キャストで内部的に呼ばれる
mapping.fetch(value, value)
といった高頻度の呼び出しで、常に余分な配列アロケーションが発生していました。
新しい実装方針
PRでは引数定義を「オプションの位置引数1つ」に変更しています。イメージとしては次のような形です:
def fetch(key, default = (missing = true; nil), &block)
if missing
# デフォルト引数なし版: Hash#fetch(key, &block)
@hash.fetch(convert_key(key), &block)
else
# デフォルト引数あり版: Hash#fetch(key, default, &block)
@hash.fetch(convert_key(key), default, &block)
end
end※実際のコードは多少異なる可能性がありますが、構造はこのような「デフォルト引数+フラグ」で *rest を避ける実装です。
これにより、
fetch(key)の場合 → 追加配列ゼロfetch(key, default)の場合 → 追加配列ゼロ
と、*extras のための配列が一切作られなくなります。
挙動の互換性の確保
PR説明によると、以下の点は全て従来通り Hash#fetch に委譲され、互換性が保持されています。
- 存在しないキーに対する
KeyErrorの内容- 例外に含まれる「キー」と「レシーバー」の情報
- デフォルト値が
nil/falseの場合の扱い - ブロックを渡した場合の挙動
- 「ブロックがデフォルト引数より優先される」ルール
つまり、次のようなパターンは全て Hash#fetch と同じ動作:
h = HashWithIndifferentAccess.new({foo: 1})
# 1) キー存在: 値を返す
h.fetch(:foo) # => 1
# 2) キー存在せず、第二引数でデフォルト指定
h.fetch(:bar, 0) # => 0
# 3) キー存在せず、ブロック指定
h.fetch(:bar) { |key| key.to_s } # => "bar"
# 4) キー存在せず、第二引数とブロック両方指定 → ブロック優先
h.fetch(:bar, 0) { 42 } # => 42
# 5) キー存在せず、何も指定しない → KeyError
h.fetch(:bar) # raises KeyError新たに追加されたテストでは、特に次がカバーされています:
- キーが存在しない場合の
KeyError(メッセージ・オブジェクト内容) nil/falseをデフォルトに渡した場合- キーが存在するがブロックもある場合(値が優先されることなど)
API上唯一の差分: arity メッセージ
fetch() を引数なしで呼んだ場合のエラーメッセージが、Ruby 本体の Hash#fetch に揃います。
- 以前: メソッドの引数定義が
1+のように見えるメッセージ - 変更後:
Hash#fetchと同様に"wrong number of arguments (given 0, expected 1..2)"のような「1..2」を示すメッセージ
実際の呼び出し可能な引数パターンは以前から 1〜2個 だったので、実行時の挙動としては変化なしで、エラーメッセージ表記だけが揃った形です。
- 影響範囲・注意点
影響範囲
この最適化により恩恵を受ける代表的な箇所:
ActionController::Parameters(params) を通じたHashWithIndifferentAccess#fetchの全呼び出しparams.fetch(:q)params.fetch(:page, nil)など
- Active Record の enum 実装における内部的な
fetchPost.where(status: :live).to_a- enum の代入・validation 時の内部マッピング
PRのベンチマーク(オブジェクト数カウント)では、例として次のような削減が報告されています(いずれも「1呼び出しあたり」):
params.fetch(:q): 3 オブジェクト → 2 オブジェクトhwia.fetch(:a): 1 オブジェクト → 0 オブジェクトPost.where(status: :live).to_a: 77 オブジェクト → 74 オブジェクト
YJIT 使用時でも同様に削減が確認されており、「アロケーション数の削減」のみを確認したもので、時間性能についての明示的な数値は提示していません。
注意点
- 公開APIの呼び出し方法(引数の数・型・ブロックの扱いなど)は変更されていません。
fetch呼び出し側でmethod(:fetch).arity等に依存していた場合の挙動は、Ruby のHash#fetchにより近くなるはずですが、そのようなメタプログラミング前提コードは極めて稀と思われます。- 例外 (
KeyError) のメッセージや内容はHash#fetchと同等になるようテストで担保されており、実務コードへの影響はほぼゼロと考えてよいです。
- 参考情報 (あれば)
- 対象クラス:
ActiveSupport::HashWithIndifferentAccess- ファイル:
activesupport/lib/active_support/hash_with_indifferent_access.rb
- ファイル:
- 追加テスト:
activesupport/test/hash_with_indifferent_access_test.rb- ケース: missing key, falsy default (
nil/false), 存在キー + ブロックなど
- 関連するRails機能:
ActionController::Parameters(params)- Active Record の enum マッピング (
where(status: :symbol), enum の代入/検証)
#59533 Build WhereClause#ast predicates in one pass
マージ日: 2026/11/2 | 作成者: @thomasklemm
- 概要 (1-2文で)
WhereClause#ast 生成時に、空文字列の述語("")を除外する処理を「配列を減算してから map する」二段階から「一度の走査で判定して構築する」形に変更し、余分な配列生成・オブジェクト確保を削減するパフォーマンス改善です。挙動(どの述語が残るか、順序)は従来と同一に保ったまま、1リレーションあたり1配列分のアロケーションを削っています。
- 変更内容の詳細
元の処理の概要
ActiveRecord::Relation::WhereClause#ast 内にある predicates_with_wrapped_sql_literals というメソッド(もしくはローカル処理)で、
- まず
predicates(where 条件を表す配列)から空文字列""をArray#-で取り除く - その結果に対して
mapして AST ノードを構築する
という二段階の処理を行っていました。
イメージとしてはこんなコードです:
# 旧イメージ(擬似コード)
def predicates_with_wrapped_sql_literals
filtered = predicates - [""] # 1. 空文字列だけ取り除く(別配列を生成)
filtered.map { |node| wrap_sql_literal(node) } # 2. AST 向けに変換
endここでのポイント:
Array#-は新しい配列を返すため、predicatesとは別に一時配列filteredを確保していました。- 除外条件は「要素が
""とeql?で比較して等しいかどうか」であり、Array#-も内部的にeql?を用いています。
今回の変更
上記の「減算してから map」する二段階を廃止し、単一の map(あるいは each_with_object)処理の中で「空文字列をスキップしつつ AST を構築する」スタイルに変更しています。
擬似コードとしては次のような変化です:
# 新イメージ(擬似コード)
def predicates_with_wrapped_sql_literals
predicates.each_with_object([]) do |node, result|
next if node.eql?("") # ここで "" をスキップ
result << wrap_sql_literal(node) # AST 用に変換して push
end
endあるいは map + compact パターンではなく、「スキップしながら push」する構造になっているイメージです。
重要な点:
- 空文字の判定は
node.eql?("")で行っており、元々Array#-が行っていた比較ロジックと厳密に一致します。 - 元の実装同様、
""以外の順序は変更しておらず、述語の出現順は完全に維持されます。 - 「空文字列は where 句の述語として無視する」というこれまでの仕様は変わっていません。
パフォーマンス効果
PR 説明にあるように、WhereClause#ast 呼び出し1回あたり 1つ分の配列アロケーションが減少します。
作者による計測(Ruby 4.0.7, Rails main)では:
- Fizzy (board/cards/notifications ページ)
- board: 103 個のオブジェクト削減 / リクエスト
- cards: 26 個のオブジェクト削減 / リクエスト
- notifications: 10 個のオブジェクト削減 / リクエスト
- Writebook: 4–16 個のオブジェクト削減 / リクエスト
いずれも「アロケーション数のみ」の計測で、処理時間の短縮については明示的な主張はしていませんが、GC 負荷のごくわずかな低減には寄与する変更です。
- 影響範囲・注意点
- 対象:
- Active Record の
WhereClause#ast生成処理(内部 API)。 - 実行時に
whereを含むクエリがコンパイルされる際に利用されます。
- Active Record の
- 挙動:
- 空文字列
""を where 述語として与えた場合に無視される、という既存の挙動は維持されています。 - 述語の順序も維持されており、SQL 生成結果に論理的な変更はありません。
- 空文字列
- 後方互換性:
- Public API (
Model.where(...)の使い方など) に対する互換性上の影響は想定されません。 - もしアプリケーションが内部的に WhereClause の AST 表現に依存している場合(かなりレアですが)、空文字述語がスキップされる前提自体は従来通りです。
- Public API (
- テスト:
- Active Record の SQLite スイートが従来通り全て通過しているため、SQL 生成・実行レベルでの互換性は確認済みです。
注意点として、挙動的には変更なし・純粋に配列アロケーションを減らすマイクロ最適化と見なして差し支えありません。独自に WhereClause#ast をフックしているような高度に内部依存したコードを書いていない限り、アプリ側で意識すべき変更点はほぼありません。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59533
- 対象クラス:
activerecord/lib/active_record/relation/where_clause.rb - 関連: Ruby の Array 演算
Array#-は要素比較にeql?を使用し、新しい配列を返す(= 1 配列分アロケーションが増える)- 今回はこれを「一度のループで条件分岐と AST 構築をする」スタイルに変更することで、
Array#-による一時配列の生成を無くしている。
#59245 Use RDoc fixed-width markup instead of backticks in API docs [ci skip]
マージ日: 2026/11/2 | 作成者: @ttrweb
- 概要 (1-2文で)
RDoc マークアップを使っている API ドキュメント内で、本来コードとして表示したい箇所がバッククォート(`...`)で囲まれていたため正しく等幅コード表示されていなかった問題を修正し、RDoc 推奨の+...+や<tt>...</tt>に置き換えた PRです。挙動変更は一切なく、API ドキュメントの表示のみが改善されます。
- 変更内容の詳細
背景・問題点
Rails の多くのコメントは「RDoc デフォルトマークアップ」を前提としており、# :markup: markdown がないファイルでは Markdown 記法(`code`)はコードとして扱われません。
その結果、例えば以下のようなコメントは:
# To disable this subsort behavior, set `implicit_order_column` to `["column_name", nil]`.RDoc 6.15.0 では:
- バッククォートが “curly quotes” に変換される
- 中身が
<code>として表示されない(単なるテキスト扱い)
という不適切なレンダリングになっていました。
対応内容
RDoc の API ドキュメントガイドラインで推奨されている「等幅フォント用マークアップ」に従い、該当コメント中のバッククォートを以下のように置き換えています。
- 単純な識別子やメソッド名など →
+implicit_order_column+のように+...+で囲む ["column_name", nil]のような+...+内に+が含まれたり、表現が複雑なもの →<tt>["column_name", nil]</tt>のように<tt>...</tt>を使用
概念的な差分イメージ:
- # set `implicit_order_column` to `["column_name", nil]`
+ # set +implicit_order_column+ to <tt>["column_name", nil]</tt>対象となった主な API コメント:
ActiveRecord::ModelSchema.implicit_order_column=ActiveRecord::FixtureSet(複合主キーのセクション)ActiveRecord::QueryMethods::WhereChain#associatedActiveRecord::FinderMethods#findActiveRecord::Relation#upsert_all(:on_duplicateオプションの説明)ActiveRecord::TestFixtures::ClassMethods#skip_transactional_tests_for_database#use_transactional_tests_for_database
ActiveSupport::Cache::RedisCacheStore.newRails.autoloadersRails::Application#dotenvsRails::Generators::Testing::Assertions#assert_initializer
なお次は変更対象外です:
- コメント内のコードブロックの中にあるバッククォート
:nodoc:が付いたメソッド- メソッド本体内のコメント
実施確認
PR 作成者は、変更前後で該当ファイルのみを RDoc 6.15.0 で HTML 生成し比較:
- 変更前: 15 箇所が “curly quotes” 付きテキストとして表示
- 変更後: すべて
<code>としてレンダリングされることを確認
rake rdoc による全体ビルドは実行していませんが、局所的な確認は行われています。
- 影響範囲・注意点
実行時挙動・API 仕様への影響
- すべてコメントのみの変更であり、ランタイムの挙動・シグネチャ・永続化動作などには一切影響しません。
- テスト追加・変更もなく、CHANGELOG も不要と判断されています(ドキュメントのみの変更)。
影響範囲
- RDoc ベースの API ドキュメント閲覧時の表示改善のみ。
- RDoc 6.15.0 以降で特に問題になっていた “curly quotes” 表示が解消されます。
implicit_order_columnをはじめ、複合主キーやupsert_allのオプションなど、利用頻度の高い API のドキュメントの可読性が向上します。
注意点 / 今後の指針
- 既存・新規のコメントで Markdown 的にバッククォートを使うと、RDoc マークアップファイルでは再び同様の問題が起きるため、等幅フォント表現には:
- 基本:
+code_like_this+ - 複雑なリテラルなど:
<tt>["example", nil]</tt>を用いるべき、という指針が再確認できます。
- 基本:
- 将来的に
# :markup: markdownを付けて Markdown ベースに移行するなら話は別ですが、RDoc デフォルトマークアップを前提とする限りは、Markdown 記法を混ぜないほうが安全です。
- 既存・新規のコメントで Markdown 的にバッククォートを使うと、RDoc マークアップファイルでは再び同様の問題が起きるため、等幅フォント表現には:
- 参考情報 (あれば)
Rails API Documentation Guidelines(固定幅フォントの推奨マークアップ)
https://edgeguides.rubyonrails.org/api_documentation_guidelines.html#fixed-width-font関連 PR: 同種の修正を行った PR #58158(本 PR はそれと同系統のドキュメント修正)
#59524 Let a frozen CachingKeyGenerator work on Ruby 3.3
マージ日: 2026/11/2 | 作成者: @czj
- 概要 (1-2文で)
Ruby 3.3 でActiveSupport::CachingKeyGenerator#freezeを呼ぶとRactor.[]=の未定義エラーで落ちていた問題を修正する PR です。Ractor ローカルなキャッシュへのアクセスにActiveSupport::Ractorsを経由するよう変更し、Ruby 3.3 でも凍結済みジェネレータが正しく動くようになりました。
- 変更内容の詳細
問題の背景
- Rails は Ruby 3.3 をサポートしているが、Ruby 3.3 には
Ractor.[]/Ractor.[]=が存在しない。 - 一方、
ActiveSupport::CachingKeyGenerator#freezeの実装がRactor.[]=に依存していたため、Ruby 3.3 上で以下のようなエラーが発生していた:
NoMethodError: undefined method `[]=' for class Ractor
activesupport/lib/active_support/key_generator.rb:71:in `freeze'- Ruby 3.4 以降では
Ractor.[]/Ractor.[]=が導入されており問題は出ないが、3.3 ジョブ (activesupport 3.3 のテスト) で失敗するため修正が必要になった。
具体的な変更点
1) CachingKeyGenerator が Ractor ストレージにアクセスする方法の変更
これまでは、キャッシュ用のストレージに直接 Ractor.[] / Ractor.[]= を使っていた部分を、ActiveSupport::Ractors.[] を経由するように変更しています。
概念的には、以下のようなイメージです (実際のコードは行数的にこの程度の差分):
# 変更前 (イメージ)
Ractor[:some_cache_key] ||= {}
# 変更後 (イメージ)
ActiveSupport::Ractors[:some_cache_key] ||= {}ActiveSupport::Ractors.[] は Ruby のバージョン差を吸収するヘルパで、
- Ruby 3.4+ :
Ractor.[]/Ractor.[]=を利用 - Ruby 3.3 :
Ractor.current[]/Ractor.current[]=を利用
というふうにフォールバック実装を持っています。
これにより、同じ「Ractor ローカル」な意味論を維持しつつ、Ruby 3.3 でもエラーにならないようにしています。
2) テストの追加
activesupport/test/key_generator_test.rb にテストが追加されています (8行追加)。
追加されたテストの内容(要約):
ActiveSupport::CachingKeyGeneratorでキーを一度生成してキャッシュを作る。- その
CachingKeyGeneratorインスタンスをfreezeする。 - もう一度同じ条件でキー生成を呼ぶ。
- 2回目もキャッシュされた同じキーが返ることを検証する。
このテストは:
- 修正前: Ruby 3.3 では
#freeze内でRactor.[]=を呼んでエラーになり失敗。 - 修正後: Ruby 3.3 と Ruby 3.4 / 3.5 / 4.0 など両方でグリーン。
ということを確認するためのものです。
- 影響範囲・注意点
- 影響範囲
ActiveSupport::CachingKeyGeneratorを利用しているコード全般 (主にクレデンシャル、Cookie/Session 暗号化キーの派生、署名付き URL などで内部的に利用されうる)。- 特に、
CachingKeyGeneratorインスタンスをfreezeして不変オブジェクトとして扱うようなコードパスに影響します。
- 挙動の変化
- 期待される挙動 (「Ractor ごとにローカルなキャッシュを持ち、その後
freezeしてもキャッシュは有効のまま」) は Ruby バージョンに依存せず一貫します。 - 既存のパブリック API や返り値の形式・意味は変わりません。あくまで内部で使う Ractor ストレージの実装をリファクタリングしたものです。
- 期待される挙動 (「Ractor ごとにローカルなキャッシュを持ち、その後
- 互換性
- Ruby 3.3 でも
#freezeによる NoMethodError が発生しなくなり、サポート範囲内の Ruby バージョンでテストが通るようになります。 - Ractor を積極的に使っているアプリでも、Ractor ローカルの意味論は
ActiveSupport::Ractorsによって保持されるため、挙動変更はほぼありません (ActiveSupport 側の実装に依存する部分はありますが、もともとその前提で利用されるクラスです)。
- Ruby 3.3 でも
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59524
- 関連する問題が発覚した PR: #59501 (activesupport 3.3 のジョブで
freezeテスト追加時に発覚) ActiveSupport::Ractorsは Rails 内で Ractor 対応をバージョン差込みで吸収するためのヘルパーモジュールで、今後も Ractor 対応の窓口として使われる想定です。
#59523 Fix in_batches with use_ranges keeping an ignored default_order
マージ日: 2026/11/2 | 作成者: @czj
- 概要 (1-2文で)
in_batches(use_ranges: true)を使ったバッチ処理で、本来無視されるはずのdefault_orderが残り続けてしまい、不要なORDER BYが付いたクエリが発行されていた不具合を修正するPRです。ranged batching(use_ranges: true)の場合に限り、default_orderも明示的なorderと同様に削除されるようになりました。
- 変更内容の詳細
問題の背景
もともと Active Record のバッチ処理 (in_batches) は、パフォーマンスと安定性のために「独自の order/id 範囲制御」を行うため、既存の並び順を無視する仕様になっています。
ただし、default_order を使っている場合に in_batches(use_ranges: true) を行うと、次のような違いが発生していました。
# 明示的な order の場合
Post.order(:title).in_batches(of: 2, use_ranges: true).first.to_sql
# => ... WHERE "posts"."id" <= 2
# (ORDER BY は取り除かれている)
# default_order の場合
Post.default_order(:title).in_batches(of: 2, use_ranges: true).first.to_sql
# => ... WHERE "posts"."id" <= 2 ORDER BY "posts"."title" ASC
# (default_order による ORDER BY が残ってしまう)この結果、
- バッチ処理としては効いていない(バッチ分割は id 範囲でやっている)
- しかし SQL 上は常に
ORDER BY posts.title ASCが付く
という「意味のない ORDER BY」が毎バッチで発生していました。
このPRでの修正内容
in_batchesの ranged branch(use_ranges: trueの経路)では、もともと明示的なorderは削除していましたが、
そこにdefault_orderも同様に削除 する処理を追加しました。- 具体的には、バッチごとに生成される Relation から
orderを取り除く際に、default_orderによる並び替えも含めてクリアするようにしています。 - 一方で、
load: trueオプション利用時use_ranges: falseの通常のバッチ処理
など、rewhereを使って元の Relation をベースにバッチを作る分岐は 従来どおり明示的なorderを保持 するため、今回の修正の対象外です。
テスト・ドキュメント
activerecord/test/cases/batches_test.rbにテスト追加default_orderを付けた Relation に対してin_batches(use_ranges: true)した場合に、返される Relation の SQL からdefault_orderによるORDER BYが消えていることを確認。
activerecord/CHANGELOG.mdに本修正の挙動変更を記述。
- 影響範囲・注意点
- 対象となるのは以下の組み合わせです:
- Active Record の
default_orderを利用している in_batches(use_ranges: true)を使っている
- Active Record の
- これまでは、バッチ内の Relation に
default_orderが残っていたため、各バッチで「id 範囲 + default_order な ORDER BY」のクエリが発行されていました。 - 今後は、このパスでは
default_orderが効かなくなる ため、クエリのORDER BYは(バッチ用の実装が内部で必要とするもの以外は)なくなります。 - そのため:
- 「
in_batches(use_ranges: true)を使って、各バッチ内のレコード順序をdefault_orderに依存していた」コードがある場合は、順序が変わる可能性があります。- とはいえ、設計上
in_batchesはもともと「順序を保証しない」ものとして扱うべきであり、仕様に沿った変更です。
- とはいえ、設計上
- パフォーマンス面では、不要な
ORDER BYが消える分、クエリプランがシンプルになり 高速化する可能性があります(特に大きなテーブルで title などへのインデックスがない場合)。
- 「
load: trueやuse_ranges: falseの場合は挙動変更はなく、既存のorderがこれまでどおり保持されます。
- 参考情報 (あれば)
- 該当PR: https://github.com/rails/rails/pull/59523
- 関連Issue/PR:
default_orderが無視される仕様に関する既存の問題: https://github.com/rails/rails/issues/39525- 直前の関連PR(follow-up 元): https://github.com/rails/rails/pull/59496
default_orderは Rails 7.1 以降で導入された API で、スコープのデフォルトの並び順を定義するために利用されますが、in_batchesのような機能では明示的に無視されることがあります。
#59522 Don't load Herb when compiling with another ERB implementation
マージ日: 2026/11/2 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
Rails の ERB テンプレートコンパイル時に、別実装(デフォルトの Erubi など)を使っている場合でも常にHerbが autoload されてしまう問題を修正し、Herbを明示的に選んだときだけロードされるようにした PR です。これにより MRI では無駄な gem ロードが避けられ、JRuby では C 拡張のherbロード失敗によるレンダリングエラーが解消されます。
- 変更内容の詳細
問題の背景
Rails には ERB テンプレートエンジンとして複数実装を切り替える仕組みがあります(デフォルトは Erubi)。ERB#implementation_for で現在の実装が Herb かどうかを判定する際に implementation == Herb という比較を行っていましたが、この「Herb という定数に触れる」こと自体が autoload をトリガし、herb gem がロードされていました。
- MRI: 使っていないのに
herbgem が毎回ロードされるだけ(無駄だが致命的ではない) - JRuby:
herbは C 拡張で Java 版がないためrequire 'herb'が失敗し、すべての ERB コンパイルが落ちる
変更のポイント
1) Herb がまだ autoload されていない場合は比較をスキップ
actionview/lib/action_view/template/handlers/erb.rb の implementation 判定部にガードが追加されています。
概念的には以下のような処理になります(正確なコードではなくイメージです):
# 変更前(イメージ)
if implementation == Herb
# Herb 用の特別な処理
format = template.format
end
# 変更後(イメージ)
if Object.autoload?(:Herb) && implementation == Herb
# Herb は既にロード済み or 明示的に選択されている場合だけ format を触る
format = template.format
end実際には、「まだ autoload が未解決の状態なら Herb 比較を行わない」という形で、
Herb定数にアクセスして autoload を起こさない- したがって
herbgem を暗黙にロードしない
という挙動を実現しています。
ここでの重要な前提は PR の説明にもある通り:
erb_implementation = :herbと設定すると、その時点でHerb定数が参照されてロードされる
つまり、
Herbがまだ autoload 状態で触られていない =
そもそもアプリ側はerb_implementation = :herbを設定していない- よって「まだロードされていないのに Herb である」というケースは起こらない
ため、「autoload 未解決なら Herb 比較をスキップ」しても意味的には破綻しない、というロジックです。
2) 条件の評価順は保持
元々の条件式の順番(implementation == Herb のチェックの前後関係)は変えず、「Herb の比較自体を行うかどうか」のレベルでガードしています。PR で明示されている通り:
RubyTrackerは public なformatを持たないテンプレートを handler に渡すので、formatを呼ぶのは implementation が Herb のときだけにすべき
という制約があり、評価順を変えると format を誤って呼んでしまうリスクがあるため、既存挙動を崩さないように配慮しています。
3) テストの追加
actionview/test/template/handlers_test.rb にテストが追加されています(+16 行)。
テスト内容の要点:
- 別プロセスで Ruby を起動
- HTML テンプレートとテキストテンプレートをコンパイル
- その後も
Herbの autoload がまだ「pending(未解決)」であることを確認
という形をとっています。
同一テストプロセス内だと「前のテストがすでに Herb をロードしたかどうか」に依存してしまうため、「サブプロセスを使ってクリーンな状態で確認する」構成になっています。
- 影響範囲・注意点
Rails アプリ側の設定変更は不要
デフォルトで Erubi を利用しているアプリや、別実装を指定しているアプリは、この修正によって動作が壊れることは想定されていません。Herbを明示的に利用していないなら、単に「無駄な gem ロードが減る」だけです。JRuby + Action View での ERB レンダリング改善
JRuby head で Action View のテストを回した際、- 変更前:
template_test.rb+render_test.rbで 61 failures / 364 errors - 変更後: 3 failures / 16 errors
まで減少しており、大半がherbautoload 由来のエラーだったことが分かります。残りは別の JRuby 固有の問題とのことです。
- 変更前:
Herb を本当に使いたい場合
erb_implementation = :herbのように明示的に設定している場合は、その時点でHerbがロードされるため今回のガードには引っかかりません。
つまり、「Herb を選択したのに Herb がロードされない」という事態にはなりません。挙動変更の影響の種類
まだリリースされていない Herb サポートに対するバグ修正であり、「既存リリース版の挙動が変わる」という類いのものではありません。このため CHANGELOG は更新されていません。
- 参考情報 (あれば)
- PR 本文にある通り、Herb サポート自体はまだリリース前の機能であり、CHANGELOG への記述は見送られています。
- 今回の修正は「autoload されていない定数を不用意に比較しない」という Ruby の常套パターンでもあり、他のオートロードされるコンポーネントについても似た問題が起こり得るため、設計・実装上の参考になるケースと言えます。
#59520 Don't gsub preload Link hrefs that need no encoding
マージ日: 2026/11/2 | 作成者: @amatsuda
- 概要 (1-2文で)
preload_link_hrefが毎回String#gsubを実行して余計なオブジェクトを生成していた問題に対し、「エンコードが不要な href 文字列はそのまま返す」ことで、stylesheet_link_tag/javascript_include_tag/preload_link_tagのメモリアロケーションを削減する最適化です。振る舞い(出力される HTML)の変更はなく、パフォーマンス改善のみを目的とした変更です。
- 変更内容の詳細
背景
preload_links_headerが有効(デフォルト)な場合、stylesheet_link_tagjavascript_include_tagpreload_link_tag
呼び出し時にpreload_link_hrefが毎回実行されます。
- 旧実装では、href に含まれる「エンコード対象文字」があるかどうかに関係なく
String#gsubを実行しており、マッチがなくても新しい String オブジェクトが生成されるため、各タグ呼び出しごとに余計なオブジェクトが 2 個増えていました。 - 実際のアセットURL(例:
/assets/application.cssなど)には、preload_link_hrefがエンコード対象としている文字(<、>、空白類、制御文字など)が含まれることはほとんどありません。
今回の変更の核心
preload_link_href の中で、次のような「ショートサーキット」を入れた形になります(イメージの擬似コード):
def preload_link_href(href)
# エンコード対象文字が 1 つも含まれていない場合は gsub を呼ばず、そのまま返す
unless href.match?(/[<>\s[:cntrl:]]/)
return href
end
# 1つでも含まれている場合は従来通り gsub でエンコード
href.gsub(/[<>\s[:cntrl:]]/) do |char|
# 既存のエンコードロジック(実際のコードでは < 等または %xx 等に変換)
end
endポイント:
- 文字列を走査するのは同じですが、「エンコードが不要」であれば
gsub自体を呼ばないため、新しい文字列オブジェクトが作られません。 <・>・空白(スペース、タブ、改行など)・制御文字を含む href の場合のみ、従来通りのgsubによるエンコードを行います。- これにより、通常のアセットURL(ほぼ全てエンコード不要)では、余計な 2 オブジェクトの割り当てがなくなります。
効果(PR記載の測定結果)
Ruby 4.0.9、preload_links_header 有効時の 1 呼び出しあたりのオブジェクトアロケーション数:
| 呼び出し | 変更前 | 変更後 |
|---|---|---|
stylesheet_link_tag("application") | 34 | 32 |
javascript_include_tag("application") | 33 | 31 |
preload_link_tag("/x.woff2") | 30 | 28 |
各呼び出しで 2 オブジェクトずつ削減されており、大量アクセスのアプリケーションでは GC 負荷軽減が期待できます。
- 影響範囲・注意点
振る舞いの互換性
- href にエンコード対象文字が含まれるケースでは、これまでと同じ gsub ロジックが動くため、HTML 出力の挙動は従来通りです。
- エンコード不要な href では、以前も
gsubの結果として「元と同じ文字列内容」が返ってきていたので、この変更により出力 HTML が変わることはありません(変わるのは内部的なオブジェクト生成数のみ)。
パフォーマンス上の影響
- sitemap・SPA・大規模なアセット構成など、
stylesheet_link_tag/javascript_include_tag/preload_link_tagを多数呼び出すビューで、全体としてわずかな GC 回数の削減・レスポンス時間の安定化が期待できます。 - ただし、1リクエストあたり数回レベルの利用であれば差はほぼ誤差です。主に「高トラフィック」かつ「テンプレート内でこれらのヘルパーを多用する」ケースで効果が出ます。
テストや互換性上の注意
- PR上ではテストの追加/更新チェックボックスは未チェックですが、既存テストが通っている前提でマージされているため、
- API シグネチャは変わっていない
- 既存の public API (
stylesheet_link_tag等) の使い方を変える必要はない
という理解で問題ありません。
- 今後、
preload_link_hrefのエンコード対象文字を増やす/変える場合は、今回のショートサーキット条件(match?の正規表現)も忘れずに同期する必要があります。ここがズレると「本来エンコードすべき href を素通ししてしまう」バグになるので注意が必要です。
- 参考情報 (あれば)
- 対象PR: https://github.com/rails/rails/pull/59520
- 関連PR(
preload_link_href追加元): #59511 - パフォーマンス観点でのポイント:
String#gsubは「マッチが無いときも新たな String を返す」ため、ホットパスでは事前条件付きで呼び出しを避ける最適化が有効。- 今回のように「エンコードが必要な href はレアケース」という前提が成り立つ場合、**「レアケースだけ高コスト処理を行い、通常ケースはできるだけ素通しにする」**のは典型的な最適化パターン。
#59516 Allow ActiveRecord::Relation#unscope(:default_order) to remove default ordering while keeping explicit ordering
マージ日: 2026/11/2 | 作成者: @kehra
- 概要 (1–2文で)
ActiveRecord::Relation#unscopeに:default_orderを指定できるようになり、関連付けられた「デフォルトの ORDER 句」だけを取り除きつつ、明示的に指定したorderはそのまま残せるようになりました。これにより、unscope(:order)と対になる挙動として「デフォルト順のみ解除」がサポートされます。
- 変更内容の詳細
何ができるようになったか
これまで:
unscope(:default_order)を呼ぶとArgumentErrorが発生して使えなかったunscope(:order)は「明示的なorderを削除して、default_orderは残す」動き
今回の変更後:
unscope(:default_order)が有効な指定として扱われる- Relation に設定されている default_order だけ を外し、明示的な
orderは保持する
内部的には、VALID_UNSCOPING_VALUES に :default_order が追加され、unscope で指定可能なキーとして認識されるようになっています。
動作イメージ / サンプルコード
# default_order と明示的な order が両方あるケース
posts = Post.order(:id).default_order(:title)
# 変更後は、default_order だけを削除できる
posts = posts.unscope(:default_order)
# 生成されるSQLイメージ
# SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC比較のため:
# こちらは明示的 order を消して default_order を残す
Post.order(:id).default_order(:title).unscope(:order)
# => ORDER BY "posts"."title" ASC(default_order 側のみ残る想定)テストでは、unscope(:default_order) を使った際に:
- default_order の ORDER 句が削除されること
- 既に設定済みの明示的な
orderが影響を受けないこと
が確認されています。
また、activerecord/CHANGELOG.md にこの挙動追加が記述され、公式にサポートされる API として扱われます。
- 影響範囲・注意点
- 対象バージョン: この PR がマージされた以降の Rails (Active Record) バージョンで利用可能になります。既存のバージョンでは
unscope(:default_order)は引き続きArgumentErrorになるため、バージョン依存コードに注意が必要です。 - 既存コードへの互換性:
- これまで
unscope(:default_order)はエラーになっていたため、「呼び出すと挙動が変わってしまう」既存コードは基本的に存在しない想定です(あっても今は落ちているはず)。 - したがって、この PR による後方互換性の問題はほぼありません。
- これまで
- 明示的 order と default_order を両方使っているコード:
- これまでは default_order だけをピンポイントに消す手段が事実上なかったため、
reorderやunscope(:order)で両方まとめてクリアしていたような箇所では、より意図に沿った実装としてunscope(:default_order)に置き換えられる可能性があります。
- これまでは default_order だけをピンポイントに消す手段が事実上なかったため、
- API 設計上の位置づけ:
unscope(:order)…「明示的なorderを消して default_order を残す」unscope(:default_order)…「default_order だけを消して明示的なorderを残す」
という対称な API となり、クエリビルドの制御が細かく行いやすくなります。
reorder(nil)との関係:reorder(nil)が default_order をクリアすべきかどうか、という別の議論 (#59416) から切り出された変更です。- 今回の PR はその議論に依存せず、「
unscopeによる default_order の解除機能」だけを独立して提供するものです。
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/59516
- 関連ディスカッション:
reorder(nil)が default_order を消すべきかどうかの議論: https://github.com/rails/rails/pull/59416
- 実際に利用する場合のパターン例:
- モデル側で
default_scope { default_order(created_at: :desc) }等を設定しつつ、一部のクエリでだけ「ユーザー指定のorderを優先したいが、default_scope による default_order は外したい」ケースでunscope(:default_order)が有効に使えます。
- モデル側で
#59504 Don't copy ASCII-only names when probing the static root
マージ日: 2026/11/2 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
ActionDispatch::FileHandler#root_entry?で、毎回文字列をバイナリコピーしていた処理を見直し、「ASCII だけのパス名」に対してはコピーを行わず、そのまま join するようにしたパフォーマンス改善の PR です。静的ファイル以外へのリクエストでの不要なオブジェクト生成を減らし、わずかながらアロケーション削減・高速化を図っています。
- 変更内容の詳細
背景
ActionDispatch::FileHandler#root_entry? は、
「リクエストのトップレベルパス(例: /posts/1 なら posts)を静的ファイル用 root と結合し、そのファイルが存在するかどうかを調べる」
という処理を行っています。
以前の PR (#59053) では、このときに name.b を呼んでおり、
File.exist?(File.join(@compiled_root, name.b))のように「名前を毎回バイナリエンコーディングにコピーしてから」 root に join していました。
Ruby の String#b は「同じバイト列を持つ BINARY エンコーディングの新しい文字列を返す」ため、リクエストごとに新たな文字列オブジェクトが確実に 1 つ増えるという状態でした。
実際には、ASCII だけで構成されるパス名であれば、バイト列は同じであり、root 側との join でも特に問題になりません。
今回の変更内容
この PR では、
「トップレベルのパス名が ASCII のみで構成されているかどうか」をチェックし、
- ASCII のみ: そのまま使う(コピーしない)
- 非 ASCII を含む: 従来通り
name.bでバイナリコピーしてから join
という分岐を入れています。
※ 実際のパッチは 1 行の置き換え程度ですが、イメージとしては以下のような処理になります(擬似コード):
def root_entry?(name)
# 例示的なイメージコード
joined =
if name.ascii_only?
File.join(@compiled_root, name) # コピーなし
else
File.join(@compiled_root, name.b) # 非 ASCII のときだけバイナリコピー
end
File.exist?(joined)
end重要な点は、最終的に File.exist? に渡されるパス自体は以前と変わらないことです。
非 ASCII を含む場合は、引き続き name.b を使っているため、挙動は従来通り維持されます。
テストの追加
actionpack/test/dispatch/static_test.rb に 6 行のテストが追加されており、
- ASCII のパス名での挙動
- 非 ASCII のパス名での挙動
が壊れていないことを確認する目的と考えられます。
- 影響範囲・注意点
影響範囲
- 対象:
ActionDispatch::FileHandler経由での静的ファイルの存在チェック(ActionDispatch::Staticミドルウェア) - 特に、「静的ファイルではないパス」へのリクエスト(通常のコントローラへのルーティングになるリクエスト)でのオブジェクトアロケーションがメインの影響箇所です。
計測結果では、単一ファイル構成のプロダクションアプリ (Ruby 4.0.7) におけるアロケーション数が 1 個減っており:
| request | before | after |
|---|---|---|
GET /posts/1 (HTML) | 352 | 351 |
ActionController::API JSON show | 312 | 311 |
| 404 | 341 | 340 |
というように、リクエストごとに 1 オブジェクト削減されています。
完全にマイクロ最適化ですが、頻度の高いコードパスなので積み上げると効果があります。
また、単純なパス結合処理のマイクロベンチマークでは:
- ASCII 名 (
posts) の場合: いずれの実行モード (interpreter / YJIT / ZJIT) でも「after」が速い - 非 ASCII 名 (
日本) の場合: interpreter では少し遅くなるが、YJIT / ZJIT ではほぼ同等
となっており、「通常の英字パスが大半のアプリ」では恩恵があり、「非 ASCII パスが多いアプリ」でもほぼ影響なし、という構図です。
なお、Static#call 全体で見れば stat(2) システムコールが支配的なので、この差は全体時間にはほぼ現れません。
挙動の互換性・注意点
File.exist?に渡されるパスは「ASCII 名」「非 ASCII 名」いずれも、従来と同じバイト列であり、ファイル探索の結果が変わることはありません。- 非 ASCII 文字を含むリクエストパス、および非 ASCII を含む static root を使用している場合も、従来と同じ挙動になるよう維持されています。
- CHANGELOG の更新は行われておらず、「目に見える仕様変更」ではなく、内部実装の最適化という扱いです。
そのため、一般的な Rails アプリ開発者が意識すべき互換性リスクはほぼなく、
「静的ファイル配信周りがほんの少しだけ軽くなった」程度に捉えて問題ありません。
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/59504
- 関連 PR(
root_entry?導入元): https://github.com/rails/rails/pull/59053 - 対象クラス:
ActionDispatch::FileHandler/ ミドルウェアActionDispatch::Static - 計測環境:
- Ruby 4.0.7(アロケーション数)
- Ruby 4.1.0dev(マイクロベンチ: 5,000,000 回, before/after 交互 10 ラウンド)
#59511 Percent-encode href in preload Link header entries
マージ日: 2026/11/2 | 作成者: @fuzzyrichie
- 概要 (1-2文で)
Rails のpreload_link_tag/stylesheet_link_tag/javascript_include_tagが返すレスポンスヘッダLink(および 103 Early Hints)内のhrefを、RFC 8288 に沿ってパーセントエンコードする修正です。>など不正な文字が URL に含まれていても、Linkヘッダが壊れて複数のリンクとして誤解析されないようにします。
- 変更内容の詳細
問題点
これまで preload_link_tag などは、Link ヘッダ値を以下のように素朴に組み立てていました:
preload_link = "<#{href}>; rel=#{rel}; as=#{as_type}"ここで href に > などが含まれていると、<> でくくられた URI リファレンスが途中で閉じたように見えてしまい、その後ろが追加のパラメータや別の link-value として解釈されてしまいます。
PR 説明の例:
preload_link_tag("https://cdn.example/x.png>; rel=preload; as=image, <https://cdn.example/s.css>; rel=stylesheet; x=")
# 実際の Link ヘッダ:
# Link: <https://cdn.example/x.png>; rel=preload; as=image,
# <https://cdn.example/s.css>; rel=stylesheet; x=>; rel=preload; as=見てわかる通り、href に埋め込まれた >; rel=preload; as=image, <... が、そのままヘッダ構文として解釈されてしまい、意図しない複数の Link エントリにつながります。
HTML タグ側 (<link rel="preload" href="...">) はサニタイズされており問題ないため、「HTML と Link ヘッダで解決される URL が食い違う」という状態でした。
今回の対応内容
RFC 8288 (Web Linking) に従い、Link ヘッダの <URI-reference> の中にそのまま書けない文字を href に対してパーセントエンコードしてからヘッダを組み立てるように変更しています。
エンコード対象は:
<>- 空白文字 (スペース等)
- 制御文字
これらは元々「URI としてもそのままは不正」な文字なので、HTML 上ではブラウザが同じくパーセントエンコードした URL を要求することになり、「HTML とヘッダで実際にアクセスされる URL が揃う」ようになります。
一方で、
,(カンマ);(セミコロン)
はエンコードしません。理由:
- RFC 8288 的に
<...>の内側に現れても問題ない - 画像 CDN 等で
/w_100,h_100/のように頻繁に出現する - 既存アプリでの URL 互換性への影響を避けるため
また、既に %XX 形式でエンコードされている部分は「二重エンコードされない」ように実装されています。
どこが変わったか
対象ヘルパ:
preload_link_tagstylesheet_link_tag(preload_links_header有効時の Link/Early Hints の部分)javascript_include_tag(同上)
ファイル単位では:
actionview/lib/action_view/helpers/asset_tag_helper.rbhrefをヘッダに書き出す直前で、上記文字をパーセントエンコードする処理を追加
actionview/test/template/asset_tag_helper_test.rb- 不正文字を含む
hrefを指定したときに、Linkヘッダ側だけが正しくパーセントエンコードされることを確認するテストを追加
- 不正文字を含む
actionview/CHANGELOG.md- 上記挙動変更を記載
(PR 本体には具体的なメソッド名は書かれていませんが、典型的には href を正規化する小さなプライベートメソッドを追加し、それを preload_link_tag 系のヘッダ生成コードから呼び出す形になります。)
参考となる挙動イメージ:
# 入力
href = 'https://cdn.example/x.png> foo'
# HTML の link タグ: (ブラウザが内部的に URL をエンコードしてアクセス)
<link rel="preload" href="https://cdn.example/x.png> foo" as="image">
# Link ヘッダ: Rails 側で先にエンコード
Link: <https://cdn.example/x.png%3E%20foo>; rel=preload; as=image- 影響範囲・注意点
- 影響範囲
preload_link_tag,stylesheet_link_tag,javascript_include_tagを利用し、かつhrefに</>/ 空白 / 制御文字を含む(または含み得る)URL を指定している場合- 特に、外部 CDN 等へのフル URL を直接指定しているケースで影響があり得ます。
- 仕様的には正当な挙動
- これらの文字は元々「そのままの URI」としては不正かつ、
Linkヘッダでも許されないものなので、パーセントエンコードすることは仕様上正当です。 - 実際のところ、多くのブラウザは HTML からのリクエスト時にも同じようにエンコードして送るため、HTML 経由でアクセスされる URL と
Linkヘッダ経由の URL がより一致する方向の変更になります。
- これらの文字は元々「そのままの URI」としては不正かつ、
- 互換性・注意点
- これまで「不正な URL をそのままヘッダに流していても、たまたま CDN 側で受け入れていた」ようなレアケースでは、ヘッダ側の URL が変わる可能性があります。
- ただし、HTTP/URL の仕様に従う限り、本来期待されるのは今回の新挙動です。
as,type,crossorigin,integrity,fetchpriorityなどの他パラメータは今回の変更の対象外で、これまで通りそのまま挿入されます。これらは主に開発者がリテラルで与えるものなので、セキュリティ上の意味での「エスケープ強化」はhref部分のみに限定されています。
- セキュリティ的観点
- ヘッダインジェクション的な挙動(
href内の>や空白を悪用してパラメータや別リンクに紛れ込ませる)が抑制されるため、防御的にもプラスの変更です。
- ヘッダインジェクション的な挙動(
- 参考情報 (あれば)
- RFC 8288: Web Linking
Linkヘッダフィールドと<URI-reference>の構文を定義
- 対象となる Rails ヘルパ
この PR によって、Link ヘッダの href が RFC とブラウザの実挙動により忠実に揃うようになり、壊れたヘッダや予期せぬ複数 Link への分割を防げるようになります。
#59486 Honor unless_exist and avoid double compression in MemCacheStore#write_multi
マージ日: 2026/11/1 | 作成者: @kehra
- 概要 (1-2文で)
MemCacheStore#write_multiの挙動が単体のwriteと揃うように修正され、unless_exist: trueが正しく尊重されるようになったほか、Dalli 側での二重圧縮が起きないように:compressオプションの扱いが調整されました。これにより、既存キーの誤上書きと不必要な圧縮が防がれます。
- 変更内容の詳細
背景となる問題点
MemCacheStore#write_multi から Dalli の set_multi を使うパス(#58066 で導入)が、単体の write/write_entry と比べて次の2点で挙動がずれていました。
unless_exist: trueが無視される
本来は「キーが存在しない時だけ書き込む」べきですが、set_multiを通してしまうことで既存キーも上書きされていました。問題となる挙動の例:
rubycache.write("foo", "bar") cache.write_multi({ "foo" => "baz" }, unless_exist: true) cache.read("foo") # 実際: "baz" / 期待: "bar"二重圧縮が起きうる
Active Support 側で:compressオプションに応じてシリアライズ+圧縮した値を Dalli に渡しているにもかかわらず、そのまま Dalli のset_multiにcompress:オプションを渡していました。
Dalli クライアント側の設定 (compress: falseなど) を上書きしてしまい、4KB 以上のデータでは Dalli が再度圧縮を試みる「二重圧縮」が起きる可能性がありました。
今回の修正内容
1. unless_exist: true の扱いを単体書き込みと揃える
MemCacheStore#write_multi_entries の内部ロジックを変更し、unless_exist: true が指定されている場合は「まとめて set_multi する」経路を取らず、各エントリごとに通常の write 処理にフォールバックするようになりました。
- 単体の
writeは、unless_exist: trueのとき Dalli のaddを使います(存在するキーは上書きしない)。 write_multiでも同じ挙動を再利用するために、「まとめ書き」ではなく「1件ずつ write する」ように切り替えています。
イメージとしては次のような感じです(実際のコードは多少異なりますが、ロジックの方向性として):
def write_multi_entries(hash, **options)
if options[:unless_exist]
# まとめて set_multi せず、各エントリを既存の write ロジックで処理
hash.each do |key, value|
write(key, value, **options)
end
else
# 既存通りの set_multi 経路
entries = hash.transform_values { |value| serialize_entry(value, **options) }
# ↓ここで set_multi を呼ぶ
end
endこれにより、次のコードが期待どおり動作するようになります。
cache.write("foo", "bar")
cache.write_multi({ "foo" => "baz", "new" => "value" }, unless_exist: true)
cache.read("foo") # => "bar" (既存値は保持)
cache.read("new") # => "value" (新規キーは書き込まれる)2. :compress オプションを Dalli に渡さないようにする
MemCacheStore は write_serialized_entry などの内部で
- 値をシリアライズ
- Active Support 側の
:compressオプションに応じて圧縮
という処理を済ませてから、Dalli クライアントに値を渡します。
PR ではこの設計に合わせて、write_multi_entries でも
- シリアライズ・圧縮が終わった後
- Dalli の
set_multiを呼ぶ前に
オプションハッシュから :compress を削除するように変更しています。これにより:
- Dalli クライアントの設定 (
compress: falseなど) を意図せず上書きしない - Active Support が既に圧縮したデータを Dalli が再圧縮しようとすることを防ぐ
という効果があります。
- 影響範囲・注意点
対象:
ActiveSupport::Cache::MemCacheStoreを使い、write_multiを以下のように使っているアプリケーションunless_exist: trueを指定しているケース- 大きめのデータを
compress: trueなどで圧縮しつつwrite_multiしているケース
挙動の変化:
write_multi(..., unless_exist: true)
既存では「既に存在するキーも上書きされていた」のが、今後は「既存キーは保持、新規キーのみ書き込み」という正しい挙動になります。
もしバグ前提でロジックを組んでいた場合(既存キーも上書きされることを期待していた場合)は、動作が変わります。- 圧縮の扱い
これまでwrite_multi経由で Dalli に:compressが渡っていたため、Dalli 側が設定にかかわらず圧縮していた可能性があります。
今後は Active Support 側の圧縮のみが効き、Dalli はクライアント設定に従うようになります。結果として、CPU 使用量の低下やレスポンス改善が期待できる一方で、「実は今までより圧縮されなくなる」可能性はあります(本来の設計に戻る形)。
パフォーマンス面:
unless_exist: trueのときは、set_multiが使えず 1件ずつの書き込みになるため、大量のキーをunless_exist: trueで書く場合は若干のパフォーマンス低下がありえます。- ただしその代わり、Dalli の
addを使うことで「既存キーを誤って潰さない」という正しいセマンティクスが保証されます。
互換性:
- #58066 自体が未リリースのため、公式リリースベースでは「以前の (Rails が元々持っていた) 正しい挙動に揃えた」修正であり、リリース間の後方互換性の問題はほぼありません。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59486
set_multiを導入した以前の PR: https://github.com/rails/rails/pull/58066- Dalli の圧縮関連ドキュメント(参考用):
この PR で追加されたテストにより、以下が回帰テストとしてカバーされています。
write_multi(..., unless_exist: true)が既存キーを上書きしないこと- bulk write で Dalli 側の圧縮が呼ばれないこと(Active Support 側での圧縮のみが行われること)
#59493 Make Relation#sum with a block up to 1.5x faster by not mapping the records first
マージ日: 2026/11/1 | 作成者: @czj
- 概要 (1-2文で)
ActiveRecord のRelation#sumにブロックを渡した場合の実装を見直し、中間配列mapを作らずに直接sumするようにして最大 1.5 倍程度高速化した PR です。基本的に返り値・例外は変わりませんが、「eachを独自にオーバーライドした Relation」に対する挙動だけが従来と変わる可能性があります。
- 変更内容の詳細
2-1. 何をしているか
現状(この PR 以前)の Relation#sum(ブロック付き)はざっくり以下のような流れでした:
# 擬似コードイメージ(旧)
records = load # or to_a
# ブロックを map で適用して値の配列を作る
values = records.map(&block)
# その配列に対して sum
values.sum(initial_or_column)この PR では、Array#sum がブロックを直接受け取って要素ごとに処理できることを利用し、中間の map 用配列を作らずに一気に sum します:
# 擬似コードイメージ(新)
records = load # or to_a
records.sum(initial_or_column, &block)Ruby の Array#sum は、ブロックありの場合でもカハン・バブシュカ法による浮動小数点の補償を含めて正しく合計を出せるため、元々 map してから sum していたのと数値的な結果は一致します。
2-2. なぜ速くなるか
map用の中間配列を作らなくなるため:- オブジェクトアロケーションが減る(ベンチマークでは 1 呼び出しあたり 2 個 → 0 個に削減。Decimal の場合は 7 個 → 5 個)
- 余計なループ(
map→ その後sum)が 1 回分なくなり、sumだけで完結する
- 結果として、ブロック付き
sumで- 小さい配列(3 要素)で 1.2〜1.5 倍程度
- 30 要素の配列でも 1.1〜1.25 倍程度
までスループット向上(ベンチマークの数値は PR 説明の表どおり)。
2-3. サンプルコードの挙動イメージ
対象となるのは、例えば次のようなコードです:
order = Order.includes(:line_items).find(id)
items = order.line_items # 既にロード済み Relation
# 単純な属性合計
items.sum(&:quantity)
# ブロック内で計算するケース
items.sum { |item| item.weight * item.quantity }
# BigDecimal のような数値型でも同様
items.sum(&:price)これらは 返り値も例外の出方も変わりません が、内部実装が
「items.to_a.map { ... }.sum → items.to_a.sum { ... }」
のように変わったと考えるとイメージしやすいです。
2-4. 互換性検証
PR では、Ruby 4.0.9 / 4.1.0-preview1 上で:
- 0〜12 レコードの Relation / ロード済み association / アンロードの association
- ブロックの戻り値として:
- Integer(64bit を超える値も含む)
- Float(NaN, ±Infinity, -0.0, 補償が効く値)
- BigDecimal, Rational, Complex
- String, nil, それらの混在
- 初期値あり/なし (
0,0.0,BigDecimal("0"),1r,"",nil,[],1e308など)
という 20,000 ケースを両実装で比較し、
- 結果の「値」と「クラス」
- 発生する例外のクラスとメッセージ
がすべて一致することを確認済みです。
テスト追加も行われており、has_many に未保存レコードが混じるケースで整数・浮動小数を足し合わせるテストが加わっています(これは旧実装でも通る=仕様として互換)。
- 影響範囲・注意点
3-1. パフォーマンス面の影響
- 主な対象:
Relation#sumにブロックを渡しているコード- 特に、ロード済み
has_many/has_many :throughなどの関連に対して繰り返しsumしているケース
- 実測:
- 3 件の
has_manyの場合で 1.2〜1.5x - 30 件でも ~1.1〜1.25x
という程度のスループット向上(Ruby 4.0 / 4.1 両方、Interpreter / YJIT / ZJIT の各パターン)。
- 3 件の
Array#sum のブロック付き実装をそのまま使うだけなので、JIT にも優しい形になっています。
3-2. 挙動が変わる可能性があるケース
唯一の仕様差分は、「Relation の each を独自にオーバーライドしている場合」です。
従来:
class CustomRelation < ActiveRecord::Relation
# 例: each で一部のレコードをスキップしたり、フィルタしたりする
def each
super do |record|
next if record.skip?
yield record
end
end
end
relation = CustomRelation.new(...)
relation.sum(&:value)
# → 実質的には relation.each.map(&:value).sum
# → each が返したレコードだけが対象この PR での新実装:
# すでにレコードをロード済みの場合は、内部で保持している配列に対して
records.sum(&:value)
# → relation.to_a.sum(&:value) のイメージ
# → Relation が持つ全レコードを対象に Array#sum を呼ぶ
# → `each` のオーバーライドは通らないつまり、
- これまでは
eachが返したレコードのみ合計対象 - これからはロード済みレコード(
to_aが返す配列)すべてが合計対象
になります。
この挙動は、既に pluck, first, size などが「ロード済み Relation に対しては each を経由しない」ことと一貫していますが、each のオーバーライドに依存したコードを書いている場合には影響が出る可能性があります。
もし「どうしても each を通したい」という要件があれば、PR の議論でも触れているように、super(initial_value_or_column, &block) で Enumerable#sum を通す実装にする、という選択肢も考えられます(ただしその場合は再度パフォーマンスを測る必要があります)。
3-3. 実務的な影響の見積もり
- 典型的な Rails アプリでは、
Relation#eachをオーバーライドすること自体がかなりレアケース - 一方で、「関連をロードしてから Ruby 側でブロック付き
sum」するパターンはわりと多い(例: 見積もり、合計金額、数量計算など) - よって多くのアプリでは
- 性能面はわずかに改善
- 互換性面の影響はほぼゼロ となる可能性が高い変更です。
- 参考情報 (あれば)
この PR の主なポイント
Relation#sum(ブロック付き) が内部でArray#sumを素直に活用するようになった- 中間配列を作らずに済み、オブジェクト数削減+ループ削減で 1.1〜1.5x 程度高速化
- 数値型(Integer, Float, BigDecimal, Rational, Complex)や文字列・nil・混在ケースを含めて、膨大なランダムケースで挙動一致を確認済み
- 互換性上の差分は「
eachをオーバーライドした Relation でブロック付きsumを呼んだ場合」のみ
コードレベルで見ると、
activerecord/lib/active_record/relation/calculations.rbのsum実装の 1 行差し替えレベルの変更であり、テスト (calculations_test.rb) にブロック付きsumのカバレッジが拡充されている、という非常にピンポイントな PR です。
#59505 Hash the query cache key once less on a miss
マージ日: 2026/11/1 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
Active Record のクエリキャッシュ (QueryCache::Store) において、キャッシュミス時にキーのハッシュ計算が1回少なくなるように最適化した PR です。機能的な挙動は変えず、キャッシュミス時のオーバーヘッドを削減してクエリ実行を高速化しています。
- 変更内容の詳細
背景・問題点
対象メソッドは ActiveRecord::ConnectionAdapters::QueryCache::Store#compute_if_absent です。このメソッドは:
- 引数のキー
keyがキャッシュに存在しなければ、yieldした結果をキャッシュに入れて返す - 存在すれば、キャッシュ済みの値を返す
という、典型的な「存在しなければ計算して保存する」ヘルパです。
既存コードでは、おおよそ次のような処理が行われていました(擬似コード):
@map.delete(key) # ここで一度 key をハッシュして lookup & 削除
@map[key] ||= yield # ここでまた読み取り(ハッシュ)、ミスだとさらに代入(ハッシュ)Ruby の Hash では、キーの比較や検索のたびに hash を呼ぶため、
@map.delete(key)… 1回目のハッシュ@map[key](読み取り) … 2回目のハッシュ@map[key] = value(代入) … 3回目のハッシュ
という形で、キャッシュミス時に同じキーを最大3回ハッシュしていました。
特に Active Record のクエリキャッシュキーは [sql, binds] のように配列であり、
sql文字列bindsはQueryAttributeオブジェクトの配列- 各
QueryAttributeはさらに type 情報などを持つ
といった複雑な構造を持つため、キー 1つのハッシュ計算コストがそれなりに高い、というのがボトルネックになっていました。PR 説明では Ruby 4.1.0dev で、1つの bind を持つキー1個のハッシュに約 1.2µs かかるとされています。
また、Rails のクエリキャッシュは「リクエストごとにクリアされる」ため、多くのクエリがキャッシュミスになり、この余計な 1 回のハッシュが蓄積するとそれなりのオーバーヘッドになります。
変更内容
この PR では、ミス時の代入で ||= を使わず、直接代入する形に変更し、キャッシュミス時のハッシュ回数を 3回 → 2回に削減しています。
差分自体は 1 行だけのシンプルな変更で、イメージとしては次のような修正です(擬似コード):
# 修正前
@map.delete(key)
@map[key] ||= yield
# 修正後
@map.delete(key)
@map[key] = yieldポイント:
@map.delete(key)の時点で「キーが無い」ことはわかっているので、その後に@map[key]しても必ずnilであり、「存在チェック付き代入 (||=)」を行う意味がない。- よって、
yield結果をそのまま代入してよい。 - これにより、
@map[key]の「読み取り → 比較」フェーズが省略され、ハッシュ計算が 1 回減る。
PR 中のベンチマークでは、Post.find(1) 相当のクエリを対象として、キャッシュに10件登録済みの状態で、同じキーを削除→取得するケースで測定しています。300,000 回、before/after を交互に10ラウンド回した結果は次の通り:
| 実行モード | 変更前 | 変更後 | 後の方が速かったラウンド数 |
|---|---|---|---|
| インタプリタ | 5207 ns | 3996 ns | 10/10 |
| YJIT | 5102 ns | 3979 ns | 10/10 |
| ZJIT | 5608 ns | 4260 ns | 10/10 |
すべてのモードで約 20~25% 程度の短縮が見られており、最適化としては十分有意な効果があります。
- 影響範囲・注意点
機能面の影響
- クエリキャッシュのロジック・外部挙動は変わりません。
- 「キーが無ければ
yieldした値を保存して返す」「あれば既存の値を返す」という契約はそのまま。
- 「キーが無ければ
- 変更対象は
QueryCache::Store#compute_if_absent内部のミス時の実装詳細のみであり、API 互換性の問題はありません。 - ヒット時 (
@mapにキーが存在する場合) のパフォーマンスや挙動は一切変わりません。
性能面の影響
- 影響するのはクエリキャッシュミス時のパスのみです。
- レスポンスごとにキャッシュがクリアされる typical な Rails アプリでは、多くのクエリがミスになるため、リクエスト全体で見るとわずかではあるものの、全体的なクエリ処理が少し軽くなる可能性があります。
- 専用のベンチマークでは 20% 前後の短縮が示されていますが、実際のアプリケーションでは:
- 他の処理(DB ネットワーク I/O や AR オブジェクト生成など)が支配的
- クエリキャッシュがヒットする割合 などによって体感影響は小さいことも多いかもしれません。とはいえコアライブラリのホットパス改善としては有益です。
安全性・互換性
- 変更ファイルは 1 ファイル 1 行のみで、非常に局所的です。
- Active Record SQLite3 のテストスイートはすでに通過していると報告されています。
compute_if_absentに依存するコード(内部 API を前提にした monkey patch 等)が、@map[key] ||= ...という実装細部に依存していない限り、互換性問題はほぼ考えられません。
- 参考情報 (あれば)
- 対象ファイル:
activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb - 関連概念:
- Active Record Query Cache
- Ruby の Hash における
#hash呼び出し回数とパフォーマンス
- ベンチマーク条件:
- Ruby 4.1.0dev
- キー:
[sql, [QueryAttribute]] - エントリ数: 10件
- 300,000 反復 × before/after 交互10ラウンド
- すべてのモード(インタプリタ / YJIT / ZJIT)で after が優位
この PR は極めて小さな変更ながら、Active Record クエリキャッシュというホットパスに対する純粋な最適化であり、Rails アプリケーション全体のパフォーマンスをわずかに底上げする改善といえます。
#59510 Unescape backticks in Markdown API docs
マージ日: 2026/11/1 | 作成者: @kamipo
- 概要 (1-2文で)
Markdown 形式に変換された Rails の API ドキュメント内で、コードスパンとして使うはずのバッククォートがエスケープされてしまい文字どおり表示されていた問題を修正し、正しくコードとしてレンダリングされるようにした PR です。併せて、もともと Markdown だった Action Text のコメントが変換時に崩れていた箇所も原文の意図に沿うように復元しています。
- 変更内容の詳細
背景
- もともと Rails の多くの API ドキュメントは RDoc 形式でした。
- RDoc → Markdown 変換の過程で、RDoc では「リテラル文字」としてバッククォートを扱う必要があったため、バッククォートが ``` のようにエスケープされていました。
- しかし、変換後のファイルは Markdown として解釈されるため、本来コードスパンとして囲みたかった箇所が「バックスラッシュ付きバッククォートの文字列」として表示されてしまう問題が発生していました。
この PR では、「RDoc 時代の事情でエスケープされてしまったバッククォート」を Markdown 向けにアンエスケープし、code などが正しくインラインコードとして表示されるようにしています。
主な変更点
1) Markdown コメント内のバッククォートのアンエスケープ
多くのファイルで、コメント(YARD 形式 / RDoc 形式の API ドキュメント部分)の中にあるバックスラッシュ付きバッククォートが修正されています。
(イメージ例)
修正前(RDoc → Markdown 変換後の状態)
ruby# Use `\`perform_later\`` to enqueue a job.→ Markdown としては「`perform_later`」という文字列になり、コードスパンにならない。
修正後
ruby# Use `perform_later` to enqueue a job.
PR の説明に即して言うと:
- RDoc 時代:
- RDoc ではバッククォートを文字通り表示したくて ``` と書いていた (RDoc のマークアップルールに沿った書き方)。
- Markdown 時代:
- 同じテキストが Markdown としてレンダリングされるようになったが、``` は「バックスラッシュ付きバッククォート」という単なる文字列になり、コードスパンに解釈されない。
- 対応:
- これらを Markdown 用に書き直し、「インラインコード」として意図していた部分は素直に
`code`となるよう修正。
- これらを Markdown 用に書き直し、「インラインコード」として意図していた部分は素直に
この修正が、以下を含む多くのファイルにまたがっています:
actionview/lib/action_view/renderer/partial_renderer/collection_caching.rbactivejob/lib/active_job/continuation.rbactivejob/lib/active_job/queue_adapters/async_adapter.rbactivemodel/lib/active_model/attribute_assignment.rbactivemodel/lib/active_model/attribute_methods.rbactiverecord/lib/active_record/connection_adapters/abstract_adapter.rbactiverecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rbactiverecord/lib/active_record/connection_adapters/postgresql/database_statements.rbactiverecord/lib/active_record/core.rbactiverecord/lib/active_record/dynamic_matchers.rb- …など計 37 ファイル
いずれも「コード例」「メソッド名」「オプション名」などをバッククォートで囲んで説明しているコメントが対象です。
2) Action Text の Markdown ドキュメントの復元
actiontext/lib/action_text/markdown_conversion.rb については、他と事情が少し異なります。
- このファイルのコメントは、もともとから Markdown 前提で書かれていました。
- RDoc → Markdown 変換処理の中で、このファイルにも機械的な変換がかかってしまい、Markdown 的には正しかった記述が「壊れた」状態になった箇所があります。
- 例:
<br>タグが削除される - 例: 括弧やブラケットの位置が変わる
- 例:
PR 内容によると、このファイルでは:
<br>などの HTML を用いたレイアウト調整を含むコメントを、変換前(オリジナル)の Markdown テキストと同じ意味/表示になるように手動で戻しています。- 併せて、このファイル内に含まれるバッククォートの扱いも Markdown として自然な形になるよう整理されています。
ここでの主眼は「Action Text のドキュメントを、変換前と同じ表示・意味になるように復元する」ことです。
- 影響範囲・注意点
影響範囲:
- 変更はすべてコメント(ドキュメント)に対するもので、Ruby の実行コード・挙動には影響しません。
- Rails API ドキュメント (公式サイトや
rails doc生成物) を Markdown レンダラーで閲覧した際の見た目・可読性が向上します。 - とくにメソッド名、クラス名、オプションキー、コードスニペットなどが正しくコードスパンとして表示されるようになります。
実務的な注意点:
- ライブラリ/アプリケーション側で、これらの「崩れた Markdown コメント」をコピー & ペーストして README や自前ドキュメントに転記していた場合、自分たちのドキュメント側にも同様の問題が残っている可能性があります。
- 例:
\perform_later`のような表記が残っている、
` が消えていて行の区切りが不自然、など。
- 例:
- 今後 Rails 本体に対してドキュメント PR を出す際は、「RDoc 時代の癖でエスケープされたバッククォート」を真似せず、Markdown として素直に
`code`を書けばよい状況になっています。
- ライブラリ/アプリケーション側で、これらの「崩れた Markdown コメント」をコピー & ペーストして README や自前ドキュメントに転記していた場合、自分たちのドキュメント側にも同様の問題が残っている可能性があります。
後方互換性:
- コードそのものには変更がないため、互換性に関する心配は不要です。
- CI や静的解析でコメントも検査しているような特殊なツールチェーンを組んでいる場合のみ、差分として検知される可能性がありますが、実害はありません。
- 参考情報 (あれば)
- PR 本体:
- rails/rails#59510 — "Unescape backticks in Markdown API docs" (Author: kamipo)
- 関連する背景:
- Rails リポジトリ全体として、長年使われていた RDoc → Markdown への大規模なドキュメント形式移行が行われており、この PR はその後処理(変換による Markdown の崩れを修正する作業)の一環です。
- 実務でのヒント:
- 自身のプロジェクトで RDoc や別フォーマットから Markdown に移行する場合も、「フォーマット固有のエスケープ(今回の ``` など)」が Markdown 上で意図した表示になっているかを確認することが重要です。
#59509 Skip DynamicMatchers for names not starting with find_by_
マージ日: 2026/11/1 | 作成者: @kamipo
- 概要 (1-2文で)
ActiveRecord の動的 finder (find_by_xxx/find_by_xxx!) 判定ロジックにおいて、respond_to?時の無駄なマッチ処理をスキップする最適化が行われました。find_by_で始まらないメソッド名についてはDynamicMatchersの詳細なパターンマッチを行わないことで、respond_to?が約 2 倍高速になっています。
- 変更内容の詳細
何が変わったか
対象: activerecord/lib/active_record/dynamic_matchers.rb の respond_to_missing? まわりの処理が 1 行変更されています。
従来は:
- 任意のメソッド名について
- パブリックメソッドとして存在しなければ
DynamicMatchers#respond_to_missing?が呼ばれFindBy/FindByBangの両パターンに対してMethod.match(name)を実行して、find_by_xxx/find_by_xxx!かどうかを判定していた
というフローでした。
この PR では:
- まずメソッド名が
find_by_で始まるかどうかをチェックしfind_by_で始まらない場合は、Method.matchによる正規表現等の重いマッチ処理を呼ばずに即座に「対応していない」と判定find_by_で始まる場合だけ、従来通りFindBy/FindByBangのパターンマッチへ進む
という早期リターンが追加されています。
疑似コードにすると以下のようなイメージです:
def respond_to_missing?(name, include_private = false)
name = name.to_s
# 新たに入った最適化
return false unless name.start_with?("find_by_")
# ここから下が従来通りの Finder / Finder! 動的マッチ処理
FindBy.match(name) || FindByBang.match(name) || super
end※実際のコードはもう少し抽象化されていますが、概念的にはこのような変更です。
パフォーマンスへの効果
PR の説明によると:
- 対象: モデルクラスに対する
respond_to?呼び出しで、かつ - メソッド名が存在しない場合に
DynamicMatchersまでフォールスルーしたケース
において:
- 変更前: 189ns
- 変更後: 95ns
と約 2 倍の高速化が確認されています。
Rails では、フォームビルダ、シリアライザ、各種メタプロ系ライブラリなどが内部的に respond_to? を多用するため、この種のナノ秒レベルの最適化でも、アプリケーション全体では無視できない差になる場合があります。
- 影響範囲・注意点
既存の動作に対する互換性
find_by_xxx/find_by_xxx!形式の動的 finder の挙動自体は変わりません。find_by_で始まる名前に対しては、従来と同じマッチロジックが使われるため、互換性への影響は実質ゼロと考えて問題ありません。
respond_to?の結果find_by_で始まらないメソッド名については、以前もDynamicMatchersではfalseを返していましたが、その判定プロセスが軽くなっただけです。- そのため、
Model.respond_to?(:some_method)の真偽値が変わるケースはありません。
注意が必要そうなケース
- 独自に
method_missing/respond_to_missing?をオーバーライドして、find_by_以外の名前にもDynamicMatchersに似たロジックを食い合わせているようなかなりトリッキーなメタプログラミングをしていなければ、影響はまずありません。 - 通常のアプリケーション開発者・gem 開発者目線では、この変更を原因とする挙動変化を意識する必要はほぼないはずです。
- 独自に
性能面での期待できる効果
Model.respond_to?(:something)のような呼び出しが大量に行われるコード(特にメインループやレンダリングパス付近)で、わずかにオーバーヘッドが減る可能性があります。- マイクロベンチマークでは差が出ていますが、実アプリで体感できるかどうかはワークロード次第です。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59509
- 関連する内部機構:
ActiveRecord::DynamicMatchers- 動的 finder (
find_by_...,find_by_...!) - Ruby の
method_missing/respond_to_missing?プロトコル
#59189 Fix inaccurate examples in Active Record docs [ci skip]
マージ日: 2026/11/1 | 作成者: @bastin27
- 概要 (1-2文で)
Active Record の API ドキュメントおよびガイド中の、実際の挙動と食い違っていたサンプル出力が修正された PR です。コードの挙動は一切変えず、ドキュメントのみを実装どおりの正しい内容に揃えています。
- 変更内容の詳細
2-1. CollectionProxy#destroy の戻り値の例を修正
対象: activerecord/lib/active_record/associations/collection_proxy.rb
もともとの例では、関連のコレクションに対して destroy("4") を呼んだ結果が「単一レコードを返す」かのように書かれていましたが、実際には ID を引数に渡した場合は find(records) で解決され、「配列(Array)で返る」のが正しい挙動です。
背景:
delete_or_destroy系のメソッドは、引数が ID の場合に内部でfind(records)を呼ぶfindは、複数 ID を取れるインターフェースであり、戻り値は配列 (もしくはレコード単体) となるが、API ドキュメント上の近くの例 (delete("1")) ではすでに配列で返ることが説明されていたdestroy("4")の例だけ単数であるかのような出力になっており、不整合だった
修正の方向性:
person.pets.destroy("4")のサンプル出力を「配列を返す例」に合わせる(=delete("1")と同じパターンになるように)ことで、実際の戻り値と一致させています。
イメージ(擬似的な例):
person.pets.destroy("4")
# 以前の例: #<Pet id: 4, ...> のような単体を想定していた
# 修正後: [#<Pet id: 4, ...>] のように Array を想定した書き方に変更※実際の PR では 1 行の表現修正レベルです。
2-2. find_or_initialize_by のタイムスタンプの例を修正
対象: guides/source/active_record_querying.md
もともとのクエリガイドでは、find_or_initialize_by の例で返ってきたレコードに created_at / updated_at がセットされているような出力になっていました。しかし find_or_initialize_by は「条件に合うレコードがなければ new するだけ」で、save は呼びません。そのため、実際には以下が正しい:
- 新規に初期化されたレコード:
idはnilcreated_at,updated_atもnil
- データベースに保存されていないので、タイムスタンプはまだ入りません
修正の方向性:
- 例の出力から、
created_at/updated_atに値が入っているような記述を削除し、未保存レコードにふさわしい出力に変更しています。
イメージ(擬似的な例):
book = Book.find_or_initialize_by(name: "Agile Web Development with Rails")
book.persisted? # => false
# 以前の例: created_at / updated_at に値があるように書かれていた
# 修正後: created_at: nil, updated_at: nil を前提とした出力2-3. lock.first の生成 SQL の例を修正
対象: guides/source/active_record_querying.md
ガイドの lock の節で、Book.lock.first の SQL 例から ORDER BY 句が抜けていました。しかし、first はデフォルトスコープ (主キー昇順など) に従って ORDER BY books.id ASC を自動的に付与します。同じガイド内の first の説明では、すでにこの ORDER BY が含まれた SQL が示されており、一貫性がありませんでした。
修正の方向性:
Book.lock.firstの生成 SQL の例に、ORDER BY books.id ASCを含めるように修正- 実際の Rails が生成する SQL と、ガイドの例を一致させています
イメージ(擬似的な例):
Book.lock.first
# 以前の記述 (例):
# SELECT "books".* FROM "books" FOR UPDATE LIMIT 1;
#
# 修正後 (例):
# SELECT "books".* FROM "books" ORDER BY "books"."id" ASC LIMIT 1 FOR UPDATE;- 影響範囲・注意点
- 影響範囲:
- 実装コード・挙動には一切変更なし
- Active Record の YARD/rdoc コメントおよび Active Record Querying ガイドの内容のみ修正
- 開発者が意識すべき点:
- ドキュメントと実動作のズレが解消されたことで、以下のような誤解が減ります:
collection.destroy(id)の戻り値を単一オブジェクトと思い込むfind_or_initialize_byが自動で保存してタイムスタンプまで設定するように勘違いするlock.firstがORDER BYなしでレコードを取得するかのように解釈する
- 既存コードの変更は不要ですが、今後サンプルや解説を書く際は:
- 「initialize 系は保存しないのでタイムスタンプは
nil」 - 「
firstは暗黙にORDER BYを付ける」 - 「ID 指定の削除/破棄は
findを通るため配列になることがある」 といった挙動を踏まえるとドキュメントとの整合性が保てます。
- 「initialize 系は保存しないのでタイムスタンプは
- ドキュメントと実動作のズレが解消されたことで、以下のような誤解が減ります:
- 参考情報 (あれば)
- 対象メソッド/機能:
ActiveRecord::Associations::CollectionProxy#destroyActiveRecord::FinderMethods#find_or_initialize_byActiveRecord::QueryMethods#lock+Relation#first
- 関連ドキュメント:
#59497 Fix preloading has_many :through with a default_order scope
マージ日: 2026/11/1 | 作成者: @czj
- 概要 (1-2文で)
has_many :throughでスコープにdefault_orderを使っている場合に、preload時だけ並び順が無視されていた不具合を修正する PR です。これにより、怠惰ロード(通常の関連アクセス)とpreloadの結果の並び順が揃うようになります。
- 変更内容の詳細
問題の挙動
前提となるモデル:
class Author < ActiveRecord::Base
has_many :categorizations
has_many :categorized_posts, through: :categorizations, source: :post
has_many :categorized_posts_by_title, -> { default_order(:title) },
through: :categorizations, source: :post
end期待される挙動(怠惰ロード時)は:
Author.first.categorized_posts_by_title
# => title でソートされているところが、preload を使うと:
Author.preload(:categorized_posts_by_title)
.first.categorized_posts_by_title
# => categorizations の順番(中間テーブルの順)になり default_order が無視されるpreload(:categorized_posts, :categorized_posts_by_title) のように他の through 関連と一緒にプリロードした場合も同様で、default_order は効きませんでした。
また、default_scope 側で default_order を定義したケースも同じ問題が発生していました。
修正方針
has_many :through 用の preloader(ActiveRecord::Associations::Preloader::ThroughAssociation)は、これまで以下の3か所で「並び順」を解釈していましたが、いずれも order_values(明示的な order)しか見ていませんでした。
- 各 owner(例: Author)に紐づくレコードを並び替えるとき
- 兄弟の関連(同じ source を共有する別の関連)で既にロードされているレコードを再利用する際、「同じ order か」を判断するとき
- through 関連が、条件付きで source レコードを eager load するときのクエリに order を反映するとき
この PR では、上記3か所すべてで default_order_values も考慮するように修正しています。挙動の優先順位は、怠惰ロード(通常の関連アクセス)と同じに揃えています:
- 明示的な
orderが一箇所でも指定されていれば、それが最優先 reorder(nil)のように明示的に order を消した場合は、default_orderにフォールバック- through 側の関連自身に
orderがあれば、それは保持される(default_orderに上書きされない)
これにより、たとえば:
has_many :categorized_posts_by_title,
-> { default_order(:title) },
through: :categorizations, source: :postで定義した関連を:
Author.preload(:categorized_posts_by_title).first.categorized_posts_by_titleとした場合も、怠惰ロード時と同様に title でソートされた状態で取得されるようになります。
テストの追加
以下をカバーするテストが追加されています:
has_many :through+ スコープでdefault_orderを指定した場合に、preloadでも並び順が保たれること- source モデル(例:
Post)の default scope にdefault_orderがある場合に、preloadでも同じ並びになること - through 関連自体に
orderがある場合、そのorderがdefault_orderより優先されること - 兄弟の関連と同じ source を共有しつつ、
default_orderを使っているケースでも、重複ロード回避や順序の扱いが正しく動作すること
該当ファイル:
activerecord/lib/active_record/associations/preloader/through_association.rbactiverecord/test/cases/associations/eager_test.rb- テストモデル:
Author,Categorization,Post
- 影響範囲・注意点
影響範囲
has_many :throughをpreload/includes(preload パスを通るケース)で事前ロードしており、かつその関連や source 側でdefault_orderを利用しているアプリケーション- 上記のケースでは、「これまで preloading 時だけ並び順が崩れていた」状態が解消され、怠惰ロード時と同じ順序になります
注意点
default_order:オプションをhas_many :throughに直接指定した場合の「through 側の順序」は、この PR ではまだ無視されます- plain な
has_manyに対するdefault_order:の対応は #58667 で進行中であり、その影響範囲に踏み込まないよう、この PR ではあえて触れていません
- plain な
eager_loadは依然としてdefault_orderを一切考慮しません- この PR は
preloadの挙動を「怠惰ロードと同じにする」ものであり、eager_loadの仕様はそのままです
- この PR は
orderを明示しているコードは、今回の変更の影響を受けません(従来どおり、明示的なorderが優先)
実務的には、「has_many :through + default_order を使っていて、includes/preload 時に順序が変だと感じていた」場合、それが修正される変更と考えておけば問題ありません。
- 参考情報 (あれば)
- 関連 PR / Issue:
default_orderの導入: #39525- プレローダーが兄弟関連のロード済みレコードを再利用する修正: #59444, #59473
- plain
has_manyのdefault_order:オプション対応: #58667(進行中)
- この PR では
CHANGELOGは更新されていません(default_order自体がまだ未リリースのため)。
#59508 Exclude default_order from collection cache version query
マージ日: 2026/11/1 | 作成者: @kamipo
- 概要 (1-2文で)
Relation#compute_cache_versionがコレクションキャッシュキー算出時のクエリからdefault_orderを除外するようになり、PostgreSQL で発生していたPG::GroupingError(GROUP BY 不備)を解消する修正です。これによりdefault_orderを使ったリレーションでも安全にcache_key/cache_versionが取得できます。
- 変更内容の詳細
何が問題だったか
Relation#compute_cache_version は、コレクション用のキャッシュキーに利用するために、以下のような集約クエリを発行します。
- レコード数:
COUNT(*) - 最終更新時刻:
MAX(updated_at)など
前のPR (#59436) で「明示的な order は削除」されていたものの、default_order は残ったままだったため、こんなケースで問題が起きました:
Developer.default_order(:name).cache_keydefault_order(:name) によって内部的に ORDER BY developers.name が残ったまま、COUNT(*), MAX(updated_at) を計算するクエリが発行されると、PostgreSQL では次のようなエラーになります。
PG::GroupingError: column "developers.name" must appear in the
GROUP BY clause or be used in an aggregate function集約関数 (COUNT, MAX) を使うクエリで ORDER BY だけが残っている状態が、PostgreSQL の集約 + GROUP BY の制約に引っかかっていました。
今回の修正内容
Relation#compute_cache_version 内で、キャッシュバージョン(キャッシュキー)算出のためのリレーションを組み立てる際に:
- 明示的な
orderだけでなく - 暗黙的に付与される
default_orderも
クエリから除外する ように変更されています。
変更行は 1 行のみですが、「キャッシュキー用のクエリを構築する際には、いかなる order 情報(明示・デフォルト問わず)も含めない」という方針が明確になった形です。
テストの追加
activerecord/test/cases/collection_cache_key_test.rb に 11 行のテストが追加され、例えば以下のようなケースがカバーされていると考えられます:
developers = Developer.default_order(:name)
developers.cache_key # または cache_version
# => PostgreSQL でも例外なく動くことを確認このテストにより、default_order を指定したリレーションでも cache_key / cache_version の取得が例外を出さないことが保証されます。
- 影響範囲・注意点
影響範囲
- ActiveRecord の「コレクションキャッシュキー」関連のみが対象です。
- 例:
@developers = Developer.where(active: true)@developers.cache_key_with_version等
- 例:
default_orderを利用するスコープやクエリで、cache_key/cache_versionを呼んだ場合の挙動が安定します(エラーにならなくなる)。
- ActiveRecord の「コレクションキャッシュキー」関連のみが対象です。
注意点 / 互換性
- キャッシュキー算出時のクエリは、これまでも
COUNT(*)とMAX(updated_at)のような集約値のみに依存しており、order に依存したキャッシュキーではありません。
そのため、default_orderを除外してもキャッシュキーの意味論は変わりません。 - すでに本番で PostgreSQL を使っていて、
default_orderを使ったスコープに対して- コレクションの
cache_key/cache_versionを呼んでいる
場合、今まで落ちていた画面/エンドポイントがエラー無しで動くようになります。
- order 情報を含めたキャッシュキーを期待していた場合(順序の違いで別キャッシュにしたい等)は、そもそもこの API はその用途に向いていないため、別途キー設計を行う必要があります(例: 並び順を含めた自前のキーを組み立てる)。
- キャッシュキー算出時のクエリは、これまでも
- 参考情報 (あれば)
- 元PR: #59436
Relation#compute_cache_version周りのロジック変更のフォローアップとして今回の修正が入っています。 - 関連メソッド:
ActiveRecord::Relation#cache_keyActiveRecord::Relation#cache_key_with_versionActiveRecord::Relation#compute_cache_version
これらは Rails のフラグメントキャッシュ・テンプレートキャッシュなどで頻用されるため、キャッシュキー周りの安定性向上につながる変更です。
#59507 Fix flaky ChainedAliasAssociationsTest
マージ日: 2026/11/1 | 作成者: @kamipo
- 概要 (1-2文で)
ChainedAliasAssociationsTestが他テストから残ったコメントデータの影響で不安定(flaky)になる問題を修正した PR です。テストのsetupでコメントテーブルを明示的にクリアし、このテスト自身が作成したデータだけに依存するようにしました。
- 変更内容の詳細
問題の背景
- このテスト (
ChainedAliasAssociationsTest) は fixtures を宣言していない にもかかわらず、 他のテストでロードされた「コメントの fixture データ」がテーブルに残っているケースがある。 - あるビルドでは
postsテーブルがたまたま空になっていると、SQLite が@target(テスト内で作る Post レコード)の id として 1 を再利用する。 - 一方、他テスト由来のコメント fixture である
greetings/more_greetingsもpost_id: 1を持っている。 - その結果、本来このテスト内で作成したレコードだけにマッチしてほしい
ChainComment.where(routed_post: @target)が、
想定外に fixture 由来のコメント (greetings,more_greetings) も拾ってしまい、テスト結果が不安定になる。
対応内容
activerecord/test/cases/associations/chained_alias_associations_test.rb に setup でのクリア処理が 3 行追加されました(概念的には以下のようなもの):
class ChainedAliasAssociationsTest < ActiveRecord::TestCase
def setup
super
ChainComment.delete_all
end
# ... 既存のテスト ...
end※ 実際のクラス名・モデル名は PR の文脈からの推測ですが、趣旨は「対象テストの setup でコメント用テーブルを空にする」ことです。
ポイント:
- このテストクラスが使うコメントテーブルを、テスト開始前に
delete_allで空にする。 - これにより、
- 他テストでロードされた fixture コメント行が残っている
@target.id == 1になってしまう
といった条件が重なっても、クエリの結果が汚染されない。
- 影響範囲・注意点
- 影響範囲は Active Record のテストコード (chained alias associations のテストクラス) に限定され、本体のライブラリコードには変更なし。
- CI 上でたまに落ちていた
ChainedAliasAssociationsTestのフレークが解消される見込み。 delete_allを使っているため、- テストデータはテストメソッドごとに「クリーンな状態」から開始される
- パフォーマンスへの影響はごく小さい(対象テーブルはテスト環境の小さなテーブル)
- 開発者側で追加の対応が必要になる箇所は基本的にありませんが、
- Rails のテストを書く際に「fixtures を宣言していないのに他テストのデータが残っている可能性」をあらためて意識する
- テストが特定 ID(例: 1)に依存していないか確認する といった観点で、自身のテストコードにも活かせるパターンです。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59507
- ビルド失敗ログ(フレーク再現例):
https://buildkite.com/rails/rails/builds/135828/canvas?sid=01a19737-b768-4bc2-aadc-77f2d08d99e3&tab=output#01a19737-b7d5-4f72-a7a6-d5fd5fb6c96e/L11713 - 関連トピック:
- テスト間のデータ汚染防止
- SQLite における AUTO INCREMENT / ID 再利用の挙動
- Rails テストにおける fixtures と transactional tests の扱い
#59506 Extract effective_order_values to pick the orders a query is built with
マージ日: 2026/11/1 | 作成者: @kamipo
- 概要 (1-2文で)
RelationがクエリのORDER BYを決定する際に、「明示的な order がなければ default_order を使う」というルールを一箇所 (effective_order_values) に集約した PRです。build_order,distinct_relation_for_primary_key,in_batchesなどでバラバラに実装されていた同じ判定ロジックを共通化し、今後の実装漏れや不整合を防ぐ狙いがあります。
- 変更内容の詳細
2-1. 問題背景
これまで以下のような処理で、クエリの並び順を決めるロジックが重複していました。
build_orderorder_valuesが空ならdefault_order_valuesを使う、というフォールバックを内部で持っていた。
distinct_relation_for_primary_key(#59496)- 上記と同じルールをコピーして利用していた。
in_batches- バッチ処理時の順序判定を独自に実装していた。
結果として、「order_values が空のときに default_order_values へフォールバックする」という重要なルールが、3箇所で別々に記述され、今後新しい呼び出し元を実装する際にフォールバックを忘れるリスクがあった、というのがこの PR の動機です。
2-2. effective_order_values の導入
ActiveRecord::Relation::QueryMethods に、新たに「実際にクエリ構築に使われる order 一式」を返すメソッドが導入されています(名前が PR タイトルの effective_order_values)。
ざっくりしたイメージ:
def effective_order_values
if order_values.present?
order_values
else
default_order_values
end
end※ 実際のコードはもう少し細かいかもしれませんが、本質は「order_values があればそれを優先、なければ default_order_values」というルールを共通化することです。
これにより、クエリビルドやバッチ処理など、「実際に SQL の ORDER 句に何を使うか」を決める箇所は、order_values と default_order_values を個別に意識せず effective_order_values だけを見ればよくなります。
2-3. build_order / distinct_relation_for_primary_key / in_batches の修正
変更された主な箇所は次の通りです。
activerecord/lib/active_record/relation/query_methods.rbbuild_orderが内部で見ていた「order_valuesが空ならdefault_order_values」という判定がeffective_order_valuesに切り出され、そこから参照されるようになりました。- ほかのメソッドも、直接
order_values+default_order_valuesを見るのではなく、必要に応じてeffective_order_valuesを利用する形に整理されています。
distinct_relation_for_primary_key関連(schema_statements.rb)- #59496 で導入された「primary key で distinct な relation を作るロジック」の中で、
order_values/default_order_valuesフォールバックの判定がローカルに複製されていましたが、それをeffective_order_valuesに置き換えています。
- #59496 で導入された「primary key で distinct な relation を作るロジック」の中で、
activerecord/lib/active_record/relation/batches.rb(in_batches)- バッチ処理 (
in_batches) でクエリのソート順を決める際の判定が、従来は独自にorder_values等を見ていましたが、ここもeffective_order_valuesを用いる方向に揃えられています。 - これにより、「バッチ処理だけ order の扱いがちょっと違う」といった細かい不整合が減り、
default_scope等によるデフォルト order を考慮する一貫した挙動になります。
- バッチ処理 (
サンプル的挙動イメージ
class User < ApplicationRecord
default_scope { order(id: :asc) }
end
# 明示的な order がない場合
rel = User.where(active: true)
rel.effective_order_values
# => [Arel::Nodes::Ordering(id ASC)] (default_scope の order)
# 明示的な order を指定した場合
rel = User.where(active: true).order(created_at: :desc)
rel.effective_order_values
# => [Arel::Nodes::Ordering(created_at DESC)]
# default_scope の order は上書きされるin_batches や distinct_relation_for_primary_key などは、この effective_order_values を見て ORDER を決定する想定なので、「どういう順序でレコードが走査されるか」を推論しやすくなります。
- 影響範囲・注意点
既存アプリの動作への影響は原則的に無し (後方互換)
- もともと各所で同じ判定ロジックをしていたものを共通化しただけなので、仕様として変化させる PR ではありません。
- ただし、以前に部分的にロジックがズレていた場合(たとえば
in_batchesが他の箇所と微妙に異なる判定をしていた場合など)には、「仕様として期待されていた挙動」に統一されるため、ごく限定的に挙動の揺れが解消される可能性があります。
default_scope/default_orderを多用しているコードdefault_scopeに order を含めていて、in_batchesや internal な distinct クエリがそれをどう解釈しているかに依存したワークアラウンドがあった場合、より一貫した扱いに揃えられることで、副作用が出る可能性はゼロではありません。- とはいえ、この PR は「Rails 内部が本来意図しているルールに統一する」ものなので、バグ依存コードでない限り、影響は限定的と考えられます。
内部 API 依存している場合の注意
effective_order_values自体は内部 API 的な位置付けである可能性が高く、アプリケーションコードや gem から直接使うことは推奨されません(互換性の保証対象外になりやすい)。- ただし、「Rails の内部で order 判定が統一された」という前提を元に、挙動を推測しやすくなった点は、ORM レベルの拡張を行う gem 作者にとっては有益です。
- 参考情報 (あれば)
- 該当 PR:
- 関連 PR (ロジックが複製されていた元):
- https://github.com/rails/rails/pull/59496 (
distinct_relation_for_primary_key追加・修正)
- https://github.com/rails/rails/pull/59496 (
- コード的なキーポイント
Relationが ORDER を決定する際には、今後「order_valuesかdefault_order_valuesか」を各所で気にするのではなく、effective_order_valuesという単一のソースを見るようになっている、という設計方針の整理と理解が重要です。
#58667 Apply has_many default_order: when the association is preloaded
マージ日: 2026/11/1 | 作成者: @carlosdanielpohlod
- 概要 (1-2文で)
has_many default_order:オプションが、関連を「遅延ロードする場合だけ」効いて「preload した場合は効かない」という不整合がありましたが、このPRで preload 時にもdefault_order:が適用されるように修正されています。これにより、default_order:オプションと lambda 版 (-> { default_order(...) }) の挙動が、少なくとも lazy load と preload の経路では揃いました。
- 変更内容の詳細
これまでの問題点
has_many で default_order: オプションを指定した場合:
class Author < ApplicationRecord
has_many :posts, default_order: "posts.id ASC"
end通常の遅延ロード:
rubyauthor.posts.map(&:id) # => [1, 2, 4, 5, 6] # default_order が適用されて昇順preload 経由のロード:
rubyAuthor.preload(:posts).find(author.id).posts.map(&:id) # => [6, 5, 4, 2, 1] # default_order が無視され、DBのデフォルト順
というように、preload 時には default_order: が効いていませんでした。
一方、lambda 版で書くと:
class Author < ApplicationRecord
has_many :posts, -> { default_order("posts.id ASC") }
endこの場合は lazy load・preload の両方で並び順が揃うため、CHANGELOG などで「default_order: オプションと lambda 版は同等」とされていた内容と、実際の挙動が食い違っていました。
何をしたか
問題の原因は、preload 時に利用される AssociationReflection#join_scopes が、lambda が定義されている場合にしかスコープを返していなかったことです。そのため、default_order: のようなオプションで指定された情報が preloader に伝播していませんでした。
このPRでは:
AssociationReflection#join_scopesをオーバーライドし、- lambda がある場合に加えて
default_order:のようなオプション経由の設定も反映したスコープ を返すように修正。
Relation::VALUE_METHODSに:default_orderが含まれているため、Preloader::AssociationPreloader::Branchのmerge処理の中で、他の relation の値メソッド同様にdefault_orderがマージされるようになります。
オーバーライドを
AbstractReflectionではなくAssociationReflectionに限定しているのは、PolymorphicReflectionはAbstractReflectionを継承しつつoptionsを持たないため、- そこを巻き込むと壊れる可能性があるためです。
実装上の変更は小規模で、preloader に渡す scope に「default_order を含めておく」という一点です。
- 影響範囲・注意点
挙動の変化 (互換性)
今まで:
has_many :posts, default_order: "posts.id ASC"としていると:
author.posts
→default_orderが適用されて並び替えられるAuthor.preload(:posts).find(id).posts
→default_order無視、DBのデフォルト順
今後 (このPR適用後):
author.postsAuthor.preload(:posts).find(id).posts
のどちらも default_order が適用された順序になります。
そのため、preload を使っていて「今までは暗黙的に順序なしになっていた」ケースでは、post の並び順が変わる可能性があります。
もし API レスポンスなどで、preload 済みの関連をそのまま配列の順番を信じて返していた場合、返る順番が変わることになります(ただし、宣言上は「
default_orderを指定しているのに今まで効いていなかった」状態だったので、仕様としては今回の挙動の方が自然です)。
依然として変わらない点 / 制限事項
PRの説明に明記されていますが、以下はこのPRでは変わりません:
JOIN パスでは順序は適用されない
- JOIN を使うクエリ (例:
Author.joins(:posts).where(...)) では、default_order:も lambda 版のdefault_orderも、これまで通り JOIN 側には影響しません。 - 今回の修正はあくまで「preload 経由で関連レコードをロードするとき」に限定されます。
- JOIN を使うクエリ (例:
has_many :through の source に
default_order:を書いた場合例:
rubyclass Author < ApplicationRecord has_many :authorships has_many :posts, through: :authorships end class Authorship < ApplicationRecord belongs_to :author belongs_to :post end class Post < ApplicationRecord has_many :authorships has_many :authors, through: :authorships has_many :comments, default_order: "comments.id ASC" endこうした「:through 先の has_many に
default_order:を付けた」ようなケースで、preload 時の順序が未定義なのは従来通りで、このPRではそこには手を入れていません。
開発時の実務的な注意
default_order:を既に使っていて、かつpreload/eager_load/includes(...).preload_valuesを使っているコードは、関連配列の順序に依存していないか確認しておくと安全です。- もし「preload のときはあえて順序なしで良い / 速い方が嬉しい」という前提で書かれていたコードがある場合は、明示的に
.reorder(nil)などを挟んでルールを固定化すべきです。
- 参考情報 (あれば)
類似のギャップとして挙げられているPR:
- #57632
- #57538
これらも「関連定義オプションと実際のロードパスでの挙動のズレ」を埋めるクラスの修正で、今回のPRはその延長線上にある位置づけです。
関連するコード位置の概要:
ActiveRecord::Reflection::AssociationReflection#join_scopes
→ preloader に渡す scope を決定ActiveRecord::Associations::Preloader::Association/Preloader::Branch
→ relation のVALUE_METHODSをマージする処理でdefault_orderも考慮
このPRによって、「関連に指定した default_order: が preload でも一貫して効く」という、より期待に沿った挙動になります。
#59503 Don't copy the response media type just to check it
マージ日: 2026/11/1 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
Response#media_typeを毎回呼ぶことで発生していた余分なオブジェクト生成を避けるため、内部的に直接ヘッダを参照するようにし、CORS まわりの JS レスポンス判定もcontent_typeベースに変更して、1リクエストあたりの割り当てオブジェクト数と処理時間を少し削減するパフォーマンス改善のPRです。挙動は従来と同じになるよう維持されているため、外部APIとしての振る舞いは変わりません。
- 変更内容の詳細
背景
- 以前の変更 (#58966) により、
Response#media_typeはメモ化済みのメディアタイプを返す際にも文字列コピーを行うようになっていました。 - Rails ではリクエストごとに必ず走る以下のような処理が、この
media_typeを「ただチェックするだけの目的」で呼んでいました:- レスポンスコミット時 (
assign_default_content_type_and_charset!) - cross-origin JavaScript レスポンスチェック (
non_xhr_javascript_response?)
- レスポンスコミット時 (
- これにより「判定のためだけに不要な文字列オブジェクトが生成される」コストが、全リクエストに対して乗ってしまっていました。
実際の変更点
対象ファイルは2つです。
1) ActionDispatch::Response#assign_default_content_type_and_charset!
これまでは(擬似コードです):
def assign_default_content_type_and_charset!
if media_type.nil?
self.content_type = "text/html; charset=utf-8"
end
endのように media_type 経由で「メディアタイプがセットされているか」を確認していました。
この media_type 呼び出しが、メモ化済み値をコピーする実装になった結果、ここで毎回文字列オブジェクトが1つ増えていました。
本PRでは、
media_typeを経由せず- パース済みヘッダ情報を内部的に直接参照してチェック
するように変更し、「チェックのためだけのコピー」を避けています。
コード上は +3/-3 行の小変更で、条件判定に使うメソッドを切り替えている形です。
2) RequestForgeryProtection#non_xhr_javascript_response?
CSRF(および CORS 絡み)のための after_action で、全ての GET リクエスト後に呼ばれるメソッドです。
従来は response.media_type を使って JavaScript レスポンスかどうかを判定していましたが、本PRでは response.content_type に対して、先頭一致の正規表現で判定するように変更されています。
イメージ:
# 変更前(イメージ)
def non_xhr_javascript_response?
response.media_type == "text/javascript" || response.media_type == "application/javascript"
end
# 変更後(イメージ)
def non_xhr_javascript_response?
# media type は content_type の先頭なので、^ でアンカーした正規表現で同じ判定が可能
response.content_type&.match?(%r{\A(?:text|application)/javascript})
endポイント:
media_typeはContent-Typeヘッダの「先頭部分」(例:"text/html")を表す。content_typeは"text/html; charset=utf-8"のようなヘッダ全体。- 「メディアタイプは常にヘッダの先頭に来る」ことを利用して、
content_typeの先頭にアンカーした正規表現で同等の判定ができるため、media_typeを呼ばなくても挙動を一致させられる、という設計になっています。
これにより、JS レスポンス判定でも media_type 呼び出しに伴う文字列コピーが不要になりました。
ベンチマーク結果
単一ファイルの production アプリでの 1リクエストあたりの割り当て数 (Ruby 4.0.7):
| リクエスト種別 | before | after |
|---|---|---|
GET /posts/1 (HTML) | 352 | 350 |
GET /posts.json | 1020 | 1018 |
ActionController::API JSON | 312 | 311 |
1〜2オブジェクト/リクエストの削減ですが、これが全リクエストに乗るため、大規模トラフィックでは無視できない差になります。
text/html; charset=utf-8 レスポンスに対しての2つのチェックを 2,000,000 回繰り返した場合 (Ruby 4.1.0dev):
| モード | before | after | after が速かったラウンド数 |
|---|---|---|---|
| interpreter | 857 ns | 820 ns | 8/10 |
| YJIT | 331 ns | 260 ns | 10/10 |
| ZJIT | 369 ns | 320 ns | 10/10 |
JIT 環境で特に改善が顕著で、メソッド呼び出しや文字列コピー削減が効いていることがわかります。
- 影響範囲・注意点
- 公開APIとしての挙動:
Response#media_typeの「外向けの振る舞い」自体は変えておらず、内部の利用箇所がmedia_typeを避けるように変わっただけです。non_xhr_javascript_response?の条件は、media_type→content_type+ anchored regexp に変わりましたが、メディアタイプが常にヘッダの先頭に来るという前提に基づいており、通常のレスポンスでは従来と同じ結果になる設計です。
- セキュリティ面:
- CSRF / CORS チェックのロジック自体は同等になるように移植されているため、機能的な緩和・強化は意図されていません。
- 独自ミドルウェアやライブラリで、非常に変則的な
Content-Typeヘッダを組み立てている場合(標準形式に従っていない場合)は、稀に判定が変わる可能性はありますが、HTTP の仕様的にはヘッダ先頭に media-type が来ることが前提なので、正しくないヘッダ側に問題があります。
- パフォーマンス:
- 削減量は1〜2オブジェクト/リクエストと小さいものの、全リクエストで確実に走る処理のため、トラフィックの多いアプリケーションでは累積的には効果があります。
- JIT利用時は特に高速化が見込めます。
互換性上大きなリスクはほぼなく、「ごく小さなコストを毎リクエストから確実に削っている」タイプのマイクロオプティマイズです。
- 参考情報 (あれば)
- 対象PR: https://github.com/rails/rails/pull/59503
- 関連PR(
media_typeの挙動変更): https://github.com/rails/rails/pull/58966 - 該当コード:
actionpack/lib/action_dispatch/http/response.rbactionpack/lib/action_controller/metal/request_forgery_protection.rb
#59496 Fix eager loading with limit and default_order
マージ日: 2026/11/1 | 作成者: @czj
- 概要 (1-2文で)
eager_loadにlimit/offsetを組み合わせたときにdefault_orderが無視され、その結果 PostgreSQL / MySQL でSELECT DISTINCT+ORDER BYの整合性が取れずにエラーになる不具合を修正した PR です。distinct用に主キーを取得する内部クエリでも、明示的な order と同様のロジックでdefault_orderを考慮するようになりました。
- 変更内容の詳細
問題の背景
以下のようなコードで、eager_load + limit + offset + default_order を使った場合:
Post.eager_load(:comments).default_order(:title).limit(2).offset(1).to_a内部的には「関連を eager load するために対象レコードの id を SELECT DISTINCT で一度取得する」クエリが発行されますが、その際に
ORDER BYがdefault_orderに基づいて付く一方でSELECT DISTINCTのカラムにORDER BY対象のカラムが含まれていない
という状態になってしまい、PostgreSQL / MySQL から次のようなエラーが出ていました:
PG::InvalidColumnReference: for SELECT DISTINCT, ORDER BY expressions must appear in select list
これは Active Record 内部の distinct_relation_for_primary_key が、
columns_for_distinctに渡す order 情報としてorder_valuesだけを使っていた- 一方でクエリ本体は
order_valuesが空ならdefault_order_valuesを使って ORDER BY を組み立てる
という不整合があったためです。
つまり:
- 実際の SQL の
ORDER BY→default_orderも考慮される - DISTINCT で「どのカラムを SELECT すべきか」→
default_orderが考慮されない
というズレがあった、という整理になります。
修正内容
distinct_relation_for_primary_key が distinct 用カラム抽出に利用する「order の決定ロジック」を、build_order と同等に変更しました。
これまで:
order_values(明示的な order のみ)をcolumns_for_distinctに渡していたこれから:
build_orderと同様に- 明示的な
order(order_values) があればそれを優先 - なければ
default_order_valuesを使う
- 明示的な
というルールで order 情報を決定し、その結果を columns_for_distinct に渡すようにしています。
これにより、
Post.eager_load(:comments).default_order(:title).limit(2).offset(1).to_aのようなケースでも、内部の
SELECT DISTINCT "posts"."id" ...
ORDER BY "posts"."title" ...において、SELECT DISTINCT の対象カラムに title が含まれる、という整合した形になり、PostgreSQL / MySQL でもエラーにならず実行できます。
テスト追加
activerecord/test/cases/associations/eager_test.rb に次のテストが追加されています (概略):
- 既存の
test_limited_eager_with_orderをベースに orderではなくdefault_orderを使ったパターンを追加
これにより、
- SQLite: もともとエラーにならない (SQLite は
SELECT DISTINCTのORDER BYにゆるい) - PostgreSQL: 修正なしだとエラー → 修正ありでパス
ということを自動テストで保証しています。
CHANGELOG
activerecord/CHANGELOG.md に今回の挙動修正が追加されており、
default_orderを考慮するようになったこと- 同系統の変更 (#59415 / #59436:
exists?や集計系 API に対するdefault_order対応)
と同じ「ファミリー」の修正であることが明示されています。
- 影響範囲・注意点
影響範囲
対象となるのは以下のようなクエリです:
eager_loadを使っているlimitまたはoffsetを使っている- 明示的な
orderがない/少ないが、default_orderが設定されている - バックエンド DB が PostgreSQL か MySQL(=
SELECT DISTINCT+ORDER BYの整合性を厳密にチェックする)
実際の影響としては:
- これまで PG / MySQL で例外が出ていたクエリが、正常に動くようになる
- SQLite ではもともと通っていたが、クエリ内容がより論理的に整合する形になる
パフォーマンス・実行計画への影響
- DISTINCT で取得するカラムに、
default_order対象のカラムが含まれるようになります。 - そのため、いくつかのケースでは
- DISTINCT 対象カラム数が増えることで実行計画・インデックス利用パターンがわずかに変わる可能性があります。
- ただし、もともとはエラーになっていたパターンの修正であり、「成功するクエリが遅くなる」よりも「落ちていたクエリが通るようになる」性格が強い変更です。
他機能との関連・注意点
- PR本文で言及されているように、
in_batches(use_ranges: true)も現在は「無視されているはずのdefault_orderが残ったままになり、各バッチに余計なORDER BYが付く」という別の問題を抱えていることが示唆されています。- これは本 PRでは未対応で、今後の follow-up PR または #59416 側で扱われる予定です。
distinct_relation_for_primary_keyは eager loading 以外にも内部的に使われている可能性があるため、distinct + default_order 組み合わせ部分で、挙動の変化がないか(または期待通りか)を意識しておくと安心です。
- 参考情報 (あれば)
本 PR:
Fix eager loading with limit and default_order(#59496)関連 PR:
- #59415:
exists?にdefault_orderを考慮させた変更 - #59436: 集計計算(calculations)に
default_orderを考慮させた変更 - #59416:
in_batches/ バッチ処理まわりでのdefault_order取り扱いに関する PR(follow-up 候補とされている)
- #59415:
バグ再現例:
ruby# NG: 修正前は PG / MySQL でエラー Post.eager_load(:comments) .default_order(:title) .limit(2) .offset(1) .to_a # OK: 明示的な order のときはもともと動いていた Post.eager_load(:comments) .order(:title) .limit(2) .offset(1) .to_a
この PR により、「明示的な order がないが default_order はある」ケースでも、前者と同じように正しく動くようになっています。
#59495 Keep the scale of non-:decimal types in numericality validation
マージ日: 2026/11/1 | 作成者: @czj
- 概要 (1-2文で)
Railsの数値バリデーション (numericality) が、:decimal以外の型でも「スケール(小数桁数)」を考慮して丸めた上で検証するように戻されたPRです。Rails 8.1 での挙動(型が持つスケールで丸めてから判定)を、#59246 による変更で一部崩れていたのを修正し、PostgreSQLのmoney型などでも従来どおりの判定になるようにしています。
- 変更内容の詳細
背景となる問題
numericality バリデーションは、#59246 で「属性の型情報を使って値をキャストしてから検証する」ようになりましたが、「型の type が :decimal を返す場合に限る」という条件が付いていました。
その結果、以下のように ActiveRecord::Type::Decimal を継承していても、type メソッドで :decimal 以外を返すカスタム型(や、PostgreSQL money 型)では、型が持つ scale(小数点以下桁数)で丸めずに 検証されるようになってしまいました。
class PriceType < ActiveRecord::Type::Decimal
def type = :price
end
class Post < ActiveRecord::Base
attribute :price, PriceType.new(precision: 5, scale: 2)
validates :price, numericality: { less_than_or_equal_to: 10 }
end
post = Post.new(price: "10.001")
post.price # => 10.0 (属性として読み出すと小数2桁に丸められる)
post.valid? # => 8.1では true, main では falseRails 8.1 では scale に従って丸めてから比較していたため true でしたが、type == :decimal 限定の変更により 10.001 <= 10 として比較されて false になるという差分が発生していました。
PostgreSQL の money 型も同様で、Rails 側では :money という type シンボルを返しつつ scale = 2 を持つため、同じ問題が起きます。
このPRの核心的な変更点
「
:decimalかどうか」ではなく「スケールを持っているか」で判定するように変更- これまで:
- 型の
typeが:decimalのときだけ、属性の型でキャストしてからnumericalityバリデーションを行っていた。
- 型の
- これから:
- 型が「
scaleを持っている(=小数桁数が定義されている)」場合には、そのスケールを使って丸めた値でバリデーションを行う。
- 型が「
- つまり、
type == :decimalチェックは、「スケールを持たないDecimalWithoutScaleの扱いのため」にのみ残しつつ、スケールを持つ型一般(カスタム型やmoneyを含む) を対象に挙動を揃えるようにしています。
- これまで:
バリデーション側で「スケールに応じた丸め」を行うようにした
- 型に
scaleがある場合、numericalityバリデーションの中で- 型でキャストした数値(Float や Numeric)を
- スケールの桁数に丸めてから、
less_than_or_equal_toなど各種比較を行います。
Decimal型の場合は、もともとcast時点でスケールが適用されているため、今回の追加の丸めロジックは事実上「ノーオペレーション(値は変わらない)」です。- 一方 PostgreSQL の
OID::Moneyは、- 文字列入力に対しては文字列→数値への変換時に丸めるが、
- Float や Numeric を入力された場合は、その値をそのまま返す仕様
になっているため、バリデーション側で明示的に丸めないと、スケールが効かない ケースが出てきます。
- PRでは
OID::Money自体のキャスト仕様はあえて変えず、「8.1 で属性として返っていた値の挙動(互換性)」を維持したまま、バリデーション層でのみ丸めを補正する形をとっています。
- 型に
normalizesでラップされた属性にも対応normalizesによって属性型がラップされている場合、ラッパーはscaleやtypeを委譲するが、クラスとしてType::Decimalのインスタンスではない、というケースがあります。- これを
is_a?(Type::Decimal)で判定してしまうと見逃されますが、「scaleを持っているか」で判定する方式に変えたことで、こうしたラッパー型も正しくカバー できるようになっています。
テスト追加
activerecord/test/cases/adapters/postgresql/money_test.rb- PostgreSQL
money型を使ったときに、numericalityバリデーションがscale = 2に基づいて丸めて判定されることを確認するテストを追加。
- PostgreSQL
activerecord/test/cases/validations/numericality_validation_test.rb- カスタム Decimal 型や、スケール付き型+
normalizesなどの組み合わせに対して、8.1 と同じ挙動(スケールで丸めてから検証)になることを確認。
- カスタム Decimal 型や、スケール付き型+
- 影響範囲・注意点
影響する主なケース
ActiveRecord::Type::Decimalを継承して、typeを:decimal以外(:priceなど)にしているカスタム型で、scaleを指定しているもの。- PostgreSQL の
money型属性に対してnumericalityバリデーションを使っているコード。 normalizesでラップした数値属性で、内部的にはスケール付き Decimal 型を使っているもの。
挙動の変化の方向性
- Rails 8.1 → main(#59246 適用後)で「急に厳しくなっていた」ケースが、このPRで8.1 と同じ「丸めてから判定」挙動に戻ります。
- つまり、8.1 からのアップグレードを考えるアプリケーションにとっては「元の挙動に戻る」形であり、新たな破壊的変更ではなく、一時的な後退(リグレッション)の修正と位置づけられます。
注意点
- 元々 #59246 の挙動(スケールを無視して生の数値で比較)を前提に実装してしまったコードがもしすでに存在していれば、その部分は再び 8.1 の挙動に揃えられることになります。
- ただし PR本文にもある通り、#59246 自体がまだリリースされておらず、CHANGELOG も更新しない運用方針のため、実際のリリース版 Rails を使っている限りでは「8.1 → 次バージョン」での挙動変化は発生しない想定です。
- 参考情報 (あれば)
- 関連Issue / PR:
- #59246: numericality バリデータが属性の型でキャストするようにした変更(今回の挙動差の原因)。
- 実例:
- PostgreSQL
money型(type: :money, scale: 2)や、ActiveRecord::Type::Decimalを継承したカスタム型でtypeを独自のシンボルにしている場合に、このPRで8.1同等の挙動(属性のスケールで丸めた値で検証)が保証されます。
- PostgreSQL
#59485 Add support for comma-separated values in ENV["RAILS_MASTER_KEY"]
マージ日: 2026/11/1 | 作成者: @JeffTsang
- 概要 (1-2文で)
ENV["RAILS_MASTER_KEY"]にカンマ区切りで複数のマスターキーを指定できるようになり、Rails が順番に試して復号に成功したキーを採用することで、master key のローテーションをしやすくする変更です。credentials.yml.enc/encrypted系ファイルの復号処理が、この複数キー対応になりました。
- 変更内容の詳細
挙動の変更点
これまで:
ENV["RAILS_MASTER_KEY"]には単一の文字列のみを指定可能- Rails はその 1つのキーで
config/credentials.yml.enc等の復号を試み、失敗した場合はエラー
この PR 以降:
ENV["RAILS_MASTER_KEY"]に「カンマ区切りで複数キー」を指定可能- 例:bash
export RAILS_MASTER_KEY="old_key,new_key"
- 例:
- Rails は内部で
ENV["RAILS_MASTER_KEY"]をカンマで split し、左から順に各キーを使って復号を試行 - いずれかのキーで復号に成功したら、そのキーを採用し処理を継続
- すべてのキーで復号に失敗した場合は従来どおりエラー
実装イメージ
ActiveSupport::EncryptedFile(およびそれを利用する credentials 読み込み処理)で以下のようなロジックが追加されています(概念的なイメージ):
keys = ENV["RAILS_MASTER_KEY"].to_s.split(",") # カンマ区切りで複数キーを取得
decrypted = nil
keys.each do |key|
begin
decrypted = decrypt_with(key)
break if decrypted # 復号に成功したらループを抜ける
rescue ActiveSupport::MessageEncryptor::InvalidMessage
# 次のキーを試す
end
end
raise "Could not decrypt credentials" unless decrypted実際のコードはもう少し抽象化されていますが、概念としては「カンマ区切りでキーを列挙し、先頭から順に復号を試す」だけです。
テスト
railties/test/application/credentials_test.rb に以下のようなテストが追加されています(内容の要約):
- 複数のキーを ENV に設定し、
- 1つ目のキーで復号に失敗し
- 2つ目のキーで復号に成功する
というケースをテスト
- 復号が成功し、期待どおり credentials が読めることを確認
railties/CHANGELOG.md にも、この挙動変更(新機能)が明記されています。
- 影響範囲・注意点
主な影響範囲
- 影響を受けるのは「
ENV["RAILS_MASTER_KEY"]を使って credentials/encrypted ファイルを復号している箇所」全般です。- 代表例:
config/credentials.yml.encの読み込み - 複数環境 credentials (
config/credentials/production.yml.encなど) も同じ仕組みを共有しているため、同様の挙動になります。
- 代表例:
利用シナリオ(ローテーション例)
- 旧キー:
OLD_KEY - 新キー:
NEW_KEY
デプロイ前:
- アプリ側は
RAILS_MASTER_KEYとして"OLD_KEY"を利用中
- アプリ側は
ローテーション移行期:
credentials.yml.encなどを 新しいキーNEW_KEYで再暗号化- 本番環境などの ENV を以下のように設定bash
RAILS_MASTER_KEY="OLD_KEY,NEW_KEY" - Rails はまず
OLD_KEYを試みて失敗し、NEW_KEYで復号に成功 → 新キーに透明に切り替え可能
完了後:
- 一定期間安定稼働を確認した後、
OLD_KEYを削除し、bashのみにすることで、完全に新しいキーへ移行RAILS_MASTER_KEY="NEW_KEY"
- 一定期間安定稼働を確認した後、
※逆順 ("NEW_KEY,OLD_KEY") にしておくと「新旧両方で復号できる状態のファイル」があった場合の挙動が変わるため、運用ポリシーに応じて順序を決める必要があります(通常は「今後メインにしたい方を先頭」にするのが安全)。
注意点
- 区切りは カンマ (
,) 固定 であり、空白トリミングなどの扱いに注意(.split(",")ベースなので"old, new"のように空白を入れると" new"がそのままキー候補になり失敗要因になりうる) - すべてのキーで復号に失敗するとこれまでどおり起動時にエラーになるため、
- ENV へのキー指定ミス
- 順序や値のコピペミス には引き続き注意が必要
- 復号を試すキーが増えるほど、起動時に復号を試行する回数が増えるため、極端に大量のキーを列挙するのは非推奨(現実的には 2〜3 個程度に留める想定)
- 参考情報 (あれば)
- 対応元 PR/Issue: https://github.com/rails/rails/pull/59388
- 変更ファイル:
activesupport/lib/active_support/encrypted_file.rbENV["RAILS_MASTER_KEY"]の複数キー解釈ロジックの追加
railties/test/application/credentials_test.rb- 複数キーの復号テストの追加
railties/CHANGELOG.md- 「
RAILS_MASTER_KEYがカンマ区切り複数キーをサポートした」旨の記載追加
- 「
#59502 Pass enums added to a parent down to existing subclasses
マージ日: 2026/11/1 | 作成者: @kamipo
- 概要 (1-2文で)
親クラスに後から追加した enum を、既に存在しているサブクラスのdefined_enumsにも自動的に反映するようにした修正です。これにより、enum 自体のメソッド・スコープは動いているのに、defined_enumsを使う箇所(fixtures や uniqueness validation など)だけが不整合を起こす問題が解消されます。
- 変更内容の詳細
これまでの挙動と問題点
ActiveRecord::Enum はクラスメソッド enum で定義され、内部的に defined_enums というハッシュ({ "status" => { "active" => 0, ... } } のような構造)を持ちます。
inherited フックにより、サブクラスごとに defined_enums のコピーが作られますが、
class Parent < ApplicationRecord
end
class Child < Parent
end
Parent.enum status: { active: 0, archived: 1 }のように、
- 先に
Child < Parentを定義 - その後に
Parent.enumを定義
という順番になっている場合、
- enum メソッド (
child.active!,Child.statusesなど) - enum 用スコープ (
Child.active) - enum 用の属性タイプ(型キャスト)
は継承により正しく動く一方で、
Child.defined_enums["status"]には新しく追加された enum 情報が入っていない
という不整合が発生していました。
defined_enums は次のような場面で利用されるため、ここが欠落するとバグにつながります。
- fixtures で enum ラベルをシンボル/文字列から値に解決する処理
- uniqueness validation で enum の値を扱う処理 など
つまり、「見た目上 enum は使えるが、内部で defined_enums を見る処理だけがこける」という微妙でハマりやすい不具合が起きうる状態でした。
今回の修正内容
enum 定義時に、親クラスだけでなく既存のサブクラスにも新しい enum を伝播させるようにロジックが追加されました。
ポイント:
- 親クラスに
enumを定義したとき、すでに存在するすべての子孫クラスのdefined_enumsにも同じ enum 定義を登録する。 - ただし、子孫側で同じ名前の enum を独自に定義している場合は上書きせずにスキップする。
擬似コード的には次のようなイメージです(実際の実装はもっと細かいですが、挙動のイメージ用):
def enum(definitions)
super # 既存の enum 定義処理
definitions.each_key do |name|
descendants.each do |descendant|
# 子孫クラスが同名の enum を自前で持っていたら触らない
next if descendant.defined_enums.key?(name.to_s)
# 親で定義した enum を子孫の defined_enums にもコピー
descendant.defined_enums[name.to_s] = defined_enums[name.to_s]
end
end
endテスト (activerecord/test/cases/enum_test.rb) では概ね以下を確認しています:
- 親に後から enum を追加しても、すでに定義済みのサブクラスで
defined_enumsにその enum が現れること。 - サブクラスが同名の enum を定義している場合は、そのサブクラスの
defined_enumsが親の定義で上書きされないこと。
CHANGELOG にも次のような形でエントリが追加されています(要約):
- 親クラスに後から追加された enum が、既存のサブクラスにも
defined_enumsレベルで反映されるようになった。
- 影響範囲・注意点
影響を受けるケース
- 以下のように「サブクラスを定義した後で親に enum を追加している」コードベースで、かつ、
- fixtures や validation などで
defined_enumsベースの処理をしている箇所
class Parent < ApplicationRecord
end
class Child < Parent
end
# このタイミングで初めて enum を定義する
Parent.enum status: { active: 0, archived: 1 }このようなコードでは、今後は Child.defined_enums["status"] も正しく設定されるため、
- これまで fixtures で enum ラベルが解決できずに落ちていたようなテストが、問題なく通るようになる可能性があります。
- 逆にいえば、「本来落ちるべき(誤った前提の)テストが、今後は通るように見える」ということはなく、基本的には不具合修正寄りの変更です。
互換性・破壊的変更の有無
- サブクラスで独自に同名の enum を定義している場合は上書きしないため、既存の意図的な挙動は維持されます。
- したがって後方互換性は高く、既存アプリに対して破壊的な変更になる可能性は低いと考えられます。
注意点
- 「親に enum を書く位置」をクラス定義の順序に依存させていたような非常に特殊なメタプログラミングをしていた場合、その挙動が変わる可能性はあります。ただし一般的な Rails アプリではまず問題にならないレベルです。
- 独自に
defined_enumsを直接操作しているようなコードがあれば、この修正と競合しないか一応確認すると安心です(通常はそのようなコードはないはずです)。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59502
- 関連する概念:
ActiveRecord::Enumのドキュメント: https://api.rubyonrails.org/classes/ActiveRecord/Enum.htmldefined_enums: enum 名から定義内容へのマッピングを保持するクラスメソッドで、fixtures や validation の内部処理で利用される。
#59442 Fix Ractor proxy savepoint rollback after before_commit
マージ日: 2026/11/1 | 作成者: @yorzi
- 概要 (1-2文で)
Ractor 用の ActiveRecord プロキシで、before_commitコールバックが例外を投げた場合に「セーブポイントがメイン側に開きっぱなしになり、トランザクションの深さがズレる」バグを修正する PRです。セーブポイントのロールバック時に、メイン側とプロキシ側でトランザクションの深さを正しく同期させるように変更されています。
- 変更内容の詳細
何が問題だったか
- Ractor プロキシ(
RactorConnectionHandler)は、Ractor 内部とメインスレッド側でトランザクション状態を同期するために「セーブポイント」を使っています。 - セーブポイントをコミットするタイミングで
before_commitコールバックが例外を投げるケースがあります。 - このとき、
- プロキシ側では
TransactionManager#commit_transactionによって、すでにそのセーブポイントが「pop」されている(=current_transactionから外されている)。 - 一方でメイン側には、対応するセーブポイントがまだ残っていることがありうる。
- プロキシ側では
- これにより:
rollback_savepoint_transactionが呼ばれた時点で、プロキシ側のcurrent_transactionはすでにそのセーブポイントを指していないため、「ロールバック対象がない」と判断してメイン側のロールバックをスキップしてしまう。- 結果として、メイン側ではセーブポイントで行った書き込みが残り続け、さらにメイン側とプロキシ側で「トランザクションの深さ」が 1 段階ずれてしまう、という不整合が起きる。
どう直したか(ロジック)
この PR の要点は「セーブポイントがメイン側でどの深さにあるかを計算して、その深さに合わせてメイン側だけロールバックする」ようにしたことです。
rollback_savepoint_transactionに以下の変更が入っています:- 「メイン側におけるセーブポイントのトランザクション深さ(depth)」を求める。
- セーブポイントがまだ
current_transactionの場合:
→ その時点のopen_transactionsの値が、そのセーブポイントの深さ。 - すでに
commit_transactionによって「pop 済み」の場合:
→ メイン側は 1 つ浅くなっているので、「open_transactions + 1」がそのセーブポイントが存在していた深さ。
- セーブポイントがまだ
rollback_main_transaction(depth)を呼び出し、
「メイン側のopen_transactionsがdepth以上である間だけロールバックを行う」ようにする。
- 「メイン側におけるセーブポイントのトランザクション深さ(depth)」を求める。
- これにより、次のような振る舞いが保証されます:
before_commitで例外が起きてセーブポイントのリリース(コミット)が失敗した場合、- メイン側がすでにセーブポイントをロールバックし、1 レベル浅くなっているなら、追加のロールバックは行われず、そのままにする。
- テスト:
test_failed_savepoint_release_is_rolled_back_on_the_main_side
- テスト:
- メイン側がすでにセーブポイントをロールバックし、1 レベル浅くなっているなら、追加のロールバックは行われず、そのままにする。
- セーブポイントが「まだ current」の状態でロールバックされる場合は、従来通りの挙動を維持し、必要に応じてメイン側は
RestartParentTransactionを使った置き換えを行う。- テスト:
test_savepoint_rollback_on_an_inactive_connection_keeps_the_main_side_in_step
- テスト:
- 通常の「外側のリアルトランザクション」は、これまで通り
rollback_main_transactionを depth なしで呼び出し、従来の挙動を変えない。
「名前」ではなく「深さ」で比較する理由
- セーブポイントを名前(savepoint name)で対応づけるのではなく、「トランザクションの深さ(depth)」で同期を取っている理由は、
- メイン側が
RestartParentTransactionを使って「親トランザクション相当の再開」を行うケースがあり、このRestartParentTransactionにはセーブポイント名が存在しないためです。
- メイン側が
- そのため、「セーブポイント名で 1 対 1 に対応付ける」設計はとれず、トランザクションのネストレベル(open_transactions の深さ)を基準にして同期する方針になっています。
テスト追加・変更
主に 2 つのテストが追加されています:
ractor_connection_handling_test.rbtest_savepoint_rolled_back_by_before_commit_is_rolled_back_on_the_main_side- Ractor プロキシ越しに、
before_commitがセーブポイントをロールバックするシナリオを直接テストし、
「メイン側のセーブポイントも正しくロールバックされ、深さが同期していること」を検証。
transaction_callbacks_test.rbtest_before_commit_rollback_rolls_back_the_savepointsqlite3_ractorアダプタを通してモデルを使ったシナリオで、同じコードパスを通るケースをテスト。- こちらも
before_commitがロールバックを引き起こした際に、メイン側のセーブポイントが正しくロールバックされているかを検証。
なお、Ractor 用のテストでは:
- 外側のトランザクションは「joinable(他のトランザクションに参加可能)」にしておき、その内側に non-joinable なセーブポイントを置く構成を採用。
- 外側を non-joinable にしてしまうと、SQLite が
DEFERREDトランザクションを開始し、この isolated なテストファイル内でも他テストとの絡みでdatabase is lockedを引き起こしたため、joinable を選択しているという補足があります。
実行確認
- ローカルで以下のテストスイートを実行:
sqlite3(10195 runs)sqlite3_ractor(9460 runs)
- いずれも 0 failures。
pggem 不足に起因するConnectionHandlersMultiDbTest#test_switching_connections_with_database_urlのエラーのみ発生(PostgreSQL/MySQL は未実行)。- Ractor connection handler はまだリリース前のため、CHANGELOG への追記はなし。
- 影響範囲・注意点
- 対象:
- ActiveRecord の Ractor 用 connection handler(
ractor_connection_handler/abstract_proxy_adapter.rb)周辺の「トランザクション・セーブポイントの同期ロジック」に限定されます。 - 通常の(非 Ractor)接続アダプタのトランザクション処理は変更されていません。
- ActiveRecord の Ractor 用 connection handler(
- 挙動面:
before_commitコールバック内で例外が発生し、その際にセーブポイントやネストしたトランザクションを利用しているケースで、- これまで「テスト上は通ってしまっていたが、実際にはメイン側に書き込みが残る/トランザクション深度がずれる」ような微妙な不整合があった場合、今回の修正で正しくロールバックされるようになります。
- トランザクション深度の扱いがより厳密になったため、もし独自に Ractor プロキシと同様のメカニズムを模倣しているコードがあれば、
同期方法(名前ベースではなく depth ベース)に注意が必要です。
- パフォーマンス:
- 変更は深さ計算と条件付きロールバック程度であり、通常ケースのオーバーヘッドはごく小さいと考えられます。
- 互換性:
- 公開 API のシグネチャや通常のトランザクション API は変わっていないため、アプリケーションコードから見た破壊的変更はありません。
- ただし、「バグに依存していた」ケース(メイン側のセーブポイントが残ることを前提にしていたような挙動)があれば、その部分は修正が必要になります。
- 参考情報 (あれば)
- 関連 Issue: #59441
- 関連 PR: #59440(同様の不具合を見つけた別変更)
- 変更ファイル:
activerecord/lib/active_record/connection_adapters/ractor_connection_handler/abstract_proxy_adapter.rb- セーブポイントロールバック・トランザクション深度計算ロジックの修正。
activerecord/test/cases/connection_adapters/ractor_connection_handling_test.rb- Ractor プロキシでのセーブポイント・
before_commitロールバック挙動のテスト追加。
- Ractor プロキシでのセーブポイント・
activerecord/test/cases/transaction_callbacks_test.rb- モデル経由での
before_commitロールバック+セーブポイント挙動のテスト追加。
- モデル経由での
#59494 Raise on assigning attr_readonly attributes on subclasses with generated writers
マージ日: 2026/11/1 | 作成者: @kamipo
- 概要 (1-2文で)
attr_readonlyを親クラスに後から指定した場合でも、そのサブクラスで同じ属性に対する代入時に正しくActiveRecord::ReadonlyAttributeErrorを発生させるようにした修正です。以前は、サブクラス側に既に生成済みの writer メソッドが残っており、例外が出ず「書き込みは無視されるがエラーにもならない」という潜在的バグがありました。
- 変更内容の詳細
問題の背景
Rails の attr_readonly は、指定された属性に対する writer メソッドを「読み取り専用」として扱うために、内部で属性メソッドの再生成などを行っています。
しかし、次のようなケースで問題が発生していました。
class Parent < ApplicationRecord
end
class Child < Parent
# ここで一度 attribute methods が生成されるような操作が行われる
# 例: Child.first, Child.columns など
end
# 後から readonly を宣言
Parent.attr_readonly :some_attr
record = Child.new
record.some_attr = "value" # ← 本来は ActiveRecord::ReadonlyAttributeError を期待
record.save期待される動作:
some_attrは readonly になったので、代入時にActiveRecord::ReadonlyAttributeErrorが発生する。
実際の動作(修正前):
- 例外は発生しない。
- しかし readonly 属性として扱われるため、結果として値は保存されない。
- 「書けたように見えるが、実際は保存されない」というサイレントな失敗状態になる。
原因:
attr_readonlyが呼ばれたクラス(上記例ではParent)については属性メソッドをundef_methodで消して再生成していたが、
すでに attribute methods が生成済みのサブクラス (Child) が持つGeneratedAttributeMethodsモジュール内の writer メソッドは消していなかった。- そのためサブクラス側では古い writer が生き残り、readonly でないかのように代入できてしまっていた。
この PR の対応内容
activerecord/lib/active_record/readonly_attributes.rb の変更により、
attr_readonly呼び出し時に「そのクラス自身」だけでなく「全ての子孫クラス(descendants)」についても、対象属性の attribute メソッドをundefineするように変更。
これにより:
- 親クラスで
attr_readonlyを宣言すると、 - その時点で既に attribute methods が生成されていたサブクラスも含めて、
- 対象属性のメソッドが一旦消され、
- 再生成される際には「readonly を考慮した writer(もしくは例外を投げる動作)」になる。
結果として、親クラスで readonly にした属性に対して、サブクラスから代入しても正しく ActiveRecord::ReadonlyAttributeError が発生するようになります。
テストと CHANGELOG
activerecord/test/cases/base_test.rbにテストが追加され、- 親クラスに
attr_readonlyを後から設定した場合に、 - サブクラスの生成済み writer 経由でも
ActiveRecord::ReadonlyAttributeErrorが発生することを検証しています。
- 親クラスに
activerecord/CHANGELOG.mdに、この挙動修正が明記されています。
- 影響範囲・注意点
影響範囲
対象となるのは次の条件を満たすコードです:
- ActiveRecord モデルの継承階層がある(親クラスとサブクラス)。
- サブクラス側で既に attribute methods が生成されている(レコード取得やカラム参照などを行っている)。
- その後で、親クラスに
attr_readonlyを追加している。 - サブクラスから、その readonly にされた属性へ代入している。
これまでは「エラーは出ないが、値も保存されない」というサイレントな不整合だった箇所が、
今後はActiveRecord::ReadonlyAttributeErrorを明示的に投げるようになります。
注意点 / マイグレーション上の観点
もし既存コードで「サブクラスから readonly 属性に代入しようとしている」箇所があれば、
これまでは気付かないまま保存されていなかった可能性があり、今回の修正により例外が顕在化します。対応としては、
- そもそもその属性を書き換えるべきでないなら、代入コードを削除・修正する。
- readonly にするタイミング(
attr_readonly呼び出し)を見直す。 - readonly にしない方針に戻す(仕様として書き換えを許容する)
などを検討する必要があります。
テストコード上で「例外が出ないこと」を暗黙に前提にしていた場合、
今後はReadonlyAttributeErrorが出るためテストが失敗する可能性があります。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59494
- 変更ファイル:
activerecord/lib/active_record/readonly_attributes.rb: readonly 属性設定時に descendants の attribute methods も undefine するように変更。activerecord/test/cases/base_test.rb: 上記挙動を確認するテスト追加。activerecord/CHANGELOG.md: 挙動変更の記述を追加。
この変更により、「readonly にしたはずなのに、サブクラス側からは書けてしまう/でも保存されない」という直感に反する挙動が解消され、例外として早期に検知できるようになっています。
#58315 Fix belongs_to touch option incorrectly triggering on models with composite primary keys
マージ日: 2026/11/1 | 作成者: @masom
- 概要 (1-2文で)
belongs_toにtouch: trueを付けた関連が「複合外部キー」を持つ場合に、存在しえない親レコードを毎回WHERE ... IS NULLで探しに行ってしまう不正なクエリが発行されるバグを修正した PRです。複合キーのうちどれかがnilの場合は「以前の親」は存在しないとみなして、親のtouch用クエリをスキップするようにしました。
- 変更内容の詳細
問題の背景
前提:
belongs_toにtouch: trueを付けると、その子レコードの外部キーが変わったときに「前の親」と「新しい親」の両方のupdated_atなどを更新する動作があります。単一キーの場合(例:
belongs_to :post, touch: true)は、外部キーpost_idの変更前の値 (changes["post_id"].first) がnilなら「そもそも前の親はいない」と判断して何もしません。しかし複合キーの場合(例:
(shop_id, book_id)など)、旧キーを配列で再構成する処理が以下のようになっており、[nil, 123]のように一部がnilでも「truthy な配列」として扱われてしまっていました:rubyold_ids = foreign_key.map do |fk| changes[fk] ? changes[fk].first : o.read_attribute(fk) end # old_ids が [nil, something] でも truthy なので、 # 旧親の lookup が走ってしまうその結果、「初めて親を設定したとき」や「複合キーの一部だけが埋まっているとき」にも、ありえない主キーで
find_by(id: nil, ...)のようなIS NULL条件付きクエリが毎回発行されてしまっていました。
主キー列は NULL 不可である前提なので、このクエリは必ずヒットしない無駄なクエリです。
修正内容
ActiveRecord::Associations::Builder::BelongsTo.touch_record 内の「旧親のキーを再構成するロジック」にガードを追加し、「複合キーのどれか1つでも nil が含まれていれば旧親 lookup 自体をスキップ」するように変更されています。
変更の中核はこの部分です:
old_ids = foreign_key.map do |fk|
changes[fk] ? changes[fk].first : o.read_attribute(fk)
end
old_ids unless old_ids.any?(&:nil?)old_idsに複合キーの旧値を詰めるまでは従来と同じ。old_ids.any?(&:nil?)がtrueつまり「どれか1つでもnilがある」場合はnilを返すように変更。- 呼び出し側では
old_foreign_idがnil(falsy)なら旧親探索用のfind_byを行わない、という既存の分岐と整合します。 - 主キー列は NULL 不可という性質から、
[nil, 123]のような部分的なキーで旧親にマッチすることはないため、「本来触るべき親を見逃す」ことにはなりません。
単一キーのときの振る舞い(old_id が nil なら旧親 lookup をしない)と、複合キーの挙動が揃った形になります。
テスト追加
activerecord/test/cases/associations/belongs_to_associations_test.rb と test/models/cpk/book.rb に複合主キー(CPK)用のテストとフィクスチャが追加されています。新しい Cpk::TouchableBook モデルを使い、次を検証しています:
- 初回関連付け時(旧親なし)
- 初めて親をセットするケースで、
WHERE ... IS NULLを含む旧親 lookup クエリが一切発行されないこと。
- 初めて親をセットするケースで、
- 複合キーの一部だけセットされている場合
- 複合キーのうち一部のカラムしか埋まっていない状態でも、旧親 lookup が走らないこと。
- 実際の「親の付け替え」時
- 旧親が実在するケースでは、依然として旧親に対して
touchが行われること(過剰にスキップしていないこと)の保証。
- 旧親が実在するケースでは、依然として旧親に対して
- 影響範囲・注意点
- 対象:
belongs_to ... , touch: trueを使っていて- その関連が「複合外部キー(Composite Foreign Key)」を通じて親を参照しているケース
- 影響:
- これまでは、初回作成やキーの一部のみがセットされた状態で、不要な
IS NULL付き SELECT が毎回走っていましたが、この PR によりそれらが発行されなくなります。 - 正常系の動作としては、「実際に前の親がいる場合のみ」旧親の
touchが行われるようになり、期待どおりの挙動です。
- これまでは、初回作成やキーの一部のみがセットされた状態で、不要な
- パフォーマンス:
- 特に大量 insert・バルク insert の場面で、複合キー +
touch: trueを用いていた場合、無駄クエリが減ることで DB 負荷が軽減される可能性があります。
- 特に大量 insert・バルク insert の場面で、複合キー +
- 互換性:
- 主キー列が非 NULL であるという Active Record/DB 設計の一般的前提に基づいたガードであり、正当な旧親タッチが失われるケースは想定されていません。
- もし複合主キーに NULL を許容するような異例のスキーマを組んでいる場合(通常推奨されません)は、振る舞いが変わる可能性がありますが、そのような設計自体が AR の想定外と言えます。
- 参考情報 (あれば)
- 対象コード:
activerecord/lib/active_record/associations/builder/belongs_to.rbのtouch_recordまわり
- 関連する概念:
belongs_to ... , touch: trueの挙動(親のupdated_at自動更新)- Composite Primary Keys / Composite Foreign Keys
- 本 PR のポイント:
- 単一キーと複合キーで「旧親タッチの条件分岐」のロジックを揃えた調整であり、機能追加というより「複合キー周りだけバグっていたものを整合させた」修正です。
#59488 Fix preloading has_one through associations sharing a table
マージ日: 2026/11/1 | 作成者: @ozamasa
- 概要 (1-2文で)
has_one :throughで、through側とsource側が同じテーブルを使う STI サブクラスの場合に、事前読み込み(preload/eager_load)するとnilが返ってしまうバグを修正した PR です。STI のtype条件が競合していたクエリ生成を見直し、正しいサブクラスを3クエリで確実にプリロードできるようにしています。
- 変更内容の詳細
問題の背景
構成イメージ:
class Route < ApplicationRecord
self.inheritance_column = :type
end
class BranchRoute < Route
# through 側 (中間) の STI サブクラス
has_one :customer_route
end
class CustomerRoute < Route
# source 側 (最終ターゲット) の STI サブクラス
end
class Branch < ApplicationRecord
has_one :branch_route
has_one :customer_route, through: :branch_route
endBranchRoute と CustomerRoute は同じテーブル routes を共有する STI サブクラス (type カラムで区別) だとします。
Branch.includes(:customer_route) のように has_one :through をプリロードするとき、従来の実装では:
- through リレーション (
branch_routes) 側に、source 側のスコープ(例:type = 'CustomerRoute')までマージしてしまう - しかし through 側はすでに
type = 'BranchRoute'でスコープされている - 結果として
WHERE type = 'BranchRoute' AND type = 'CustomerRoute'のような「両立しない条件」が生え、レコードが取れずnilになっていた
これが #43657 で報告されていた不具合です。
修正方針
「through クラスと source クラスが同じテーブルを共有している場合」に限って挙動を変え、
- through 用のクエリ
- through 側(STI サブクラス)に必要な条件だけでレコードをロードする
- source 側の
type条件などは through リレーションには混ぜない
- source 用のクエリ
- その後、preloader の「source filtering パス」を使い、source 側の関連を別クエリでロードする
- このクエリに
type = 'CustomerRoute'などのターゲット条件を適用する
という2段階のプリロードに変更しています。
これにより:
- through 側クエリ:
type = 'BranchRoute'の条件だけ → 正しく BranchRoute が取れる - source 側クエリ:
type = 'CustomerRoute'の条件を持った別クエリ → CustomerRoute が正しく紐付く
実装のポイント(through_association.rb)
activerecord/lib/active_record/associations/preloader/through_association.rb のロジックが変更されています(+4/-2)。
概ね以下のような判定が入ったと考えられます(※実際のコードは多少異なる可能性がありますが、意図はこういうものです):
def preload
if through_and_source_share_table?
# source 条件は through リレーションにマージしない
preload_through_records_without_source_scope
preload_source_records_separately
else
# 従来通り: through に source 条件をマージするパス
preload_through_records_with_source_scope
end
endthrough_and_source_share_table? のような判定で、through と source が同じテーブルを pointing しているか見て、同一テーブルの場合だけ source-filtering の経路を使います。
テスト
has_one :through + STI + 同一テーブルパターンを再現する回帰テストが追加されています:
activerecord/test/cases/associations/has_one_through_associations_test.rb(+46)- 補完的に
has_many :through側のテストも追加 (has_many_through_associations_test.rb+32)
テストでは次を検証:
- 対象の
has_one :through関連が 3クエリでプリロードされること- おそらく:
- parents (e.g. Branch)
- through (BranchRoute)
- source (CustomerRoute)
- おそらく:
- プリロード後、その関連が
loaded?とマークされていること - 関連へのアクセスが追加クエリなしで行われ、期待通りのレコードが返ること
また、以下一式が実行されており、既存影響もチェック済みです:
has_one/has_many/nested throughと eager-loading 周辺テスト: 572 runs / 2,013 assertions- association テスト全部: 1,668 runs / 5,821 assertions
- Active Record SQLite スイート全体: 9,740 runs / 34,424 assertions / 0 failures, 0 errors
CHANGELOG.md にも今回のバグ修正が追記されています (+11)。
- 影響範囲・注意点
- 影響対象:
has_one :through(および類似のロジックを使うhas_many :through)で、- through クラスと source クラスが 同じテーブルを共有する STI サブクラス なケース
- 挙動の変化:
- これまで
includes/preload/eager_load時にnilが返っていたパターンで、正しく関連オブジェクトが返るようになります。 - その代わり、クエリは最大3本(親 / through / source)発行されることがあります(以前は1つのクエリで済まそうとして失敗していた)。
- これまで
- パフォーマンス:
- 問題があるパターンでクエリ数は増えますが、「そもそも正しくロードできない」状態から「確実にロードできる」状態になる変更です。
- 同じテーブルを共有していない(別テーブルを使う)through + source 構成の場合は、これまで通りのクエリ生成が使われ、パフォーマンス特性も変わりません。
- 互換性:
- 仕様としては「本来期待される
has_one :throughの振る舞いに近づく」修正なので、破壊的な変更ではありません。 - もしアプリ側が「STI の
typeの競合でnilが返ること」を前提にしたワークアラウンドを持っていた場合は、そのコードが不要になる(あるいは重複する)可能性があります。
- 仕様としては「本来期待される
- 参考情報 (あれば)
- 対応 Issue: #43657
「STI subclasses with shared table cause nil preloaded has_one :through」関連 - PR 本体: rails/rails#59488
- 関連ファイル:
activerecord/lib/active_record/associations/preloader/through_association.rbactiverecord/test/cases/associations/has_one_through_associations_test.rbactiverecord/test/cases/associations/has_many_through_associations_test.rbactiverecord/CHANGELOG.md
#59491 Pass attribute aliases added to a parent down to existing subclasses
マージ日: 2026/11/1 | 作成者: @kehra
- 概要 (1-2文で)
親クラスにあとから追加したalias_attributeが、既に独自のattribute_aliasesを持っているサブクラスには反映されず、クエリでエイリアス名がそのままカラム名として使われてしまう不具合を修正した PR です。alias_attribute実行時に子孫クラスへもエイリアス定義(lookup 用ハッシュ)を伝搬させることで、Active Record の問い合わせでも正しくエイリアスを解決できるようにしています。
- 変更内容の詳細
問題の具体例
元の問題は「親に後付けした alias_attribute が、既に attribute_aliases を複製済みの子クラスには載らない」というものです。
class Post < ActiveRecord::Base; end
class SpecialPost < Post
alias_attribute :content, :body
end
# 後から親に alias を足す (concern や再オープンでよくある)
Post.alias_attribute :headline, :title
SpecialPost.first.headline
# => メソッド呼び出しは OK (メソッドは継承されている)
SpecialPost.where(headline: "a").to_a
# => ActiveRecord::StatementInvalid: no such column: posts.headline
# attribute_alias? が false になるため、"headline" がカラム名と解釈される理由:
alias_attributeは- 「メソッド定義」(
headline,headline=など) - 「lookup 用のハッシュ
attribute_aliasesの更新」 を行う
- 「メソッド定義」(
- サブクラスが一度でも自分で
alias_attributeを定義すると、その時点でattribute_aliasesの「自前コピー」を持つようになり、以降は親とハッシュを共有しない - その後で親に
Post.alias_attribute :headline, :titleを追加しても、- メソッドは継承されるので
SpecialPost#headline自体は呼べるが SpecialPost.attribute_alias?("headline")はfalseのまま- そのため Active Record のクエリビルダはエイリアス変換を行わず、
posts.headlineを実カラムとして SQL に出してしまう
- メソッドは継承されるので
同じ現象が、抽象クラス経由でも起きます。
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
class Post < ApplicationRecord
# 自分では alias を定義していなくても、
# attribute メソッド生成時に `id_value` などで attribute_aliases がコピーされる
end
ApplicationRecord.alias_attribute :headline, :title
Post.where(headline: "a") # => やはりカラム解決に失敗する変更の主眼
この PR は ActiveModel::AttributeMethods.alias_attribute を拡張し、
- 親クラスで
alias_attributeを呼んだとき - そのクラスの descendants(子孫クラス) を走査して
- すでに独自の
attribute_aliasesハッシュを持っているクラスに対しても
同じ alias を「lookup ハッシュ側だけ」コピーする
というふるまいを追加しています。
これにより:
- サブクラス:
- すでに親からメソッドは継承している(従来通り)
- さらに、自前の
attribute_aliasesにもエントリが追加される
- 結果として
attribute_alias?やクエリビルダが alias を認識し、SQL では元のカラム名が使われるようになる
実装ポイント
処理場所
- 修正は
ActiveModel::AttributeMethods側
(ActiveRecord::Base.alias_attributeはsuperでここを呼ぶだけなので、Active Model を直せば Active Record も直る)
- 修正は
処理内容の流れ(概念的)
擬似コードイメージ:
rubydef alias_attribute(new_name, old_name) # 従来通り、自クラスの attribute_aliases を更新 attribute_aliases[new_name.to_s] = old_name.to_s # 新仕様: descendants も更新 descendants.each do |descendant| # 親と attribute_aliases を共有しているクラスはスキップ next if descendant.attribute_aliases.equal?(attribute_aliases) # 既に同名エイリアスがある場合は上書きしない(後述のトレードオフ) next if descendant.attribute_aliases.key?(new_name.to_s) descendant.attribute_aliases[new_name.to_s] = old_name.to_s end enddescendants の利用
descendantsメソッドを使うためにactive_support/core_ext/class/subclassesを require- Active Model 単体ではこれが自動ロードされないため、明示的に読み込む必要がある
テスト変更
activemodel/test/cases/attribute_methods_test.rb- 親に後から alias を追加したときに、サブクラスの
attribute_alias?もtrueになるケースを追加
- 親に後から alias を追加したときに、サブクラスの
activerecord/test/cases/attribute_methods_test.rb- Active Record のモデル/抽象クラスを絡めたシナリオで、クエリにおいて alias が解決されることを検証
CHANGELOG
activemodel/CHANGELOG.mdにバグ修正として追記
- 影響範囲・注意点
影響範囲
- 対象:
- ActiveModel::AttributeMethods を継承したクラス(Active Record のモデル含む)
- 特に、親/抽象クラスに alias を後付けする設計(concern や再オープン)があるアプリ
- 効果:
- 「親にあとから追加した
alias_attributeが、すでに生成済みのサブクラスにも正しく効く」 - Active Record のクエリ (
where,order,select等) で attribute エイリアスが安定して使える
- 「親にあとから追加した
この挙動は既存コードに対して基本的には「バグ修正」として期待通りの方向の互換性です。
トレードオフ / 注意点
PR の説明にも明記されている重要な仕様:
サブクラスで既に同名エイリアスが存在する場合は、親の変更で上書きしない
理由:
- サブクラスが持っている
attribute_aliases["headline"] = ...が- もともと自分で定義したものか
- 過去に親からコピーされたものか
を区別できない
- 安全側に倒して「サブクラス側の定義を優先する」というポリシーにしている
そのため:
rubyclass Post < ApplicationRecord; end class SpecialPost < Post alias_attribute :headline, :short_title end # あとから親でエイリアスを変更 Post.alias_attribute :headline, :title SpecialPost.attribute_aliases["headline"] # => "short_title" のまま (従来と同じ / 新仕様でも変えない)- サブクラスが持っている
つまり「親が既存の alias を別の属性に付け替えても、既に独自コピーを持つサブクラスは古いまま」という点は、(今までもそうだったし)今後も変えない仕様です。
この仕様は、親の設計を変えたときにサブクラスの意味が破壊されるのを防ぐ目的で妥当といえますが、
- 「親で alias を付け替えたつもりが、サブクラスで古い紐付けのまま残る」
ことはあり得るので、 - alias を全面的に整理する場合は、サブクラス側に古い alias が残っていないかも確認したほうがよいです。
- 参考情報 (あれば)
類似の修正:
- PR 説明中に言及されている通り、これは「親にあとから追加した関連を子孫クラスにも反映する」修正 (#59462 / 元問題 #20678) と同じパターンを、属性エイリアスに適用したものです。
add_reflection(アソシエーション定義)と同様のロジックをalias_attributeにも導入した、と理解するとよいです。
設計上のポイントまとめ:
- メソッド定義(reader/writer)は継承に任せる
attribute_aliasesという「解決テーブル」をクラス階層全体で一貫させる
(ただしサブクラス独自の定義は尊重する)- そのために descendants を走査して lookup ハッシュを同期する、という実装になっています。
#59492 Pass readonly attributes added to a parent down to existing subclasses
マージ日: 2026/11/1 | 作成者: @kehra
- 概要 (1-2文で)
親クラスにあとからattr_readonlyを追加した場合でも、それ以前から存在していた子クラスにも正しく読み取り専用属性が引き継がれるようにした修正です。これにより、親と子でreadonly_attributesの内容がずれて、一部のサブクラスだけ読み取り専用制約が効かない不整合が解消されます。
- 変更内容の詳細
これまでの問題
以下のような状況で不具合がありました。
class Post < ActiveRecord::Base; end
class SpecialPost < Post
attr_readonly :body
end
# 後から親クラスに readonly 属性を追加(concernや再オープンなど)
Post.attr_readonly :title
SpecialPost.readonly_attributes
# => ["body"] # ← 本来 :title も入ってほしい
post = SpecialPost.first
post.title = "changed" # ここで例外は発生しない
post.save! # DB上の title が更新されてしまう- 親
Postに追加したattr_readonly :titleが、すでに存在する子クラスSpecialPostのreadonly_attributesに反映されていませんでした。 - そのため、
Postではtitleを変更しようとするとActiveRecord::ReadonlyAttributeErrorが発生する一方で、SpecialPostでは変更が通ってしまうという一貫性のない挙動になっていました。
この問題は、以前の PR #59462 で「関連 (associations)」に対して行われた修正(add_reflection が子クラスにも情報を伝搬するようにした)と同種のものです。
今回の修正内容
attr_readonly が呼ばれたときの挙動を変更し、クラス自身だけでなく、その子孫クラスにも readonly 属性を伝搬するようにしました。
内部的には:
- 親クラスで
attr_readonlyを実行すると- 自クラスの
_attr_readonlyに属性名を追加 - さらに
descendantsを走査し、それぞれのクラスについて:- そのクラスが「独自の
_attr_readonlyのコピー」を持っている場合にだけ新しい属性を追加
(まだ親と配列を共有しているクラスは、親側での追加だけで内容が反映されるためスキップ)
- そのクラスが「独自の
- 自クラスの
というロジックになっています。
これにより、先ほどの例は次のように挙動が揃います。
class Post < ActiveRecord::Base; end
class SpecialPost < Post
attr_readonly :body
end
Post.attr_readonly :title
SpecialPost.readonly_attributes
# => ["body", "title"] # 親の :title も含まれる
post = SpecialPost.first
post.title = "changed"
post.save! # => ActiveRecord::ReadonlyAttributeError が発生仕様・トレードオフ
PR説明にもある通り、今回の修正が行うのは**「readonly 属性リストの伝搬のみ」で、属性メソッドの再生成までは行わない**という割り切りがあります。
その結果:
- 親に
attr_readonlyを追加した時点より前に、子クラスでその属性のメソッドが生成済みだった場合:- 子クラスで代入しても例外 (
ReadonlyAttributeError) は発生しない - しかし、保存時に値は DB に反映されない(読み取り専用扱い)
- 子クラスで代入しても例外 (
- これは「親と子が
_attr_readonlyの同じ配列インスタンスを共有していたケース」でもすでに起きていた挙動であり、今回の変更で新たに発生する副作用ではないと説明されています。
- 影響範囲・注意点
影響を受けるのは以下のようなコードパターンです:
- STI や継承を使っている
ActiveRecordモデル - 子クラスで既に
attr_readonlyを宣言済み - その後に、親クラス側へ
attr_readonlyで新たな属性を追加している(concern ロード順や再オープンで起こりがち)
- STI や継承を使っている
この変更によって:
- これまでサイレントに更新できていたサブクラスの属性更新が、例外や更新拒否として表面化する可能性があります。
- すでに本番運用中のアプリケーションでは、「子クラスから親の readonly 属性を更新していたコード」があれば、それが壊れる(正しくは修正される)ことになるため、移行時には動作確認が必要です。
例外が出ないが保存されないケース:
- 一部のケース(属性メソッドが先に生成されている)では、更新時に例外は発生しないものの値は永続化されません。
- テストを書く際には、「例外が出ること」だけでなく「DB 上の値が変わらないこと」も合わせて確認しておくと安全です。
- 参考情報 (あれば)
この PR が解決したのと同種の問題(アソシエーションの伝搬):
- #59462 – associations に対する
add_reflectionの伝搬修正 - #20678 – 元となる関連の readonly 伝搬問題
- #59462 – associations に対する
変更点は以下の 3 ファイル:
activerecord/lib/active_record/readonly_attributes.rbattr_readonlyの実装に、子孫クラスへの伝搬ロジックを追加
activerecord/test/cases/base_test.rb- 親クラスにあとから readonly 属性を追加したときのサブクラスの挙動を検証するテストを追加
activerecord/CHANGELOG.md- 上記の動作変更(バグ修正)がドキュメント化されている
#59435 Fix counter cache double increment with partial inserts disabled
マージ日: 2026/11/1 | 作成者: @ozamasa
- 概要 (1-2文で)
has_many :through+ counter cache +partial_insertsを無効にした場合に、関連を1件作成しただけで counter cache が2回インクリメントされてしまう不具合を修正するPRです。未変更の counter cache カラムを INSERT 文から除外することで、メモリ上の値と DB 上の値が二重に更新されないようにしています。
- 変更内容の詳細
問題の背景
構成イメージ:
class Owner < ApplicationRecord
has_many :ownerships
has_many :items, through: :ownerships
end
class Ownership < ApplicationRecord
belongs_to :owner, counter_cache: true
belongs_to :item
endさらに、グローバル or モデルレベルで:
ActiveRecord::Base.partial_inserts = false
# または Owner.partial_inserts = falseこの状態で:
owner = Owner.new
owner.items << Item.create!
owner.save!のような処理をすると、本来 owners.items_count は 1 になるべきところが 2 になってしまう、というバグがありました。
理由:
partial_inserts = falseのときは、レコード作成時に「すべての属性」が INSERT に含まれる。has_many :throughの counter cache では、Owner(owner) 側のメモリ上の counter 値がINSERTに書き込まれる。- 同時に、
Ownershipのbelongs_to :owner, counter_cache: trueコールバックが DB 上の counter カラムを+1する。 - 結果として「INSERT で 1」「コールバックで +1」で合計2になってしまう。
修正内容の要点
PR の説明曰く:
Exclude unchanged counter-cache columns from create statements.
Explicitly assigned (changed) counter values are still included in theINSERT.
つまり:
- 未変更 (dirty ではない) な counter cache カラム は INSERT の対象から外す。
- 開発者が明示的に
owner.items_count = 5のように 自分で値を設定した場合 は、その値は「変更あり」とみなされるので、従来通り INSERT に含める。
これにより:
Ownerの新規作成時に、counter cache カラムを触っていなければ:- INSERT では
items_countを書き込まない(DB のデフォルト値 / NULL)。 Ownershipのbelongs_toコールバックが+1し、DB 上の値は正しく 1 になる。
- INSERT では
- 逆に、開発者が初期値を明示的に設定したい場合は、その値がそのまま INSERT される(コールバックとの整合は開発者の責任)。
実装のポイント (推測を含む)
変更ファイルから読み取れるポイント:
activerecord/lib/active_record/attribute_methods/dirty.rbが変更されており、
「どの属性を INSERT に含めるか」を決めるロジック(dirty tracking によるカラム選択)に手が入っています。- 具体的には、「counter cache カラムで、かつ値が変更されていないもの」を
INSERT対象から外す分岐が追加されたと考えられます。 has_many :through向けのテスト (has_many_through_associations_test.rb) とcounter_cache_test.rbにテスト追加があり、
上記の再現ケース(partial_inserts false + has_many :through + counter_cache)をカバーするようになっています。
サンプル的な期待動作:
ActiveRecord::Base.partial_inserts = false
owner = Owner.new # items_count は nil / 0 (モデル定義による)
owner.items << Item.create! # counter_cache によりメモリ上の items_count は 1 になるが…
owner.save! # INSERT では items_count を明示的に書かない
# => Ownership の belongs_to コールバックにより DB 上の items_count は 1明示的に設定した場合:
owner = Owner.new(items_count: 10)
owner.items << Item.create!
owner.save!
# -> INSERT では items_count = 10 が書かれる
# -> その後 counter_cache のコールバックで +1 される可能性があり、結果は 11 になる
# (この挙動は従来どおりで、PR の対象外)- 影響範囲・注意点
- 対象:
counter_cacheを使っているbelongs_to/has_many/has_many :through関連。- 特に
partial_inserts = falseを有効にしているアプリケーション。
- 影響:
- これまで「新規作成時に counter cache が2重にインクリメントされていた」ケースで、正しく1回だけ増えるようになります。
- 「counter cache カラムを明示的に設定した上で save する」ケースでは、明示設定された値はこれまで通り INSERT に含まれます(挙動は変わらない)。
- 注意点:
- もし既存アプリが、バグ前提のワークアラウンド(例: あえて counter 値を1少なくしておく、等)をしていた場合、そのワークアラウンドは不要になるため、挙動が変わる可能性があります。
- counter cache カラムに対してカスタムなデフォルト値や手動管理をしている場合は、この変更によって INSERT タイミングが変わる(DBデフォルトに任せる)箇所がないか確認した方が安全です。
- 参考情報 (あれば)
- 対応 issue: #47555
- 変更ファイル:
activerecord/CHANGELOG.mdactiverecord/lib/active_record/attribute_methods/dirty.rbactiverecord/test/cases/associations/has_many_through_associations_test.rbactiverecord/test/cases/counter_cache_test.rb
- 関連機能:
counter_cache(belongs_to :foo, counter_cache: true)partial_inserts(INSERT 時に変更されたカラムだけ送る最適化)- ActiveRecord の dirty tracking (
*_changed?,changed_attributesなど)
#59490 Pass aggregations added to a parent down to existing subclasses
マージ日: 2026/11/1 | 作成者: @kehra
- 概要 (1-2文で)
composed_ofで定義した aggregation を、後から親クラスに追加した場合でも、既に存在しているサブクラスに正しく伝播させる修正です。これにより、サブクラスでもreflect_on_aggregationやwhereが期待通りに動作するようになります。
- 変更内容の詳細
何が問題だったか
次のような状況を考えます:
Money = Struct.new(:cents)
Counter = Struct.new(:value)
class Post < ActiveRecord::Base; end
class SpecialPost < Post
composed_of :counter, class_name: "Counter", mapping: [%w(count value)]
end
# 後から親クラスに aggregation を追加
Post.composed_of :balance, class_name: "Money", mapping: [%w(cents cents)]このとき:
SpecialPost.first.balance # => #<struct Money cents=5> (reader は動く)
SpecialPost.reflect_on_aggregation(:balance) # => nil (← ここが問題)
SpecialPost.where(balance: Money.new(5)) # => TypeError: can't cast Money
Post.where(balance: Money.new(5)) # => 正常に動作- サブクラスは
balanceの reader / writer メソッドは使えるが、 reflect_on_aggregation(:balance)がnilを返すため、- クエリ中での型キャスト (
where(balance: Money.new(5))) に失敗していた、というバグです。
これは以前、関連 (has_many など) 向けに修正された #59462 / #20678 と同種の問題で、「クラス定義後に親クラスへ追加されたメタ情報がサブクラスに十分に伝播していない」というパターンです。
何を変更したか
ActiveRecord の Reflection 実装で、aggregation 追加時の挙動を変更しています。
対象コード: activerecord/lib/active_record/reflection.rb
- 変更されたメソッド:
add_aggregate_reflection(name, reflection) - 動き:
- まず親クラス自身の
aggregate_reflectionsに aggregation を登録 - その後、
descendantsを辿ってサブクラスを走査し、必要に応じて同じ aggregation をコピーして登録するようにした
(これは associations 用のadd_reflectionと同じ戦略)
- まず親クラス自身の
ポイント:
- 「すでに独自の
aggregate_reflectionsハッシュを持っているサブクラス」で- かつ「同名の aggregation が 定義されていない」クラスには、親で追加された aggregation をコピーして追加する
- もしサブクラス(またはその途中のクラス)が、同じ名前の aggregation を定義している場合は、「オーバーライド」とみなし、その定義を優先して上書きしない
- 「まだ親と
aggregate_reflectionsのハッシュを共有しているだけのサブクラス」は、親に追加された時点で見えているので、追加処理をスキップする
これにより、次が保証されます:
Post.reflect_on_aggregation(:balance) # => #<ActiveRecord::Reflection::AggregateReflection ...>
SpecialPost.reflect_on_aggregation(:balance) # => 同様に reflection を返す
SpecialPost.where(balance: Money.new(5)) # => 正しくクエリを発行できるまた、associations と違って:
- aggregation には reflection cache が存在しない
- ハッシュのキー順序も意味を持たない
ため、associations で行っていたような except(name) による再構築などは不要として実装されています。
テストとドキュメント
変更ファイル:
activerecord/CHANGELOG.md- aggregation 継承バグ修正のエントリを追加
activerecord/test/cases/aggregations_test.rb- 上記のような「親に後から aggregation を追加した場合」の継承挙動を確認するテストを追加
activerecord/lib/active_record/reflection.rbadd_aggregate_reflection内の子クラス伝播ロジックを追加(+13/-1)
- 影響範囲・注意点
影響範囲
- ActiveRecord の
composed_ofaggregation を使っていて、 - 「親クラスに後から aggregation を追加する」パターン(concern やクラス再オープンなど)
- かつ「すでに独自の aggregation を持ったサブクラス」が存在するケース
このパターンで:
- 以前は
reflect_on_aggregationがnilを返していたサブクラスが、今後は正しい reflection を返すようになります。 - その結果、
where(balance: Money.new(5))のような「aggregation への値オブジェクトを伴うクエリ」が、サブクラスでも問題なく動作するようになります。
注意点
- サブクラス側で同名の aggregation を定義している場合は「上書きされない」ため、既存のオーバーライド挙動は維持されます。
- これはバグフィックスであり、期待されていたが動いていなかった継承挙動を正すものなので、通常のアプリケーションで後方互換性への影響は小さいはずです。
- もしアプリ側で「サブクラスはあえて
reflect_on_aggregationで見えないことを前提にしていた」ような非常に特殊なロジックを書いていた場合は、挙動が変わる可能性がありますが、想定されるユースケースとしてはかなりレアです。
- 参考情報 (あれば)
- 関連 PR(同様の問題の association 版修正):
- #59462: associations を親に後から追加したときの子クラスへの伝播修正
- #20678: 上記に関連する過去の議論/修正
- aggregation 機能 (
composed_of) のリファレンス:- Official Rails Guides: Active Record Aggregations
https://guides.rubyonrails.org/active_record_querying.html#aggregation(※公式 URL 構成はバージョンにより変わる可能性あり)
- Official Rails Guides: Active Record Aggregations
#58966 Fix the response Content-Type memo from #59096
マージ日: 2026/11/1 | 作成者: @linyiru
- 概要 (1-2文で)
Rails 8.1.4以降で導入予定だった「レスポンスの Content-Type をパースした結果のメモ化処理 (#59096)」に潜んでいた、オブジェクト共有・エンコーディング・不正バイト処理など5つの不具合をまとめて修正するPRです。response.media_type/response.charsetの挙動を 8.1.4 相当の期待値に合わせつつ、安全で予測可能なメモ化ロジックにします(ただし、どれもまだリリース前の挙動なので既存アプリには影響なし)。
- 変更内容の詳細
背景: 何が問題だったか
#59096 で「レスポンスの Content-Type ヘッダをパースした結果(media_type, charset)を、文字列パースコスト削減のためにメモ化」する仕組みが入りましたが、実装上、以下のような問題がありました:
ヘッダ文字列をキーにしていたため、
String#replaceに追随しないrubyresponse.headers["Content-Type"] = "text/html; charset=utf-8" response.media_type #=> "text/html" response.headers["Content-Type"].replace("application/json; charset=utf-8") response.media_type # 本来は "application/json" を返すべきだが、 # 古いパース結果 "text/html" が返り続ける→ メモのキーが「ヘッダ文字列オブジェクト自身」だったため、同じオブジェクトを
replaceしても「別のヘッダ」と認識できず、古い結果を使い続けていた。media_type/charsetがメモ済みの“同一インスタンス”を返すため破壊的変更が波及するrubymt = response.media_type mt << "x" # 呼び出し側がやりがちな誤り response.media_type #=> 以降すべて "text/htmlx" になる→ 呼び出し側が破壊的に変更すると、メモされているオブジェクト自体が壊れる。
ASCII だけの文字列の場合にエンコーディングの違いを無視していた
rubyresponse.headers["Content-Type"] = "text/plain".b # ASCII-8BIT(BINARY) response.headers["Content-Type"] = "text/plain" # UTF-8 # 値「text/plain」は同じなので == でヒットしてしまい、 # 一度目の BINARY な結果がそのまま使われ続ける→ パース結果のキャッシュが、エンコーディング差異を考慮せずに再利用されてしまう。
不正バイトを含むヘッダで例外が2回目以降別種になる
rubyresponse.headers["Content-Type"] = "text/html; charset=\xFF" response.media_type # 1回目: ArgumentError response.media_type # 2回目: NoMethodError (メモキーが壊れている)→ パース失敗時のメモ状態が壊れてしまい、同じコードなのに実行回数によって別の例外になる。
writer 経由でセットしたときにヘッダ文字列とは違うエンコーディングが残る
rubyresponse.content_type = "text/plain".b # BINARY response.charset = :UTF8 # Symbol # 本来は、最終的なヘッダ文字列のエンコーディング(たとえば UTF-8)に揃った # パース結果が欲しいが、writer に渡した元のオブジェクトの encoding が残ってしまう
どれも #59096 の挙動であり、まだ gem としてはリリースされていない時点の問題です。
このPRでの修正ポイント
1. メモキーの持ち方の見直し
- **ヘッダ文字列そのものではなく、「パース後に取得した frozen コピー(
-raw)をキーとして保存」**するようになりました。 - かつ、パースが成功した後にのみキーを保存します。
→ これにより:headers["Content-Type"].replace(...)で中身が変わったら、新しいヘッダとして再パースされる。- パースに失敗した時点ではメモされないので、後の挙動が壊れにくい。
実装イメージ(擬似コード):
def parsed_content_type
raw = headers["Content-Type"]
return unless raw
if @parsed_content_type_raw == raw
return @parsed_content_type # 既にパース済み
end
parsed = parse(raw) # ここで ArgumentError 等が起きうる
@parsed_content_type_raw = raw.dup.freeze # 成功したらキーを保存
@parsed_content_type = parsed
end2. エンコーディングも含めて比較
- 「値だけでなくエンコーディングも一致しているか」を比較に含めるように変更。
- これにより、
"text/plain".b→"text/plain"のようなケースでも、別のヘッダとして扱われ、再パースされるようになります。
3. media_type / charset は必ずコピーを返す
- メモにはオリジナルを保存するが、呼び出し側へ返すときは
dup等でコピーを返すように変更。 - 呼び出し側が
<<やgsub!など破壊的メソッドを呼んでも、後続のresponse.media_type/response.charsetが壊れない。
サンプル:
mt1 = response.media_type
mt2 = response.media_type
mt1.equal?(mt2) #=> false (毎回別オブジェクト)4. writer からのメモ priming の条件を厳密化
content_type=やcharset=(writer メソッド)からメモを事前セットする場合、- 与えられた値のエンコーディングがヘッダ文字列と同じ場合のみメモを priming する。
- そうでない場合(BINARY, US-ASCII, Symbol など)のときは、必ずヘッダ文字列をパースし直す。
- これにより、最終的なヘッダ文字列のエンコーディングと、
media_type/charsetのエンコーディングがずれないようになります。
イメージ:
def content_type=(value)
headers["Content-Type"] = value.to_s
if value.encoding == headers["Content-Type"].encoding
prime_memo_from(value)
else
clear_memo
end
end(実装はもう少し複雑ですが、考え方としては上記のような感じです)
5. パラメータ・空白のないヘッダは regexp をスキップ
"text/html"のように セミコロン付きのパラメータも空白もない場合は、regexp を用いた解析をスキップします。- これは主にパフォーマンス対策で、
media_type/charsetの返却時に毎回コピーを返す (= 1アロケーション増える) 影響を打ち消す形。
テストの更新
- 既存テスト
test_parsed_content_type_is_memoized_while_the_header_is_unchangedは- 以前は「
media_typeが同じオブジェクトを返す」ことを前提にしていたが、 - 今回の修正により「常にコピーを返す」仕様へ変更済みのため、
- 「同じオブジェクト」をチェックするのではなく、「同じパース結果(値)」をチェックするようにテストを変更しつつ、メモ化自体は依然としてカバーするように修正。
- 以前は「
- 新規テスト 4 本を追加し、上記 5 つの問題のうち少なくとも 4 つを明示的に再現・検証。
test_content_type_set_through_the_writers_reads_back_like_a_parsed_headerでは、値だけでなくエンコーディングも比較するようになり、writer 経由のケースも問題 5 を含めてカバー。
Action Pack 全体テスト: 4432 ケースすべてパス。
- 影響範囲・注意点
影響範囲
ActionDispatch::ResponseのContent-Type周り (media_type,charset,content_type=など) の内部実装。- 外部 API としては
- 返ってくるオブジェクトが「毎回別インスタンス」になる
- 不正ヘッダでの例外種類やタイミングが安定化
- writer 経由のエンコーディングの扱いが、実際のヘッダ文字列と整合するようになる
といった点で、より直感的・一貫した挙動になります。
破壊的変更っぽく見える点
response.media_type/response.charsetに対して破壊的操作をして、かつ「後続の呼び出しにもその変更が効いている」ことに依存しているコードは、今回の変更で動かなくなります。- ただし、これはそもそも「メモ化キャッシュを意図的に破壊している」挙動への依存であり、避けるべきパターンです。
==比較時にエンコーディング差異を無視していた挙動にも依存しないほうがよいです(比較処理としてはより厳密になった)。
実務への影響
- この PR が修正している挙動は、いずれもまだリリースされていない(main ブランチ上のみの)挙動なので、現行安定版(8.1.4 など)を使っているアプリケーションには直接の影響はありません。
- 将来のリリースで
Content-Type周りのメモ化が入る際には、すでに本 PR の修正が含まれており、より安全な実装が利用される想定です。
- 参考情報 (あれば)
- 元のメモ化導入 PR: #59096
- 本 PR: #58966 – Fix the response Content-Type memo from #59096
- 主な対象コード:
actionpack/lib/action_dispatch/http/response.rb- テスト:
actionpack/test/dispatch/response_test.rb
今後、Content-Type を直接操作するコードや独自ミドルウェアを書く場合は、「ヘッダ文字列のエンコーディング」と「API が返す文字列のエンコーディング」がより厳密に揃えられるようになっている点、および media_type / charset は毎回コピーが返る点を前提にするとよいです。
#59484 Make or up to 1.28x and and up to 1.57x faster by comparing only the values set on either relation
マージ日: 2026/11/1 | 作成者: @linyiru
- 概要 (1-2文で)
ActiveRecord のRelation#orとRelation#andが、内部で行う互換性チェックを最適化することで最大 1.3〜1.6 倍程度高速化された PRです。互換性エラー時の挙動やエラーメッセージは変えずに、不要な比較処理だけを削って性能を上げています。
- 変更内容の詳細
背景: 遅かったのはどこか
Relation#or と Relation#and を呼ぶたびに実行される structurally_incompatible_values_for が、プロファイル上で「自己時間トップ」になっていたのが動機です。
このメソッドは、2つの Relation が or / and で結合可能かどうかを判定するために、内部状態(SELECT, WHERE, GROUP, ORDER, LIMIT など)を比較します。
従来は、STRUCTURAL_VALUE_METHODS(約30個ある Relation の構造的な値を表すメソッド群)について、両方の Relation で全項目を機械的に比較していました:
- 各 Relation に対して約30種の値をすべて取り出す
- 片方ともう片方ですべて突き合わせる
- 合わないものがあれば「構造的に互換性がない」と判定し、その一覧をエラーメッセージ用に保持
しかし実際には、典型的な Relation はこの 30 個のうち「1〜2個程度しかセットされていない」ため、ほとんどが両方とも nil で、比較しても意味がありません。
新しいアプローチ
この PR では、「両方とも nil である値は互換性問題になり得ない」という事実を利用し、実際にどちらか一方で値がセットされているキーだけを比較するように変更しています。
大まかな流れは次のようなイメージです(擬似コード):
# 旧: すべての構造的メソッドを毎回チェック
STRUCTURAL_VALUE_METHODS.each do |name|
left_value = left.send(name)
right_value = right.send(name)
# 両者が異なれば incompatible として扱う...
end
# 新: どちらかで値がセットされているキーだけを比較
keys = (left_values.keys | right_values.keys) # 両方の Relation で使われたキーの和集合
keys.each do |key|
left_value = left_values[key]
right_value = right_values[key]
# 両者が異なれば incompatible として扱う...
end具体的には:
- 比較の対象となる「構造的な値」を、
STRUCTURAL_VALUE_METHODS全体から毎回機械的に取りに行くのではなく、- 「左の Relation で値があるキー」
- 「右の Relation で値があるキー」 の和集合を取って、そのキーだけ比較します。
- 互換性がないキーが一つでも見つかったら、その時点で従来通りに「全項目分の順序付きリスト」を構築して、エラーメッセージの内容・順序が旧実装と完全に一致するようにしています。
- これにより「通常は高速だが、エラー発生時だけは従来通りフルチェック」なので、機能面では完全互換です。
正しさの検証
作者は以下のような方法で新旧実装の等価性を検証しています:
- ランダムに生成した 20,000 ペアの Relation を対象に、
- 生成元:
where,select,order,reorder,limit,offset,joins,left_joins,includes,preload,group,having,distinct,readonly,lock,references,annotate,optimizer_hints,extending,unscope,from,strict_loading,create_with,none,with, 名前付きスコープ などの組み合わせ
- 生成元:
- 旧実装と新実装で
structurally_incompatible_values_forの結果を比較- 20,000 ペアすべてで結果が一致
- うち 16,541 ペアは「互換性なし」であり、その際の不一致リスト・順序も完全一致
性能改善の具体的数字
ベンチマークの条件:
- benchmark-ips(2秒ウォームアップ + 3秒計測)
- idle な x86_64 Linux / 1コア固定
- オブジェクト:
a = Post.where(poly_type: [...])b = Post.where(poly_type: "foo", poly_id: 1)s = Post.select(1).where("...")
結果(i/s = 1秒あたりの実行数):
| ケース | Ruby 3.4.10 | Ruby 3.4.10 +YJIT | Ruby 4.0.9 +YJIT |
|---|---|---|---|
a.or(b) | 203.2k → 260.8k (1.28x) | 382.9k → 481.3k (1.26x) | 528.2k → 666.5k (1.26x) |
s.and(a) | 349.4k → 547.8k (1.57x) | 622.6k → 882.6k (1.42x) | 793.5k → 1070.3k (1.35x) |
#54943 to_sql | 13.3k → 13.8k (1.04x) | 42.3k → 44.7k (1.06x) | 56.0k → 59.9k (1.07x) |
補足:
orは 1.24〜1.33倍andは 1.32〜1.59倍- メモリアロケーション数は変化なし
- #54943 のエンドツーエンドな
to_sqlの改善は 4〜7% 程度で、ばらつきも大きい(今回の変更はボトルネックの一部分だけを改善しているため)
コード変更としては activerecord/lib/active_record/relation/query_methods.rb のみで、+24/-6 行と小さめの変更です。
- 影響範囲・注意点
- 対象:
- ActiveRecord の
Relation#or,Relation#andを利用するすべてのコードパス - 内部的には
structurally_incompatible_values_forの実装(Relation 同士の互換性チェック)
- ActiveRecord の
- 仕様面の変更なし:
or/andで互換性がない Relation を組み合わせた場合のエラー発生条件- その際に出るエラーメッセージの内容・リスト順 は旧実装と完全に一致するように作られています。
- パフォーマンス面:
or/andを多用するクエリ組み立て(特に複雑なスコープを組み合わせるケース)で、CPU 時間が削減される可能性が高いです。- 逆に、単純なクエリで
or/andをほとんど使わないアプリでは、体感差はほぼ出ません。
- リスク:
- ロジックとしては互換性チェックの「比較対象の絞り込み」のみで、完全に仕様互換であることがランダムテストで検証済み。
- 互換性判定に依存したメタプログラミング等をしていない限り、挙動変更のリスクは低いと考えられます。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59484
- パフォーマンス問題としての元ネタ: https://github.com/rails/rails/issues/54943
- 変更ファイル:
activerecord/lib/active_record/relation/query_methods.rbのstructurally_incompatible_values_for実装部分の最適化
- 実務的には:
- 大量の
or/andを組み合わせた動的検索フォーム・高度な検索API・共通スコープの多用などがある場合、Rails バージョンアップによる恩恵が出やすい変更です。
- 大量の
#59473 Fix preloading ordered has_many :through reusing loaded source records
マージ日: 2026/11/1 | 作成者: @czj
- 概要 (1-2文で)
has_many :throughの関連をプリロードするとき、同じ関連先を使う「順序なし」と「順序あり」の関連を混在させた場合に、順序あり側のorderが効かず「through 側の並び順」で返ってしまうバグを修正した PR です。条件付き (where) のケースに続き、orderのケースでも「既にロード済みの中間・ソースレコードをそのまま再利用しない」ようにすることで正しい並び順を保証します。
- 変更内容の詳細
問題の挙動
次のような関連があるとします:
class Author < ActiveRecord::Base
has_many :categorizations
has_many :categorized_posts, through: :categorizations, source: :post
has_many :categorized_posts_desc, -> { order(id: :desc) }, through: :categorizations, source: :post
end
Author.preload(:categorized_posts, :categorized_posts_desc)
.first
.categorized_posts_desc
# 期待: id DESC で並んだ posts
# 実際: categorizations の並び順で並んだ postscategorized_posts(順序なし)を先にプリロードしたあとに、categorized_posts_desc(order(id: :desc) 付き)をプリロードすると、後者でも前者でロード済みの posts がそのまま使い回され、order が反映されませんでした。
内部的には、has_many :through のプリロードは
- through レコード (
categorizations) をロード - source レコード (
posts) をロード records_by_ownerで owner (authors) ごとにソースを割り当て
という流れですが、「既に categorizations 経由で posts がロード済みならそれを再利用する」という最適化が働き、その再利用された配列には ORDER BY が効いていないため、records_by_owner が「期待していない順序」を前提にインデックスを作ってしまうのが原因です。
対応内容
activerecord/lib/active_record/associations/preloader/through_association.rb のロジックを変更し、リフレクションスコープに order が含まれている場合は、既にロード済みのソースレコードを再利用せずに改めてクエリするようにしました。
要点:
- 既存:
where条件が付いているスコープの場合は、ソースレコードを「再利用せず再クエリする」挙動(#59444 で修正済み)orderだけの場合は「再利用する」ため、順序が壊れる
- 今回の変更:
source_scope_filters_source?の判定に「orderがあるか」を追加orderが含まれている関連スコープでのプリロード時には、中間関連にぶら下がっている既存のソースレコードを無視して、改めてORDER BY付きで SELECT し直す
これにより、次のようなケースでも常に id DESC が守られます:
Author.preload(:categorized_posts, :categorized_posts_desc)
.first
.categorized_posts_desc
# => 必ず id DESC の順序になるテスト追加:
activerecord/test/cases/associations/eager_test.rbに、今回の不具合を再現するテストが追加され、- 「先に順序なしの
has_many :throughをプリロード」 - 「あとで
order付きのhas_many :throughをプリロード」 - 結果の順序が正しいか を検証。
- 「先に順序なしの
activerecord/test/models/author.rbにテスト用のスコープ(関連定義)が 1 つ追加。
ドキュメント:
activerecord/CHANGELOG.mdにバグ修正として追記。
- 影響範囲・注意点
影響するケース:
- 同じ
through/sourceを共有するhas_many :throughが複数あり、- 片方はスコープなし(または
orderなし) - もう片方は
order付き
- 片方はスコープなし(または
- かつ、順序なしの方が先にプリロードされ、その後に順序ありの方をプリロードするケース
(手動のpreloadだけでなく、goldiloader などの gem によるバッチロードでも起きうる)
- 同じ
挙動の変化:
- 上記ケースで、以前は「順序ありの関連でも through の順序で返ってしまう」ことがあったが、常にスコープに定義した
ORDER BYが反映された結果になる。 - その代わりに、「順序ありの through 関連をプリロードするタイミングで追加クエリが 1 本発行される」可能性がある。
- 具体的には「順序なし → 順序あり」の順でプリロードしたときに、順序あり側でソースレコードを再取得する。
- 上記ケースで、以前は「順序ありの関連でも through の順序で返ってしまう」ことがあったが、常にスコープに定義した
既存最適化との関係:
- 「through 側のスコープが条件付きでソースを eager load する経路」(例えば
includes+where等でソースがまとめてロードされるケース)は今回触っていないため、test_preload_scoped_has_many_through_reuses_eager_loaded_source_recordsのようなケースのクエリ数は増えていません。 - つまり「フィルタ条件だけ」のケースでは、従来の「再利用によるクエリ削減」は維持され、
orderが絡むケースだけ再取得する形に限定されています。
- 「through 側のスコープが条件付きでソースを eager load する経路」(例えば
パフォーマンス上の注意:
- 同じ
has_many :throughを複数のバリエーション(order や where 付き)で heavy に使うコードでは、プリロード順序や gem 側のバッチ戦略により、クエリ数が増える可能性があります。 - ただし増えるのは「順序あり側のソース再取得 1 クエリ」レベルに限定されており、明確なバグ修正の代償としては許容範囲と考えられます。
- 同じ
- 参考情報 (あれば)
- 関連 issue:
- #56110:
has_many :throughのプリロードで、where/order付きスコープの挙動が壊れる問題
- #56110:
- 関連 PR:
- #59444:
where条件付きのhas_many :throughプリロード時に、条件なしでロードされたソースレコードを再利用しないようにした変更(本 PR はそのorder版)
- #59444:
- 外部報告:
- salsify/goldiloader#166: goldiloader がバッチロードした結果としてこのバグが顕在化した事例
#59483 Build cookie jars' verifier and encryptor on first use
マージ日: 2026/11/1 | 作成者: @linyiru
- 概要 (1-2文で)
SignedKeyRotatingCookieJarとEncryptedKeyRotatingCookieJarが、クッキーの署名・暗号化用オブジェクト(MessageVerifier/MessageEncryptor)を「生成時」ではなく「初めてクッキーを読む/書くとき」に遅延生成するように変更されました。これにより、対象クッキーが存在しないリクエストでの不要なキー導出・オブジェクト生成が減り、特にAPI等でセッションを毎回読むアプリケーションのパフォーマンス(オブジェクト数・レイテンシ)が改善します。
- 変更内容の詳細
背景と問題点
従来:
cookies.signed/cookies.encryptedなどの key-rotating な cookie jar は、「jar インスタンスが作られた時点」で以下を行っていました:MessageVerifierまたはMessageEncryptorの生成- そのための鍵導出(
ActiveSupport::KeyGenerator) - 暗号化クッキーの場合は:
- 回転用の encryptors の準備
- レガシー AES-CBC → AES-GCM 移行用 encryptor の準備
しかし、実際に verifier/encryptor を使うのは「クッキーの読み書きが行われたときだけ」
よくあるパターンとして:
ActionDispatch::Session::CookieStoreがcookies.signed_or_encrypted経由でセッションを読む- セッション cookie が付いていないリクエストでも jar が生成され、無駄に鍵導出される
- Devise の
rememberableが毎回cookies.signed["remember_user_token"]を読む- 実際にはクッキーが存在しないケースでも jar 生成時に verifier が作られる
結果として、「クッキーが存在しないのに、毎リクエストで鍵導出などの重い処理をしている」という非効率が発生していました。
今回の変更の要点
ポイント: verifier/encryptor の生成を「初回アクセス時」に遅延させた。
SignedKeyRotatingCookieJar/EncryptedKeyRotatingCookieJarの挙動:- jar のインスタンス化時には verifier/encryptor を作らない
- 代わりに、以下のタイミングで初めて生成する:
- 対象クッキーを読むとき(実際に存在する or デコードを試みるとき)
- 対象クッキーに書き込むとき
動作仕様は変わらず:
- 一度生成された verifier/encryptor は、その後の同一 jar 操作で再利用される
- 鍵導出や key rotation の設定方法、AES-CBC → AES-GCM の移行処理は従来と同じロジック
パフォーマンス測定結果
Rails main 相当の最小アプリで、ActionDispatch::Cookies + ActionDispatch::Session::CookieStore 経由のリクエストを測定(Ruby 3.4.1, 20,000 req × 15 trial の中央値):
| リクエスト内容 | オブジェクト数 (before → after) | 時間 (before → after) |
|---|---|---|
| セッション読み取り、セッション cookie 無し | 64 → 25 | 13.0 → 6.4 µs |
| セッション読み取り、セッション cookie 有り | 変化なし | 変化なし(約 57 µs) |
| セッション書き込み | 変化なし(~140) | 変化なし(約 39.6 µs) |
- 「セッション cookie が無い request」では:
- 1リクエストあたり約40オブジェクト削減 & 約 6 µs 程度の短縮
- 既に cookie を持っているリクエスト・書き込み時は、もともと verifier/encryptor を使っていたので、ほぼ変化なし
実際の本番アプリ(API + Devise 使用, クライアントは cookie を送らない)での計測例:
- 1リクエストあたり約 79 オブジェクト削減
- 例: 3,495 → 3,416 個
- レイテンシの改善(理論上 ~13 µs)は、他のばらつきの中に埋もれて end-to-end では有意には確認できず
テストまわりの変更点
「キー導出時に例外を投げるカスタム key generator」を使ったテストを追加:
cookies.signed[:missing_key]など、「存在しないクッキーを読む」ケースで:- 以前(main): jar 作成時に key generator が呼ばれ、例外が飛ぶ
- 変更後: key generator は呼ばれず
nilを返す
- 対象:
cookies.signedcookies.encryptedcookies.signed_or_encrypted
既存の Action Pack・Railties のテストはすべてパス:
- cookie / session middleware
- key rotation
- AES-CBC → AES-GCM のセッションアップグレード
- 影響範囲・注意点
直接の影響
- 主に影響を受けるのは以下のようなアプリケーション:
CookieStoreセッションを使用していて、before_actionなどで毎回セッションを読むが、実際には cookie が付いていない API リクエストが多い- Devise の rememberable を有効にしているが、クライアントが
remember_user_tokencookie を送ってこないパターンが多い
このようなケースでは、「リクエストごとに不要な key derivation・暗号オブジェクト生成をしていた」分が削減されます。
エラーハンドリングのタイミング変更
- これまで:
- cookie jar インスタンスを生成する瞬間に key generator / cipher 設定のエラーが発生していた
- 今後:
- 初めて cookie を読み書きしたときにエラーが発生する
実務上の注意点:
- アプリ起動時には気づけず、該当 cookie を最初に使ったリクエストで例外が起きるようになる
- 例えば:
- 誤った secret_key_base / encrypted cookie の設定
- サポートしていない cipher の指定
- をしていた場合、「jar を作った瞬間」ではなく「最初の cookie アクセス時」に例外が上がる点に注意
ただし、通常は起動時に cookie にアクセスするコード(ヘルスチェックなど)があるか、あるいは最初の少数リクエストで露見するはずなので、運用上の影響は限定的と思われます。
互換性
- 公開 API のインターフェイス・挙動(戻り値など)は基本的に変わっていません。
cookies.signed[:missing]/cookies.encrypted[:missing]がnilを返す仕様も従来どおりで、例外を rely しているコードは本来想定されません(今回の修正で、むしろ正しい動きに近づいています)。- key rotation, AES-CBC → AES-GCM 移行など、暗号周りの実動作は同一です(初回アクセスのタイミングが変わるだけ)。
- 参考情報 (あれば)
関連するクラス/モジュール:
ActionDispatch::CookiesActionDispatch::Cookies::SignedKeyRotatingCookieJarActionDispatch::Cookies::EncryptedKeyRotatingCookieJarActionDispatch::Session::CookieStoreActiveSupport::MessageVerifierActiveSupport::MessageEncryptorActiveSupport::KeyGenerator
パフォーマンス観点:
- high-QPS な API サーバで、cookie ベースのセッションを「一応読む」けれど、実際には cookie が付かないケースが多いときに最も効く最適化です。
- 1リクエストあたり数十オブジェクト削減・数 µs 改善ですが、トラフィックが多いほど GC 頻度などにポジティブな影響が見込めます。
#59476 Fix Active Support inline RBS signatures
マージ日: 2026/10/31 | 作成者: @amatsuda
- 概要 (1-2文で)
Active Support に最近追加された「インライン RBS 型定義」と、実際の Ruby 実装とのズレをまとめて修正した PRです。主に「メソッドが本当は受け付けない値を受け付けることになっていた型」や、「戻り値の型が実装より粗かった箇所」が、実装に沿うように精密化されています。
- 変更内容の詳細
全体像
- 対象は Active Support のインライン RBS (
# :sigコメント形式) で、実装と異なる部分をgem_rbs_collectionにある手書きシグネチャと突き合わせて修正。 - 修正は大きく分けて:
- 「実装では拒否される値を受け付けることになっていたシグネチャの修正」
- 「振る舞いは正しいが、型がラフすぎたシグネチャの精密化」
- 実装コード自体はほぼ変えず、型側の修正が中心です。
実装が拒否する値を許容していたシグネチャの修正
1) ActiveSupport::ErrorReporter#report / #unexpected
Before (誤った型イメージ)
def report(..., severity: Symbol?): void # nil も OK という扱いAfter (正しい型のイメージ)
def report(..., severity: Symbol): void # nil は NG- 実装では
severityがSEVERITIESに含まれるかをチェックしており、nilを渡すとArgumentError。 - よって
Symbol?(nil許容)ではなく、Symbol固定に修正。
挙動例
reporter.report(error, severity: nil) # 実装上 ArgumentError になる→ 型もこれを反映して nil 不可。
2) String#pluralize
Before
def pluralize(count: Integer?, locale: Symbol?): StringAfter
def pluralize(count: Integer?, locale: Symbol): String- 実装では
localeにnilを渡すとI18n::InvalidLocaleが発生。 - 従って「
nilも受け取れる」という型は誤り。
挙動例
"post".pluralize(2, nil) # => I18n::InvalidLocale が raise される→ locale は Symbol のみ受け取るシグネチャに修正。
3) Array#in_groups / Array#in_groups_of のグループサイズ
Before
def in_groups_of(number: Integer | Float, ...): Array[Array[...]]After
def in_groups_of(number: Integer, ...): Array[Array[...]]- 実装は
Integerを期待しているが、Floatを渡しても Ruby 側でto_i的な解釈で動いてしまうケースがある。 - 型として「Float も正式サポート」とは言えないため、
Integerのみに制限。 in_groupsも同様に修正。
挙動例(実装側の実際の動き)
[1, 2, 3].in_groups_of(2.5)
# => [[1, 2], [3, nil], [nil]]このように「たまたま動く」挙動はあるが、型として保証したい仕様ではないので Integer のみに。
4) ActiveSupport::Rescuable.rescue_from
Before (ざっくり 1 パターン)
def rescue_from(*klasses, with: ^(Exception) -> untyped ?): voidAfter (block 版 / with: 版の 2 パターンに分割)
with:キーワードを使うオーバーロード- ブロックを渡すオーバーロード
加えて:
- 「
with:もブロックもどちらも指定しない」呼び出しは実装上ArgumentErrorになるため、型的にも「必ずどちらかが必要」であることを表現。 - ブロックハンドラは実際には
instance_execでレシーバに対して実行されるため、型もそれに即した形に。
挙動イメージ
# OK: with: ハンドラ
rescue_from SomeError, with: :handle_some_error
# OK: ブロックハンドラ
rescue_from SomeError do |exception|
# ...
end
# NG: どちらも指定していない
rescue_from SomeError # => ArgumentError (実装)5) assert_changes
Before
def assert_changes(expression, message: untyped?, &block): void
# expression: Proc | Kernel となっており、ほぼ何でも通ってしまうAfter
def assert_changes(
expression: String | Symbol | ^() -> untyped,
message: String? = nil,
&block: ^() -> untyped
): void- 実装では
_expression_to_callableがString | Symbol | Procのみを受け付けている。 - それに合わせて:
expressionの型:String | Symbol | ^() -> untypedに限定messageは実際にはString?で扱われているので、untyped->String?に精密化
使用例
assert_changes -> { user.reload.name } do
user.update!(name: "new")
end
assert_changes :updated_at, "timestamp should change" do
user.touch
end型が粗く、実装よりも情報量が少なかった箇所の精密化
6) Array#in_groups / Array#in_groups_of の戻り値・fill オプション
fill_withによって以下の 3 パターンがあるため、オーバーロードで表現:fill_with省略 /nil… パディング要素はnilfill_with: false… パディングなし(最後の配列のサイズが小さくなり得る)fill_withに任意の値 … その値でパディング
グループの中に
nilが入るのは「パディングにnilを使う場合のみ」であることを型に反映。in_groups_ofにブロックを渡した場合の戻り値も修正:- 実装上は「ブロックに各グループを渡して、元の配列自身を返す」ため、
- 戻り値型を「グループの配列」ではなく「レシーバの
Array」に合わせている。
挙動例
[1,2,3].in_groups_of(2)
# => [[1, 2], [3, nil]]
[1,2,3].in_groups_of(2, false)
# => [[1, 2], [3]]
[1,2,3].in_groups_of(2, 0)
# => [[1, 2], [3, 0]]
[1,2,3].in_groups_of(2) { |group| p group }
# ブロックに [1,2], [3,nil] が渡され、戻り値は [1,2,3] (元配列)7) String#in_time_zone
- ゾーン指定あり / なし で戻り値が異なることをオーバーロードで表現。
- ゾーン指定あり:
ActiveSupport::TimeWithZone? - ゾーン指定なし:
Time?
- ゾーン指定あり:
- 空文字列 (
"") の場合は両方ともnilを返し得るので、?付き。
挙動例
"2024-01-01".in_time_zone # => ActiveSupport::TimeWithZone
"2024-01-01".in_time_zone("UTC") # => ActiveSupport::TimeWithZone
"".in_time_zone # => nil8) String#squish! / String#remove!
- 戻り値は「標準の
String」ではなく レシーバ自身 (self) であることを反映。SafeBufferのようなサブクラスに対しても、SafeBufferのまま戻ってくる。
挙動例
buf = ActiveSupport::SafeBuffer.new(" foo ")
res = buf.squish!
res.class # => ActiveSupport::SafeBuffer
res.equal?(buf) # => true→ 型でも self を返すようにして、サブクラス情報が失われないようにした。
9) 真偽値フラグ引数を bool → boolish に
対象メソッド:
String#titleizeString#humanizeString#parameterizeString#foreign_keyString#indentString#indent!これらのオプション引数は実装上「真偽そのもの」ではなく「truthiness(真として評価されるか)」でチェックしている。
- 例:
if options[:uppercase]のような単純真偽値チェック
- 例:
そのため、厳密な
boolではなく、RBS 用語での「boolish(true と評価されうる型)」として定義。
イメージ
"foo".titleize(keep_id_suffix: "yes") # "yes" も truthy として扱われる→ 実装と整合が取れるように、型も「Ruby 的な truthiness」を許容する。
- 影響範囲・注意点
主な影響は「型チェック/型補完ツール」側
- RBS を用いた静的解析、型補完、エディタ支援などで、より実装に忠実な警告・補完が行われるようになります。
- 実装挙動そのものはほとんど変えていないため、既存アプリのランタイム挙動への影響は基本的にありません。
型上は NG になるケース
ErrorReporter#report(severity: nil)のように、実装上もエラーになる呼び出しが、型でもコンパイルエラーとして指摘されるようになります。Array#in_groups_of(2.5)のような「たまたま Ruby が受け入れていた」ケースは、型的には不正な呼び出しとして扱われます(実装は変わっていないため、実行は従来どおり可能)。
負の影響になりにくい修正
String#in_time_zoneやsquish!がより正確な戻り値型になることで、エディタ補完や後続処理の型推論精度が向上。assert_changesやrescue_fromのインタフェースが正しくモデリングされたことで、テストコード・コールバック定義時のミスを静的に検出しやすくなります。
RBS inline の制約による未対応ポイント
HashWithIndifferentAccess[T, U]やArrayInquirer[T]は、本来ジェネリクスだが、インライン RBS はジェネリッククラスを定義できないため、戻り値型は「生のクラス」まで。Object#in?/#presence_inはuntypedのまま。gem_rbs_collectionでは_IncludePというインタフェースで表現しているが、インラインでは定義できないため。
- 参考情報 (あれば)
- PR 本体:
- Rails:
Fix Active Support inline RBS signatures(#59476)
- Rails:
- 対応する
gem_rbs_collection側の修正:- https://github.com/ruby/gem_rbs_collection/tree/main/gems/activesupport/8.1
- 上記の Active Support 8.1 用シグネチャは、この PR の作者自身によるものとのこと。
- RBS インラインの制約について:
#59308 Instrument connection pool checkout waits
マージ日: 2026/10/31 | 作成者: @andrewfader
- 概要 (1-2文で)
Active Record のコネクションプールで、空きコネクションがなくてcheckoutがブロックしている待ち時間を、checkout_wait.active_recordというイベントとして計測・通知できるようにした変更です。これにより、プール逼迫時の「よく分からない遅さ」が可観測になり、ConnectionTimeoutErrorが出る前から問題把握・チューニングがしやすくなります。
- 変更内容の詳細
何を計測するようになったか
- 対象: Active Record のコネクションプール (
ActiveRecord::ConnectionAdapters::ConnectionPool) におけるacquire_connection内の「待ち時間」 - 条件:
- アイドルなコネクション取得が失敗
- バックグラウンドコネクションの取得もできない
- 新規コネクションの作成もできない
→ つまりプールが一杯で、他のスレッドがコネクションを返すのを待つしかない状態
- その「ブロックして待っている区間」を
checkout_wait.active_recordという Active Support Instrumentation イベントとして発火します。
イベント名とペイロード
- イベント名:
checkout_wait.active_record
- 主なペイロード:
:connection_pool- 対象となるコネクションプールオブジェクト(もしくは識別可能な情報)
:checkout_timeout- 設定されている
checkout_timeoutの値(秒)
- 設定されている
- タイムアウトした場合:
:exceptionConnectionTimeoutErrorなどが入る(ActiveSupport::Notifications 標準の:exception形式に準拠)
イベントの「duration(処理時間)」は「実際にブロックして待った時間」になります。
つまり、checkout に成功した場合も、タイムアウトした場合も、その前にどれくらい待たされたかが計測可能です。
実行タイミングとロックとの関係
- イベント発火はプールのロックの「外側」で行われます。
- これにより、Instrumentation 自体がプールのロック競合を悪化させないように配慮されています。
- 例外として、「ピン留めされていたコネクションが破棄され、プール側にフォールバックして取り直す」ような経路では、ロック内で発火される場合がありますが、これはレアケースです。
コード変更の概要
変更ファイル:
activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rbacquire_connection内にActiveSupport::Notifications.instrument("checkout_wait.active_record", payload) { ... }のような計測ブロックが追加された形(+3/-1 行程度の小変更)。
activerecord/test/cases/connection_pool_test.rbcheckout_wait.active_recordイベントが正しく発火し、ペイロードや例外情報が期待通りであることを確認するテストが追加(+33 行)。
guides/source/active_support_instrumentation.md- Active Support Instrumentation ガイドに
checkout_wait.active_recordの説明を追加(+12 行)。
- Active Support Instrumentation ガイドに
activerecord/CHANGELOG.md- 新しい計測イベント追加のエントリを追加(+8 行)。
サンプルでの利用イメージ
アプリ側でこのイベントを購読すると、プール逼迫状況が可視化できます。例:
ActiveSupport::Notifications.subscribe("checkout_wait.active_record") do |name, start, finish, id, payload|
wait_ms = (finish - start) * 1000.0
pool = payload[:connection_pool]
timeout = payload[:checkout_timeout]
Rails.logger.warn(
"[DB checkout wait] waited=%.1fms timeout=%.1fs pool=%s" % [
wait_ms,
timeout,
pool.object_id # あるいは pool.spec.config などでより識別しやすく
]
)
endもしくは StatsD / Prometheus などにメトリクスとして送ることもできます:
ActiveSupport::Notifications.subscribe("checkout_wait.active_record") do |_name, start, finish, _id, payload|
wait = finish - start # 秒
MyStatsd.histogram("db.checkout_wait", wait, tags: ["timeout:#{payload[:checkout_timeout]}"])
end- 影響範囲・注意点
- パフォーマンス・動作への影響
- 通常の
checkout(すぐにアイドルコネクションが取れる場合)は、このイベントは発火しません。 - イベントはロック外で発火するため、プールの性能劣化リスクは低いです。
- ただし、プールが慢性的に逼迫している環境では、このイベントが頻発するため、購読側(ログ出力・メトリクス送信)のオーバーヘッドに注意する必要があります。
- 通常の
- エラー(
ConnectionTimeoutError)との関係- これまでは、
ConnectionTimeoutErrorが起きるまで「何が遅いのか分からない」状態になりがちでしたが、 - 今後はタイムアウト発生前から、待ち時間分のイベントが飛ぶため、「プールに空きがなくて詰まっている」ことを早期に検知できます。
- これまでは、
- 計測の粒度
- 計測対象はあくまで「プールが一杯で他スレッドの返却を待っている時間」のみです。
- コネクション取得前後のアプリ処理時間やクエリ実行時間は別途、既存の instrumentation(
sql.active_recordなど)で見る必要があります。
- バージョン依存
- この PR は Rails 本体への変更なので、マージされたバージョン以降(Active Record の該当バージョン)でのみ利用可能です。
- 自前で
ConnectionPool#checkoutに prepend して計測していた場合、この公式イベントに切り替えることで、パッチの維持コストを減らせます。
- 参考情報 (あれば)
- 追加されたイベント:
checkout_wait.active_record
- 主な関連ドキュメント:
guides/source/active_support_instrumentation.md- Active Record セクションに、新しいイベントの説明が追記されています。
- 関連クラス:
ActiveRecord::ConnectionAdapters::ConnectionPoolActiveSupport::Notifications
この変更により、コネクションプールの飽和状態を監視・可視化しやすくなるので、トラフィック増加時のスケーリングや、pool / checkout_timeout 設定のチューニングに役立ちます。
#59404 Compile Arel SQL up to 1.23x faster under YJIT by avoiding each_with_index in ToSql
マージ日: 2026/10/31 | 作成者: @czj
- 概要 (1-2文で)
Arel の SQL 生成処理(ToSql ビジター)からeach_with_index/injectを排除し、YJIT(および ZJIT)で最適化しやすいeachベースのループに書き換えることで、SQL コンパイルを最大約 1.23 倍高速化するパフォーマンス改善 PRです。生成される SQL 自体はビット単位で同一であり、挙動変更はありません。
- 変更内容の詳細
背景・問題点
- プロファイルの結果、リレーションを SQL にコンパイルする際の自己時間の約 10% を
Enumerable#each_with_indexが占めていることが判明。 - 対象箇所は
Arel::Visitors::ToSql#inject_joinなどで、AND/OR の連結、SELECT の投影リスト、ORDER BY など、SQL 文字列を組み立てるほぼ全ての「区切り文字を挟みながら列挙する」処理。 each_with_indexは C 実装であり、内部でArray#eachを呼んで Ruby 側に yield する構造になっているため、YJIT が Ruby レベルでループ全体を最適化できない。- 一方 Ruby 3.4+ では
Array#eachが Ruby 実装となり、YJIT がループ本体をそのまま JIT コンパイルできるため、each_with_indexよりeach+ Ruby 変数で index/フラグを持つ方が高速になりうる。
具体的なコード変更ポイント
変更ファイルは 1つのみ:
activerecord/lib/arel/visitors/to_sql.rb(+6 / -12)
主な変更は次の3点です。
(1) inject_join から each_with_index を除去
元のイメージ(擬似コード):
def inject_join(sql, values, join_string)
values.each_with_index do |value, i|
sql << join_string unless i == 0
# value に対する visit 等
end
endこれを「最初の要素かどうか」を判定する first フラグに書き換え:
def inject_join(sql, values, join_string)
first = true
values.each do |value|
unless first
sql << join_string
end
first = false
# value に対する visit 等(元と同じ)
end
endポイント:
- アルゴリズム・出力 SQL は同じまま、
each_with_indexを使わない構造に変更。 - これによりループ全体が Ruby コードだけで構成され、YJIT がまとめて最適化しやすくなる。
(2) visit_Arel_Nodes_SelectStatement での inject を each に変更
Arel::Nodes::SelectStatement を SQL に変換するビジター visit_Arel_Nodes_SelectStatement 内で:
- cores の列挙に
injectを使っていた部分を、eachベースの普通のループに変更。injectも C 実装で yield を繰り返すため、YJIT 観点ではeach_with_indexと同種のボトルネックになりうる。
- ロジックとしては「複数 core を結合して SELECT 文全体の SQL を構築する」処理はそのままに、Ruby ループを素直に書き下した形に。
具体的なコードは PR 本文には載っていませんが、パターンとしては次のような変化です:
# 変更前のイメージ
cores.inject(String.new) do |sql, core|
# core を visit して sql に << して返す
end
# 変更後のイメージ
sql = +""
cores.each do |core|
# core を visit して sql に << する
end
sql(3) SELECT の ORDER BY を collect_nodes_for 経由に統一
これまで:
- UPDATE / DELETE の ORDER BY はすでに
collect_nodes_for経由で処理していた。 - SELECT の ORDER BY だけ、
collect_nodes_for相当の処理をその場でインライン実装していた。
今回:
- SELECT の ORDER BY も
collect_nodes_forを利用するように変更し、ロジックを共通化。 collect_nodes_for/inject_joinをオーバーライドしているアダプタ側は、今後 SELECT の ORDER BY に対してもそのオーバーライドが適用されるようになる。
この変更も、生成される SQL は従来とバイトレベルで同一になるよう確認済みです。
- 影響範囲・注意点
性能面の影響
PR のベンチマーク結果(arm64, 5回交互計測の中央値):
- Ruby 4.0.9 + YJIT:
- compile: 168.8k → 207.6k i/s (+23.0%)
- to_sql: 57.9k → 63.0k i/s (+8.8%)
- Ruby 3.3.13 + YJIT:
- compile: +7.2%
- to_sql: +3.1%
- Ruby 4.1.0-preview1 + ZJIT:
- compile: +13.5%
- to_sql: +5.0%
- 素のインタプリタ (YJIT/ZJIT なし) でもわずかにプラスか横ばい(3–4% 程度の改善〜誤差レベル)。
補足:
- メモリアロケーション数は「変更なし」。
- メリットが大きいのは Ruby 3.4+ の「
Array#eachが Ruby 実装」な環境で、YJIT (および ZJIT) を有効にした場合。 - Ruby 3.3 では
Array#eachがまだ C 実装なので、効果は小さめ(それでも若干の改善)。
機能的な影響(互換性)
- Rails/ActiveRecord 本体の振る舞いとしては、生成される SQL はテスト用に生成した 5,000 個のランダムなリレーションに対して「バイト列レベルで完全一致」と確認済み。
- inlined values / bind values あり・なし双方で比較。
- 「構造的に不正」なケース 216 件についてもエラー内容は一致。
- したがって、アプリケーションレベルの SQL 内容・挙動に変更はない前提。
ただし、次の点は注意が必要です:
サードパーティアダプタへの影響
- private メソッドである
collect_nodes_forまたはinject_joinをオーバーライドしているアダプタ(独自 DB アダプタや fork など)がある場合:- これまでは SELECT の ORDER BY にはそのオーバーライドが効いていなかったが、
- 今回から SELECT の ORDER BY も
collect_nodes_for/inject_joinを通るようになる。
- つまり、「ORDER BY の SQL の書き方をアダプタ側でカスタマイズ」している場合、その挙動が SELECT にも波及する可能性がある。
- 本来は private メソッドのオーバーライド自体が非推奨だが、もしやっている場合は挙動確認を推奨。
テスト・ドキュメント
- テスト追加・更新はこの PR では実施されていない(既存テストと SQL 同値性チェックでカバー)。
- CHANGELOG も更新されていない(挙動変更ではなく微小なパフォーマンス改善のため)。
- 参考情報 (あれば)
関連 Issue: #54943 「Slow query generation」
- 本 PRはそのボトルネックの一部(Arel ToSql レベルの
each_with_index由来のもの)を解消するもの。 - まだ残っている
each_with_index(INSERT のカラム、VALUES の行、BoundSqlLiteralの配列バインド、CTE など)も同様に変換し得るが、この PR では未対応。
→ 著者は「同じPRでやるか、後続PRにするか、それとも放置するか」についてメンテナへの意見を求めている。
- 本 PRはそのボトルネックの一部(Arel ToSql レベルの
技術的ポイントのまとめ:
- YJIT / ZJIT 最適化を考えると、「C 実装の
each_with_index/injectより、Ruby 実装のeachとフラグ・インデックス変数を使う」方が有利。 - SQL 生成のようなホットパスでは、標準 Enumerable メソッドの抽象化を少し崩してでも、JIT に寄せたループ構造にする価値があるケース。
- YJIT / ZJIT 最適化を考えると、「C 実装の
#59479 Keep the optimized URL helpers with default URL options that don't shape the path
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
routes.default_url_optionsやdefault_url_optionsにhostなどの「パスに影響しないオプション」だけが設定されている場合でも、パフォーマンスの良い「最適化された URL ヘルパー」を使い続けられるようにする変更です。これにより、特にシリアライザや ActivityPub などリクエスト外で URL を大量生成するケースで、オブジェクト数・処理時間の削減が見込まれます。
- 変更内容の詳細
これまでの挙動
routes.default_url_options(ルートセット全体のデフォルト)や
コントローラ/メイラーのdefault_url_options(レシーバ単位のデフォルト)に 何か1つでも オプションがあると、post_path(1)やpost_url(1)といった「最適化された URL ヘルパー」が無効になり、- 常に汎用ルート解決経路(
RouteSet#url_forがフルに動く「general path」)が使われていました。
これは、
hostしか設定していなくても同様で、
たとえばシリアライザ/メイラーでよくやる次のような設定だけでも最適化ヘルパが無効化されていました:rubyRails.application.routes.default_url_options[:host] = "example.com" # または def default_url_options { host: "example.com" } end一方で
RouteSet#url_for自体は、RESERVED_OPTIONS(予約済みオプション)として、:host, :protocol, :port, :script_name, :trailing_slash, ...などは パス生成には使わず、
最終的な URL の組み立て (ActionDispatch::Http::URL) にだけ渡すようになっています。以前の PR (#59350) で、明示的に引数で渡した 予約済みオプションについては
最適化ヘルパー内で既にうまく扱うようになっていましたが、
default_url_options 経由のオプション は依然として「最適化ヘルパーを無効にする要因」のままでした。
今回の変更のポイント
「パスに影響しない default_url_options だけなら最適化を維持」
default_url_options に含まれるキーが
RESERVED_OPTIONSに含まれる、かつ:paramsではない
のみで構成されている場合、最適化された URL ヘルパーをそのまま使うように変更されました。
具体例(いずれも「最適化ヘルパーを維持」されるようになる):
ruby# ルートセット全体 routes.default_url_options = { host: "example.com", protocol: "https", trailing_slash: false, } # コントローラ側 def default_url_options { host: "example.com", port: 3000 } end逆に、以下のような「パスやクエリに影響する」オプションが1つでも混ざると、 従来どおり汎用ルート解決経路にフォールバックします:
ruby# パスを変えるものの例 { locale: :en } # /en/posts/1 など { format: :json } # /posts/1.json # クエリを増やすものの例 { params: { page: 2 } } { page: 2 } # 文字列キー { "locale" => "en" }
ルートセット側の default_url_options のマージ順を、
RouteSet#url_forに合わせるRouteSet#url_forは、最後にroutes.default_url_optionsをマージする挙動になっています。- 最適化されたヘルパーで default_url_options を扱う際にも、
この挙動と 同じマージ順 になるように変更されています。 - これにより、同じ入力(パラメータ・default_url_options)に対して
「最適化ヘルパー」と「general path(url_for 経由)」が 同じ URL あるいは同じエラー を返すことが保証されます。
「そもそも最適化できないルート」は、default_url_options があるときも従来どおり general path を使う
- 一部のルートは構造上、最適化ヘルパーで安全に扱えません。例:
- 「予約済みオプションと同名のセグメントがあるルート」
例:get "/:host/posts/:id", to: "posts#show" - 「必須セグメントの前にオプショナルセグメントがあるルート」
例:scope "(:locale)" do; resources :posts; end
- 「予約済みオプションと同名のセグメントがあるルート」
- この PR では、そういった最適化不能なルート に対しては、 default_url_options が設定されている場合に 無理に最適化しない ようになっています。
- これにより、引数の位置解釈(positional arguments)などで
general path と挙動がずれないようにしています。
- 一部のルートは構造上、最適化ヘルパーで安全に扱えません。例:
パフォーマンス計測結果(Ruby 3.4)
予約済みオプションだけを default_url_options に設定している場合の改善例:
呼び出し オブジェクト数 (before → after) 時間 (before → after) post_url(1)+routes.default_url_options[:host]27 → 11 7.41 → 2.88 µs post_path(1)+routes.default_url_options[:host]25 → 9 6.40 → 2.10 µs post_url(1)+def default_url_options = { host:, protocol: }27 → 12 7.89 → 3.20 µs いずれも 2〜3 倍近い高速化と、オブジェクト数の半減に近い効果が出ています。
実アプリ(Mastodon v4.7.4)での効果
Mastodon ではシリアライザや ActivityPub 用に次のような default_url_options を利用:
ruby{ host: host, protocol: "https://", trailing_slash: false, }これにより、タイムライン 1 リクエストあたり 302 回もの route helper 呼び出しが
すべて general path になっていましたが、この PR 適用後は最適化ヘルパーが使われるようになり、 1 リクエストあたりのオブジェクト数が以下のように削減されています(3 回の交互実行で同一値):Endpoint main このブランチ 削減率 Home timeline (20 statuses) 120,721 113,401 −6.1% Public timeline 74,648 70,028 −6.2% Account statuses 93,309 87,609 −6.1% ActivityPub outbox page 62,900 57,731 −8.2% Status 11,261 11,030 −2.0% なお、これは #59474(requirements 付きルートを最適化可能にした変更)の効果も含みます。
Mastodon の ActivityPub ルートは requirements を持ち、従来は default_url_options があると最適化を妨げていました。テスト
- 22 種類のヘルパー呼び出し × 10 パターンの default_url_options について、
- 最適化ヘルパーと general path が同じ URL もしくは同じエラーを返すことを確認。
- default_url_options を routes 側とレシーバ側(コントローラやオブジェクト)それぞれに設定したパターンを網羅。
- オプショナルセグメント、グロブ、format、エスケープ済みセグメント、requirements のあるルートとその失敗ケース、 クエリパラメータ・URLオプションなどを含む。
- さらに以下を確認するテストも追加:
- 条件を満たす default_url_options があるときに、きちんと最適化ヘルパーが使われること。
- パスに影響する default_url_options があるときには、依然として general path が使われること。
- route set の defaults がちゃんとマージされていること。
- 最適化不能なルートが default_url_options のせいで誤って最適化されないこと。
- 影響範囲・注意点
パフォーマンス面
hostやprotocolなど、パスに影響しない default_url_options を多用しているアプリ(API サーバー、マイクロブログ系、SNS など)で特に恩恵が大きいです。- URL ヘルパーを大量に呼ぶ箇所(シリアライザ、バックグラウンドジョブ、ActivityPub など)でオブジェクト数・CPU 時間が目に見えて減る可能性があります。
互換性・挙動
- 最適化ヘルパーと general path で URL/エラーの結果が一致するよう厳密にテストされており、
正常系・異常系ともに挙動変更は原則ありません(内部実装の差し替えに近い)。 - ただし、もしアプリ側が
- 「わざと general path にフォールバックさせていた」
- かつ「default_url_options に予約済みオプションだけを載せていた」 ような極端なケースでは、今回からフォールバックしなくなりパフォーマンスが向上しますが、 その挙動変化を前提にしていたコードがあると影響を受ける可能性はあります(通常は想定されないパターンです)。
- 最適化ヘルパーと general path で URL/エラーの結果が一致するよう厳密にテストされており、
default_url_options の設計方針への示唆
- 「パスを変える要素(
locale,formatなど)」「クエリを変える要素(params,pageなど)」と、 「ホストやプロトコルなどの URL メタ情報」を default_url_options で明確に分けて管理すると、 今回の最適化の恩恵を最大限に受けられます。 default_url_optionsに「クエリパラメータ相当」を足していくと、
今回の最適化条件から外れてしまい general path に戻る点に注意してください。
- 「パスを変える要素(
- 参考情報 (あれば)
- 対象 PR: https://github.com/rails/rails/pull/59479
- 関連 PR(予約済みオプションを最適化ヘルパーが扱えるようにしたもの): https://github.com/rails/rails/pull/59350
- 関連 PR(requirements 付きルートの最適化): https://github.com/rails/rails/pull/59474
- 実測に用いられたアプリ: Mastodon v4.7.4 https://github.com/mastodon/mastodon
#59471 Reduce allocations of polymorphic URL helpers with arrays and actions
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
polymorphic_path / polymorphic_url 系ヘルパーを「配列引数」や「アクション付き」で呼び出したときのオブジェクト生成数を減らし、パフォーマンスを改善する変更です。動作(生成されるURLやヘルパー名)は一切変えず、内部実装だけを最適化しています。
- 変更内容の詳細
対象となるヘルパー
polymorphic_path,polymorphic_urledit_polymorphic_path,new_polymorphic_path, などアクション付きのポリモーフィックヘルパー- 特に配列を使った呼び出し:
polymorphic_path([user, comment])polymorphic_path([user, Comment])polymorphic_path([:edit, post])polymorphic_path([:edit, user, comment])form_with(model: [post, comment])link_to "Edit", [:edit, post]などでオブジェクトアロケーションを削減しています。
主な最適化ポイント
1) 配列のコピー回数削減
これまで:
polymorphic_methodがcompactした結果として 新しい配列 を作る- さらに
handle_listなどでその配列を 再度コピーして処理
変更後:
polymorphic_methodがcompactで作った配列を、そのまま後続処理でも再利用し、不要なコピーを行わない。
これにより、polymorphic_path([user, comment]) のようなケースで毎回発生していた余分な配列生成が減ります。
2) シンボルから文字列への変換方法の変更
これまで:
Symbol#to_sを使っており、毎回新しい文字列オブジェクトを生成していた。
変更後:
Symbol#nameを使用。これは 凍結された(frozen)文字列 を返すため、余計なアロケーションが発生しにくい。
[:edit, user, comment] のようにアクションやスコープをシンボルで渡すパターンで、文字列化コストが下がります。
3) ルートヘルパー名の組み立て方法の最適化
これまで(イメージ):
- ルート名のパーツを配列として組み立てruby
parts = ["edit", "user", "comment"] helper_name = parts.join("_") + "_path" joinで一度文字列を作り、それに_pathなどをさらに連結していた。
変更後(イメージ):
- 1つの文字列として直接組み立てる:ruby
helper_name = "edit_user_comment_path"
あるいは同等の「一回のビルドで済む」形に近づけ、Array#join + 文字列結合という複数段階のアロケーションを減らしています。
4) アクション付きヘルパーの処理経路を簡略化
対象例:
edit_polymorphic_path(post)new_polymorphic_path([user, comment])- その他
edit_...,new_...などアクションプレフィックス付きポリモーフィックヘルパー
これまで:
edit_polymorphic_path(post)は内部的にpolymorphic_pathにaction: :editを含んだoptionsハッシュを渡すpolymorphic_path側でその:actionを取り出して別処理- その過程で
Hashのコピー・マージ・削除が行われる
- 結果として、アクション情報をハッシュ経由でぐるっと回すために不要なオブジェクトが増えていた。
変更後:
- 他にオプションがない場合:
- アクション情報(
:edit,:newなど)を、直接ビルダーに渡すように変更。 - 余計な
Hashを作らずに済む。
- アクション情報(
- ただし以下の場合は従来通り
polymorphic_path/polymorphic_urlを通す:- 他のオプションがある
- オプションハッシュが渡されている
- polymorphic mapping を使っている
これにより、edit_polymorphic_path(post) というごく一般的なケースでのコストも削減されています。
ビフォー / アフターのアロケーション数
アプリ側で resources :posts と resources :users { resources :comments } がある環境、ビューコンテキストからの呼び出し時:
| 呼び出し | main (変更前) | this branch (変更後) |
|---|---|---|
polymorphic_path([user, comment]) | 17 | 14 |
polymorphic_path([user, Comment]) | 16 | 13 |
polymorphic_path([:edit, post]) | 17 | 13 |
polymorphic_path([:edit, user, comment]) | 18 | 14 |
edit_polymorphic_path(post) | 11 | 8 |
polymorphic_path(post) / user_comment_path(user, comment) | 変化なし | 変化なし |
特に「ネストされたリソース + 配列」「アクション付き」のパターンで 3〜5 オブジェクト程度の削減が見られます。
- 影響範囲・注意点
出力結果は完全に互換
- 生成されるURL、named route helper の名前ともに、変更前と同一です。
- テスト (
polymorphic_routes_test.rb) もその前提で通っています。
主な影響範囲
- Action Pack の
ActionDispatch::Routing::PolymorphicRoutes - それを利用する Action View の URL/フォームヘルパー (
link_to,form_with,form_for等) が内部的に高速化されます。 - 既存のアプリケーションコードやルーティング DSL の書き方は一切変更不要です。
- Action Pack の
パフォーマンス的なインパクト
- 単発呼び出しでは数オブジェクトの差ですが、ビュー内で多用される(特にネストされたリソースを大量に表示する)ケースでは GC 負荷の減少が期待できます。
resourcesを多用し、form_with(model: [parent, child])を頻出させているようなアプリほど恩恵が大きいです。
互換性上の懸念
- public API の挙動は変えていないため、通常の利用では互換性問題は起きにくいです。
- もし
polymorphic_pathの内部実装に依存した monkey patch を書いている場合は、内部の配列/ハッシュの扱いが一部変わっている点に注意が必要です。
- 参考情報 (あれば)
変更ファイル:
actionpack/lib/action_dispatch/routing/polymorphic_routes.rb- polymorphic ルーティングのコア実装で、主にここに最適化が入っている。
actionview/test/activerecord/polymorphic_routes_test.rb- 既存テストの強化またはカバレッジ確認用の追加が行われている(新しい振る舞いはない)。
テスト状況:
polymorphic_routes_test.rbを含む Action View / Action Pack の両テストスイートが、warning を error 扱いにした状態でパスしていると明記されています。
#59464 Compute action_methods without stringifying every public instance method
マージ日: 2026/10/31 | 作成者: @igorwwwwwwwwwwwwwwwwwwww
- 概要 (1-2文で)
コントローラのaction_methodsを計算する際に、全ての public インスタンスメソッドを一度 String 化してから Set に入れていた処理を見直し、Symbol のままルートヘルパー名を差し引くことで、大規模アプリ(例: GitLab)のブート時間とオブジェクトアロケーションを大幅に削減するパフォーマンス改善です。挙動は従来と同一になるように保ちつつ、内部実装とキャッシュ戦略を最適化しています。
- 変更内容の詳細
背景 / 問題点
AbstractController::Base#action_methodsは、各コントローラごとの「アクションとして扱うメソッド名の Set」を計算します。- 現状は:
- コントローラの「全ての public インスタンスメソッド」を列挙
- それらをすべて String に変換
- さらに
UrlForレイヤで「ルートヘルパーメソッド名の集合」を String として取り出し、それを差し引き - 残ったものを Set として
action_methodsにする
- 多くのコントローラで
url_helpersを include しているため、public メソッドの大半が「ルートヘルパー」であり、それらを毎回 String 化しては Set 演算、という処理が非常に重くなっていました。- GitLab 例
- コントローラ 684 個
- 各コントローラの public インスタンスメソッド約 8800 個
- うち約 8100 個がルートヘルパー (ルート数 3300 に由来)
action_methods計算にだけ 5.4 秒 (全体ブート 33 秒のうち) 消費- 結果的な
action_methodsSet 自体はごく少数の「本当のアクションメソッド」しか含まないのに、そこに至るまでの計算コストが高かった。
- GitLab 例
変更のアイデア
- 「ルートヘルパー名を差し引く」ところまでは、Symbol のまま処理する。
- 最終的に「コントローラの action 名」として公開するタイミングでだけ String に変換して Set を作る。
- 併せて、「ルートヘルパー名のリスト (helper_names)」も、Symbol 版をメモ化して再利用する。
実装上の変更点
1. AbstractController::Base#action_methods の分割
従来:
def action_methods
# (イメージ) public_instance_methods から String の Set を作る実装
@action_methods ||= begin
methods = public_instance_methods(true).map(&:to_s)
Set.new(methods).freeze
end
end新しい構造 (概要):
# Symbol のリストを返す内部用フック (nodoc)
def internal_action_methods # 仮名:実際には :nodoc: な名前
# 戻り値は Symbol の配列/集合
end
def action_methods
# internal_action_methods の結果を String に変換して
# Frozen な Set として公開
@action_methods ||= internal_action_methods
.map!(&:to_s)
.to_set
.freeze
end- 実際のメソッド名は PR では
:nodoc:付きフックとして定義されており、外部 API ではなく内部用です。 - ポイントは、「コントローラが持つ public メソッド一覧」から「実際のアクション候補の Symbol 集合」を返すフェーズと、それを String の Set に仕上げるフェーズを分離したことです。
2. UrlFor 側のオーバーライド対象の変更
- これまでは
UrlForがaction_methodsを直接オーバーライドし、そこからルートヘルパーを差し引いていました。 - 今回、
UrlForは Symbol リストを返す内部フック (上記internal_action_methods相当) をオーバーライドするように変更。- すなわち:
- ベース実装で「コントローラの public インスタンスメソッド (Symbol 群)」を取得
UrlForで「Symbol のままルートヘルパー名の集合を差し引く」- 残った Symbol 群だけが「アクション候補」として返る
- その後、ベース側で String 化して Set 化するため、「公開される
action_methodsの API と結果」は変わりません。
- すなわち:
3. ルートヘルパー名 (helper_names) の Symbol 版メモ化
対象: action_dispatch/routing/route_set.rb
- これまで:
helper_namesが呼ばれるたびに、新しい String の配列を生成して返していた。- つまり
action_methods計算ごとに、ヘルパー名 String 群を都度生成 → Set 演算 → GC 負荷大。
- 変更後:
- ルートコレクション (RouteSet) に「Symbol のヘルパー名一覧」をメモ化して保持。
- ルートが追加されたりコレクションがクリアされたときだけ、このメモをクリア。
- 既存の
helper_namesメソッドのインターフェイスは変更しない (必要ならメモ化した Symbol 群を元に String を返すなどの内部実装に留まる)。
疑似コードイメージ:
class ActionDispatch::Routing::RouteSet
def helper_names
# 従来 API はそのまま (String の配列を返す)
end
def helper_names_symbols
@helper_names_symbols ||= begin
# 全ルートからヘルパー名 Symbol を列挙してメモ化
end
end
def clear!
@helper_names_symbols = nil
super
end
end4. CHANGELOG
actionpack/CHANGELOG.md に、このパフォーマンス改善と互換性についての記述が追加されています。
パフォーマンス効果 (PR 計測値)
- 合成アプリ (600 controllers / 3000 resources):
- 旧実装:
- 1.22 秒
- 3.6M オブジェクトアロケーション
- 新実装:
- 0.41 秒
- 12.6K オブジェクトアロケーション
- 旧実装:
- GitLab 本番ブート:
- 全体ブート時間: 32.7 s → 28.7 s (約 4 秒短縮)
action_methods計算コストが大きく削減
なお、GitLab 673 コントローラについて、旧実装と新実装で action_methods の Set を 1 つずつ比較し、「1つたりとも差分がない」ことを検証済みです。
- 影響範囲・注意点
- 公開 API / 挙動:
AbstractController::Base#action_methodsの返り値 (内容・型) は従来と同一を維持する方針です。UrlForを include しているコントローラのaction_methodsも結果は変わらない想定です。helper_namesもインターフェイスは変更なし。
- 影響がありうるケース:
- Rails 内部の非公開フック (
:nodoc:で追加された Symbol リスト返却フック) を、独自に monkey patch したり rely するようなコードがあると影響を受ける可能性があります。 UrlForやAbstractController::Baseに対して、action_methods自体を再定義・上書きするようなメタプログラミングをしている場合は、内部構造の変更と競合しうるため、アップデート時に挙動を確認してください。
- Rails 内部の非公開フック (
- 性能的な期待:
- ルート数 × コントローラ数が多い大規模モノリスほど恩恵が大きいです。
- 開発環境のブート時間や Spring のプリロード時間、CI の起動時間短縮などにも寄与します。
- 互換性:
- CHANGELOG にもある通り、ユーザランドから見える範囲での互換性は保たれており、「パフォーマンス改善のみ」を意図した変更です。
- 参考情報 (あれば)
- 該当 PR: https://github.com/rails/rails/pull/59464
- 関連クラス:
AbstractController::Base#action_methodsAbstractController::UrlForActionDispatch::Routing::RouteSet
- 類似の最適化パターンとして、「Symbol のまま集合演算し、最終出力フェーズでだけ String 化する」「ルート定義が変わらない限りヘルパー名をメモ化する」という設計は、大規模 Rails アプリで頻出するパフォーマンスチューニングの基本パターンと言えます。
#59468 Autoload Arel
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
Active Record がrequire "arel"していたのをやめて、Arelをオートロードに変更し、「実際に必要になったタイミングでのみ Arel を読み込む」ようにした PR です。これにより開発環境でのブート時間短縮と読み込まれるファイル・オブジェクト数の削減を狙っています。
- 変更内容の詳細
何をしているか
これまで:
active_recordをrequireした時点でArel一式(ノード・ビジター・コレクタなど、約80ファイル)を即座に読み込んでいた。- その結果、
bin/rails系コマンドがアプリケーションをロードするたびに Arel も毎回ロードされていた。
この PR 以後:
- Active Record 側から Arel を「即時 require」するのではなく、「
Arelという定数が参照されたタイミングで自動的にロードされる」ようにした(オートロード化)。 - 実際には
ActiveRecord::Baseがロードされる際にArel::Table.engineのロードフックを通じて Arel が読み込まれるので、通常の Rails アプリでの利用パターンは変わらない。 - つまり「Arel は引き続き Active Record 経由で普通に使えるが、Active Record を require した直後にはまだ読み込まれておらず、必要になったときに読み込まれる」構造になる。
- Active Record 側から Arel を「即時 require」するのではなく、「
挙動の互換性
- 説明中の例:ruby
require "active_record" # これまでと同様に動く Arel::Table.new(name: "users")active_recordだけを require した状態でも、Arel::Table.newが従来どおり動作することが保証されています。
背景として、ActiveRecord::Baseロード時のArel::Table.engine設定処理がフックになっており、そのタイミングで Arel がオートロードされます。
テスト追加内容
railties/test/application/loading_test.rb にテストが追加されています。内容は概ね次の検証です:
- 開発モードでアプリケーションをブートした直後は、
Arelがまだオートロード対象として残っていること。 ActiveRecord::Baseをロードすると、そのタイミングで Arel が実際にロードされ、Arel定数が解決可能になることArel::Table.engineが正しくセットされること
このテストは現行 main ブランチでは失敗し、この PR の変更によってパスするようになります。
また、Arel 自身を含む Active Record のテストスイート(SQLite 使用、10,135 テスト)が通っていることが明記されています。
パフォーマンス計測結果
- この PR 単体による影響(開発モード/
rails new直後のアプリ):- Development boot, median: 620 ms → 604 ms(約 -16 ms)
- 読み込まれるファイル数: 1461 → 1383(-78 ファイル程度)
- オブジェクト数: 321,683 → 314,585(約 -7k)
シリーズ全体(他の PR: #59465〜#59469 すべて適用)では、トータルで 619 ms → 513 ms と大きめの効果があることが説明されていますが、この PR はその中で Arel 部分を担当する変更です。
- 影響範囲・注意点
影響範囲
- 主に影響するのは開発環境でのブートシーケンスです。
bin/rails系コマンド(server,console,runner,testなど)がアプリケーションをロードする際に、不要なプロセスでは Arel を読み込まなくなります。
- 本番環境(
config.eager_load = true)では、イager ロード時に結局 Arel 一式がロードされるため、本番のブート挙動・性能は実質的に変わりません。
既存コードへの影響
通常の Rails/Active Record 利用パターンではほぼ影響はありませんが、以下のような特殊なケースでは意識しておくとよいです:
- Active Record を require しているが、
ActiveRecord::Baseをロードしない構成で、かつ Arel を直接使うコード:- 例:
require "active_record"だけ行い、ActiveRecord::Baseを一切触らずにArel::Tableなどを直接使うカスタムコード。 - この場合でも、この PR の説明によれば
Arel::Table.new(name: "users")は従来通り動くよう考慮されていますが、「どのタイミングで Arel がロードされるか」を前提にした非常にトリッキーなコードを書いている場合は挙動が変わる可能性があります。
- 例:
Arelがオートロードかどうかに依存したメタプログラミングをしている場合:- たとえば
ActiveSupport::Dependencies.autoload_pathsやautoload?("Arel")の結果に依存するようなコードなど。 - Rails 内部実装寄りのケースなので、通常のアプリ開発ではまず問題になりません。
- たとえば
デバッグ時の注意
- 開発環境で「起動直後に Arel 関連のコードがまだロードされていない」ことがあり得ます。
何か定数解決問題が起きた場合は、ActiveRecord::Baseのロードタイミングと Arel のオートロードタイミングを意識すると切り分けがしやすくなります。
- 参考情報 (あれば)
- この PR は、開発ブート時間短縮シリーズの一部:
- #59465: テストランナーをテスト時のみ読み込む
- #59466: Action Mailbox の Mail 拡張を InboundEmail 利用時にのみ読み込む
- #59467: Marcel を ActiveStorage::Blob 利用時にのみ読み込む
- #59468: 本 PR(Arel をオートロード)
- #59469: Action View のヘルパーを使用時にのみ読み込む
- これらはすべて「
rails/allで一括 require していたコンポーネントを、必要になったときにロードするようにする」という方針で統一されており、開発体験(ブートの速さ・メモリ)を改善しつつ、本番の eager load には影響を与えない設計になっています。
#59465 Load the test runner only when testing
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
このPRは、Railsアプリのブート時に常に読み込まれていたテストランナー関連(特に Prism と Rake::FileList)を、「実際にテストを実行する時だけ」読み込むように変更するものです。これにより、開発環境でのアプリ起動や各種bin/railsコマンドの起動時間が数十ミリ秒改善されます。
- 変更内容の詳細
何が問題だったか
従来は、rails/all を require するプロセス(開発サーバ、コンソール、bin/rails 各種コマンドなど)が 常にテストランナーを読み込んでいた ため、以下が毎回ロードされていました:
rails/test_unit/runner- そこからさらに:
rails/test_unit/test_parser(Prism を使用)rake/file_list
しかし、テストランナーは本来「テストを実行するときにだけ必要」なものであり、開発サーバやコンソールなどには不要です。この不要な読み込みが、開発時のブートを遅くしていました。
具体的な変更点
主な変更ファイルは 2 つです。
1) Rails::TestUnitRailtie の読み込みタイミング調整
変更ファイル: railties/lib/rails/test_unit/railtie.rb
今までは Rails::TestUnitRailtie がロードされるタイミングで:
rails/test_unit/line_filtering- → それ経由で
rails/test_unit/runner - → さらに Prism /
rake/file_list
…まで一気に読み込まれていました。
このPRでは、line_filtering の require を ActiveSupport::TestCase が拡張される瞬間まで遅延 させています。
イメージとしては:
# 以前: Railtie が読み込まれた時点で line_filtering を require していた
require "rails/test_unit/line_filtering" # ※これが runner → Prism を引きずる
ActiveSupport.on_load(:test_case) do
# line_filtering で定義される機能をここで使う
end
# 変更後: on_load ブロックの中で require する
ActiveSupport.on_load(:test_case) do
require "rails/test_unit/line_filtering" # ← 実際にテストケースがロードされる時だけ
# ActiveSupport::TestCase に line_filtering 機能を混ぜ込む
endこれにより:
- テストが実際にロードされる(=
ActiveSupport::TestCaseがロードされる)まではline_filteringおよびrunner/ Prism /rake/file_listは読み込まれない bin/rails testや Rake のテストタスク、Minitest プラグインなどは、自分で runner を require 済み なので挙動は変わらない
という状態になります。
2) 新しいテストの追加
変更ファイル: railties/test/application/runner_test.rb
bin/rails runner 実行時に「テストランナーが読み込まれていないこと」を確認するテストが追加されています。
ポイント:
- 開発・本番両方の環境に対して、
bin/rails runnerを起動し、- そのプロセス内で
Rails::TestUnit::Runnerなどがロードされていないことを検証
これにより、「アプリブート時にテストランナーを読み込まない」という今回の変更が、将来の改修で誤って元に戻されないようにテストで担保しています。
- 影響範囲・注意点
影響範囲
- 対象:
rails/allを読み込む全てのプロセス(開発サーバ、コンソール、bin/rails runner等)- 特に「テストを実行していない」プロセスでの挙動が変わります(=テストランナーを読まなくなる)。
- 非対象:
- テスト実行系 (
bin/rails test, Rake の test タスク, Minitest プラグインなど)- これらは元々 runner を明示的に require しているので、挙動は変わりません。
- 本番環境の eager load 時:
- Action View 側で Prism を eager load するため、最終的に Prism は読み込まれます。
- ただし「9ファイル & 3.2kオブジェクト」分程度の削減効果はある模様。
- テスト実行系 (
パフォーマンスへの影響
新規アプリ (rails new デフォルト) を使った計測結果(Ruby 3.4.12 / Debian 13):
- 開発環境でのブート時間(median)
- main: 620 ms → このPR: 598 ms(約 22 ms 削減)
- ロードされるファイル数
- 1461 → 1443(▲18)
- 割り当てオブジェクト数
- 321,684 → 305,655(▲約 16k)
bin/rails コマンドでアプリを起動する場合も概ね同程度の削減があり、bin/rails runner nil(development)では 868 ms → 770 ms 程度の改善が報告されています(このPR単体以外の改善も含む全体測定)。
注意点 / 互換性
- 通常の使い方 (
bin/rails test,rails test, Rake タスク経由のテスト実行) では挙動は変わりません。 - 「アプリケーションブート時に、テストランナーが必ずロードされている」ことに依存するようなコード(かなり特殊ケース)があると、今回の変更で壊れる可能性があります。
- 例:
config/initializers内でRails::TestUnit::Runnerクラスを参照している、など - そういったケースでは、必要に応じて自前で
require "rails/test_unit/runner"する必要があります。
- 例:
- 参考情報 (あれば)
- PRで挙げられている関連最適化 PR 群(いずれも「必要になったときだけロードする」方向の変更):
- #59465 本PR: Load the test runner only when testing(−22 ms)
- #59466: Load Action Mailbox's Mail extensions with InboundEmail(−40 ms)
- #59467: Load Marcel with ActiveStorage::Blob(−13 ms)
- #59468: Autoload Arel(−16 ms)
- #59469: Load Action View's helpers when they're used(−13 ms)
- 上記5つを全部適用した場合の新規アプリ開発ブート:
- 619 ms → 513 ms
- ロードファイル −190
- オブジェクト −54k
Rails の「起動時に全部読み込む」設計から、「必要になったタイミングでロードする」方向への細かなチューニングの一環として捉えられるPRです。
#59452 [8-1-stable] Require useragent when loading ActionController::AllowBrowser
マージ日: 2026/10/31 | 作成者: @k0kubun
- 概要 (1-2文で)
Rails 8.1系において、ActionController::AllowBrowserが内部で使うuseragentgem を「最初のリクエスト時」ではなく「コントローラ定義時(クラス評価時)」に読み込むようにしたバックポートです。挙動やAPIは変えずに、JITコンパイラへの悪影響を減らし、起動後の最初のリクエスト時のオーバーヘッドを避けるためのパフォーマンス改善です。
- 変更内容の詳細
何が変わったか
元の変更 (#57974) を 8-1-stable にそのまま cherry-pick したもので、実質 2 行差分の小さな修正です。
これまでの 8.1 では:
allow_browserを使ったコントローラで- リクエストが初めて来たタイミングの
before_actionの中で require "useragent"が呼ばれていた
という構造になっていました。
このPRでは:
allow_browserのクラスマクロを呼んだタイミングでrequire "useragent"を実行するように変更しています。
擬似コードでいうと、イメージは以下のような変化です(実際のコードは2行変更のみですが、概念的な違いを示します):
変更前(イメージ)
class ApplicationController < ActionController::Base
allow_browser versions: :modern
before_action :check_browser
private
def check_browser
require "useragent" # ← 最初のリクエスト中にロードされる
# UserAgent.parse(request.user_agent) ...
end
end変更後(イメージ)
class ApplicationController < ActionController::Base
# クラス定義が評価されるとき(= 起動時の eager_load 時)に require
allow_browser versions: :modern # ← ここで require "useragent"
end実際の差分としては actionpack/lib/action_controller/metal/allow_browser.rb 内で:
- これまで
before_action内にあったrequire "useragent"を削除 allow_browserクラスマクロの実行時にrequire "useragent"を行うように変更
という2行の置き換えに近い内容です。
なぜそうするのか(動機)
- Rails 8.1 では、
allow_browserを利用する最初のリクエスト中にuseragentをrequireしていたため、- すでに JIT コンパイラが「もう完成形」と見なしていたコードが、
- ランタイム途中で新しい定数やメソッドの定義によりパッチポイントが無効化され、
- ZJIT での再コンパイルが発生してしまう、という問題が確認されています(ruby/ruby#19617 の
shipitベンチマーク)。
config.eager_load = trueな本番環境では、「アプリケーション起動時」にクラス定義が評価されるので、allow_browserクラスマクロ実行時にrequire "useragent"しておけば、- 起動時に gem が読み込まれ、JIT が安定した状態でリクエスト処理に入れるようになります。
useragentはすでにactionpackのハード依存関係であり、- 遅かれ早かれ必ず読み込まれるものなので、
- 「ちょっとだけ早め(クラス評価時)に require する」ことによる互換性問題は基本的に発生しません。
- 影響範囲・注意点
振る舞い面での影響
- 公開APIおよび表向きの挙動は変わりません。
allow_browserのオプションや判定ロジック、レスポンス内容などは従来通りです。
- 変わるのは「
useragentがいつロードされるか」だけです。- 変更前: 該当コントローラへの最初のリクエスト中にロード
- 変更後: コントローラで
allow_browserを宣言した時点(= eager_load 環境では起動時)でロード
パフォーマンス / JIT への影響
- 利点:
- 本番環境(
config.eager_load = true)では、アプリ起動後の最初のリクエスト時にuseragentを読み込むオーバーヘッドがなくなります。 - JIT (ZJIT) の観点では、最初のリクエスト途中で大きな
requireが走らないため、定数・メソッドの再解決や再コンパイルを減らせます。
- 本番環境(
- 影響範囲:
allow_browserを使っているコントローラが存在するアプリケーション。- それ以外のコントローラでは
allow_browserを呼ばない限りuseragentは読み込まれない点は従来通りです。
注意点・互換性
useragentはactionpackのハード依存なので、通常の Rails プロジェクトでは特別な対応は不要です。- もし非常に特殊な構成で、
- Rails がブートする前 / gem が揃う前に
allow_browserを評価するような独自ローダやコード生成をしている場合、 - そこで
require "useragent"が発生するタイミングが変わる可能性があります。 - ただし、現実的にはほぼ想定されないケースです。
- Rails がブートする前 / gem が揃う前に
- テスト:
actionpack/test/controller/allow_browser_test.rbはこのブランチでも通過することが確認されています。- 実際に
allow_browser versions: :modernを持つコントローラを定義すると、- 変更前: 最初のリクエスト時に
$LOADED_FEATURESにuseragentが追加 - 変更後: コントローラ定義時点で
$LOADED_FEATURESに追加 が確認されています。
- 変更前: 最初のリクエスト時に
- 参考情報 (あれば)
- 元PR(main/8-2系相当への変更): https://github.com/rails/rails/pull/57974
- このPR(8-1-stable 向けバックポート): https://github.com/rails/rails/pull/59452
- cherry-pick 元コミット:
7b321bce4c70bcaf7d4724bb208d8cdd255b657a - Ruby JIT 関連の議論: https://github.com/ruby/ruby/issues/19617
このPRは、「allow_browser を使うときに useragent がいつロードされるか」をよりJITフレンドリーなタイミングに前倒しする、純粋なパフォーマンス改善(挙動非変更)のバックポートと捉えて問題ありません。
#59470 Don't add a trailing slash after a required format in optimized URL helpers
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
Rails の URL ヘルパー最適化で導入された挙動により、「必須フォーマット付きのルート +trailing_slash: true」のときに、拡張子の後ろに余計なスラッシュ (/projects/1.json/) が付いてしまう不具合を修正する PRです。一般ルート生成ロジックと同様に、フォーマットが必須セグメントの場合は末尾スラッシュを付けないよう、最適化ヘルパー側の条件分岐が調整されています。
- 変更内容の詳細
問題の背景
以下のようなルーティングを考えます:
# config/routes.rb
get "/projects/:id.:format", as: :projectこのとき、trailing_slash: true を指定して URL ヘルパーを呼ぶと:
project_path(1, :json, trailing_slash: true)
# 現在(問題ありの挙動):
# => "/projects/1.json/"
# 期待される挙動(従来の一般パス生成と同じ):
# => "/projects/1.json"#59350 で追加された「最適化された URL ヘルパー」は、trailing_slash のように「パス構造を変えない」オプションについては、一般的なパス生成ロジックをバイパスして高速経路を通るようになっています。
しかし、その最適化ルートでは「フォーマットがルートの必須パーツとして指定されている場合」を正しく考慮しておらず、format: オプション経由で指示されたフォーマットしか見ていませんでした。その結果:
- 位置引数でフォーマットが渡されている (
project_path(1, :json, ...)) - かつ ルート定義上
:formatが必須セグメント (:id.:format) になっている
というケースで、本来は末尾スラッシュを付けるべきでないのに "/" を付与してしまう差異が、最適化ヘルパーと一般ヘルパーの間で生じていました。
今回の修正内容
actionpack/lib/action_dispatch/routing/route_set.rb の最適化 URL ヘルパー生成ロジックで、フォーマットがルートの必須セグメントとして存在する場合は trailing_slash を無視し、末尾スラッシュを追加しないように条件を変更しています。
擬似コード的には、以下のようなイメージです(実際のコードはもう少し低レベルですが、意味としてはこうなります):
# 変更前 (イメージ)
path = build_optimized_path(...)
path << "/" if options[:trailing_slash]
# 変更後 (イメージ)
path = build_optimized_path(...)
if options[:trailing_slash]
# ルートに :format が必須セグメントとして含まれていない場合のみ付与
unless route_has_required_format_segment?
path << "/"
end
endつまり、「フォーマットが必須な一部のパターン (:id.:format など)」では、trailing_slash: true を指定しても "/projects/1.json" のままで、拡張子 .json の後に / が付かないようにしています。一般パス生成ロジックと挙動を揃える調整です。
テスト追加
actionpack/test/dispatch/routing_test.rb にテストが追加されています。内容としては、以下のようなケースを網羅し、「最適化パス生成」と「一般パス生成」が同じ結果を返すことを確認しています。
project_path(1, :json, trailing_slash: true)project_path(1, :json, trailing_slash: true, anchor: 'frag')project_path(1, :json, trailing_slash: true, page: 2)
テストでは「最適化ヘルパー経由」と「一般パス生成経由」の結果を比較しており、現状の main ブランチではこの比較が失敗するところを、この PR によって一致するように修正しています。
- 影響範囲・注意点
- 対象:
:formatをルートの必須パーツに含めているルート(例:get "/projects/:id.:format")- かつ、
*_path/*_urlヘルパー呼び出しでtrailing_slash: trueを使っているケース
- 影響内容:
- これまで (まだ未リリースの main のみ)
"/projects/1.json/"のように「拡張子の後に/が付いていた」最適化ルートの挙動が、"/projects/1.json"に戻ります。 - 一般的な URL 仕様としても、拡張子の直後に意味のないスラッシュが入るのは不自然であり、既存の安定版 Rails の挙動とも整合します。
- これまで (まだ未リリースの main のみ)
- 後方互換性:
- バグ修正であり、リリース済みバージョンにはまだ入っていない挙動を正すものなので、実運用中アプリへの互換性影響は基本的にありません。
- もし main ベースで
"/projects/1.json/"に依存するようなコードや外部サービス連携を組んでいた場合は、URL が"/projects/1.json"に変わる点に注意が必要ですが、そのような使い方は通常想定されません。
- 参考情報 (あれば)
- 関連 PR: #59350 – URL ヘルパーの最適化で、
trailing_slashなどパス形状に影響しないオプションを最適化パスで扱うようにした変更。 - 影響コンポーネント:
ActionDispatch::Routing::RouteSet(ルーティングと URL ヘルパー生成)- Action Pack のルーティング/URL 生成周りのテストスイートは、warnings-as-errors でもパスしているとのこと。
#59474 Optimize URL helpers for routes with requirements
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
constraintsなどの「セグメントに対する正規表現制約」を持つルートでも、URLヘルパーの高速な最適化パス (OptimizedUrlHelper) を使えるようにしたパフォーマンス改善のPRです。制約違反時のエラー挙動や、オプション付き URL 生成の挙動は既存の一般パスと揃えたまま、オブジェクト生成数と実行時間を大きく削減しています。
- 変更内容の詳細
何が問題だったか
Rails のルーティングでは、以下のように constraints でセグメントを正規表現制約するのがよくあります:
resources :posts, constraints: { id: /\d+/ }
get "/@:username/:id",
to: "statuses#show",
as: :short_account_status,
constraints: { username: %r{[^@/.]+}, id: /\d+/ }ところが、従来の UrlHelper.optimize_helper? は
route.path.requirements.empty? # => true でないと最適化しないという条件だったため、「制約付きルート」は常に一般的な URL 生成パス(遅い方)を通っていました。
Mastodon の例では、パブリックタイムラインの 1 リクエスト内で 302 回のルートヘルパー呼び出しのうち 122 回がこの「制約付きルート」だった、という事例が示されています。
この PR が行うこと
1. 制約付きルートでも OptimizedUrlHelper を使う
constraints を持つルートに対しても、条件付きで OptimizedUrlHelper を使えるようにしました。
ただし、その際に
- 必須セグメント(
:id,:usernameなど)については
そのルートのrequirementsに定義された正規表現と突き合わせてチェックする - チェックに失敗した場合(未指定 / 不一致) は
これまで通り一般パスにフォールバックし、同じエラーを投げる
という挙動を追加しています。
ここで使われる正規表現は、従来の一般パスと同じく「アンカー付き(\A … \z)」のもの(requirements_for_missing_keys_check)なので、挙動が従来と変わらないように注意されています。
疑似コード的にはイメージとして:
def optimized_helper(...)
if all_required_segments_match_requirements?(args)
# 早いパス(OptimizedUrlHelper)
build_url_fast(...)
else
# 従来の一般パスにフォールバック
build_url_general(...)
end
end2. オプションセグメントの扱い
- 一般パス側は、「フォーマット以外のオプションセグメント」は 現在のリクエストから値を埋める 仕様があります。
- 一方、
OptimizedUrlHelperは「リクエスト由来でオプションセグメントを埋める」ことはしません。
そのため、この PR では次のポリシーが取られています:
フォーマット (
.:format) 以外のオプションセグメントを持つルート は
引き続き 最適化しない(一般パスのまま)例:
rubyscope "(:locale)", locale: /en|es/ do resources :posts endここでは
(:locale)がオプションセグメント & 制約付きなので、このルートは最適化対象外。
テストTestOptionalScopesWithOrWithoutParamsでそのケースをカバーしています。
逆に言うと、
- 制約付きだけど
- 「フォーマット以外のオプションセグメントを持たないルート」
に限って OptimizedUrlHelper が適用されます。
3. パフォーマンス改善の具体的な数字
Ruby 3.4 での 1 コールあたりのオブジェクト数と時間:
| 呼び出し例 | オブジェクト数 (before → after) | 時間 (before → after) |
|---|---|---|
post_path(1) | 25 → 10 | 5.97 → 2.18 µs |
post_url(1, host: "example.com") | 28 → 13 | 7.15 → 3.40 µs |
short_account_status_url("alice", 1, host: "example.com") | 28 → 13 | 8.36 → 4.02 µs |
1 回あたり数 µs・十数オブジェクトの差ですが、タイムライン表示など URL ヘルパーの多用箇所ではトータルで効く改善になります。
4. テスト追加内容
新しいテストでは、「最適化パス」と「一般パス」が
- 同じ URL を生成するか
- 同じエラーメッセージを投げるか
を多様な引数パターンで比較しています。カバーしている引数:
- 制約に マッチする値
- マッチしない値
nil- 空文字
- 末尾に改行を含む値
to_paramを実装したオブジェクト
さらに、それぞれについて:
- オプション無し
formatオプションanchor(フラグメント)オプション- クエリパラメータ付き
hostオプション付き
を組み合わせてテストし、挙動が完全に一致することを検証しています。
また、制約付きかつオプションセグメントを持つルート((:locale) など)で、「リクエストからオプションが埋まる」従来挙動も維持されていることを確認しています。
著者は
- 「制約チェックを外す」とテストが落ちる
- 「オプションセグメントを持つルートも最適化する」とテストが落ちる
ことも確認済みと明記しています。
- 影響範囲・注意点
- 対象:
resources ... constraints: {...}のような 制約付きルートの URL ヘルパー 全般- とくに、ID や username に対する正規表現制約を多用するアプリ(Mastodon など)では効果が大きい
- 互換性:
- 正規表現の適用方法は従来と同じ(アンカー付き)で、
「引数が制約に合わないときに発生するエラー」は、メッセージ含めて以前と同じになるよう調整されています。 (:locale)など フォーマット以外のオプションセグメントを持つルートは最適化しない ので、
その種のルートの挙動は完全に従来通りです。
- 正規表現の適用方法は従来と同じ(アンカー付き)で、
- アプリ側の対応:
- 通常は何も変更不要で、そのままパフォーマンス向上の恩恵を受けられます。
- もし以前から URL 生成時の例外メッセージに依存したコードがあっても、
テストで「一般パスと同じメッセージ」であることが保証されています。 - 自作のルーティング拡張(エッジなメタプロ / monkey patch)をしている場合は、
UrlHelper.optimize_helper?周りの挙動変化の影響がないかは一応確認した方が安全です。
- 参考情報 (あれば)
変更ファイル:
actionpack/lib/action_dispatch/routing/route_set.rbUrlHelper/OptimizedUrlHelperと、どのルートを最適化対象にするかのロジックが変更
actionpack/test/dispatch/routing_test.rb- 制約付きルート + 最適化ヘルパーの比較テストが多数追加
ベンチマークコード(PR 記載):
rubyRoutes = ActionDispatch::Routing::RouteSet.new Routes.draw do resources :posts, only: :show, constraints: { id: /\d+/ } get "/@:username/:id", to: "statuses#show", as: :short_account_status, constraints: { username: %r{[^@/.]+}, id: /\d+/ } end H = Routes.url_helpers # objects: GC.stat(:total_allocated_objects) over 1,000 calls # time: best of 5 runs of 100,000 calls
この PR により、「制約付きだが素直な(オプションがない)ルート」の URL ヘルパーは自動的に高速化され、
特に大量に URL ヘルパーを叩くアクションで GC 負荷とレスポンス時間の削減が期待できます。
#59472 Read the user agent's version and browser once in allow_browser
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
allow_browserが内部で利用するBrowserBlockerにおいて、UserAgent から「ブラウザ名」と「バージョン」を何度も再計算していた処理を一度だけにまとめることで、各リクエストあたりのオブジェクト生成数と処理時間を削減するパフォーマンス改善です。Rails が新規生成したアプリのデフォルト設定(allow_browser versions: :modern)で、UA パースのオーバーヘッドを軽減します。
- 変更内容の詳細
背景となる問題
Rails の新規アプリケーションでは、生成される
ApplicationControllerにデフォルトで以下のようなコードが入る想定です:rubyclass ApplicationController < ActionController::Base allow_browser versions: :modern endallow_browserはリクエストごとに UserAgent をパースしてブラウザやバージョンを判定しますが、その内部で使われるUserAgentオブジェクトが、versionを読むたびにバージョン解析をやり直すbrowserを読むたびにブラウザ判定をやり直す
という実装になっており、BrowserBlocker側でも- version を 2 回
- browser を 3 回
読み出していました。
その結果、1 回の
allow_browserチェックでWebkit#applicationが 27 回呼ばれるなど、不要なスキャンが大量に走っていました。
この PR の変更点(ロジック面)
BrowserBlocker 内で、UserAgent からブラウザ名とバージョンを「一度だけ」取得し、それをローカル変数にキャッシュしてから後続の判定に使うように変更しています。
概念的には、以下のようなイメージです(擬似コード):
変更前(イメージ)
def blocked?(user_agent)
# それぞれ内部で UA の products をスキャンする
if user_agent.browser == "Safari"
# ...
end
if user_agent.version < required_version
# ...
end
# 別の条件でも再び user_agent.browser / .version を使う
if user_agent.browser == "Safari" && user_agent.version < another_version
# ...
end
end変更後(イメージ)
def blocked?(user_agent)
browser = user_agent.browser # 一度だけ計算
version = user_agent.version # 一度だけ計算
if browser == "Safari"
# ...
end
if version < required_version
# ...
end
if browser == "Safari" && version < another_version
# ...
end
end実際の差分としては actionpack/lib/action_controller/metal/allow_browser.rb に数行のローカル変数導入が入っており、user_agent.browser / user_agent.version が複数回呼ばれていた箇所を、そのローカル変数参照に置き換えています。
テストの追加
actionpack/test/controller/allow_browser_test.rb にテストが追加されています。
- 目的: 「ブロックされる(拒否される)リクエスト」において、UserAgent の
version参照回数が 1 回browser参照回数が 1 回
になっていることを検証する。
このテストは、main ブランチの時点では
version2 回browser3 回
呼ばれてしまうため失敗し、この PR の変更により通るようになります。
また、allow_browser 関連のテストは Rails CI と同じく warnings as errors 設定で実行されており、Action Pack 全体のテストスイートも問題なくパスすることが確認されています。
- 影響範囲・注意点
パフォーマンスへの影響
計測環境: Ruby 3.4.12, Debian 13, Ryzen 7 8745HS
allow_browser による 1 回のブラウザチェックあたりのオブジェクト数と時間:
| ブラウザ | main | このブランチ |
|---|---|---|
| Safari 18 (macOS) | 300 objects, 36.7 µs | 240 objects, 28.2 µs |
| Chrome 130 (Windows) | 273 objects, 31.4 µs | 221 objects, 27.3 µs |
| Firefox 132 (Linux) | 281 objects, 30.4 µs | 206 objects, 24.6 µs |
| Safari 18 (iPhone) | 315 objects, 43.0 µs | 253 objects, 33.3 µs |
新規 Rails アプリの root ページ (生成されたレイアウト + link 1 つ程度) に対する 1 リクエスト全体では:
- 882 objects → 822 objects / request
と、約 60 個(≒ 7% 弱)のオブジェクト削減になっています。
処理時間も UA チェック分が数 µs 単位で短縮されるため、高トラフィック環境では多少のスループット改善が見込めます。
機能・挙動への影響
allow_browserの外部 API(メソッドシグネチャや設定オプション)には変更なし。- UserAgent パース結果をローカル変数に保持するだけで、ロジック自体は変えていないため、許可/ブロックされるブラウザの判定結果も変わりません。
- キャッシュはブロック処理メソッド内の一時変数レベルであり、スレッドセーフティや共有状態の問題も生じません。
注意点
UserAgentオブジェクトのbrowser/versionが呼び出しごとに違う値を返すような前提(内部状態を書き換える、lazy に変化する等)に依存している場合は挙動が変わる余地がありますが、通常の使い方かつ現在のUserAgent実装では想定されないケースです。- 変更対象は Action Pack の
allow_browser実装に限定されているため、UserAgentをアプリ側で直接利用しているコードには影響しません。
- 参考情報 (あれば)
変更ファイル:
actionpack/lib/action_controller/metal/allow_browser.rbBrowserBlocker内でuser_agent.browser/user_agent.versionをローカル変数にキャッシュする変更。
actionpack/test/controller/allow_browser_test.rbbrowser/versionの参照回数を検証するテストの追加。
実運用上のインパクト:
- 新規アプリのデフォルト設定で、全リクエストが
allow_browserを通過するため、アプリ規模が小さいほどこの最適化のメリットが相対的に大きくなります(他処理が重くなってくると相対的な比率は下がるが、それでも無駄な UA パースを減らせる利点は残る)。 - レイテンシや GC 負荷を少しでも下げたい API サーバなどでも地味に効くタイプの最適化です。
- 新規アプリのデフォルト設定で、全リクエストが
#59478 ActiveSupport::Duration: precompute float versions of constants
マージ日: 2026/10/31 | 作成者: @byroot
- 概要 (1-2文で)
ActiveSupport::Duration が内部で使っている時間定数(1分=60秒、1時間=3600秒、1日=86400秒など)について、「float 版」をあらかじめ計算しておくことで、to_f呼び出しを減らし、Duration 周りの浮動小数演算を高速化するパフォーマンス改善の PR です。元 PR #59266 の修正として入り、同等の計算結果を保ったまま割り算やfdivのコストを下げています。
- 変更内容の詳細
何をしたか
ActiveSupport::Duration内部で使っている時間関連の整数定数に対して、対応するFloat版の定数を追加し、それを使うように変更しています。- 例:
- これまで:
SECONDS_PER_DAY = 86_400を使い、必要に応じてSECONDS_PER_DAY.to_fを呼んでいた - 変更後:
SECONDS_PER_DAY_F = 86_400.0のような float 定数を定義し、最初から float 同士の演算を行う
- これまで:
- 例:
その結果、
Duration#in_hours,Duration#in_days,Duration#parts内部などで行われるn / SOME_INT.to_fn.fdiv(SOME_INT)
のようなパターンが、
n / SOME_FLOATn.fdiv(SOME_FLOAT)
に置き換わる(あるいは Ruby がより最適化しやすい形になる)ように調整されています。
なぜそれで速くなるのか
PR 内のベンチマークは「整数定数を都度 to_f する場合」と「最初から float 定数を使う場合」を比較しています。
ベンチマークコードの本質は次の通りです:
n = 100000
d = 86400 # Integer
f = 86400.0 # Float
n/d.to_f # パターン1: Integer 割り算 + to_f
n/f # パターン2: Float との割り算
n.to_f/f # パターン3: 先に n を to_f
n.fdiv(d) # パターン4: Integer.fdiv(Integer)
n.fdiv(f) # パターン5: Integer.fdiv(Float)計測結果から分かること:
- 4.1-dev / 4.0 系いずれの Ruby でも、
/to_f(整数で割ってからto_f)より、最初から float を使う/f,fdiv(f)の方が 1.1〜1.3倍程度高速 - Ruby 4.0.7 では
fdiv(Integer)が特に遅く、fdiv(Float)が最速 - Ruby 4.1-dev では
fdiv(Integer)もだいぶ速くなっているが、それでも「都度to_f」より「最初から float」の方が一貫して速い
→ つまり、「定数側をあらかじめ float にしておき、演算時に to_f を呼ばないようにする」ことで、Duration 内部の計算コストを削減できる、というのがこの PR の狙いです。
- 影響範囲・注意点
影響範囲
- 影響を受けるのは、ActiveSupport::Duration が内部で秒換算などを行う箇所です。
- 例:
5.minutes.to_f,1.hour.to_f,1.day.to_f2.days / 3のような計算で内部的に行われる秒換算duration.in_hours,duration.in_daysなどの内部実装
- 例:
- API の挙動(戻り値の型や見た目)は変わらない想定です。
- 元々 float を返していたところは引き続き float を返し、計算結果の「人間が見て分かるレベル」の値も変わらないように設計されています。
注意点(浮動小数の誤差について)
- この PR は「整数 → float への変換タイミング」を変えているだけで、整数演算を float 演算に置き換えるような大胆な変更ではありませんが、
- 既存でも float を使っていた箇所について、「to_f の場所」が変わるため、strict equality (
==でのビットレベル同一期待) をしているような 極端に厳しいテスト がある場合は、誤差によりごく小さな差が出る可能性があります。
- 既存でも float を使っていた箇所について、「to_f の場所」が変わるため、strict equality (
- 通常の Rails アプリで、
Duration#to_fやin_hoursなどの結果をそのまま UI 表示やおおまかな計算に使っている範囲では問題にならないはずです。
パフォーマンス的なインパクト
- 変更は activesupport の 1 ファイル・十数行レベルの軽微なもので、互換性を崩さず局所的に高速化しています。
- Duration の計算が非常に頻繁に走るようなアプリケーション(大量の時間計算・スケジューリング・ログ集計など)では、微小ですが GC や CPU 使用率の観点でプラスに働く可能性があります。
- 参考情報 (あれば)
- 元 issue / PR
- 本 PR が修正している元 PR: https://github.com/rails/rails/pull/59266
- Ruby の数値演算最適化の文脈
- Ruby 3.2 以降、
fdivや除算の最適化、YJIT による数値演算高速化が進んでおり、「整数 to float 変換の回数を減らしておく」ことはベンチマークでも度々言及されています。
- Ruby 3.2 以降、
- PR で使われたベンチマークパターンは、自身のアプリで「to_f の位置を変えたらどの程度影響が出るか」を見る際の参考にもなります。
#59294 Forward arguments from HashWithIndifferentAccess#merge to update
マージ日: 2026/10/31 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
ActiveSupport::HashWithIndifferentAccess#mergeが、可変長引数の受け渡し方法を従来の「配列にまとめてから splat」でなく、Ruby 3.4 以降で効率的に動作する「...による引数転送」に書き換えられました。これにより一部 Ruby バージョンで割り当て(allocation)削減とわずかな性能改善が見込まれます。
- 変更内容の詳細
何を変えたか
対象:activesupport/lib/active_support/hash_with_indifferent_access.rb
HashWithIndifferentAccess#merge メソッド内で update を呼び出す部分の引数の渡し方のみが変更されています。
もともとの典型的なイメージは次のような形です(擬似コード):
def merge(*args, &block)
dup.update(*args, &block)
endこれを「引数転送演算子 ...」を用いた形に変更しています:
def merge(...)
dup.update(...)
end※ 実際のコードは多少文脈が異なるかもしれませんが、変更の本質はこの「*args + *args での再スプラット」から「... でそのまま転送」にする点です。
なぜ変えたか
従来の実装では:
mergeが*argsで引数を一度配列にまとめるupdate(*args)で再度それをスプラットしupdate側の*restで再び配列として受け取る
という二重の配列生成が発生していました。
Ruby 3.4 以降では def foo(...); bar(...); end のような「... による引数転送」は、呼び出し元の引数配列を再生成せずに渡せるよう最適化されているため、余分な配列割り当てを避けられます。
Ruby 3.3 では挙動・オーバーヘッドはほぼ変わりませんが、コードとしても意図が明確になります。
ベンチマーク結果(PR 説明より)
hwia.merge(hash) に対する 50,000 回 × 15 バッチの計測:
- Ruby 3.3.5
- allocations: 4 → 4(変化なし)
- 947ns → 929ns(わずかに速い)
- Ruby 3.4.0-preview 以降相当(4.0.7, 4.1-dev 等)
- allocations: 4 → 3(1つ減少)
- 時間も数 % 程度の改善
YJIT では現状 send_forwarding のフォールバックが多発するため、merge 呼び出しごとのフォワーディングが最適化されておらず、約 3% 程度遅くなるケースが観測されています。一方、インタプリタと ZJIT では僅かながら高速化が見られます。
- 影響範囲・注意点
挙動の互換性
- 公開 API(
HashWithIndifferentAccess#mergeの振る舞い)は変更されていません。- 受け取れる引数の種類・個数
- ブロックの扱い
- 戻り値 いずれもこれまでと同じです。
- そのため、通常のアプリケーションコードから見たときの互換性リスクは極めて低いです。
パフォーマンス面
- Ruby 3.4 以降:
HashWithIndifferentAccess#mergeに対するアロケーションが 1 つ減る想定。- 大量に
mergeを呼び出す処理(例:パラメータのマージを繰り返す API 層など)では、微小ながら GC プレッシャーの削減・処理時間の改善が期待できます。
- Ruby 3.3 系:
- アロケーション数は変わらず、性能差もごく小さい(計測上は誤差レベルの改善)。
- YJIT 利用時:
- 現状の YJIT は
...のフォワーディングコールをコンパイルしていないため、HashWithIndifferentAccess#mergeに対して統計上はsend_forwardingのフォールバックカウントが増えます。 - 説明では 3% 程度の遅さが観測されていますが、これは非常にマイクロなベンチマーク環境での数字であり、実アプリケーションでは他の要因に埋もれる可能性が高いです。
- 将来の Ruby / YJIT 側の最適化で改善される見込みがあります。
- 現状の YJIT は
その他の注意点
- 変更ファイルは 1 ファイル・4 行(+2/-2)のみであり、テストスイート(Active Support)は通過済みです。
- バグフィックスや表向きの挙動変更ではないため、CHANGELOG の更新は行われていません。
- 参考情報 (あれば)
- PR 本体: https://github.com/rails/rails/pull/59294
- 関連トピック:
- Ruby の引数転送 (
def method(...); other(...); end) に関する最適化は Ruby 3.4 以降で導入・強化中。 - 同作者による類似の最適化 PR(特に
#59293)も、HashWithIndifferentAccess周辺や Active Support の内部での引数転送パターンの最適化を目的としていると考えられます。
- Ruby の引数転送 (
#59320 Ignore blank forwarded hosts during request host extraction
マージ日: 2026/10/31 | 作成者: @svyatov
- 概要 (1-2文で)
Rails のActionDispatch::Request#hostがX-Forwarded-Host: ,のような「空のフォワードホスト」ヘッダでNoMethodErrorを起こしていた不具合を修正し、空の値は無視してHTTP_HOSTなどの従来ロジックにフォールバックするようにした PR です。これにより、HostAuthorization を含むリクエスト処理が壊れにくくなり、非標準的・壊れたプロキシヘッダにもより強くなります。
- 変更内容の詳細
問題の背景
現在の(PR 適用前の)ActionDispatch::Request#raw_host_with_port は概ね以下のような動きをしていました(PR 説明の「OriginalRequest」と同等):
def raw_host_with_port
@raw_host_with_port ||= if forwarded = x_forwarded_host.presence
forwarded.split(/,\s?/).last
else
get_header("HTTP_HOST") || "#{server_name}:#{get_header('SERVER_PORT')}"
end
endここで問題となるケース:
- リクエストヘッダ:
X-Forwarded-Host: ,x_forwarded_hostは空文字列扱いになる(header は存在するが中身が「,だけ」など)presenceの扱い・split の結果として「最終的に空文字 or 何も無い」状態になりうる- その結果
raw_host_with_portがnilを返し、最終的にRequest#hostの中でNoMethodError(nilに対してメソッド呼び出し) が発生 - 同様に HostAuthorization ミドルウェアも、許可されたホストを記録する際に例外を起こすことがある
HTTP 的には壊れた・非標準的なヘッダですが、Rails としては「異常系ヘッダでアプリケーションが 500 エラーになる」のを避けたい、というのがこの PR のモチベーションです。
今回の修正内容
PR では「パース後に空になってしまった X-Forwarded-Host を無視し、HTTP_HOST などにフォールバックする」というチェックを追加しています。
おおまかな挙動は以下のようになります(擬似コードレベルの説明):
def raw_host_with_port
@raw_host_with_port ||= begin
forwarded = x_forwarded_host.presence
if forwarded
parsed = forwarded.split(/,\s?/).last
if parsed.present? # ← 新たに「パース後も空かどうか」を確認
parsed
else
# パース結果が空なら Forwarded Host を無視し、HTTP_HOST などへフォールバック
get_header("HTTP_HOST") || "#{server_name}:#{get_header('SERVER_PORT')}"
end
else
# そもそも X-Forwarded-Host が無い or 完全に空の場合は従来どおり
get_header("HTTP_HOST") || "#{server_name}:#{get_header('SERVER_PORT')}"
end
end
endPR 説明中の具体例:
X-Forwarded-Host: ,
Before: raw_host_with_port => nil; host raises NoMethodError
After: raw_host_with_port => original Host; host and port parse normallyつまり、
X-Forwarded-Hostがカンマだけ、空白だけ、最後のエントリが空、などのケースでは- 「Forwarded Host としては無効」とみなし、
HTTP_HOST(またはSERVER_NAME:SERVER_PORT)にフォールバック
- 有効な値が入っている通常ケース(
example.com,example.com:3000,first, second:3000など)では、これまで通りX-Forwarded-Hostが優先される
テストの追加
以下のファイルにテストが追加されています。
actionpack/test/dispatch/request_test.rb- カンマだけの
X-Forwarded-Host - 空白だけのリスト
- 最終エントリが空のリスト
- ホスト名とポートのパース などをカバー
- カンマだけの
actionpack/test/dispatch/host_authorization_test.rb- HostAuthorization ミドルウェアに対して:
- 空/カンマだけの
X-Forwarded-Hostでも許可ホストなら問題なく通過する - 禁止ホストなら従来どおりブロックされる といったケースを確認
- 空/カンマだけの
- HostAuthorization ミドルウェアに対して:
いずれも「元々の host 認可ロジックは変えないが、空の Forwarded Host のせいで例外だけ出る」という状況を防ぐための回帰テストです。
- 影響範囲・注意点
影響範囲
- 対象:
ActionDispatch::Request#host/#raw_host_with_portX-Forwarded-Hostを見るあらゆるミドルウェア・アプリケーションコード- 例:
ActionDispatch::HostAuthorization
- 例:
- 具体的な影響:
- 空(あるいはカンマだけ・空白だけなど)の
X-Forwarded-Hostヘッダが来たときに- 以前:
NoMethodErrorで 500 エラーになる可能性 - 以後: ヘッダを無視し、
HTTP_HOSTなどのオリジナルホストにフォールバック
- 以前:
- 空(あるいはカンマだけ・空白だけなど)の
- Forwarded Host が「正しく設定されている」環境の挙動は変わりません。
既存アプリへの影響・互換性上の注意
- 多くの正しいリバースプロキシ設定では:
- もともと
X-Forwarded-Hostは空ではないため、この変更による挙動差はありません。
- もともと
- 一部の壊れた/怪しいプロキシが
X-Forwarded-Host: ,のようなヘッダを送っていた場合:- これまでは「Forwarded Host を優先しつつ、結果として例外・500 になっていた」可能性
- 今後は「Forwarded Host を無視し、オリジナルの
HTTP_HOSTに基づく挙動」になります
- もしアプリ側で独自に
request.env["HTTP_X_FORWARDED_HOST"]をパースしている場合は、この PR の変更は直接は関係ありません。ただし、- Rails の
request.hostとアプリ独自ロジックで Forwarded Host の解釈が食い違う可能性があるため、Forwarded 系ヘッダを扱うコードはこの挙動に合わせて整理しておくとよいです。
- Rails の
セキュリティ的な観点
- この PR は「許可/禁止判定を変える」のではなく
- 「Forwarded Host が壊れていても例外で落ちないようにする」ための堅牢性向上が主眼です。
- HostAuthorization のロジック自体(どのホストを許可とみなすか)は変更されておらず、
- 修正後も、Forwarded Host に有効な値が入っていれば従来どおりそれが優先されます。
- 逆に言うと、「空または空白だけの
X-Forwarded-Hostでホスト偽装を狙う」といった攻撃ベクトルは- この修正により「エラーにはならないが、偽装にもならずオリジナルホストになる」という挙動になります。
- 参考情報 (あれば)
- 関連 PR: #19433
- 「ヘッダ自体が空」のケースを扱った既存の修正があり、本 PR はそのロジックを「パース後に空になってしまうケース」にまで拡張した位置づけです。
- パフォーマンス:
- PR に添付されたマイクロベンチマークでは、標準的なケース(Host のみ、Host+Port、Forwarded Host のみ、Forwarded chain)はほぼ同等またはわずかに改善。
- 空の Forwarded ヘッダに対しては若干遅くなりますが、実運用ではレアケースと想定され、全体スループットへの影響は実質無視できるレベルです。
- 適用に際し:
- 追加の設定・マイグレーション・依存関係変更は不要。
- Rails をこのコミットを含むバージョンに上げるだけで利用できます。
#59374 Write Duration#iso8601 values as plain decimal numbers
マージ日: 2026/10/31 | 作成者: @wleeaf
- 概要 (1-2文で)
ActiveSupport::Duration#iso8601 が、常に ISO 8601 準拠かつ ActiveSupport::Duration.parse で再パース可能なフォーマットになるように修正した PR です。浮動小数点数や Rational を含む duration でも、指数表記や分数表記ではなく「通常の 10 進小数」で出力されるようになります。
- 変更内容の詳細
問題点
従来の ActiveSupport::Duration#iso8601 は内部パーツをそのまま to_s していたため、以下のような問題がありました。
- Float は
Float#to_sにより指数表記になることがある- 0.0001 未満の値、非常に大きな値など
- Rational は
Rational#to_sにより「分数」として出力される
結果として、ISO 8601 として不正な文字列が生成され、ActiveSupport::Duration.parse で読めないケースがありました。
0.00005.seconds.iso8601 # "PT5.0e-05S" # ISO 8601 的には不正
1e16.seconds.iso8601 # "PT1.0e+16S"
Rational(3, 2).days.iso8601 # "P3/2D"
ActiveSupport::Duration.parse("PT5.0e-05S")
# => ActiveSupport::Duration::ISO8601Parser::ParsingErrorこのような値は実際に発生し得ます:
- 非常に短い経過時間測定:
(t1 - t0).secondsがマイクロ秒オーダー DateTime - DateTimeがRational日数で返るケース
対応内容
ISO8601Serializer 内に format_number ヘルパーを導入し、duration の各構成要素の数値出力を一元化しました。
ポイント:
Float の扱い
BigDecimal(float.to_s).to_s("F")で「指数表記を使わない 10 進小数」に変換- 桁は
Float#to_sと同じ精度を維持しつつ、表記だけをプレーン小数にする
Rational の扱い
- 一旦 Float に変換してから上記の Float 経路で出力
- ISO 8601 の「P3/2D」のような分数表記を廃止し「P1.5D」のような小数表記に統一
Integer / BigDecimal / precision オプション経路
- これらは従来どおりの扱いを維持し、今回の変更は Float / Rational に限定
修正後の挙動例
0.00005.seconds.iso8601 # => "PT0.00005S" # 指数表記ではなく小数
1e16.seconds.iso8601 # => "PT10000000000000000.0S"
Rational(3, 2).days.iso8601 # => "P1.5D"従来エラーになっていたものも、ISO 8601 的に妥当な形式になり、パースできるようになります。
テストの追加・変更
activesupport/test/core_ext/duration_test.rb の test_iso8601_output にテストケースを追加・強化:
- 追加ケース
- 秒単位の小さな Float (
0.00005.seconds) - 時間単位の小さな Float
- Rational 日数 (
Rational(3, 2).days) - 時間 + Rational 秒 (混在ケース)
- 秒単位の小さな Float (
- 各
iso8601出力がActiveSupport::Duration.parseでパースでき、元の duration と 等価であること を確認するようにテストを拡張
これらのテストは本 PR の変更なしでは失敗し、変更後は通ることが確認済みです。
また、テストスイート全体をある程度走らせた結果、main と同じ既知の失敗のみで、新規の失敗は増えていないことが報告されています (主にタイムゾーン関係のテスト)。
CHANGELOG
activesupport/CHANGELOG.md に今回の仕様変更が追記されています。
(バグ修正だが挙動が変わるため、利用者向けに通知されるべき変更として扱われている)
- 影響範囲・注意点
- ISO 8601 文字列のフォーマットが変わる
- 以前:
"PT5.0e-05S","P3/2D"などが出ることがあった - 以後:
"PT0.00005S","P1.5D"などプレーンな 10 進小数に統一
- 以前:
ActiveSupport::Duration.parseとの往復互換性が向上Duration#iso8601→Duration.parseのラウンドトリップでエラーになっていたケースが解消される
- 過去の出力に依存したコードへの影響
- 文字列フォーマット (指数表記 / 分数表記) に強く依存したパース・比較ロジックがある場合は影響を受ける可能性があります。
- 例:
"P3/2D"を前提として自前パーサを書いている、指数表記を期待している等
- 例:
- ただし、ISO 8601 準拠の一般的なパーサ (数値部が 10 進小数であることを想定しているもの) にはむしろ互換性が高まる変更です。
- 文字列フォーマット (指数表記 / 分数表記) に強く依存したパース・比較ロジックがある場合は影響を受ける可能性があります。
- 数値の丸め・精度に関する注意
- Rational は Float を経由するため、理論上は Float 変換に伴う丸めの影響を受けます。
- とはいえ、もともと
DateTime - DateTimeが Rational になる程度の文脈であり、かつ元々 ISO 8601 として不正な分数表記だったため、実務上は「妥当な仕様変更」と考えられます。 - Float 部分は
Float#to_s相当の精度なので、以前と比較して「値そのものの精度」が落ちる変更ではなく、「表記の仕方」だけを変えています。
- 参考情報 (あれば)
- 関連 Issue / PR
- #42344, #42348: 大きな整数に対する ISO 8601 出力の修正 (今回の変更はその残りのケース対応)
- 影響する主なユースケース
- 計測時間など短時間の duration を ISO 8601 でログ・API 返却するアプリ
DateTime差分を ISO 8601 で表現・保存し、後でDuration.parseで復元したいワークフロー
- 実装の要点
ISO8601Serializerにformat_numberを導入し、Float/Rational → BigDecimal → "F" 形式の文字列、というパスで一貫した 10 進小数表記を実現している点がコアの変更です。
#59375 Set Vary to Sec-Fetch-Site directly when it is empty
マージ日: 2026/10/31 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
ActionController::Baseの CSRF 対策用after_actionがVaryヘッダにSec-Fetch-Siteを追加する処理について、Varyが空のケースでは文字列操作を行わず、直接"Sec-Fetch-Site"をセットするようにしてオブジェクト生成と処理時間を削減するパフォーマンス改善のPRです。既にVaryが存在する場合の挙動は変えず、空の場合のみ最適化しています。
- 変更内容の詳細
背景
- Rails の CSRF 対策 (
ActionController::RequestForgeryProtection) では、ブラウザから送られるSec-Fetch-Siteヘッダに基づく保護を行うため、after_actionで全レスポンスにVary: Sec-Fetch-Siteを付与しています。 - これまでの実装は、「現在の
Varyを取得 →split(',')→strip→uniq→"Sec-Fetch-Site"を足してjoin(', ')」という形で、たとえVaryがまったく設定されていない(空文字/nil)場合でも同じ処理を通していました。 - 多くのレスポンスではそもそも
Varyが設定されていないため、「Sec-Fetch-Siteだけを付けたいだけなのに、毎回複数の文字列や配列を生成している」という無駄なアロケーションが発生していました。
何を変えたか
append_sec_fetch_site_to_vary_header(名称はPR説明より推定)内で、レスポンスにVaryがまだ存在しない/空である場合は、直接response.headers["Vary"] = "Sec-Fetch-Site"と代入するように変更。Varyが既に存在する場合は、従来通り- カンマ区切りで分割
- 空要素除去・空白除去
Sec-Fetch-Siteを重複しないように追加- 再びカンマ区切り文字列として結合 という既存ロジックを使い続けます。
コードイメージ(あくまで説明用の擬似コード):
def append_sec_fetch_site_to_vary_header
vary = response.headers["Vary"]
if vary.blank?
# ここが今回の最適化
response.headers["Vary"] = "Sec-Fetch-Site"
else
# 従来通りの安全なマージ処理
values = vary.split(",").map { _1.strip }.reject(&:empty?)
values << "Sec-Fetch-Site" unless values.include?("Sec-Fetch-Site")
response.headers["Vary"] = values.join(", ")
end
endActionController::Rendering が "Accept" ヘッダに対して行っている最適化と同様のパターンを、Sec-Fetch-Site に対して適用した形です。
計測結果
単一ファイル構成の production アプリ (Ruby 4.0.7, 全レスポンスで
Varyが未設定) での 1 リクエストあたりのオブジェクトアロケーション削減:GET /posts.json: 1073.1 → 1067.1- HTML show: 677.2 → 671.4
- HTML index (20件): 2360.0 → 2353.3
append_sec_fetch_site_to_vary_header単体ベンチ (Ruby 4.1.0dev, 200,000回×15バッチ×before/after 交互で測定):Varyがない場合:- interpreter: 928ns (7 objects) → 587ns (1 object)
- YJIT: 581ns → 256ns
- ZJIT: 635ns → 314ns
Vary: Acceptがある場合は、ほぼ同等で僅差。新旧どちらが速いかはケースにより分かれるが誤差レベル。
- 影響範囲・注意点
機能面の変更は実質ゼロ
Varyが空: 以前も結果は"Sec-Fetch-Site"だけだったため、最終的なレスポンスヘッダは同じ。Varyが存在する: 既存コードパスを通るため動作は変わらない。- そのため、アプリ側から見た挙動変更・互換性問題は基本的にありません。
パフォーマンスへの影響
ActionController::Baseを継承する全コントローラに対し、CSRF 保護が有効なアクションでは毎回呼ばれるため、トラフィックの多いアプリ/API ほど恩恵があります。- 特に
Varyを明示的に使っていないアプリでは、ほぼすべてのレスポンスでこの最適化が効きます。
テスト状況
- Action Pack のテストスイート(システムテストを除く)がパスしているとのこと。
- PR時点ではこの変更専用の追加テストは書かれておらず、「外部挙動は変えていない」という前提で既存テストに頼っている状態です。
将来の拡張時の注意
- 今後、
Varyを操作する別のミドルウェア/フィルタを追加する場合も、このロジックは「Varyが空ならセット、そうでなければマージ」という形なので、挙動として自然です。 - ただし、もしアプリ側で
Varyをnilや空文字に明示的に書き換えるようなコードがある場合、その後にこのフィルタが走るとSec-Fetch-Siteだけが再セットされる、という点は頭に入れておくとよいです(これは以前から同様の結果になっていたはずです)。
- 今後、
- 参考情報 (あれば)
- 類似実装:
ActionController::RenderingがVary: Acceptをセットする際の最適化パターンを踏襲。 - ベンチマーク結果から、特に
Varyが未設定である典型ケースにおいて、オブジェクトアロケーション数・処理時間ともに目に見えて改善していることが示されています。 - CSRF 保護と
Sec-Fetch-Siteヘッダの関係については、Rails ガイドの「セキュリティ」「クロスサイトリクエストフォージェリ (CSRF)」の章や、ブラウザ Fetch Metadata (Sec-Fetch-*ヘッダ群) のドキュメントを参照すると背景理解が深まります。
#59373 Don't build an asynchronous queries session until it's needed
マージ日: 2026/10/31 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
Active Record の非同期クエリ用セッション(AsynchronousQueriesTracker)が、リクエストやジョブごとに必ずフル構築されていたのを「実際に非同期クエリが使われたときにだけ本体を構築する」ように遅延初期化した PR です。これにより、非同期クエリを使わない大多数のケースでオブジェクト生成コストと実行時間が削減されます。
- 変更内容の詳細
背景
- Active Record の executor hook(リクエスト/ジョブ実行前後で呼ばれる部分)が、毎回
AsynchronousQueriesTrackerのセッションを開始していました。 - セッション開始時に
Concurrent::AtomicBooleanやConcurrent::ReadWriteLockなど、合計 21 個のオブジェクト(うち 6 個の mutex, 3 個の condition variable)が生成されます。 - しかし、これを実際に利用するのは
load_asyncなどの「非同期クエリ API」を使った場合のみで、ほとんどのリクエスト・ジョブでは作ってすぐ破棄されていました。
変更の要点
start_sessionで「実体のセッション」ではなく「プレースホルダー」を push するように変更。- 実際のセッション(非同期クエリを追跡するためのオブジェクト群)は、
current_sessionが最初に呼ばれたタイミングで初めて構築(遅延初期化)されます。 - 一度もセッション本体が構築されなかった場合(つまり非同期クエリが 1 回も呼ばれなかった場合)は、
finalize_sessionでは単にスタックからプレースホルダーを pop するだけで終了します。 - セッションはスタック構造を維持しており、ネストした executor 実行や再入可能な実行(re-entrant runs)も従来通り動きます。
start_sessionは依然としてスタック(あるいはそのハンドル)を返すため、TestFixturesが「セッションが始まっていること」を示すフラグとして利用する挙動もそのままです。
コード自体の差分は asynchronous_queries_tracker.rb における 4 行追加 / 3 行削除のみと非常に小さく、実装としては「セッションオブジェクトの生成をラップし、最初のアクセス時まで遅らせる」程度の変更と考えられます。
- 影響範囲・注意点
パフォーマンスへの影響
作者提供のベンチマークから:
1ファイルの production アプリ (Ruby 4.0.7) での 1 リクエストあたりのオブジェクトアロケーション数:
ActionController::APIJSON show: 357 → 337- HTML index (20 records): 2360 → 2339
- PATCH update + redirect: 823 → 803
→ 非同期クエリを使わない典型的なリクエストでも 20 個前後のアロケーション削減が確認されています。
AsynchronousQueriesTracker.run+completeを 1 executor サイクルとして計測 (Ruby 4.1.0dev):- interpreter: 3704 ns → 347 ns
- YJIT: 2118 ns → 182 ns
- ZJIT: 2064 ns → 171 ns
→ 非同期クエリセッションまわりのオーバーヘッドが 10x 以上削減されており、executor hook 周辺のコストがかなり下がっています。
機能面の影響
- 非同期クエリ API (
load_async等) の挙動自体は変わらない想定です。- 非同期クエリを使った場合は、初回アクセス時にセッションが作られ、以後は従来通りキャンセル/待機が行われます。
- 非同期クエリを 1 度も使わなかった executor 実行では、セッション実体が作られないため、
finalize_sessionは空のプレースホルダーを pop するだけになりますが、- 「キャンセルすべきクエリも待つべきクエリもない」状態なので、意味的にも問題はありません。
既存コードへの注意点
通常の Rails アプリ開発者目線では、以下の通り互換性リスクはほぼありません。
AsynchronousQueriesTrackerの内部実装に依存しているようなメタプログラミング/モンキーパッチをしていない限り、アプリケーションコード側の修正は不要です。- 非同期クエリセッションが「executor 開始時にすぐ構築されている」ことを前提にしているテストやライブラリがある場合は、挙動が変わる可能性がありますが、そのような依存はかなりレアケースと思われます。
- Active Record の SQLite / PostgreSQL スイートは通過しているため、主要 DB + 非同期クエリの基本的な挙動は担保されています。
- 参考情報 (あれば)
- 対象ファイル:
activerecord/lib/active_record/asynchronous_queries_tracker.rb - 変更規模: 1ファイルのみ、+4 / -3 と小さな差分だが、executor hook 経由で全リクエスト・ジョブに影響するため、ランタイムコスト改善効果は相対的に大きい変更。
- この PR は CHANGELOG 更新なし(挙動は互換を保ったパフォーマンス改善扱い)。
#59376 Don't parse an empty query string
マージ日: 2026/10/31 | 作成者: @sampokuokkanen
- 概要 (1-2文で)
Request#GETがクエリ文字列を持たないリクエストでも毎回クエリ解析処理を走らせていたのをやめ、クエリ文字列がnilまたは空文字のときは即座に空の params ハッシュを返すようにして、無駄なオブジェクト生成と処理時間を削減した PR です。機能的な挙動はほぼ変えず、パフォーマンスとメモリ効率を改善する目的の変更です。
- 変更内容の詳細
何が問題だったか
- Rails の
ActionDispatch::Request#GETは、HTTP リクエストのクエリパラメータをParamBuilder.from_query_string(query_string)を通じて毎回構築しています。 - しかし、クエリ文字列が
- そもそも存在しない (
nil) - 空文字 (
"") の場合でも、 QueryParser.each_pair用の Enumerator を生成し- 空文字列の
splitなどを行った後、結局「空の Hash」を返すだけ、という無駄な処理が走っていました。
- そもそも存在しない (
今回の変更点
actionpack/lib/action_dispatch/http/param_builder.rbに、クエリ文字列が空のケースに対するショートサーキットを追加しています(+2行のみの変更)。- 処理イメージとしては次のような形です(実際のコードとは若干擬似コードですが、意図としてはこんな感じです):
module ActionDispatch
class Http::ParamBuilder
def self.from_query_string(query_string)
# ここが今回のポイント
return HashWithIndifferentAccess.new if query_string.nil? || query_string.empty?
# 従来通りのパース処理
params = HashWithIndifferentAccess.new
QueryParser.each_pair(query_string) do |key, value|
# ... パラメータの構築 ...
end
params
end
end
end- つまり、「空のクエリ文字列であれば、パーサ周りには一切触れずに、その場で新しい
HashWithIndifferentAccessを返す」ようにしています。 - 返しているオブジェクトはこれまでと同じく
- 新しいインスタンス
HashWithIndifferentAccess- ミュータブル(後から
params[:foo] = "bar"などが可能) であり、既存コードの利用パターンと互換性があります。
ベンチマーク結果
単一ファイル構成の production 環境アプリ(Ruby 4.0.7)での 1リクエストあたりアロケーション:
request before after ActionController::APIJSON show357 354 GET /posts.json1073.1 1070.1 数値差は小さいですが、「クエリなしの大量アクセス」がある場合、積み上がりで効いてくるタイプの最適化です。
ParamBuilder.from_query_string単体のベンチ(Ruby 4.1.0dev):- クエリが空文字
""の場合は、Interpreter/YJIT/ZJIT いずれも「常に after が速い」(10/10 ラウンド)。 - 通常のクエリ (
page=2&q=rails) については性能はほぼ同等(ごくわずかな差で前後するレベル)で、空クエリ向け最適化によるデメリットはほぼなし。
- クエリが空文字
- 影響範囲・注意点
影響範囲
- 対象:
ActionDispatch::Request#GET(= Rack 環境からクエリパラメータを取得する処理)に依存するすべてのコントローラ・ミドルウェア。- ただし、クエリがないケースの挙動は「空の params ハッシュを返す」という点で以前と同じです。
- 実質的な挙動変更:
- ありません。返るオブジェクトの種類と性質(
HashWithIndifferentAccess/ 新規インスタンス / ミュータブル)は変わりません。 - 違いは「内部的な無駄なパース処理が走らなくなった」ことだけです。
- ありません。返るオブジェクトの種類と性質(
注意点
- 空クエリの場合に「パラメータパーサが必ず呼ばれる」ことに依存しているようなコードがあれば理論上は影響しますが、
- そういった依存は本来内部実装に対する過度な依存であり、
- 通常のアプリケーション・ライブラリではまず想定されません。
- テスト:
- Action Pack のテストスイートは(ブラウザドライバが必要な system test を除き)通過。
- この PR 自身で新規テスト追加はしていませんが、「仕様変更」というより「最適化」であるため、既存テストの通過をもって安全性を確認しています。
- CHANGELOG:
- 性能改善・内部実装の最適化にとどまり、外部 API の仕様変更はないため、CHANGELOG は未更新です。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59376
- 関連クラス:
ActionDispatch::RequestActionDispatch::Http::ParamBuilderActionDispatch::Request::QueryParser(クエリパースの内部実装)
- 主なメリット:
- クエリなしのリクエストが多数ある API サーバや SPA バックエンドなどで、1リクエストあたりのオブジェクトアロケーション数と CPU 時間をわずかに削減。
- 内部実装が「入力に対して自然な挙動」(空入力には即座に空結果を返す)に近づき、理解しやすくなる。
#59381 Backport to 8-1 exif orientation bugfix
マージ日: 2026/10/31 | 作成者: @g-pavlik
- 概要 (1-2文で)
Rails 8.1 ブランチに対して、Active Storage の画像解析処理における EXIF Orientation 関連のバグ修正(#57290)がバックポートされた PR です。ImageMagick・Vips 双方のアナライザで EXIF の回転情報を正しく扱えるようにし、その挙動をテストとフィクスチャで保証しています。
- 変更内容の詳細
主なポイント
- 元々 main(または 8-2 以降)に入っていた EXIF Orientation のバグ修正 (#57290) を 8-1 ブランチに「チェリーピック」した PR。
- 対象は Active Storage の画像アナライザ:
ActiveStorage::Analyzer::ImageAnalyzer::ImageMagickActiveStorage::Analyzer::ImageAnalyzer::Vips
- 回転・反転(mirror/flip)を含む EXIF Orientation を正しく解釈し、画像のメタ情報(幅・高さなど)が正しく取得されるようにする修正。
コードレベルの変更(推測を含む解説)
実際の差分は 1 行置き換えレベルですが、典型的には次のような修正が入ります(イメージ):
# 例: image_magick.rb 側での orientation の扱い
identify = MiniMagick::Tool::Identify.new
identify.format "%w %h %[EXIF:Orientation]" # 幅・高さと一緒に Orientation を取得
# ...
orientation = exif_orientation_value # 1〜8 など
rotated_width, rotated_height = normalize_dimensions(width, height, orientation)vips.rb でも同様に、Vips ライブラリを使った解析時に EXIF Orientation を考慮して、最終的に返す metadata の width / height が「実際に正しい向きで見たときのサイズ」になるようにしています。
テストとフィクスチャ
- 追加されたテスト:
activestorage/test/analyzer/image_analyzer/image_magick_test.rb(+20)activestorage/test/analyzer/image_analyzer/vips_test.rb(+20)
これらは主に以下を検証していると考えられます:
- EXIF Orientation を持つ JPEG を解析した結果の width / height が期待通りであること
- 反転・回転された画像(鏡像+回転)でも正しい値が返ること
追加されたフィクスチャ:
racecar_mirrored.jpgracecar_mirrored_rotated.jpg
これらの画像に EXIF Orientation がセットされており、
mirrored: 左右反転のみmirrored_rotated: 左右反転+回転(たとえば 90° 回転)
のようなパターンをカバーすることで、以前バグが出ていたケースを再現・検証しています。
CHANGELOG
activestorage/CHANGELOG.mdに、8.1 系でも EXIF Orientation バグが修正されたことを明示するエントリが追加されています。- ライブラリの振る舞い変更として扱われており、バージョンアップ時にこの修正が入ることが分かるようになっています。
- 影響範囲・注意点
影響範囲
- 対象: Rails 8.1 + Active Storage で、ImageMagick または Vips アナライザを使っているアプリケーション。
- 特に、スマホ・デジカメなど EXIF Orientation を使って向きを決めている JPEG の以下のケースに影響:
- 回転(90/180/270 度)
- 左右・上下反転(mirror/flip)
- 反転+回転 の組み合わせ
以前は:
ActiveStorage::Analyzer::ImageAnalyzerが返すmetadata[:width]/metadata[:height]が「実際に表示される向き」と一致しないケースがあり得た
この修正後は:
- これらが EXIF Orientation を考慮した「論理的な向き」に基づく値になります。
注意点 / 互換性
- すでに「誤った width/height 前提」でビューやサムネイル生成ロジックを書いていた場合、値が変わる可能性があります。
- 例:
metadata[:width] < metadata[:height]なら縦長とみなす、といったロジックが EXIF 未対応前提で書かれていると、挙動が変わる。
- 例:
- ただし、上記は本来の正しい挙動への修正であり、一般的には改善とみなせます。
- 画像自体のバイナリは変えず、解析ロジックとメタデータのみが変わるため、既存の Blob や添付レコードの移行作業などは不要です。
- ImageMagick / libvips のバージョンやビルドに依存して
EXIF:Orientationが読めない環境では、依然として Orientation が 1(未回転)と見なされる可能性があります。その場合はこの修正の効果は限定的です。
- 参考情報 (あれば)
- 元のバグ修正 PR: #57290
この PR の実体は #57290 のチェリーピックなので、詳細な実装意図や議論はそちらを参照するとよいです。 - 画像の EXIF Orientation 仕様(代表的な値):
- 1: 通常(回転なし)
- 2: 水平反転
- 3: 180° 回転
- 4: 垂直反転
- 5: 90° 回転 + 水平反転
- 6: 90° 回転
- 7: 270° 回転 + 水平反転
- 8: 270° 回転
Active Storage の修正は、これらを考慮して width/height などを解釈するためのものです。
#59380 Read libvips' orientation field in the Vips image analyzer
マージ日: 2026/10/31 | 作成者: @g-pavlik
- 概要 (1-2文で)
Active Storage の Vips 画像アナライザが画像の回転情報を誤認識する不具合(特に TIFF など EXIF 以外で orientation が保存される場合)を修正し、libvips のorientationフィールドを一貫して参照するようにした PR です。これにより、アナライザと変換(variant)パイプラインの挙動が揃い、回転メタデータの解釈が正しくなります。
- 変更内容の詳細
問題点
従来の Vips 画像アナライザ (ActiveStorage::Analyzer::ImageAnalyzer::Vips) は、画像の回転情報を以下のように取得していました:
exif-ifd0-Orientationという「人間可読ラベル」ベースの EXIF フィールドを参照- libvips が EXIF メタデータとして回転情報を持っている場合はこれで動くが、TIFF など「ネイティブなタグ」に orientation が保存されているケースでは、このフィールドに値が入らない/ずれた値になる
このため、
- TIFF 画像などで、実際には回転メタデータがあるのに「回転なし」と判定される
- あるいは逆に、想定と異なる判定結果になる
といったバグが発生していました。
修正内容
この PR では、Vips アナライザが参照するフィールドを変更し、
- 以前:
exif-ifd0-Orientation - 以後: libvips が提供する汎用の
orientationフィールド
を使うようにしています。
Active Storage の「variant パイプライン」(画像変換処理側)ではすでに orientation フィールドを利用していたため、今回の修正で
- アナライザ(メタ情報分析)
- 変換(variant 生成)
の両方が同じ orientation を見に行くように統一された形です。
変更ファイルのポイント
activestorage/lib/active_storage/analyzer/image_analyzer/vips.rbmetadataの取得時に orientation を読むキーをexif-ifd0-Orientationからorientationに変更。- 実装イメージとしては、例えば以下のようなイメージの差分と考えられます(実際のコードは若干異なる可能性あり):
ruby# 旧実装(イメージ) def rotation metadata["exif-ifd0-Orientation"] end # 新実装(イメージ) def rotation metadata["orientation"] endこれにより、EXIF に限らず libvips が認識している orientation 情報を一貫して利用します。
activestorage/test/analyzer/image_analyzer/vips_test.rb- 新しい挙動(libvips の
orientationを読む)のためのテストケースを追加。 - 特に TIFF ファイルなど EXIF ベースでない orientation を持つ画像について、正しく回転情報が検出されることを確認するテストが含まれています。
- 新しい挙動(libvips の
activestorage/CHANGELOG.md- Active Storage の変更点として「Vips image analyzer が libvips の
orientationフィールドを読むようになった」旨を追記。 - バグ修正としてリリースノートに載る形の変更です。
- Active Storage の変更点として「Vips image analyzer が libvips の
- 影響範囲・注意点
主な影響対象:
- Active Storage で
image_processing: vipsを利用し、ActiveStorage::Analyzer::ImageAnalyzer::Vipsを使っているアプリケーション - 特に、TIFF や EXIF 以外のメタデータ領域に orientation を持つ画像を扱うケース
- Active Storage で
挙動の変化:
- 以前は
exif-ifd0-Orientationのみを見ていたため「回転なし」と判定されていた画像が、今回の変更後は「回転あり」と正しく検出されるようになります。 - これに伴い、Active Storage の解析結果として得られるメタ情報(例:
image.metadata[:rotation]相当の値)や、その結果を前提とした UI / 処理ロジックが、過去と異なる結果を返す可能性があります。 - 特に、これまで「バグを前提に」回転補正やトリミング位置を調整していたワークアラウンドがある場合は、挙動確認が必要です。
- 以前は
安全性・後方互換性:
- バグ修正としての変更であり、画像ファイルそのものを書き換えるような処理はありません。
- 「正しい orientation 情報を得られるようになる」方向の変更なので、仕様としては自然であり、多くのアプリにとっては改善になる想定です。
- ただし「過去に保存された解析済みメタデータ」をどこかにキャッシュしており、新しい解析結果と食い違う場合は、再解析 or キャッシュクリアの検討が必要になるケースがあります。
- 参考情報 (あれば)
- libvips の
orientationフィールドは、EXIF だけでなく TIFF 等のネイティブタグを含む、ライブラリ全体として解釈した回転情報を指します。 - Active Storage では、variant パイプライン側がすでに
orientationを基準にしていたため、今回の変更で「解析結果と実際の変換結果の不一致」が解消されます。 - TIFF 画像などで「サーバ側では正しい向きなのに、アプリ側のメタ情報上は回転なしと見えていた」ような症状がある場合、この PR を含むバージョンで改善が期待できます。
#59383 ActiveStorage: Fix JSON::ParserError from ffprobe duplicate keys
マージ日: 2026/10/31 | 作成者: @jdelStrother
- 概要 (1-2文で)
ActiveStorage が ffprobe の JSON 出力に重複キーが含まれる場合にJSON::ParserErrorを起こしていた問題を、JSON パース時に重複キーを許可することで修正した PRです。あわせて、JSON gem の最低バージョン引き上げと、ffprobe に依存するテストまわりの調整が行われています。
- 変更内容の詳細
2-1. ffprobe 出力の重複キーによる JSON::ParserError の回避
問題の発端は、ffprobe -show_format -show_streams -print_format json などの出力に、以下のように同じキー(ここでは date)が複数回出てくるケースがあることです:
"tags": {
"date": "2026-10-26T12:11:28-03:00",
"encoder": "Adobe Premiere Pro 2026.0 (Windows)",
"encoded_by": "Adobe Premiere Pro 2026.0 (Windo",
"date": "2026-10-26",
"creation_time": "12:12:02",
"time_reference": "0"
}JSON 自体の仕様上は重複キーは未定義扱いですが、
- JSON gem 2.x までは黙って最後のキーで上書きされる挙動
- JSON gem 3.0+ ではデフォルトで重複キーがエラー (
JSON::ParserError) になる挙動
という差異があり、Rails で使用している JSON gem を 3.0+ にすると、ActiveStorage 内で ffprobe の JSON を解析している箇所(ActiveStorage::Analyzer::AudioAnalyzer など)で例外が出るようになります。
この PR では、ActiveStorage 側の ffprobe 結果のパースで allow_duplicate_key: true を明示的に渡すようにして、重複キーを許容するように変更しています。
変更イメージ(実際のコードに準じた擬似コード):
# 変更前(概念的に)
metadata = JSON.parse(stdout)
# 変更後
metadata = JSON.parse(stdout, allow_duplicate_key: true)これにより、
- JSON gem 3.0+ でも ffprobe の出力がパースエラーにならない
- 重複キーはこれまで通り「後勝ち」で上書きされる(JSON gem の挙動に依存)
という挙動になります。
対象は少なくとも以下(diff から):
activestorage/lib/active_storage/analyzer/audio_analyzer.rbactivestorage/lib/active_storage/analyzer/video_analyzer.rb
※VideoAnalyzer も同様の形で ffprobe 出力を JSON.parse しており、そちらも合わせてallow_duplicate_key: trueが指定されています。
2-2. JSON gem の最低バージョンの引き上げ
allow_duplicate_key: オプションは JSON 2.3 以降でサポートされているため、Gemfile の JSON 依存を更新しています。
- Gemfile:
jsonのバージョン制約を2.0+から2.3+に引き上げ - Gemfile.lock: それに対応する lock ファイルの更新(+1/-1)
これにより、Rails 自体として「JSON 2.3 未満では動作保証しない」形になります。
2-3. ActiveStorage テストコードと ffprobe 依存部分の整理
ffprobe 周りのテストがいくつか調整されています。
2-3-1. ffprobe 非存在時のテスト挙動統一
VideoAnalyzerTest:- もともと ffprobe が無い環境ではテストを
skipする実装になっていた。
- もともと ffprobe が無い環境ではテストを
AudioAnalyzerTest:- ffprobe 非存在でも
skipされず、テストが失敗する可能性があった。
- ffprobe 非存在でも
この PR では、AudioAnalyzerTest にも ffprobe の存在チェックを入れ、存在しなければ skip するように修正しています。
開発者ローカルや CI で ffprobe がインストールされていないケースでも、テストが不必要に赤くならないようにする意図です。
2-3-2. command -v ffprobe 出力の抑制
テストセットアップ内部で command -v ffprobe を何度か実行し、その出力が stdout に流れてしまっていた問題を修正し、サイレントに存在チェックだけ行うようにしています。
これにより、テスト実行ログのノイズが減ります。
2-3-3. 重複タグ付きのテスト用ファイル追加
activestorage/test/fixtures/files/audio_with_duplicate_tags.wavactivestorage/test/fixtures/files/video_with_duplicate_tags.avi
ffprobe の tags に重複キーを含む状態を再現したテスト用ファイルがフィクスチャとして追加されています。
これを用いたテストで、allow_duplicate_key: true を指定していることにより JSON::ParserError が出ずに解析できることを検証しています。
2-3-4. previewer テストの微調整
activestorage/test/previewer/mupdf_previewer_test.rbactivestorage/test/previewer/video_previewer_test.rb
これらのテストファイルにも 1行ずつ変更が入っています(+1/-1)。
PR 説明から推測される範囲では、ffprobe や外部コマンド有無に応じた skip 条件や、出力抑制のための細かな調整がされたと思われます。
- 影響範囲・注意点
3-1. ActiveStorage で ffprobe を使った解析を行っている場合
- 音声 (
AudioAnalyzer) / 動画 (VideoAnalyzer) のどちらでも、ffprobe の JSON 出力に重複タグが含まれていてもJSON::ParserErrorは発生しなくなります。 - JSON パース後の Ruby の Hash において、重複キーは「最後に現れた値で上書きされる」挙動になります(JSON 2.3+ +
allow_duplicate_key: trueの既定挙動)。
→ これは従来(JSON 2.x 時代)の暗黙の挙動と実質的には同じで、エラーにならなくなった分だけ安全になった形です。
3-2. JSON gem バージョンの制約
- Rails をこのコミット以降に更新すると、
jsongem の最低バージョンが 2.3 になります。- もしアプリ側で Gemfile に
gem "json", "< 2.3"のようなピン留めをしている場合は、バージョン競合が発生します。 - レガシーな理由で古い JSON を使っている環境では、アップグレードの検討が必要です(JSON 2.3 自体は 2019年リリースで比較的古いので、通常は問題になりにくいはずです)。
- もしアプリ側で Gemfile に
3-3. CI / 開発環境のテスト挙動
- ffprobe がインストールされていない環境では、Audio/Video Analyzer 関連テストが自動的に
skipされるようになります。- これ自体はテストが不必要に壊れないというメリットがありますが、ffprobe 依存の機能を確実に検証したい場合は、CI イメージに ffprobe をインストールしておく必要があります。
command -v ffprobeの標準出力がテストログに出なくなります。ログを見て ffprobe の有無を目視していたようなケースがあるなら、挙動の変化に注意してください(通常は問題にならないはずです)。
- 参考情報 (あれば)
- 該当の問題箇所(AudioAnalyzer 内の ffprobe JSON パース)
https://github.com/rails/rails/blob/ad242026bf6a7f3f0db133749e80cf40884a9060/activestorage/lib/active_storage/analyzer/audio_analyzer.rb#L72 - JSON gem の
allow_duplicate_keyオプションは 2.3 以降でサポート
→ 3.0+ で重複キーがデフォルトエラーになったため、今回のようなオプション指定が必要になっている背景があります。
#59401 Reuse frozen PostgreSQL bind placeholders: 64 objects instead of 164 and up to 1.24x faster for an IN of 50 ids
マージ日: 2026/10/31 | 作成者: @czj
- 概要 (1-2文で)
PostgreSQL の Arel visitor がバインドパラメータ用の文字列("$1","$2"…)を毎回新規生成していたのをやめ、最大 256 個分をあらかじめ凍結したテーブルから再利用するようにした PR です。これによりオブジェクト生成数が大きく減り、特にIN句で多数の ID を渡すクエリのコンパイル性能が最大 1.24 倍ほど向上します。
- 変更内容の詳細
何をしているか
PostgreSQL visitor が SQL を生成する際、バインドパラメータを
$1,$2, ... のような placeholder 文字列で表現します。これまで:
- 各クエリの各バインドごとに
"#{n}"や"$#{n}"に相当する文字列を都度生成していました。 - 特に
$10以降は「"$"の文字列」と「"10"の文字列」のように複数オブジェクトが関わるため、IN句に大量の ID を渡すとオブジェクト数が膨らむ構造でした。 - 例:
where(id: 50 ids)で 164 個ものオブジェクトがコンパイル時に生成される。
- 各クエリの各バインドごとに
この PR の変更:
BIND_BLOCK(PostgreSQL visitor がプレースホルダ文字列を組み立てる部分)を変更し、最初の 256 個分のプレースホルダ$1〜$256を「凍結された短い文字列のテーブル」から返すようにした。- 256個の frozen String を配列などに事前構築 (約 10KB)。
- visitor 側は、番号が 1〜256 の場合はテーブルから取り出すだけ、257 以上は従来通りその場で文字列を生成。
- このテーブルは freeze 済みかつ単純な配列なので Ractor 共有可能。
TWO_DIGITSinActiveSupport::TimeZoneで使っている「2 桁の文字列キャッシュ」と同じパターンの最適化。arel/visitorsがどの visitor を使う場合でもロードされるため、PostgreSQL を使わないアプリでも起動時にこのテーブルは一度作られる。
実際のコード変更範囲は小さく、postgresql.rb 内の BIND_BLOCK 実装が以下のようなイメージに変わっています(擬似コード):
# 変更前 (イメージ)
BIND_BLOCK = ->(collector, col) do
i = collector.add_bind(col)
collector << "$#{i}" # "$1", "$2" をその場で生成
end
# 変更後 (イメージ)
POSTGRES_BIND_PLACEHOLDERS = Array.new(256) { |i| "$#{i + 1}".freeze }.freeze
BIND_BLOCK = ->(collector, col) do
i = collector.add_bind(col)
if i <= 256
collector << POSTGRES_BIND_PLACEHOLDERS[i - 1]
else
collector << "$#{i}" # 257 以降は従来通り生成
end
endテスト
- 新規テストで 300個のバインド をコンパイルし、テーブル範囲(1〜256)とそれを超える範囲(257 以降)両方をカバー。
- 重要なのは「生成された SQL が正しいか」であり、プレースホルダ再利用によるオブジェクト数の変化はアサートしていない(main でも同じ SQL が生成されることを保証)。
- そのため挙動(外部から見える SQL)は完全に後方互換です。
パフォーマンス・メモリの変化
PR 説明中のベンチ結果(to_sql_and_binds で DB に行かない純粋なコンパイル性能):
3つの bind:
ケース 1回あたりのオブジェクト数 no JIT YJIT main 12 143.8k i/s 411.0k i/s PR後 8 147.0k i/s (1.02x) 473.7k i/s (1.15x) IN句で 50 ids:ケース オブジェクト数 no JIT YJIT main 164 38.1k i/s 71.7k i/s PR後 64 41.6k i/s (1.09x) 88.8k i/s (1.24x) IN句で 500 ids:ケース オブジェクト数 no JIT YJIT main 1,514 4,742 i/s 8,702 i/s PR後 1,004 5,110 i/s (1.08x) 9,806 i/s (1.13x)
傾向として:
- bind の数が少ないクエリでもオブジェクト生成は微減し、YJIT では 10〜15% 近い向上。
- bind が多い
IN句(配列 where)では、オブジェクト数が約 40〜60% 減、実行回数も 1.1〜1.25x 程度向上。
さらに著者は「256 ではなく 1024 要素のテーブルにした場合」も試していて:
- テーブルサイズ: 約 40KB(256 の場合は約 10KB)
IN 500 idsの場合:- オブジェクト数: 514(256テーブル時は 1,004)
- 性能: no JIT 1.13x, YJIT 1.26x とさらに改善
- ただし 3 binds・50 binds といった現実的に多いケースは 256 テーブルと変わらない。
このため、「256 と 1024 のどちらが良いか?」をレビューに問いかけています。
- 影響範囲・注意点
影響範囲:
- 対象:
- ActiveRecord + PostgreSQL を使う全アプリケーションの SQL 生成 (Arel visitor) に影響。
- 特に
where(id: array)のように 大量の bind を使うクエリを頻発するアプリ(多くの ID をまとめて検索するパターン)で効果が出やすい。
- 直接的な仕様変更はなし:
- 生成される SQL のプレースホルダ形式 (
$1,$2, ...) は従来通りで、見た目も順序も同じ。 - bind 数が 256 を超えるケースでも、257 以降は従来通りの生成方法にフォールバックするだけで、SQL の互換性は保たれる。
- 生成される SQL のプレースホルダ形式 (
- メモリ:
- Rails 起動時に約 10KB の frozen String テーブルを 1つ確保(PostgreSQL を使っていなくても)。
- テーブル自体は Ractor 共有可・再利用されるためランタイムの増加はごく小さく、頻繁にクエリを発行するアプリではトレードオフとして十分割に合うと考えられる。
注意点・互換性:
- 挙動上の互換性:
- 主に内部実装の改善であり、アプリ側から見える仕様の変更はありません。
- 文字列オブジェクトの ID が変わったことに依存しているようなコード(普通は書かない)でない限り、破壊的な影響はないはずです。
- YJIT / JIT との関係:
- ベンチは Ruby 4.0.9 + macOS arm64 環境で計測されており、YJIT 有効時に特に改善幅が大きい。
- 実際の本番環境(Ruby バージョンや GC 設定、JIT 有無)によって改善幅は異なる可能性がありますが、悪化するケースは考えにくい種類の最適化です。
- 他 DB アダプタ:
- 今回は PostgreSQL visitor 専用の最適化。SQLite/MySQL 用 visitor には影響しません。
- ただしテーブル構築コードは
arel/visitorsロード時に走るため、PostgreSQL を使わないアプリでも僅かな起動時メモリ増加(〜10KB)は発生します。
- 参考情報 (あれば)
- 関連 PR:
- #59397, #59399(integer array 周りの最適化)
- #59400 (
parameterizeの最適化)
- 類似パターン:
ActiveSupport::TimeZoneのTWO_DIGITSでも、0〜99 の 2 桁文字列を事前生成してキャッシュする同様の手法を採用しており、今回の変更は Rails 内部で既に実績のある最適化パターンです。
- サイズ検討:
- 著者は 256 / 1024 のどちらが適切か、レビューで意見を募っています。
- 256: 起動時メモリ約 10KB、ほとんどの現実的な bind 数カバー、
IN 50程度が主なターゲット。 - 1024: 起動時メモリ約 40KB、
IN 500など極端に大きい配列クエリでさらに効果大。
- 256: 起動時メモリ約 10KB、ほとんどの現実的な bind 数カバー、
- 大半のアプリでは
IN 256を超えるケースはそこまで多くないため、現時点では 256 が妥当なバランスという前提で PR が書かれています。
- 著者は 256 / 1024 のどちらが適切か、レビューで意見を募っています。
#59418 Accept Symbol values for the routing format: option
マージ日: 2026/10/31 | 作成者: @cestercian
- 概要 (1-2文で)
このPRは、ルーティングのformat:オプションにSymbol(例:format: :json)を渡した場合でも、String("json")と同様に正しく扱われるようにするバグ修正です。Rails 3.2 では動いていた挙動が Rails 4.0 で静かに壊れていた回帰を解消しています。
- 変更内容の詳細
何が問題だったか
以下のようなルーティング定義をしても:
get "/users", to: "users#index", format: :json意図としては:
- このルートのデフォルト
formatが"json"になる /usersにアクセスするとparams[:format]が"json"になる/users.xmlはこのルートにマッチしない
ことを期待しますが、現状(PR前)は次のような挙動でした:
- フォーマットの「制約」も「デフォルト」も設定されない
/users.xmlもマッチしてしまうrecognize_path("/users")[:format]はnil
一方、format: "json"(文字列)と書いた場合は、期待通りに:
get "/users", to: "users#index", format: "json"
# => デフォルト format が "json"
# => /users.xml は 404(このルートにはマッチしない)となっていました。
この差は、内部で format を正規化する ActionDispatch::Routing::Mapper::Mapping#normalize_format が、true / Regexp / String のみを扱い、Symbol を素通りさせていたために起きていました。
Rails 3.2 では format: :json も正しく扱われていましたが、Rails 4.0 の変更(String === format チェック導入)で回帰が入り、以後ずっと直っていなかった、という位置づけです。
具体的な修正内容
ActionDispatch::Routing::Mapper::Mapping#normalize_format で、Symbol を String と同等に扱うように変更しています。
ポイント:
formatがSymbolの場合、to_sして"json"のような文字列に変換- その文字列に対して、従来通り:
- requirement 用の正規表現を
Regexp.compile("json")で生成 - デフォルト値として
"json"を設定
- requirement 用の正規表現を
- これにより、
params[:format]は他のデフォルト同様Stringのまま(:jsonにはならない)
コードイメージとしては:
def normalize_format(format)
case format
when true
# ...(従来通り)
when Regexp
# ...(従来通り)
when String, Symbol
value = format.to_s
[Regexp.compile(value), value]
else
# 何もしない
end
end(実際のコードは多少異なる可能性がありますが、概念としてはこういう変更です。)
この修正は get だけでなく、scope や resources も共通の normalize_format を通るため、一括で改善されます。
サンプル: get ルート
# PR後(Symbol でも OK)
get "/users", to: "users#index", format: :json
# 以下と等価な挙動になる
get "/users", to: "users#index", format: "json"/users- マッチする
params[:format] #=> "json"
/users.xml- マッチしない(別ルートがあればそちら、なければ 404)
サンプル: scope と resources
scope format: :json do
resources :users
end
# もしくは
resources :users, format: :jsonGET /usersparams[:format] #=> "json"
GET /users.xml- このスコープ / resources のルートにはマッチしない
テストとCHANGELOG
- 既存の format-option 関連テストを拡張する形で回帰テストが追加されています
format: :jsonを指定した場合に、format: "json"と同じ requirement / default になることrecognize_path("/users")[:format] == "json"になること/users.xmlがマッチしないことscopeやresources経由の場合も同様であること
actionpack/CHANGELOG.mdにこの挙動修正が記載され、外部向けに明示されています
- 影響範囲・注意点
- 影響範囲:
- ルーティングで
format: :jsonのようにSymbolを使っている(または今後使う)場合にのみ影響します - これまで
format: :jsonが「効いていなかった」アプリでは、PR適用後に挙動が変わります- 今まで
/users.xmlが通っていたルートが、通らなくなる可能性があります
- 今まで
- ルーティングで
- 既存アプリでの注意点:
- 回避のために既に
format: "json"と書いている場合は、挙動は変わりません(現状維持) - 逆に、「
format: :jsonが効いていない前提で、わざと/users.xmlも通していた」ようなコードがある場合は、修正後にルーティングが変わるので確認が必要です
- 回避のために既に
- 型について:
params[:format]は常にStringです(:jsonではなく"json")。これまでformat: "json"で得られていた挙動を壊さないように配慮されています。
- 参考情報 (あれば)
- 対応 Issue: #59412(
format: :jsonが効かないことに関する報告) - 回帰元コミット: Rails 4.0 頃に入った
format判定のString === format変更 - 類似オプションとの一貫性:
constraints: { format: :json }controller: :usersaction: :indexvia: :get
これらが元々Symbolを受け付けていたのに対し、format:だけが受け付けない状態だったのを解消し、API の一貫性を取り戻す変更です。
#59477 Check if ::UserAgent is defined before requiring
マージ日: 2026/10/31 | 作成者: @byroot
- 概要 (1-2文で)
このPRは、ActionController::Metal::AllowBrowserが::UserAgentクラスをrequireする前に、その定義有無をチェックするように変更し、不要なrequire呼び出しを避けてパフォーマンスを改善するものです。requireが$LOADED_FEATURESを都度走査するコストを、既に定義済みのケースでは回避できるようにしています。
- 変更内容の詳細
変更ファイル:
actionpack/lib/action_controller/metal/allow_browser.rb(+1 / -1)
実質的には1行のロジック修正で、イメージとしては次のような変更です(※実際のコードを概ね再構成した例):
# 変更前(例)
require "user_agent"
module ActionController
module AllowBrowser
# ...
end
end
# 変更後(例)
unless defined?(::UserAgent)
require "user_agent"
end
module ActionController
module AllowBrowser
# ...
end
endポイント:
require "user_agent"を無条件で呼ぶのではなく、トップレベルの::UserAgentがすでに定義されているか (defined?(::UserAgent)) を確認した上でrequireするようにした。- これにより、
- 既に別の場所で
UserAgentがロードされている場合 →requireを呼ばない - 未ロードの場合 → これまで通り
require "user_agent"を実行
- 既に別の場所で
- 参照されているフォローアップ元PR: https://github.com/rails/rails/pull/57974
→ 元PRでUserAgent利用まわりのロジックが導入・変更されており、その最適化・仕上げとして本PRが入っている位置づけです。
技術的背景:
- Ruby の
requireは内部的に$LOADED_FEATURES(すでにロードしたファイルの一覧)を線形探索して「同じファイルを二重にロードしていないか」を確認します。 - 大規模アプリケーションや多くのファイルがロードされる環境では、この探索コストが積み重なります。
defined?(::UserAgent)のチェックは単純な定数解決で済むため、既に定義済みならrequireするよりも高速に判定が可能です。
- 影響範囲・注意点
影響範囲:
actionpackのActionController::MetalにあるAllowBrowser機能(おそらくブラウザ判定や User-Agent ベースの許可制御に関するモジュール)がUserAgentクラスに依存する部分のみ。::UserAgentが- すでにアプリ側や別のライブラリ側で定義済み → 今後は Rails 側では
require "user_agent"を呼ばなくなる。 - 未定義 → 従来どおり
require "user_agent"が行われるため挙動は変わらない。
- すでにアプリ側や別のライブラリ側で定義済み → 今後は Rails 側では
挙動面の注意:
- 名前空間を変更しているわけではなく トップレベル
::UserAgentの有無で判断するため、以下のようなケースでも従来どおりrequireされます:ruby→ この場合、module MyApp class UserAgent # ... end enddefined?(::UserAgent)はnilなのでrequire "user_agent"が実行されます。 - 逆に、アプリ内で独自の
::UserAgentクラスを定義している場合:ruby→ Rails 側ではclass UserAgent # アプリ独自の実装 endrequire "user_agent"を行わず、その独自実装が利用され得ます。- すでにそういう構成で動かしているアプリでは、今回の変更により「意図通りに独自
::UserAgentが使われる」ことがより確定的になる反面、「本来は gem のUserAgentを使う前提だったが、たまたま同名クラスを定義していた」という場合には潜在的に挙動が変わる可能性があります。 - ただし、これは Ruby の定数解決ルールに沿った自然な結果であり、PRの目的である「無駄な
requireの回避」とトレードオフになる部分です。
- すでにそういう構成で動かしているアプリでは、今回の変更により「意図通りに独自
パフォーマンス面:
- アプリケーションの起動時やリクエスト処理中に何度も
AllowBrowserがロード・参照されるような環境では、特に$LOADED_FEATURESが肥大化している場合に軽微ながらパフォーマンス改善が見込めます。 - 変更はきわめて小さく、API 互換性への影響は限定的です。
- 参考情報 (あれば)
- フォローアップ元PR: https://github.com/rails/rails/pull/57974
→AllowBrowser/UserAgent周りの初期導入・修正の文脈を把握するのに有用です。 - Ruby
requireの挙動と$LOADED_FEATURES:- Ruby公式ドキュメント: https://docs.ruby-lang.org/ja/latest/method/Kernel/m/require.html
$LOADED_FEATURESが線形リストであることから、ファイル数が増えるほどrequireチェックが遅くなりうる、という一般的な話題を背景にした最適化です。
#59454 [8-1-stable] Run async calculations and async_find_by_sql/async_count_by_sql synchronously inside a joinable transaction
マージ日: 2026/10/31 | 作成者: @bensheldon
- 概要 (1-2文で)
このPRは、ActiveRecord 8.1系 (8-1-stable) に対して、「joinable なトランザクション内では非同期クエリを同期的に実行する」よう挙動を修正するバックポートです。async_find_by_sql/async_count_by_sqlや、非同期計算 (async_*) 系メソッドがトランザクション内で例外を起こさず安全に動くようにします。
- 変更内容の詳細
背景
- Rails 8 では
load_asyncやasync_***系 API により、クエリをスレッドプールで非同期実行する仕組みがあります。 - 一方で、joinable なトランザクション(ネスト可能・再利用可能なトランザクション)の内部で非同期クエリを飛ばすと、接続共有・ロック・トランザクション境界の整合性の問題が起こりやすく、原則として許可されていません。
- このPRでは、そのようなトランザクション内では「非同期クエリ呼び出しだとしても、内部的には同期クエリとして即時に実行する」というフォールバック動作を導入・補強しています。
変更1: async_find_by_sql / async_count_by_sql の挙動 (querying.rb)
対象ファイル: activerecord/lib/active_record/querying.rb (+9/-2)
async_find_by_sql,async_count_by_sqlが、joinable トランザクション内部かどうかを判定し、内部で使う低レベルクエリ実行メソッドが変わります。- 元PR (#59223) では
_sql_for_findが導入されていましたが、8.1系には存在しないため、async_count_by_sqlは引き続きsanitize_sql(sql)を利用する形でバックポートされています。
イメージとしては次のような処理です(概念的な疑似コード):
def async_find_by_sql(sql, ...)
if connection.current_transaction.joinable?
# トランザクション中 → 非同期は諦めて同期実行
records = find_by_sql(sql)
# すでにロード済みの Relation::LazyEnumerator を返す など
else
# トランザクション外 → 通常どおり非同期実行
async_adapter.schedule_find_by_sql(sql, ...)
end
end
def async_count_by_sql(sql, ...)
sanitized = sanitize_sql(sql)
if connection.current_transaction.joinable?
# 同期で count 実行
count_by_sql(sanitized)
else
# 非同期実行
async_adapter.schedule_count_by_sql(sanitized, ...)
end
endポイント:
- joinable トランザクション中で非同期を無理に走らせず、安全な同期クエリにフォールバックします。
- 8.1系では
_sql_for_findがないため、async_count_by_sqlは以前と同じくsanitize_sqlベースで SQL を整えています。
変更2: ids / async_ids と計算系 (calculations.rb)
対象ファイル: activerecord/lib/active_record/relation/calculations.rb (+20/-4)
- 8.1系では
idsがpluckに委譲せず、自前でselect_allを発行している実装になっています。 - このため、トランザクション内で
async_idsを呼ぶと、内部でselect_allが直接非同期実行されようとして例外が起きるという問題がありました。 - このPRでは、
idsの内部で使うselect_all相当処理を_async_select_allに切り出し、そのなかで「joinable トランザクション中かどうか」を判定して同期 / 非同期を切り替えるようにしています。
概念的には以下のような分岐です:
def _async_select_all(arel)
if connection.current_transaction.joinable?
# トランザクション中 → 同期実行
connection.select_all(arel)
else
# トランザクション外 → 非同期実行
async_adapter.select_all(arel)
end
end
def ids
# 従来: connection.select_all(arel) を直接呼んでいた
rows = _async_select_all(arel_for_ids)
rows.map { |row| row[primary_key] }
end
def async_ids
# 8.1系の実装方針に沿って、上記 _async_select_all を経由することで、
# トランザクション中なら同期、外なら非同期として動作するようになる
endポイント:
async_idsが joinable トランザクション内で例外にならないよう、ids自体も「非同期の可否を判断するレイヤー」を通すようになった、という修正です。- 結果として、
async_idsも他のasync_*メソッドと同様に「トランザクション内では同期にフォールバック」する整合性が取れます。
変更3: テスト追加 (load_async_test.rb)
対象ファイル: activerecord/test/cases/relation/load_async_test.rb (+42/-0)
- joinable トランザクションの中で以下が例外を出さず同期実行されることを確認するテストが追加されています:
async_find_by_sqlasync_count_by_sqlasync_ids- 他の async 計算関連メソッド
- 典型的には次のようなケースがカバーされます(イメージ):
test "async_find_by_sql runs synchronously inside joinable transaction" do
Post.transaction(joinable: true) do
relation = Post.async_find_by_sql("SELECT * FROM posts")
assert_equal Post.count, relation.to_a.size # 例外が出ない / 実際に結果が取れる
end
endこれにより、今回の挙動変更が実際に動作していることが検証されています。
- 影響範囲・注意点
- 対象バージョンは Rails 8.1 系 (
8-1-stable) のみです(main側の変更 #59223 のバックポート)。 - アプリケーションコードから見える主な変化:
- joinable なトランザクション内で
async_*メソッド(async_find_by_sql,async_count_by_sql,async_idsなど)を呼んだ場合、- 以前: 環境や組み合わせによっては例外や不整合が起こりうる
- 今回以降: 例外を避けるため同期的に即時実行にフォールバックする
- そのため「トランザクション内なのにバックグラウンドでクエリが走り続けている」といった危うい状態を避けられます。
- joinable なトランザクション内で
- パフォーマンス上の注意:
- joinable トランザクション内で
async_*を多用しても、実際には同期クエリとして実行されるため、期待した非同期並列実行による高速化は得られません。 - 非同期化によるスループット向上を狙う場合は、トランザクション境界の外側で
async_*を呼ぶ設計にする必要があります。
- joinable トランザクション内で
- 互換性:
- 公開APIのメソッドシグネチャは変わらず、挙動も「例外が減り、安全側に倒れる」方向の変更のため、後方互換性は高いと考えられます。
- ただし「トランザクション内で非同期になること」を前提にした特殊な実装があれば、その前提は成り立たなくなる点には注意が必要です。
- 参考情報 (あれば)
- 元PR: #59223 – 本PRのバックポート元
- 差し戻し・やり直し PR: #59310 – 本PRはその「Redo」
- 関連トピック:
- Rails の非同期クエリ (
load_async,Relation#async_*) - ActiveRecord のトランザクション管理と joinable トランザクション
- コネクションプールとマルチスレッド実行時の制約
- Rails の非同期クエリ (
#58728 Read structure dump exclusions from the database being dumped
マージ日: 2026/10/31 | 作成者: @ngan
- 概要 (1–2文で)
db:structure:dump(structure_dump)実行時に、schema_ignored_tablesの判定と PostgreSQL のsearch_path取得を「ダンプ対象DBではなく現在の接続DB」から読んでいたバグを修正し、常に「ダンプ対象の DB 設定 (db_config)」に対して接続して情報を取得するようにした PR です。これにより、マルチDB構成やカスタムタスクからActiveRecord::Tasks::DatabaseTasks.structure_dumpを直接呼び出すケースでも、期待通りに無視テーブル・search_path が反映されます。
- 変更内容の詳細
問題の背景
従来の structure_dump の挙動:
schema_ignored_tablesのパターンマッチはActiveRecord::Baseが現在接続している DB のテーブル一覧に対して行っていた。- その結果として:
- 無視すべきテーブルの判定が「ダンプ対象 DB」ではなく「現在接続中の DB」を元にされる。
- 判定後に
--ignore-table=<ダンプ対象DB名>.<テーブル名>を組み立てていたため、
「存在しないテーブルを指定する (false positive)」と「本当は無視すべきテーブルが無視されない (false negative)」の両方が起こり得た。
bin/rails db:schema:dumpはwith_temporary_pool(db_config)で一時的に接続を切り替えてからアダプタを呼ぶためこの問題は出ないが、ActiveRecord::Tasks::DatabaseTasks.structure_dumpを直接叩くケース(マルチDBアプリで自前の Rake タスクを書いていて DB をループしながらダンプする、など)ではバグが顕在化していた。
具体例(MySQL、schema_ignored_tables = [/\A_.+_gho\z/] の場合):
- 現在の接続:
activerecord_unittest - ダンプ対象:
activerecord_unittest2
期待:
activerecord_unittest2に存在してパターンにマッチする_lives_in_target_ghoを 無視 したい。
実際:
- 現在接続中の
activerecord_unittestの_lives_in_primary_ghoを見てしまい、--ignore-table=activerecord_unittest2._lives_in_primary_ghoを出力する(ダンプ対象には存在しないため実害はない)。 - 一方、本来無視すべき
_lives_in_target_ghoは「現在の接続側にはない」ため検出されず、structure.sqlに含まれてしまう。
PostgreSQL 固有の問題:
structure_dumpの最後にSET search_path TO #{connection.schema_search_path}をダンプに追記しているが、
ここでも「現在接続中の DB の search_path」を使っており、「ダンプ対象 DB の search_path」になっていなかった。
修正方針
structure_dumpが必要とする情報(テーブル一覧、schema_ignored_tablesのマッチング、schema_search_pathなど)は、
「ダンプ対象 DB のdb_configから新しく開いた接続」 を通して読むように変更。- 新しく開いた接続は ダンプ終了時に必ず閉じる(
disconnect!)。with_temporary_pool/with_temporary_connectionのようにグローバルなActiveRecord::Baseの接続プールを差し替えない。- これにより、呼び出し元の接続状態に副作用を与えない。
- MySQL / SQLite については、
schema_ignored_tables.any?な場合だけ接続を開くようにしており、
デフォルト設定(無視テーブルなし)では従来通り シェルコマンドをそのまま叩くだけで DB への接続を開かない。 - PostgreSQL はもともと search_path 取得のために接続を読んでいたので、常に接続を開く点は変わらないが、
今回の修正で「接続先が常にダンプ対象 DB」になる。
コード側のポイント(概念レベル)
ファイル単位で見ると:
activerecord/lib/active_record/tasks/abstract_tasks.rb- ダンプ対象
db_configから新しい接続を開いて処理し、終わったら閉じるための共通的な仕組みが追加されている(タスク用の抽象ヘルパ)。
- ダンプ対象
mysql_database_tasks.rb/sqlite_database_tasks.rbschema_ignored_tablesが空でなければdb_config.new_connectionで接続を開き、 そのコネクションからテーブル一覧を読み、schema_ignored_tablesでマッチングしたテーブルを--ignore-table/-Tオプションに反映。- 処理終了後に
disconnect!で接続を閉じる。
postgresql_database_tasks.rb- これまで
ActiveRecord::Base.connectionから読んでいた search_path を、「ダンプ対象 DB の接続」から読むように変更。 schema_ignored_tablesによる ignore-table の解決も同様にダンプ対象 DB への接続で行う。
- これまで
疑似コードイメージ(概念):
def structure_dump(db_config, filename)
connection = db_config.new_connection
ignored_tables = resolve_ignored_tables(connection, schema_ignored_tables)
search_path = connection.schema_search_path # PostgreSQL
# ignored_tables / search_path を元に pg_dump / mysqldump / sqlite コマンドを組み立てて実行
ensure
connection&.disconnect!
end※ 実際の実装は各アダプタごとに分かれていますが、考え方としては上記のような「ダンプ対象 db_config から接続を開いて、それを使う」という変更です。
テストの更新
- MySQL (
mysql2_rake_test.rb) / SQLite (sqlite_rake_test.rb) / Trilogy それぞれで、- 無視テーブルが「ダンプ対象 DB のものだけに適用されているか」
- 「現在接続中の別 DB にある同名テーブル」には影響しないか を検証する回帰テストが追加。
- PostgreSQL (
postgresql_rake_test.rb)- 変更量が多く、
structure_dump用のテストをヘルパでラップし、- ダンプ対象 DB の接続をモック/フェイクで用意
- 意図的に到達不能なホストを指定するケースでもテストできるようにしつつ、
- 「必ず接続が閉じられている」ことも検証するように整理。
- search_path が
"SET search_path TO dumped_schema;"を含むことを期待し、
以前のように"ambient_schema"が出てしまう場合にはテストが落ちるようになっている。
- 変更量が多く、
- 影響範囲・注意点
- 影響を受ける主なケース
ActiveRecord::Tasks::DatabaseTasks.structure_dumpを 直接 呼び出しているアプリケーション/Rake タスク。- マルチ DB 構成で、「ある DB に接続したまま別の DB を structure dump している」ようなカスタム処理。
- 期待される変化
schema_ignored_tablesによる除外が「実際にダンプしている DB」を基準に行われるようになる。- PostgreSQL では
structure.sql内のSET search_path TO ...;が「ダンプ対象 DB の search_path」になる。
- 互換性上の注意
- これまで「バグ前提の挙動」に依存していた場合(たとえば“今つながっている DB のテーブルを ignore したい”という非常に特殊な状況)は挙動が変わりますが、
通常の設計では「ダンプ対象 DB を基準にする」方が正しいため、多くの場合はバグ修正としてのプラス影響のみです。
- これまで「バグ前提の挙動」に依存していた場合(たとえば“今つながっている DB のテーブルを ignore したい”という非常に特殊な状況)は挙動が変わりますが、
- 接続/到達性に関する注意
- MySQL / SQLite:
schema_ignored_tablesが空(デフォルト)なら従来通り DB への新規接続は不要です。schema_ignored_tablesを設定している場合は、ダンプ対象 DB への接続が実際に張れる必要があります(テーブル一覧を取得するため)。
- PostgreSQL:
- もともと search_path の取得のために接続していたので「新しく到達性の要件が増える」ことはありませんが、 今回から「必ずダンプ対象 DB に対して接続を開く」ことになったため、
テストや CI で「ダンプ対象 DB だけ意図的に到達不能にしている」ような状況があれば考慮が必要です。
- もともと search_path の取得のために接続していたので「新しく到達性の要件が増える」ことはありませんが、 今回から「必ずダンプ対象 DB に対して接続を開く」ことになったため、
- MySQL / SQLite:
- リソースと副作用
with_temporary_pool/with_temporary_connectionを使わずに スタンドアロンのコネクション を開いて即クローズする方式なので、- 呼び出し元の
ActiveRecord::Baseのコネクションプールには影響しません。 - 特に SQLite では、「
with_temporary_connectionが DB ファイルを再作成してしまう」といった副作用を避けています。
- 呼び出し元の
- 参考情報 (あれば)
- PR タイトル: Read structure dump exclusions from the database being dumped
- 対応範囲:
- SQL 形式の schema dump (
schema_format = :sql) をサポートする 3 つのアダプタ(MySQL / PostgreSQL / SQLite)+ Trilogy におけるstructure_dump。
- SQL 形式の schema dump (
- 関連設定:
ActiveRecord::Base.schema_ignored_tables
(ActiveRecord::SchemaDumper.ignore_tablesと同義に扱われることが多い)
正規表現/文字列/配列などで、ダンプ時に無視したいテーブルを指定するための設定。
- 類似パターン:
- Action Cable の PostgreSQL アダプタがサブスクリプション用に
pool.new_connection+disconnect!を使っているのと同じパターンで、
「グローバルな接続状態を汚さない一時的な接続」の作り方として Rails 内で既存実績のある手法に揃えています。
- Action Cable の PostgreSQL アダプタがサブスクリプション用に
#59463 Bump trilogy to 2.13.2 and use ujis in test_character_set_connection_is_configured
マージ日: 2026/10/31 | 作成者: @yahonda
- 概要 (1-2文で)
trilogy を 2.13.2 にバージョンアップし、その影響で落ちていたConnectionTest#test_character_set_connection_is_configuredをcp932ではなくujisを使うように修正した PRです。trilogy 2.13.2 で非サポートとなった文字コードをテストから排除し、脆弱性修正後の挙動に沿うようにしています。
- 変更内容の詳細
trilogy のバージョンアップ
Gemfile.lockの更新により、使用している trilogy のバージョンを 2.13.2 に bump- trilogy 2.13.2 は次の点が大きな変更点:
- セキュリティアドバイザリ GHSA-744p-w4gc-m3gh の対応版
- 以下の文字コードサポートを削除:
big5,sjis,gbk,cp932,gb18030
- 理由: これらのエンコーディングはマルチバイト文字の 2 バイト目以降が ASCII 領域に入る可能性があり、SQL インジェクションなどのセキュリティリスクを高めうるため
テストの文字コードを cp932 → ujis に変更
対象テスト:activerecord/test/cases/adapters/abstract_mysql_adapter/connection_test.rbConnectionTest#test_character_set_connection_is_configured
以前は「接続の character_set が正しく反映されるか」を cp932 で検証していましたが、trilogy 2.13.2 では cp932 が非サポートになったため、テストが以下のようなエラーで失敗していました:
ActiveRecord::StatementInvalid: ArgumentError: Unsupported encoding: cp932
.../trilogy-2.13.2/lib/trilogy/encoding.rb:91:in 'Trilogy::Encoding.find'そこで、テスト内の接続文字セットおよびテスト文字列を cp932 から ujis(EUC-JP 相当)に変更しています。ujis は:
- 「マルチバイトの 2 バイト目が ASCII 範囲に入りうる」という性質を持たない
- trilogy 2.13.2 でもサポート対象のまま
であるため、セキュリティポリシーと互換性の両方を満たす選択になっています。
コードイメージ(概略)
※実際のコードから意図が分かるようにしたイメージです(行数変化: +7/-7):
def test_character_set_connection_is_configured
# 接続設定で character_set を指定
config = ARTest.config['arunit'].merge('encoding' => 'ujis')
connection = ActiveRecord::Base.mysql2_connection(config)
# クエリ文字列も ujis エンコードの文字列に変更
ujis_string = "..." # ujis で表現した日本語など
assert_nothing_raised do
connection.exec_query("SELECT '#{ujis_string}'")
end
endまた、説明文にある通り、trilogy 2.13.2 では「クエリ文字列を接続文字コードへトランスコードしない」仕様になったため、テスト側で最初から ujis 文字列を用意する形になっています。つまり:
- 以前: Ruby 文字列(UTF-8 など)を trilogy が接続エンコーディングに変換して送信
- 現在: アプリ側/テスト側で接続エンコーディングの文字列を渡す前提
にそろえる変更です。
- 影響範囲・注意点
ActiveRecord の MySQL 系アダプタ + trilogy 利用環境
- trilogy 2.13.2 以降を使う場合、
big5,sjis,gbk,cp932,gb18030を接続エンコーディングとして前提にしたコードやテストは動作しません。 - Rails 本体のテストは
ujisに切り替えたため、nightly CI の MySQL(trilogy) テストは通るようになります。
- trilogy 2.13.2 以降を使う場合、
アプリケーションコードへのインパクト
- アプリ側で trilogy の
encodingオプションに上記非サポートエンコーディングを指定している場合、Unsupported encodingエラーが発生するようになります。 - trilogy 2.13.2 では「クエリを接続エンコーディングに自動トランスコードしない」ため、アプリケーションがマルチバイト文字を多用し、かつ接続エンコーディングを UTF-8 以外にしている場合は、手元の文字列エンコーディングとの整合性を要確認です。
- 通常の Rails + MySQL では UTF-8(
utf8mb4)利用が一般的なため、多くのアプリでは影響は限定的と考えられます。
- 通常の Rails + MySQL では UTF-8(
- アプリ側で trilogy の
セキュリティ面
- この PR 自体はライブラリ(trilogy)の更新とテスト修正のみですが、取り込むことで GHSA-744p-w4gc-m3gh への対応版 trilogy を確実に使うことになります。
- 「ASCII 領域を含みうるマルチバイトエンコーディング」を避ける設計方針にテストも追従した形です。
- 参考情報 (あれば)
- trilogy セキュリティアドバイザリ:
- 対応コミット(非サポートエンコーディングの削除など):
- PR で問題となっていた CI の失敗ログ:
#59458 Freeze tag strings before marking them HTML safe
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
content_tagやtag.xxxなどのタグヘルパーが内部で生成する文字列を、HTML セーフ化する前にfreezeすることで、不要な文字列オブジェクトの割り当てを 1 つ削減し、リンクやフォーム要素が多いページでのメモリ・パフォーマンスを改善する変更です。挙動や API は変わらず、SafeBuffer自体は引き続き変更可能なままです。
- 変更内容の詳細
背景となる問題
content_tag_string / self_closing_tag_string は、以下のようなおおよその処理をしています:
tag_string = "<div class=\"card\">Hello</div>" # 文字列補間で生成
safe = tag_string.html_safe # SafeBuffer に包むRuby の実装上、文字列が 23 byte を超える(64bit 環境)と、
「**凍っていない文字列(String)」と「SafeBuffer の内部文字列」**が中身を共有できず、
- 補間で作られた
tag_string - その内容を共有するための「隠し文字列」
という 2 つ目の文字列オブジェクトが余計に割り当てられていました。
しかも元の tag_string はすぐ GC 対象になるため、実質「捨てるためだけの文字列」を毎回 1 個多く作っている状態でした。
今回の対策
この PR では、「HTML セーフ化する前にタグ文字列自体を freeze する」ように変更しています。
イメージとしては:
tag_string = "<div class=\"card\">Hello</div>".freeze
safe = tag_string.html_safe # SafeBuffer はこの凍結済み文字列をそのまま内部で共有こうすることで:
tag_stringは不変(frozen)なので、SafeBuffer との内容共有が可能- SafeBuffer のために新しい「隠し文字列」を作らずに済む
tag_string自体は他で参照しない(一時的なローカル変数)ので、凍結しても悪影響はない
という状態になり、タグ 1 つあたり文字列オブジェクト 1 個ぶんのメモリ割り当てを削減できます。
SafeBuffer の挙動は維持
重要なのは、「返される SafeBuffer オブジェクト自体はこれまで通り可変である」という点です。
テストでは、以下を確認しています:
content_tagtag.divtag.inputtag(任意タグ)
いずれも、
buf = content_tag(:div, "Hello", class: "card")
buf << " world" # これが問題なく動くことのように、返り値の SafeBuffer に対する追記ができ、
その追記が後続のタグ生成に影響しないことが明示的にテストされています。
このテストは、もし SafeBuffer 自体を freeze してしまった場合に失敗するよう書かれており、
あくまで「内部で一時的に作るタグ文字列だけを freeze している」ことを保証しています。
パフォーマンス計測結果
Ruby 3.4.1 arm64 + GC.stat(:total_allocated_objects) によると:
| ケース | main | この PR |
|---|---|---|
link_to("Post number 42", "/posts/42") | 5 | 4 |
content_tag(:div, "Hello", class: "card") | 5 | 4 |
tag.input(type: "hidden", name: "authenticity_token", value: "abc") | 5 | 4 |
tag.br(23byte以下で埋め込み可能) | 3 | 3 |
パーシャル内で 100 レコード分に link_to | 2148 | 2048 |
主に 23byte を超えるタグ(属性付きの多くのタグ)で 1 オブジェクト削減されており、link_to, button_to, form helpers など tag builder ベースのあらゆるヘルパーが恩恵を受ける形です。
- 影響範囲・注意点
- 影響範囲:
- Action View のタグ関連ヘルパー全般 (
content_tag,tag.*,link_to,button_to, 各種 form helper など) - しかし、外部に見える挙動や API は変更されていません。
- Action View のタグ関連ヘルパー全般 (
- 注意点:
- 返される
SafeBufferはこれまで同様にミュータブルであり、<<などで追記できます。 CHANGELOG更新は不要とされていますが、動作仕様の変更ではなくパフォーマンス最適化扱いです。- タグ文字列が 23byte 以下(オブジェクト内に埋め込める長さ)の場合、もともと追加アロケーションはほぼなかったため、改善効果はありません(
tag.brのようなケース)。
- 返される
アプリケーションコード側で、content_tag や tag が返す SafeBuffer をこれまで通り扱っている限り、
この変更による互換性問題が発生する可能性はかなり低いと考えられます。
- 参考情報 (あれば)
- PR: https://github.com/rails/rails/pull/59458
- Ruby の文字列内部表現に関するポイント:
- 64bit 環境では、23byte までの短い文字列はオブジェクト内に「埋め込み」表現される
- より長い文字列はヒープ上に別途確保され、そのポインタを持つ
- 不変(
frozen)文字列は他オブジェクトと中身を安全に共有しやすく、SafeBufferがそれを再利用できるようになると、余計なコピーや「隠し文字列」の作成が省ける
#59456 Parse each address once when calculating the remote IP
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1-2文で)
Rails のActionDispatch::RemoteIpミドルウェアで、リモート IP 判定時に同じ IP 文字列を何度もIPAddrにパースしていた処理を見直し、1回のパース結果を再利用するようにしてオブジェクト生成数を削減した PR です。挙動(どの IP がクライアントとして選ばれるか)は変えずに、特にログ出力など頻繁に呼ばれるケースのパフォーマンスを改善します。
- 変更内容の詳細
背景となる既存実装の問題
ActionDispatch::RemoteIp の内部では、以下のような流れでクライアント IP を決定しています(簡略化):
GetIp#sanitize_ipsClient-Ip,X-Forwarded-For,REMOTE_ADDRなどから候補 IP の文字列配列を作る- 各文字列を
IPAddr.newでパース to_rangeを呼んでRange<IPAddr>を作る(このときさらに 2つのIPAddrオブジェクトが生成される)- ネットマスク付きのアドレスを弾く 等のフィルタリングを行う
GetIp#first_non_proxy- 上で作った文字列群に対して、再度
IPAddr.newでパースし直しつつ、trusted_proxiesと比較して最初の「プロキシでない IP」を選ぶ
- 上で作った文字列群に対して、再度
問題点:
- 同じ IP 文字列に対して
IPAddr.newを最大 2 回呼んでいる - ネットマスクを含まないアドレスにも一律
to_rangeを呼んでおり、不要なオブジェクト(IPAddr2つ +Range)を毎回生成している - この処理はデフォルトのログ出力("Started GET ... for ...")のたびに走るため、全リクエストに対して無駄なアロケーションが発生
今回の変更ポイント
1) sanitize_ips でのパースと range 生成の最適化
- 以前は全ての IP 文字列に対して:rubyとしていたが、
ip = IPAddr.new(raw_ip) ip_range = ip.to_range # 実際にはネットマスクチェック目的IPAddrの仕様として:"1.2.3.4"のようにスラッシュがない場合は「ホストアドレス(単一 IP)」として扱われる"1.2.3.4/24"のようにスラッシュ付きの場合だけネットマスクが解釈される
- これを利用し、スラッシュを含むアドレスにだけ range を生成するように変更:
- 「スラッシュなし=常に単一ホスト」であり、ネットマスク付きアドレスかを確認するために
to_rangeする必要がない
- 「スラッシュなし=常に単一ホスト」であり、ネットマスク付きアドレスかを確認するために
これにより、一般的な "203.0.113.7" のようなアドレスでは Range と余分な IPAddr が生成されなくなります。
2) sanitize_ips でパースした IP オブジェクトの再利用
sanitize_ips内で、各文字列をIPAddrにパースした結果を保持し、そのままfirst_non_proxyに渡すように変更:- これまでは
first_non_proxy内でも同じ文字列を再度IPAddr.new(raw_ip)していた - 変更後は、
sanitize_ips由来の IP については、既に生成済みのIPAddrオブジェクトをそのまま使う
- これまでは
- ただし API 互換性のため:
first_non_proxyは依然として「文字列 or IPAddr など」を受け取れるsanitize_ipsがアプリ側でオーバーライドされて別の形式を返す可能性もあるため、「渡されていない文字列」については従来どおりIPAddr.newでパースするフォールバックを維持
3) ネットマスク付きアドレスに関するテストの追加
- これまで
Client-IpやREMOTE_ADDRに"2001:db8::1/64"のようなネットマスク付き IP が来たときの挙動はテストされていなかった - 本 PR では、変更前の main ブランチで挙動を固定してから、その結果を期待値として 14 パターン分のテストを追加
Client-IpとREMOTE_ADDRの組み合わせを変えたテーブルテストのような形X-Forwarded-Forは Rack 側のバージョン依存(Rack 3.2.7 ではスラッシュ付きアドレスを捨てる挙動)があるため、ここでは利用しないテストにしている
- このテストは:
- main (変更前) と本 PR で同じ結果になる
sanitize_ips内の「ネットマスク付きアドレスを弾くチェック」を外すとテストが落ちる
= 挙動を変えずに内部実装を改善できていることの回帰テストになっている
4) パフォーマンス(オブジェクトアロケーション)の改善
Ruby 3.4.1 arm64 / デフォルトの trusted proxies / GC.stat(:total_allocated_objects) 計測:
| ケース | main | この PR |
|---|---|---|
| LB 経由: REMOTE_ADDR=10.0.0.5, XFF=203.0.113.7 | 65 | 45 |
| 2段プロキシ: XFF=203.0.113.7, 10.1.2.3 | 101 | 65 |
| IPv6 LB: REMOTE_ADDR=fc00::5, XFF=2001:db8::7 | 142 | 96 |
| 直アクセス: REMOTE_ADDR=127.0.0.1 | 37 | 22 |
- Rack 経由のシンプルな
render plain:リクエストで、401 → 386 object allocations に削減 - マシン負荷の関係で時間計測は載せていないが、GC 負荷低下につながる変更
- 影響範囲・注意点
- 対象範囲:
ActionDispatch::RemoteIpミドルウェア内部 (GetIp#sanitize_ips,GetIp#first_non_proxy)remote_ipを利用するログ出力やアプリケーションコード全般に、間接的にパフォーマンス改善として効く
- 互換性 / 挙動:
- 公開インターフェース(
request.remote_ipなど)の挙動変更はなしとされている - ネットマスク付きアドレスに関しては、変更前と同じ結果になることをテストで保証している
sanitize_ips/first_non_proxyのメソッドシグネチャや返り値の形式は変えていないsanitize_ipsを独自にオーバーライドしているアプリや gem があっても、first_non_proxy側でフォールバックパースするため、基本的には問題は起きない設計
- 公開インターフェース(
- 他 PR (#57627) との関係:
- 両者とも
first_non_proxy内のIPAddr.new(raw_ip)行を触るためテキスト上はコンフリクトするが、意味的には両立する - #57627 で
ip.nativeを使うようにしても、それは新しいIPAddrを返すだけであり、「sanitize_ips で保持している IP オブジェクト」を破壊しないので、この PR の「パース結果の再利用」方針とは問題なく共存可能
- 両者とも
注意点としては、「自分で ActionDispatch::RemoteIp を monkey patch している」ような場合に、内部の挙動に依存していると微妙な影響が出る可能性はありますが、通常のアプリケーション利用ではそのようなケースは稀で、基本的に安全な内部最適化です。
- 参考情報 (あれば)
- 対象ファイル:
actionpack/lib/action_dispatch/middleware/remote_ip.rbactionpack/test/dispatch/request_test.rb
- 関連 PR:
- #57627:
first_non_proxy内でip.nativeを使う変更(この PR と意味的に競合しない)
- #57627:
- Rack の挙動:
- Rack 3.2.7 では
X-Forwarded-For内のスラッシュ付き IP を捨てる挙動があり、Rails 側より前段でフィルタされる - そのため、ネットマスク付き IP のテストは
Client-IpとREMOTE_ADDRに限定している
- Rack 3.2.7 では
#59453 Reuse the period of time zones without transitions
マージ日: 2026/10/31 | 作成者: @linyiru
- 概要 (1–2文で)
UTC など「一切の遷移(サマータイム等)がないタイムゾーン」について、TimeZoneがTZInfo::TimezonePeriodを毎回新規生成せずに再利用するようにして、ActiveSupport/ActiveRecord におけるタイムゾーン関連のオブジェクト生成数と処理時間を削減する変更です。機能的な挙動は変えずに、UTC をデフォルトタイムゾーンとする一般的な Rails アプリのパフォーマンスを改善します。
- 変更内容の詳細
問題背景
- Rails の
ActiveSupport::TimeWithZoneは、ローカル時刻・オフセット・略称を初めて求めるタイミングでTimeZone#period_for_utcを呼び出し、TZInfo::TimezonePeriodを取得しています。 - UTC のような「遷移のないタイムゾーン」では、tzinfo レベルでは常に同じ単一の period を返すにもかかわらず、呼び出しの度に
TZInfo::TimestampとTimezonePeriodを新しく生成していました。 - この処理は
record.created_at.to_date,l(record.created_at)など、リクエスト中に大量に呼ばれうるため、オブジェクトアロケーションとCPUコストの無駄が発生していました。
この PR のコア変更
1) 遷移を持たないタイムゾーンでは period をキャッシュして再利用
TimeZone#initializeの時点で、tzinfo の period を一度だけ調べ、「開始も終了も遷移がない period =全時刻をカバーする固定 offset のゾーン(UTC/固定オフセットの Etc/GMT±X など)」であるかを判定。- そのようなゾーンの場合、その period をインスタンス変数として保持し、以降の
period_for_utcとperiod_for_localでは、tzinfo に問い合わせず 同じ period オブジェクトを返すように変更。
イメージとしては:
class ActiveSupport::TimeZone
def initialize(*)
# 既存のロジックで tzinfo ゾーンを取得
period = @tz.period_for_utc(Time.utc(2026, 1, 1))
if period.start_transition.nil? && period.end_transition.nil?
# 遷移のないゾーンと判定
@period_without_transitions = period.freeze # ゾーンと一緒に共有可能に
end
end
def period_for_utc(time)
if @period_without_transitions
@period_without_transitions
else
@tz.period_for_utc(time) # 従来通り tzinfo に委譲
end
end
def period_for_local(time, dst = true)
if @period_without_transitions
@period_without_transitions
else
@tz.period_for_local(time, dst)
end
end
end※実際のコードは多少異なりますが、概念的にはこのような形です。
TimeZone#initializeはもともとutc?判定のために period を調べていたので、「遷移なし判定+キャッシュ」によって 追加の period 取得コストは発生しません。- 得られた period は TimeZone オブジェクトと同様に shareable にしており、Ractor で共有可能な性質も維持されています。
2) 遷移を持つタイムゾーンの挙動はそのまま
- サマータイムを持つ
America/New_Yorkなどでは、これまで通り tzinfo に問い合わせ、UTC⇔ローカル変換時に EST/EDT を切り替えます。 - 従って DST を伴うタイムゾーンの挙動は一切変わっていません。
3) テストの追加
追加テストで、主に以下を検証しています。
- UTC /
Etc/UTC/Etc/GMT+5:- 1900, 2026, 2100 など離れた年の時刻に対して
period_for_utc/period_for_localが 常に同一の period オブジェクトを返すこと。 - その period の offset, abbreviation が tzinfo のものと一致すること。
TimeWithZoneを通したローカル時刻計算も期待通りであること。
- 1900, 2026, 2100 など離れた年の時刻に対して
- 遷移を持つゾーン(例:
America/New_York):- 年や季節に応じて EST/EDT が正しく切り替わること(=キャッシュは使われず既存のロジックが使われていること)。
- UTC の
TimeZoneが依然として Ractor-shareable であること。
4) パフォーマンス/メモリへの効果
作者の計測(Ruby 3.4.1 arm64)によると:
TimeWithZone.new(utc_time, Time.zone).day(UTC)- アロケーション数: 3 → 1
Time.zone.local(...).hour(UTC)- アロケーション数: 6 → 3
America/New_Yorkではアロケーション数は変化なし(4 / 7 → 4 / 7)
Rails アプリ全体として:
- 100 レコードをロードして各レコードで timestamp を1回読むリクエスト:
- 2597 → 2197 allocations
- 同じ 100 レコードをコレクションパーシャルでレンダリング:
- 4342 → 3942 allocations
処理時間の一例(かなりノイジーな環境での計測のため参考値):
- 単一の UTC timestamp 読み込み:
- 約 1.1–2.1 µs → 0.34–0.38 µs
- 100件リクエスト(YJIT使用):
- 中央値 542 µs → 483 µs
- 影響範囲・注意点
挙動の変更は原則なし
- 仕様上は「遷移のないタイムゾーンの period オブジェクトを毎回新規生成していたものを、同一オブジェクトに変えただけ」なので、
TimeWithZoneの返す値(ローカル時間・offset・abbr)は変わりません。 - サマータイムを持つゾーンは完全にこれまで通りです。
- 仕様上は「遷移のないタイムゾーンの period オブジェクトを毎回新規生成していたものを、同一オブジェクトに変えただけ」なので、
恩恵が大きいケース
config.time_zone = 'UTC'やEtc/UTC, 固定 offset のタイムゾーン (Etc/GMT+5など) を使っているアプリケーション。- 特に大量の Active Record レコードの timestamp をテンプレート側でレンダリングしている場合に、オブジェクト生成数削減の恩恵を受けます。
互換性リスク
TimezonePeriodオブジェクトの「同一インスタンス性」に依存したコード(例えばobject_idの比較など)を書いていると、UTC ゾーンでは今後「常に同じオブジェクトが返ってくる」ようになるため、挙動が変化する可能性はあります。ただし、そのようなコードは通常の利用では想定されていません。- Ractor 互換性はテストで担保されています。
CHANGELOG なし
- 挙動の変更ではなく最適化のみのため、CHANGELOG には記載していません。
- 参考情報 (あれば)
- 関連 PR / コミット:
- #58785 およびコミット
bd2f4d1cb9TimeZone#todayにおける「UTC が一定なゾーンでの period lookup をスキップする」最適化。今回の変更はその流れをさらに進め、period オブジェクトそのものの再利用に踏み込んだもの。
- #58785 およびコミット
- 計測方法:
GC.stat(:total_allocated_objects)によるアロケーションカウント。- Ruby 3.4.1 arm64 環境でのベンチマーク。タイミングは変動が大きいため、主に allocation 数の削減が根拠となっています。
#59451 Make Parameters#to_h and #to_unsafe_h up to 1.6x faster by copying each hash once
マージ日: 2026/10/31 | 作成者: @czj
- 概要 (1-2文で)
Rails のActionController::Parametersにおけるto_h/to_unsafe_hで、ネストしたハッシュを二重コピーしていた箇所を一度のコピーに抑えることで、最大約 1.6 倍の高速化を行った PR です。挙動(返却値の構造やHashWithIndifferentAccessの性質)は維持しつつ、内部実装をより効率的なメソッドに差し替えています。
- 変更内容の詳細
何をしているか
対象は Strong Parameters のこのあたりの処理(擬似コードイメージ)です:
def to_h
# もともと:
# 1. HashWithIndifferentAccess を transform_values で変換しつつ
# 2. with_indifferent_access で再度 Indifferent に変換していた
end現状の問題点:
Parameters#to_h/#to_unsafe_hは、ネストしたHashを処理する際に:HashWithIndifferentAccess#transform_valuesが 新しいHashWithIndifferentAccessを返す- その戻り値に対してさらに
with_indifferent_accessを呼んで もう一度 dup する
- つまり、各ネストハッシュが「2回コピーされている」 状態になっている。
to_hは許可済みパラメータのマスアサイン (Post.new(params.expect(post: ...))) のたびに呼ばれるため、フォーム送信のたびにこのコストが積み上がる。
変更点
この PR では、with_indifferent_access の呼び出しを、より賢い変換メソッドである nested_under_indifferent_access に置き換えています。
nested_under_indifferent_access の挙動:
- 引数がすでに
HashWithIndifferentAccessの場合:- そのまま(不要なコピーなしで)利用
- 引数が通常の
Hashの場合:HashWithIndifferentAccessに変換
これにより:
- 「常に dup する」→「必要なときだけ変換する」 に変わる
- それでも以下の 2 ケースでは変換が必要なため、動作は維持される:
to_hにブロックを渡したとき(ブロックが plainHashを返しうる)Parameters#transform_values!で plainHashが保存されたとき
結果として:
- ネストしたハッシュは原則「1回だけ」コピーされるようになり、
to_h/to_unsafe_hが高速化
テストの追加・変更
2 つの重要なテストが追加されています:
to_h/to_unsafe_hの「完全コピー性」の保証テスト- 返り値のハッシュ/配列が、元の
Parametersオブジェクトと ネストしたハッシュや配列を共有していない ことを確認 - これにより、「高速化のために破壊的・共有的になっていないか」を担保
- 返り値のハッシュ/配列が、元の
transform_values!後のハッシュが「indifferent」で返ってくることのテストParameters#transform_values!で単なるHashを内部にセットした場合でもto_h/to_unsafe_hの結果ではそのハッシュがHashWithIndifferentAccessとして振る舞うことを確認- このテストは
nested_under_indifferent_accessによる変換をやめると失敗するため、変換が必要であることの裏付けにもなっている
擬似コードイメージ
元:
# HashWithIndifferentAccess を transform したあとで、
# with_indifferent_access でもう一度包み直していたイメージ
hash = some_hash_with_indifferent_access.transform_values { ... }
hash = hash.with_indifferent_access # ← ここで不要な二重コピーが発生変更後:
# nested_under_indifferent_access により、
# 既に indifferent なものはそのまま利用し、
# plain Hash だけを変換する
hash = some_hash_with_indifferent_access.transform_values { ... }
hash = hash.nested_under_indifferent_access- 影響範囲・注意点
影響範囲
- 対象:
ActionController::Parameters#to_h/#to_unsafe_hの内部実装 - 主に次のようなコードパスで恩恵がある:
- Strong Parameters を使ったフォーム入力のマスアサイン:ruby
Post.new(params.expect(post: [:title, :body])) # 内部で to_h が走る - 深くネストしたパラメータを
to_h/to_unsafe_hで普通のハッシュに変換する処理
- Strong Parameters を使ったフォーム入力のマスアサイン:
パフォーマンス計測結果(抜粋・概略):
to_h(フラット 5 キー): 最大 ~1.5–1.6xto_h(ネストした order): 最大 ~1.5–1.6xto_unsafe_h(ネスト): 最大 ~1.4–1.6xexpect + to_h全体では 1.08–1.11x 程度の向上(expect部分のコストも含むため)
互換性・注意点
- 48,000 パターンのランダムパラメータを使った検証で、以下が「main ブランチと完全に同じ」であることが確認済み:
to_h(許可された場合 / 許可されていない場合のエラー含む)- ブロック付き
to_h to_unsafe_hto_hash
- 比較内容:
- 返り値のクラス
- frozen 状態
default/default_proccompare_by_identity- ネスト構造
- 返り値を変更しても元の
Parametersが変わらないこと
つまり、外部から見える挙動は変わらず、パフォーマンスだけが向上している変更と見なせます。
注意が必要なケース:
ActionController::Parametersの戻り値の 内部実装の細部に依存したメタプログラミング をしている場合(HashWithIndifferentAccessの変換タイミングなど)には、挙動が微妙に変わる可能性がありますが、通常のアプリケーションコードでは問題にならない想定です。
- 参考情報 (あれば)
- この PR は、同じファイルの関連最適化である #59159, #59425 に続くものです。
- #59450 では同ファイル内の
_deep_transform_keys_in_objectのコピー部分に別途変更が入っており、この PR の差分とは競合しないと明記されています。 - CHANGELOG の更新は不要と判断されるレベル(挙動変更なしの最適化)として扱われています。
#59450 Make deep_symbolize_keys and deep_stringify_keys up to 1.48x faster by not allocating a pair per key
マージ日: 2026/10/31 | 作成者: @czj
- 概要 (1-2文で)
Hash#deep_symbolize_keys/deep_stringify_keys/deep_transform_keysとActionController::Parameters#deep_transform_keysの内部実装を変更し、キーごとに不要な[key, value]配列を作らないようにして、最大約 1.5 倍の高速化とオブジェクト生成数削減を行う最適化 PR です。外部から見た振る舞い(結果のクラス・キー順序・衝突時の優先ルール)は一切変わりません。
- 変更内容の詳細
もともとの問題点
deep_symbolize_keys / deep_stringify_keys / deep_transform_keys は、内部で _deep_transform_keys_in_object というヘルパを使っていますが、その中で次のようなイテレーションをしていました。
def _deep_transform_keys_in_object(object, &block)
case object
when Hash
object.each_with_object(object.class.new) do |(key, value), result|
result[yield(key)] = _deep_transform_keys_in_object(value, &block)
end
# ...
end
endeach_with_object によってブロックには |(key, value), result| という形で渡されるため、Rails 側が (key, value) という 2 要素配列を毎回展開することになり、「キー1つごとに [key, value] の一時配列を1個生成する」というコストがかかっていました。
深くネストした大きめのハッシュ(JSON:API 形式のペイロードなど)では、この一時配列の生成が多くなり、計測では deep_symbolize_keys の 92 個のオブジェクト割り当てのうち 66 個がこの「ペア配列」という状況でした。
今回の変更内容
この PR は、そのイテレーションを次のように変更しています。
def _deep_transform_keys_in_object(object, &block)
case object
when Hash
result = object.class.new
object.each do |key, value|
result[yield(key)] = _deep_transform_keys_in_object(value, &block)
end
result
# ...
end
endポイント:
each_with_object(self.class.new)→ ローカル変数result = object.class.newに変更- ブロック引数も
|(key, value), result|→|key, value|に変更
→[key, value]の 2 要素配列を生成せずに済む - 戻り値として
resultを明示的に返す
同じ変更が ActionController::Parameters 内部の deep_transform_keys 用ヘルパにもコピーされ、Strong Parameters 側でも同じ最適化が行われています。ただし、Parameters では各階層で to_unsafe_h を挟むため、効果は小さい(≒オーバーヘッドが相対的に大きい)ことが説明されています。
deep_transform_values はもともと transform_values を使っており、こうしたペア配列は作っていないため今回の対象外です。
性能改善の具体的な数字
JSON:API っぽいサンプル(キー 66 個、深さ 5)の計測結果(Ruby 4.x 各種 JIT 含む)では、オブジェクト割り当て数と i/s が次のように変化しています(一部抜粋):
deep_symbolize_keys- オブジェクト数: 92 → 26
- スループット:
- Ruby 4.0.9 (no JIT): 95.7k i/s → 133.8k i/s (1.40x)
- Ruby 4.0.9 (YJIT): 162.8k i/s → 241.2k i/s (1.48x)
- Ruby 4.1.0-preview1 (ZJIT): 132.5k i/s → 188.6k i/s (1.42x)
deep_stringify_keys- オブジェクト数: 92 → 26
- スループットも同程度の倍率で向上 (1.37〜1.47x 程度)
deep_transform_keys { ... }- オブジェクト数: 230 → 164
- 1.18〜1.31x ほどの改善
Parameters#deep_transform_keys も 1.08〜1.11x 程度の改善がありますが、前述の通り、Parameters 固有のオーバーヘッドが効いて劇的ではありません。
また、Hash / HashWithIndifferentAccess / Parameters に対して、ランダムにネストした 5,000 個のハッシュ(配列混在・キー衝突も含む)に同じ処理を走らせ、以下を比較して「完全一致」であることを確認済みです。
- 結果オブジェクトのクラス
- キー順序
- 値
- 影響範囲・注意点
影響範囲
ActiveSupport:Hash#deep_symbolize_keysHash#deep_stringify_keysHash#deep_transform_keysとその内部実装_deep_transform_keys_in_objectHashWithIndifferentAccessに対しても、同じ実装経由で効果あり
ActionPack/ActionController:ActionController::Parameters#deep_transform_keys
→ Strong Parameters でパラメータキーを書き換えるケース(underscoreしたり、camelCase 変換したり)に影響
動作上の挙動
- 変更前後で:
- 返ってくるオブジェクトの型
- キーの順序
- 同じキーに変換されたときの「最後の値が勝つ(last-wins)」ルール
- は明示的に変えないことが保証されています(広範な比較検証済み)。
そのため、アプリケーションレベルでは「速度とメモリが良くなっただけ」で、既存のコードを変更する必要はありません。
注意点
- 公開 API のシグネチャや例外挙動は変わっていませんが、もしアプリ内で
deep_transform_keysの挙動に依存した繊細な処理(キー衝突順序やHashWithIndifferentAccessのクラスなど)をしているなら、念のため既存テストを一通り流しておくと安心です。 ActionController::Parametersでは効果は限定的ですが、大量パラメータを扱う API・大きな JSON ボディを受け取るエンドポイントでは、レスポンスタイムや GC 圧が多少軽くなる可能性があります。
- 参考情報 (あれば)
- 類似変更: PR 説明中で触れられている #59236 も、
each_with_objectをeach+ ローカル変数に置き換えることで同様の最適化を行った PR です。 - JSON:API 形式や camelCase → snake_case 変換などで
deep_*_keysを多用するアプリでは、1 リクエストあたりのオブジェクト割り当て数がかなり減るため、トラフィックの多い API で特にメリットが出やすい変更です。
#59461 Expose the untouched association name to restrict_with_error messages
マージ日: 2026/10/31 | 作成者: @jasonmaxlee
- 概要 (1-2文で)
dependent: :restrict_with_errorのエラーメッセージに、従来の%{record}に加えてオリジナルの大文字・小文字を保持した%{association_name}を渡すようにし、多言語対応(特に名詞を大文字で書く言語)で正しい表記ができるようにした変更です。既存の英語訳などの挙動はそのまま維持されます。
- 変更内容の詳細
これまでの問題点
dependent: :restrict_with_error を使ったときのエラーメッセージは、I18n で restrict_dependent_destroy というキーを使って生成されます。その際、関連名の human 名を %{record} として渡していますが、内部で必ず downcase されていました。
- 英語圏では
- モデル側:
has_many :companies, dependent: :restrict_with_error - エラーメッセージ:
"Cannot delete client because dependent companies exist"
のような感じで、「companies」を小文字にしたいケースが多く、downcaseは都合がよかった。
- モデル側:
- しかし、ドイツ語など「名詞は先頭大文字」が文法の言語では
- 翻訳例:
companies→"Firmen" downcaseされると"firmen"となり、文法的に誤りになる。
- 翻訳例:
- さらに、
%{record}にはすでに小文字化された値しか渡されないため、「元の大文字・小文字」を翻訳側で復元することは不可能だった。
一方で、単に downcase をやめると、英語アプリで「ラベルとして」大文字書きされた翻訳(例: "Client companies")がそのまま文中に出てしまい、「… because Client companies exist」のような不自然な文章になるアプリが壊れてしまいます。
今回の変更点
この PR では、互換性を保ちつつ他言語での制御性を上げるために、次のようにしました。
has_many/has_oneそれぞれのrestrict_with_errorロジックで:- これまでどおり
%{record}には「小文字化された human 名」を渡す(既存挙動維持) - 追加で
%{association_name}に「小文字化していない元の human 名」を渡す(新しい補助キー)
- これまでどおり
これにより:
- 既存の I18n エントリで
%{record}を使っている英語アプリは、挙動一切変更なし。 - ドイツ語など、大文字を維持したいロケールでは、翻訳側で
%{association_name}を使うように変更することで正しい表記が可能になる。
具体例
従来 (英語想定のまま):
# config/locales/en.yml
en:
errors:
messages:
restrict_dependent_destroy:
has_many: "Cannot delete record because dependent %{record} exist"
has_one: "Cannot delete record because a dependent %{record} exists"このままでも挙動は変わりません。%{record} には今までどおり downcase 済みの名前が入ります。
ドイツ語などで今回の変更を活かす例:
# config/locales/de.yml
de:
errors:
messages:
restrict_dependent_destroy:
has_many: "Datensatz kann nicht gelöscht werden, da abhängige %{association_name} existieren"
has_one: "Datensatz kann nicht gelöscht werden, da ein abhängiges %{association_name} existiert"- モデル:
class User < ApplicationRecord
has_many :companies, dependent: :restrict_with_error
end- ドイツ語の翻訳で
companies→"Firmen"としている場合、%{record}を使うと"firmen"(小文字化)になってしまう%{association_name}を使えば"Firmen"のまま使える
この PR によって %{association_name} が補助的に使えるようになり、ドイツ語側のメッセージを大文字のまま書けるようになります。
コード上の変更点 (概略)
has_many_association.rb / has_one_association.rb において、restrict_with_error 実行時に I18n へ渡すオプションハッシュに association_name: を追加した、というシンプルな変更です。
イメージとしては:
# (擬似コード・実際とは多少異なります)
errors.add(:base, :restrict_dependent_destroy,
record: association_name.downcase, # 既存
association_name: association_name # 新規
)テスト:
has_many_associations_test.rb/has_one_associations_test.rbにそれぞれ 1 テストずつ追加- ドイツ語メッセージ内で
%{association_name}を利用するケースを追加 - 変更前は:
%{association_name}が存在しないためI18n::MissingInterpolationArgumentが発生- あるいは
%{record}経由だと"firmen","konto"のように小文字化される
- 変更後は:
- エラーなく
%{association_name}が補間され - 翻訳側が意図した大文字表記が保たれる
既存のtest_restrict_with_error*系テストは変更なし・全てパス。
- エラーなく
- ドイツ語メッセージ内で
activerecord/CHANGELOG.md にも、この新しい補間キー追加についてのエントリが追記されています。
- 影響範囲・注意点
- 互換性:
%{record}の意味・値は変わっていないため、既存アプリのメッセージはそのまま動作します。- 新規に
%{association_name}が追加されただけの後方互換な変更です。
- ロケールファイルの修正方針:
- 英語ロケール:
- ほとんどのケースで今までどおり
%{record}を使い続けて問題ありません。 - ラベル的に「Client companies」のように大文字混じりで翻訳している場合、
%{record}に依存しているときは従来通り小文字化された形で出力されます(挙動は変えないため)。
- ほとんどのケースで今までどおり
- ドイツ語など大文字小文字が意味を持つ言語:
restrict_dependent_destroyのメッセージを%{record}から%{association_name}に変更することで、正しい大文字表記を維持できます。
- 英語ロケール:
- もし自前で
restrict_dependent_destroyメッセージをオーバーライドしていて、かつ%{association_name}というプレースホルダをすでに別用途で使っている場合は、今回から Active Record が同名のキーを渡すようになる点に注意が必要です(通常はそのような使い方はしていないはずですが、名前衝突の可能性としてはあり得ます)。
- 参考情報 (あれば)
- 対応 Issue: https://github.com/rails/rails/issues/21064
- PR: https://github.com/rails/rails/pull/59461
- 関連する I18n キー:
errors.messages.restrict_dependent_destroy.has_manyerrors.messages.restrict_dependent_destroy.has_one
- 実際にこの変更を活かすには、
config/locales/*.yml内で%{association_name}を使うよう翻訳を更新する必要があります。
#59462 Pass associations added to a parent down to existing subclasses
マージ日: 2026/10/31 | 作成者: @jasonmaxlee
- 概要 (1-2文で)
Active Record の関連定義(has_manyなど)を親クラスに「後から」追加した場合でも、その親クラスを継承したサブクラスに正しく伝播するようにしたバグ修正です。エンジン/Concern/再オープンしたクラスで親に関連を追加したとき、既存サブクラスで関連メソッドはあるのにAssociationNotFoundErrorが出る問題を解消します。
- 変更内容の詳細
問題の背景
従来の挙動:
class Post < ActiveRecord::Base; end
class FancyPost < Post
has_many :tags
end
# 後から親に関連を追加
Post.has_many :comments
FancyPost.first.comments
# => ActiveRecord::AssociationNotFoundError
# "FancyPost には 'comments' という関連が見つからない"なぜこうなるか:
- 各モデルは
_reflectionsというハッシュで関連情報を保持している add_reflection(has_manyなどが内部的に使う処理)は、- 「関連を定義したクラス」に対して 新しい frozen な
_reflectionsハッシュを書き込む
- 「関連を定義したクラス」に対して 新しい frozen な
FancyPostがhas_many :tagsを定義した時点で、Postの_reflectionsをコピーした 自前の_reflectionsハッシュ を持つようになる- その後で
Post.has_many :commentsを呼ぶと:Postの_reflectionsは更新される- しかし
FancyPostは自前で持っているコピーを更新しないため、commentsの Reflection を知らない
- さらに、
normalized_reflections(Reflection のキャッシュ)もサブクラス側で一度読まれると、親に変更があってもキャッシュが更新されないケースがある
結果として、「親クラスに後から追加した関連」がサブクラスに見えず、例外になるというバグが起きていました。
PR で行ったこと
ActiveRecord::Reflection.add_reflection の挙動を変更して、親クラスに関連を追加したときに、既存のサブクラスにもその情報を適切に反映するようにしました。
具体的には:
親クラスに Reflection(関連)が追加されたあと、
- そのクラスの
descendants(すべての子孫クラス)を辿る
- そのクラスの
各子孫クラスに対して:
- まず、そのクラスの Reflection キャッシュ(
normalized_reflectionsなど)をクリアする
→ 親クラスの変更を正しく反映させるため - その子孫クラスが「自分専用の
_reflectionsハッシュ」を持っているかを確認- 持っていない(= 親から継承のみしている)場合:
- キャッシュだけクリアして終了。後で参照するときに、親から正しく継承される
- 自分専用の
_reflectionsを持っている場合:- 「同じ名前の関連を自分(または間のクラス)がオーバーライドしていないか」を確認
- オーバーライドがなければ、親に追加された Reflection を、その子孫クラスの
_reflectionsにも追加 - もし同名の関連がすでに子孫側で定義されていれば、そちらを優先して上書きしない
- 持っていない(= 親から継承のみしている)場合:
- まず、そのクラスの Reflection キャッシュ(
これにより、
- 後から親に追加された関連が、
- 独自の関連を持つサブクラス
- その孫クラス にも正しく見えるようになる
- ただし、サブクラス側で明示的に同名の関連を定義している場合は、これまで通りサブクラスの定義が優先される
テストで確認していること
activerecord/test/cases/reflection_test.rb に追加されたテストで以下を保証:
サブクラスが自前の関連を一つ持っている場合
- 親に後から追加した関連もサブクラスで利用できる
- サブクラスから親の関連を使ったとき、正しくサブクラス用の行(例:
fancy_postsのcomments)だけをロードする
孫クラス(子の子)でも同様に動く
サブクラスが親の Reflection を一度読んでキャッシュしている場合でも
- 親に後から追加した関連が見えるようになる(キャッシュが正しくクリアされる)
サブクラスが同じ名前の関連をオーバーライドしている場合
- これまで通りサブクラス側の関連定義が保持される(親の新しい関連で上書きしない)
1〜3 は本 PR 前は失敗していたが、変更後はパスするようになったことが示されています。4 は前後で挙動に変化なし。
さらに、この変更を入れた状態で Active Record SQLite のフルテストスイートがパスしていることも記載されています。
- 影響範囲・注意点
影響範囲
対象:
- Active Record を使った STI/継承モデル全般
- 特に、親クラスより先にサブクラスがロード・定義され、その後に親クラスへ関連が追加されるようなケース
- Rails Engine が親モデルに関連を追加する
- Concern を
includeすることで親クラスにhas_many等が後から乗る - クラス再オープン(
class Post < ApplicationRecord; endを複数ファイルで定義)で関連を追加する
これまで:
- 「メソッドは生えているのに、呼ぶと
AssociationNotFoundError」のような微妙な不整合が起き得た
- 「メソッドは生えているのに、呼ぶと
これから:
- 親に後付けした関連が、既に存在するサブクラスにも一貫して伝播する
パフォーマンス面
descendantsの走査は:- サブクラスが既に存在する状態で関連を追加したときに限り実行される
- 通常の「親クラス → その後でサブクラスを定義 → 親に関連を追加しない」という多くのケースでは
descendantsは空で、余計な仕事は発生しない
- 関連定義 (
has_manyなど) 自体はそこまで頻繁に実行されるものではないため、実運用への影響は限定的と考えられます
互換性・注意点
- サブクラスが同名の関連を明示的に定義している場合は、今まで通りサブクラスの定義が優先される
→ 挙動が変わるのは「親にだけ定義された関連」がサブクラスに見える/見えないの差分のみ - もしアプリ側で
_reflectionsやnormalized_reflectionsを直接触っているような高度なメタプログラミングをしている場合は、この変更で挙動がより一貫する方向に揃えられますが、念のため挙動確認をした方が安全です
- 参考情報 (あれば)
- 対応 Issue: #20678
- 過去の関連 PR: #24719
- 2016 年に議論されつつもマージされなかったアプローチ(sgrif, matthewd 提案)を、今回の PR で実現した形
- 変更ファイル:
activerecord/lib/active_record/reflection.rbadd_reflectionのロジック拡張(子孫クラスへの伝播 + キャッシュクリア)
activerecord/test/cases/reflection_test.rb- 上記シナリオをカバーするテストを追加
activerecord/CHANGELOG.md- 振る舞い変更として記載