Skip to content

Ruby on Rails PR Digest - 2026年 11月 ​

このページは rails/rails リポジトリにマージされたPull Requestを自動的に収集し、AIで要約したものです。

#59658 Fix Array#index_with documentation ​

マージ日: 2026/11/5 | 作成者: @byroot

  1. 概要 (1-2文で)
    Array#index_with のドキュメントから実装寄りの説明を取り除き、より API 利用者向けの内容に整理した PRです。機能や挙動には変更はなく、あくまでドキュメント改善のみです。

  1. 変更内容の詳細

対象:

  • activesupport/lib/active_support/core_ext/enumerable.rb の Array#index_with に関するドキュメントコメント

主な変更点:

  • 「内部でどう実装しているか」「どんなリファクタリング経緯があるか」といった実装詳細・履歴に近い記述をドキュメントから削除
  • 代わりに、index_with を使う開発者が知るべき以下のような点にフォーカスした説明へ整理
    • どのような戻り値になるか(ハッシュを返す)
    • ブロックあり/なしでの使い方
    • 引数を与えた場合の挙動

index_with 自体の役割(典型例):

ruby
# ブロックあり: 各要素をキー、ブロックの戻り値を値にした 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 は、こうした「どう使うか」「どう振る舞うか」の説明をわかりやすく保ちつつ、

  • 実装クラス・モジュールの詳細
  • 最適化・内部実装のテクニックや履歴(「昔はこうだった」等)
    といった、将来の実装変更で陳腐化しやすい情報をドキュメントから追い出す方向の変更です。

  1. 影響範囲・注意点
  • 挙動・API 仕様・シグネチャに変更は一切なく、既存コードへの影響はありません。
  • テスト追加・変更もなく、純粋なドキュメント修正です。
  • 今後、index_with の内部実装が変わっても、ドキュメントに矛盾が出にくくなります(実装詳細を記載しない方針にしたため)。
  • Rails のガイドラインとして「実装詳細はドキュメントではなくコミットメッセージに書く」という方針がより明確になったと解釈できます。

  1. 参考情報 (あれば)
  • 似た 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. 概要 (1-2文で)
    ActiveSupport::Inflector の pluralize / singularize が、内部でのルール適用方法を見直すことで大幅に高速化された PR です。従来は各ルールごとに sub! を実行していたのを、まず match? / include? で「マッチするルールか」を判定してから、該当ルールに対してのみ sub! を呼ぶように変更しています。

  1. 変更内容の詳細

これまでの挙動 ​

ActiveSupport::Inflector.pluralize / singularize は、内部的には apply_inflections というメソッドで「単数・複数形変換ルール」を上から順に試しています。

ざっくりしたイメージ:

ruby
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 ステップ方式に変えています。

おおよそのイメージ:

ruby
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? 経由)の導入によって、従来の変換結果が変わっていないこと
  • 正規表現・文字列ルールの両方で期待通りの動作を維持していること

を確認する回帰テストです。挙動変更ではなく純粋な最適化であることを保証するためのものと考えられます。


  1. 影響範囲・注意点
  • 対象範囲

    • ActiveSupport::Inflector.pluralize
    • ActiveSupport::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! 実行前に一段増えるので、非常にニッチなケースで動作差が出ないかだけ確認すると安心です。

  1. 参考情報 (あれば)
  • 対象コード: 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 による上乗せの効果がある

#59570 Escape unsafe OutputBuffer appends without the super dispatch ​

マージ日: 2026/11/5 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    Rails のテンプレートで <%= %> を評価した際の出力バッファ (ActionView::OutputBuffer#<<) におけるエスケープ処理を最適化し、super 経由の動的ディスパッチを避けることで、インタプリタ・YJIT の両方で高速化した PR です。
    これにより、非 html_safe な値を <%= %> で出力する処理が 20~25% 程度高速化されています(PR 記載のベンチマーク値より)。

  1. 変更内容の詳細

何をしているか ​

  • ActionView::OutputBuffer#<< で、非 html_safe な値をテンプレートに出力する際のエスケープ処理を変更。

  • これまでの流れ(簡略化):

    ruby
    # 実際には OutputBuffer#<< 内で
    # あくまでイメージ
    if !value.html_safe?
      ERB::Util.unwrapped_html_escape(value)
    else
      value
    end

    unwrapped_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% 減
  • Fizzy / Lobsters などのアプリでは 1 ページあたり 130~920 回の <%= %> append が発生するので、
    ページレンダリング全体でそれなりに効いてくる最適化です。

  1. 影響範囲・注意点

影響範囲(挙動の変化) ​

  • 標準的な Rails アプリで、<%= %> の動作(エスケープ結果)は変わりません。
  • 変わるのは「どのメソッドを経由してエスケープされるか」という呼び出し経路のみです。

互換性上の注意点 ​

  • テンプレートからの append だけは、今後は ERB::Util の singleton class を経由しません。

  • そのため、次のような“かなりトリッキーな”拡張をしている場合にだけ挙動が変わります:

    • ERB::Util の singleton class に prepend したモジュールで
      • unwrapped_html_escape
      • もしくは Active Support がロードされる前の html_escape
        をオーバーライドしているケース

    具体例(こういうことをしていると効かなくなる):

    ruby
    module 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 に対してかなり特殊なメタプログラミングをしている場合のみ注意が必要、というレベルです。


  1. 参考情報 (あれば)

#59602 Show the template's line for syntax errors compiled through Herb ​

マージ日: 2026/11/5 | 作成者: @IslamElsayed

  1. 概要 (1-2文で)
    Rails 8.2 でデフォルト HTML テンプレートコンパイラになった Herb 利用時に、テンプレートの構文エラーが「どのファイルの何行目か」を正しく表示できるようにする修正です。ERB ハンドラが Herb にテンプレートのファイル名を渡すようにしたことで、エラー画面で実際のテンプレートの該当行がハイライトされるようになります。

  1. 変更内容の詳細

何が問題だったか ​

  • 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 の内部コードが表示され、デバッグしづらい状態になっていた。

何を変更したか ​

1. ERB ハンドラで filename を渡すようにした ​

actionview/lib/action_view/template/handlers/erb.rb にて:

  • Herb へ渡すオプションに filename: template.identifier を追加。
  • template.identifier はそのテンプレートの実ファイルパス(例: app/views/posts/index.html.erb)を表す。

イメージとしては以下のような変更です(擬似コード):

ruby
# 変更前(イメージ)
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.rb
  • railties/test/application/rendering_test.rb

で、以下を確認するようなテストが追加されている:

  • Herb 経由でコンパイルしたテンプレートに構文エラー(例: 余分な <% end %>)がある場合:
    • 例外メッセージに実ファイルパスが含まれる。
    • エラー画面(テスト環境でのエラーレスポンス)に、該当テンプレートの該当行が表示される。
    • Herb の内部ソースではなく、ユーザのテンプレートコードが「around line #x」として表示される。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • load_defaults 8.2 を使っていて、HTML テンプレートが Herb でコンパイルされているプロジェクト。
    • 開発環境(error pages)やテスト環境での構文エラー表示。
  • 主な効果:
    • HTML テンプレート内の構文ミス時に、エラー画面が「正しいテンプレートファイルの正しい行」を表示するようになる。
    • デバッグ性が向上し、「Herb の内部行を見せられてもどこを直せばいいか分からない」という状況が解消される。

既知の制限・未対応部分 ​

  • Erubi には未対応:
    • HTML 以外のフォーマット(例: *.text.erb, *.js.erb)や、
    • load_defaults < 8.2(Herb がデフォルトになっていないアプリ) では Erubi が使われるが、そちらの構文エラー行ズレ問題は依然として残っている。
  • Erubi の問題の特徴:
    • Ruby がエラーを検出するタイミングがテンプレート全体を包むメソッドの末尾付近になることが多く、
    • 「ファイル末尾より後ろの行」として報告されるケースがある。
  • この PR は #51092 の一部対応であり、Erubi 側まで含めた完全な解決には至っていないため「refs #51092」という扱いになっている。

バージョン・依存関係 ​

  • Ruby 3.4.7 + Prism + parse.y で動作確認済み。
  • Herb は Gem として 0.10 も許容しているが、この PR では 0.11.0 でテストされており、0.10 での動作は未検証。

  1. 参考情報 (あれば)
  • この変更によって、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. 概要 (1-2文で)
    このPRは、Ractor 内で require を呼ぶと毎回スレッドが新規作成されてしまう現状を踏まえ、不要な require 呼び出しをスキップするための微修正を Active Support に入れたものです。実質的には 1 行の条件(または同等のロジック)変更で、Ractor 利用時のオーバーヘッドを軽減する意図があります。

  1. 変更内容の詳細
  • 変更ファイル: 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 対応ユーティリティの読み込み)に対して、「すでにロード済みか」「そもそもこの文脈では不要か」といったチェックを追加/強化している、と考えられます。

擬似的なイメージコードは以下のようなものです(実際のコードとは異なる可能性がありますが、意図のイメージとして):

ruby
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 を避ける形の条件分岐になっている可能性もあります。


  1. 影響範囲・注意点
  • 対象:
    • Ractor を利用している(あるいは利用可能な)Ruby / Rails 環境。
    • 特に Ractor 内から Rails / Active Support の機能を呼び出すパターン。
  • 効果:
    • Ractor 内での require 呼び出しが減少することで、Ractor 実行時に不必要にスレッドが増える問題を軽減。
    • その結果、Ractor を使った並列処理のパフォーマンスとリソース効率が多少改善される可能性があります。
  • 互換性:
    • 行数的にも微小変更であり、「条件を満たさない限り require しない」だけなので、通常は後方互換性が保たれます。
    • 唯一懸念があるとすれば、「実は require が必要なのに条件が厳しすぎてスキップされてしまう」場合ですが、そのようなバグがない前提でマージされています。
    • Ractor を使っていない通常の Rails アプリでは、この変更の影響はほぼありません。

運用上の注意:

  • Ractor を本格的に使っているアプリでは、この変更により Ractor ワーカーのスレッド数が過剰に増える現象が緩和されるか確認すると良いです。
  • ただし PR 説明の通り、Ruby 4.1 で根本的に修正される想定なので、Ruby 本体のバージョンアップ時には挙動が変わる(=Rails 側でのワークアラウンドの重要性が下がる)ことに留意してください。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59582
  • 関連トピック:
    • Ruby の Ractor と require の挙動(Ractor 内での require がスレッド生成と絡む既知の問題)
    • 将来の Ruby 4.1 での修正予定(Ractor + require のパフォーマンス問題)
  • コードの実態を確認したい場合は、対象コミットでの activesupport/lib/active_support/ractors.rb の差分を参照すると、require の条件付け/削除がどのように行われたかを正確に把握できます。

#59482 Map template frames in ExceptionWrapper without walking every resolver ​

マージ日: 2026/11/5 | 作成者: @linyiru

  1. 概要 (1-2文で)
    Rails の例外ページなどで行っている「バックトレース中のテンプレートフレームのマッピング」を大幅に高速・省メモリ化する変更です。従来は全ての view resolver を総なめにしてテンプレートを探していましたが、Template::Error が持つ情報とテンプレートメソッド名のパターンを利用して、必要最小限の探索に絞るようになりました。

  1. 変更内容の詳細

背景・課題 ​

  • 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 ms0.04 MB, 0.12 ms
テンプレート内でエラー発生 (500)13.52 MB, 31.4 ms0.24 MB, 0.73 ms

404/500 両方でメモリ・時間ともに 1〜2桁オーダーで削減されています。


コアアイデア ​

  1. Template::Error はすでに「どのテンプレートを経由したか」を知っている

    • テンプレートで起きた例外は、ActionView::Template::Error でラップされる。
    • このとき「どのテンプレートからどのテンプレートを呼んだか」というチェーンを sub_template_of を使って辿れるようにしていて、Template::Error#templates でその一覧を取得できる。
    • この PR では、その Template::Error#templates を積極的に利用し、「実際にバックトレースに現れるテンプレートだけ」をマッピング対象とする。
  2. 「テンプレート由来のメソッドかどうか」をフォーマットで判定

    • まだマッピングされていないバックトレースフレームが「コンパイル済みテンプレートメソッド」かどうかを、ActionView::Template.method_name? で判定できるようになった。
    • Template#method_name が生成するメソッド名のフォーマットと同期した判定ロジック・テストが追加されている。
    • これにより、「テンプレートとは無関係なメソッド」については resolver 探索をそもそも行わないで済む。
  3. resolver を総なめするのは「まだマッピングされていないテンプレートメソッド」だけ

    • バックトレースフレームが
      1. まだテンプレートに紐づけられていない
      2. Template.method_name? が true を返す(テンプレートメソッドらしい)
    • この場合に限って初めて resolver を歩いてテンプレートを探すように変わった。
    • これにより、次が達成される:
      • Routing error やプレーンな例外(Template::Error を経由しない) → resolver を一切歩かない。
      • 通常のテンプレートエラー → ほぼ Template::Error#templates の情報だけで足りるので、resolver を歩くケースが激減。
      • カスタム resolver で拡張子なし identifier を持つテンプレート → 以前の「ファイル拡張子ベースのスキップ」では取りこぼしていたが、メソッド名ベースの判定により引き続きマッピング可能。

具体的なコードレベルの変更点 ​

※ ざっくりした役割・利用方法の解説です(実際の行単位までは省略)。

1. ActionDispatch::ExceptionWrapper の変更 ​

対象: actionpack/lib/action_dispatch/middleware/exception_wrapper.rb

  • build_backtrace の処理が書き換えられ、以下の手順でフレームをマッピングするようになったと考えられます:
    1. 例外チェーン(cause を遡る)から ActionView::Template::Error を集める。
    2. 各 Template::Error に対して templates を呼び、そこに含まれるテンプレートを「コンパイル済みメソッド名 → Template オブジェクト」のマップとして保持。
    3. バックトレースの各フレームについて、上記マップを用いて素早くテンプレートに紐づける。
    4. それでも見つからないフレームのうち、Template.method_name? が true になるものだけについて、fallback として resolver を歩く。
  • これにより「まず安い情報源(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.rb

  • actionview/test/template/template_error_test.rb

  • actionview/test/template/template_test.rb

  • 「ソースマップ(バックトレース → テンプレートファイル)のテスト」で、これまで使っていたメソッド名が実際には Template#method_name では生成されない形式だった問題を修正。

  • 以後は「実際に Template#method_name が生成しうる形式」に基づいてテストすることで、method_name と method_name? の同期を保証している。


  1. 影響範囲・注意点

影響範囲 ​

  • 影響を受ける主な箇所
    • 開発・本番問わず、例外ページやログ出力に利用される「テンプレート付きバックトレース」の生成。
    • 特に 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 環境での例外処理コストがかなり下がるため、本番環境でも直接的に恩恵を受ける変更。
    • 例外発生頻度が高いシステムではメモリ使用量とレスポンス遅延の削減につながる可能性がある。

  1. 参考情報 (あれば)
  • 対応 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. 概要 (1-2文で)
    Rails のドキュメント形式を RDoc から Markdown に完全移行する一環として、ソースコード中に残っていた # :markup: markdown ディレクティブを削除し、プロジェクト全体のクリーンアップを完了した PR です。機能的な挙動や公開 API には一切変更がありません。

  1. 変更内容の詳細

背景 ​

  • 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.rb
    • activemodel/lib/active_model.rb
    • activemodel/lib/active_model/validations/format.rb
    • activemodel/lib/active_model/validations/numericality.rb
    • activerecord/lib/active_record.rb
    • activerecord/test/cases/connection_adapters/ractor_connection_handling_test.rb
  • 追加行は 0 行、削除行は合計 7 行で、コードロジックそのものは一切変更していません。

おそらく削除前は各ファイルの先頭近くに、例えば:

ruby
# :markup: markdown
#
# == Some documentation title
# ...

のような行があり、それがきれいに取り除かれた状態です。

PR 本文中にある:

bash
% rg ':markup: markdown'
%

は、ripgrep (rg) で :markup: markdown を検索した結果、ヒットが 0 件であることを示しており、この PR で「Rails リポジトリ内の Markdown ディレクティブが完全になくなった」ことを象徴的に示しています。


  1. 影響範囲・注意点
  • ランタイム挙動・API への影響はなし

    • 変更は Ruby コメント行の削除のみであり、メソッド、クラス定義、モジュール構成、バリデーションロジックなど実行コードは一切変わっていません。
    • ActiveRecord, ActiveModel, ActionView::Helpers::DateHelper などの挙動はこの PR により変化しません。
  • ドキュメント生成の挙動

    • 既にデフォルトが Markdown に切り替わっている前提なので、この PR によって新たに Markdown / RDoc の扱いが変化することもありません。
    • 「一部の未移行ファイル」を除き、Rails 全体を Markdown 前提でドキュメント生成するのが自然な状態になります。
    • 明示ディレクティブがなくても Markdown として処理されるため、今後のファイル追記・修正時に # :markup: markdown を書く必要はありません。
  • サードパーティツール/内部ツールへの影響

    • もし独自に :markup: 行をトリガーにして何かしているツール(例: 独自ドキュメントジェネレータ、スタイルチェッカ)があれば、ヒットしなくなる点に注意が必要です。
    • ただし、Rails 本体はそのような内部依存をしていないため、公式には影響なしと見てよいです。

  1. 参考情報 (あれば)
  • デフォルトマークアップを Markdown に切り替えた PR:
  • この PR (#59641) は、その後の「# :markup: markdown を全削除してリポジトリをきれいにする」最終ステップにあたります。

#59595 Load associations when the owner has an attribute named method ​

マージ日: 2026/11/5 | 作成者: @anthonyfranco

  1. 概要 (1-2文で)
    Active Record の関連読み込み時に、モデルに method という属性が存在すると ArgumentError が発生して関連がロードできなくなる不具合を修正する PR です。owner.method(:read_attr) の利用をやめ、method 属性と衝突しない形で属性値を読むように変更しています。

  1. 変更内容の詳細

問題の発生条件 ​

Rails main ブランチ (#59120 以降) で、例えば次のようなモデルがあるとします:

ruby
class Payment < ApplicationRecord
  # DB カラム: method (string)
  # 例: 'cash', 'check', 'credit_card'
  belongs_to :user
end

payments テーブルに method カラムがあり、それを Active Record が普通に属性として扱える状況で、

ruby
payment = Payment.first
payment.user # belongs_to のロード

のように関連 (belongs_to / has_many 含むすべての association) をロードしようとすると、次のような例外が発生していました:

text
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 行を置き換えています。

元:

ruby
# 概念的にはこんな形
bind_values = bind_map.bind(owner.method(:read_attr), owner, ...)

修正後:

ruby
# 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_to
    • has_many
  • 双方の association をロードするテストを追加し、例外が出ずに読み込めることを確認。

このテストは、現行 main ブランチでは失敗し、この PR の修正を適用すると通るようになります。


  1. 影響範囲・注意点
  • 影響範囲:
    • 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 では未対応)。

  1. 参考情報 (あれば)
  • このバグは #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. 概要 (1-2文で)
    Rails のキャッシュストアに LocalCache 戦略を使う際、無名クラス(匿名サブクラス)のキャッシュストアでもエラーなく利用できるようにした修正です。Rails.cache を ractor 対応のために freeze する前に LocalCache のキーを生成する仕様変更によって発生した、匿名クラスでの NoMethodError を解消しています。

  1. 変更内容の詳細

問題の背景 ​

以前の変更 (#58061) により、ActiveSupport::Cache::Strategy::LocalCache のレジストリキー(内部的に LocalCache を識別するためのキー)が initialize 内で計算されるようになりました。
元のコードでは、キー生成は概ね次のような形でした:

ruby
# 変更前のイメージ
key = :"_local_cache_#{self.class.name.underscore}"

ここで self.class.name が nil になるケースがあります。それが 無名クラス(匿名クラス)でキャッシュストアを作った場合 です:

ruby
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 をかませるように変更しています。

変更点(要約):

ruby
# 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 は nil
    • nil.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 の利用が回帰しないよう保証しています。


  1. 影響範囲・注意点
  • 影響範囲

    • 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 の追加コストは極小であり、パフォーマンス上の影響は無視できるレベルです。

  1. 参考情報 (あれば)

#59634 Accept bind values in where! and having! again ​

マージ日: 2026/11/5 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    Rails 8.1 → main の過程で where! / having! のシグネチャ変更により「バインド変数を渡せない」「既存のオーバーライドが壊れる」といった後方互換性問題が発生していたのを、元の可変長引数 (*rest) 形式に戻して修正する PR です。where / having / merge からの呼び出しも 8.1 と同じく再びスプラット渡しに戻しつつ、#59579 で入った他のメモリアロケーション最適化は維持しています。

  1. 変更内容の詳細

背景: シグネチャ変更による問題 ​

#59579 で where! と having! のメソッドシグネチャが以下のように変更されていました:

ruby
# 変更前 (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:

    ruby
    permalinks.where!("url ILIKE :url OR external_url ILIKE :url", url: "%#{url}%")
  • Canvas LMS(要約):

    ruby
    courses.where!("(courses.workflow_state='deleted' AND courses.updated_at > ?) ...", 120.days.ago)
  • crono_trigger:

    ruby
    rel.where!("executed_at >= ?", Time.parse(params[:from]))
  • mobility / active_record-acts_as:

    ruby
    def where!(opts, *rest)
      ...
    end

これらはすべて「*rest でバインド値を受け取る」ことを前提としており、シグネチャ変更により壊れていました。

この PR の対応内容 ​

  1. 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])
  2. where / having / merge から where! / having! への引数の渡し方も 8.1 と同じくスプラットに戻す

    • where / having が where! / having! を呼ぶ際、rest 引数を配列のままではなく *rest で渡すように戻しています。
    • merge(where: [permitted_params]) のようなケースでも、内部的に where! を *args 前提で呼び出す 8.1 の挙動に合わせています。
  3. #59579 のパフォーマンス最適化は極力残す

    • FROZEN_EMPTY_ARRAY を利用した割り当て削減など、「引数シグネチャ以外」の部分で行われた最適化は維持されています。
    • つまり、「API 互換性は 8.1 に戻すが、内部の不要なオブジェクト生成を減らす工夫は残す」という方針です。
  4. テスト追加

    activerecord/test/cases/relation/where_test.rb / relations_test.rb にテストが追加され、以下をカバーしています:

    • ? プレースホルダのバインド
    • 名前付きバインド(id: 1 など)
    • 配列バインド
    • where! をオーバーライドした場合に super を正しく呼べること
    • merge(where: [permitted_params]) がエラーにならず動くこと

  1. 影響範囲・注意点

影響を受ける可能性が高いコード ​

  1. where! / having! を直接呼んでいるコード

    • 代表例として PR に挙がっている Discourse / Canvas / crono_trigger など。
    • これらは Rails 8.1 時点の挙動に戻るため、「main で一時的に壊れていたものが直る」という方向の影響です。
  2. where! / having! をオーバーライドしている gem・アプリ

    ruby
    def where!(opts, *rest)
      # 何か独自処理
      super
    end

    こういった実装は 8.1 を前提に書かれていることが多いため、今回の PR によって再び期待通り動くようになります。

  3. merge(where: [params]) のように merge で where 条件をマージしている箇所

    • strong parameters を渡すようなコード(例: Model.merge(where: [permitted_params]))で main の時点では ArgumentError になっていたものが、8.1 と同様に動作するようになります。

注意点 / 移行上のポイント ​

  • この PR はあくまで「誤って導入された互換性破壊を元に戻す」ものなので、Rails 8.1 以前から正しく動いていたアプリに対して新たな互換性問題を生む可能性は低いです。
  • ただし、Rails main(#59579 適用後〜この PR マージ前)の短い期間に、変更後のシグネチャ(第二引数が配列前提など)に依存したコードを書いていた場合は、そのコードが再び壊れる可能性があります。通常のアプリや一般的な gem ではほぼ無視できるレベルだと思われます。
  • where! / having! は依然として :nodoc: であり、今後もシグネチャが変更される可能性はある内部 API である点には注意が必要です。
    • ライブラリ作者で、どうしても内部 API を使いたい場合は、今回のような互換性問題を検出できるテスト(Rails の複数バージョンでの CI)を用意しておくのが望ましいです。

  1. 参考情報 (あれば)

#59638 Fix Active Record includes with merge of an order on another model ​

マージ日: 2026/11/5 | 作成者: @kehra

  1. 概要 (1-2文で)
    includes した関連に対して、別モデル側の order / reorder を merge すると事前ロードが外れ、JOIN せずに外部テーブルのカラムを参照して SQL エラーになるリグレッションが #59590 以降発生しており、この PR はそのバグを修正します。merge 時のソート情報の取り込み方法を変更し、適切に参照テーブルが記録されるようにしています。

  1. 変更内容の詳細

問題の具体例 ​

#59590 適用後は、次のようなコードがエラーになります:

ruby
Post.includes(:author).merge(Author.order(:name)).to_a
# => ActiveRecord::StatementInvalid: no such column: authors.name

期待される挙動は:

  • includes(:author) により authors テーブルを JOIN しつつ eager load
  • merge(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つの振る舞いが変わっています:

  1. 「リレーション自身のテーブルに対する order」は、もはや references にそのテーブルを追加しない

    • 例: Post.order(:created_at) は posts テーブルを references に足さない
    • これは「自分自身のテーブルはそもそも FROM にあるので references は不要」という合理化
  2. 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 / エイリアステーブルについても類似の確認

  1. 影響範囲・注意点
  • 対象バージョン:

    • バグ自体は「まだリリースされていない」変更 (#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! 経由になることで、ごくわずかなオーバーヘッドは増えますが、もともと内部ユーティリティであり誤差レベルです。

  1. 参考情報 (あれば)

#59635 Fix FrozenError loading the Active Job Rakefile on JRuby ​

マージ日: 2026/11/5 | 作成者: @sampokuokkanen

  1. 概要 (1-2文で)
    JRuby 環境で Active Job の Rakefile を読み込む際に FrozenError が発生していた不具合を修正する PR です。ACTIVEJOB_ADAPTERS 定数の配列を「凍結(freeze)する前」に JRuby 専用の要素削除処理を行うように変更しています。

  1. 変更内容の詳細

問題の原因 ​

activejob/Rakefile では、使用するアダプタ一覧を ACTIVEJOB_ADAPTERS として定数化し、Rubocop の Style/MutableConstant 対応として freeze していました。

イメージとしては以下のようなコードになっていたと考えられます(簡略化した例):

ruby
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 前に行うように順序を変更しています。

イメージコード:

ruby
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 以外のアダプタ用タスクが列挙される
  • 両方で ACTIVEJOB_ADAPTERS は凍結されていることも確認済み

  1. 影響範囲・注意点
  • 影響範囲:
    • activejob/Rakefile 経由で定義される Rake タスク (特にアダプタごとのテストタスク) に限定されます。
    • ランタイムの Active Job 本体の挙動には影響しません。
  • JRuby 利用時:
    • これまで Rakefile のロード時に FrozenError でタスクが一切定義されなかった問題が解消されます。
    • queue_classic 向けタスク (test:queue_classic 等) は JRuby では定義されません(従来も実質的にサポートされていないので妥当な挙動)。
  • CRuby 利用時:
    • タスク一覧・挙動は従来と変わりません。
  • 注意点:
    • この PR ではテストや CHANGELOG の追加は行われていません(軽微なバグ修正として扱われている)。
    • 今後同様に「定数を freeze した後に環境に応じて中身を変える」ような実装を行うと、再度同じクラスの不具合を生む可能性があります。
      • パターンとしては「一旦ローカル変数で構築 → 条件分岐で調整 → freeze して定数化」という流れが安全です。

  1. 参考情報 (あれば)
  • 元の不具合内容:
    • 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. 概要 (1-2文で)
    JSON 出力時に <, >, & をエスケープする処理について、「そもそもこれらの文字が含まれていない場合」は gsub! を実行しないようにして無駄な全体走査をスキップする最適化が行われました。出力結果の仕様は一切変えずに、特に JS セパレータ非エスケープ時のパフォーマンスを改善する変更です。

  1. 変更内容の詳細

背景 ​

  • Ruby 4.1 より前の環境で、Rails は json gem 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 実装のイメージ表現):

ruby
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
end

HTML_ENTITY_REGEX 自体(/[<>&]/ のような union)や置換内容は従来どおりです。

テストの追加 ​

activesupport/test/json/encoding_test.rb に 16 行のテストが追加されており、代表的なケースで 従来と全く同じ JSON が出力されること を検証しています。
新しいテストでは、少なくとも以下のような点を確認していると考えられます(変更規模から推測される内容):

  • <, >, & を含まない JSON 文字列が、以前と同じ結果であること。
  • <, >, & を含む場合に正しくエスケープされ、以前と同じ結果になること。
  • JS セパレータのエスケープ有無設定ごとに、挙動が変わらないこと。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • Rails の ActiveSupport::JSON を用いた JSON シリアライズ処理。
    • 特に Ruby 4.1 未満 + json 2.15.2 以降で JSONGemCoderEncoder が使われているパス。
    • config.load_defaults 8.1 以降(または同等設定)で「JS セパレータ非エスケープ」が有効な環境でのメリットが大きい。
  • 変更内容はあくまで「gsub! を実行するかどうかの事前チェック」だけなので、JSON 文字列の仕様上の出力は変わりません。

パフォーマンスへの影響 ​

ベンチ結果(Ruby 4.0.9, JS セパレータ非エスケープ、ns/call):

ケースmainこの PR
{"id":1,"name":"x"}537 / 354482 / 278
11 カラムのレコード949 / 759547 / 328
&, <b>, U+2028, U+2029 を含む短いドキュメント2,744 / 2,3162,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 未満向けのホットスポット最適化として説明されています)。

  1. 参考情報 (あれば)

この PR は「JSON エンコード周りのホットパスを、挙動を変えずに軽くする」タイプのマイクロ最適化であり、API パフォーマンスチューニングをしている開発者にとっては地味ながら有益な改善です。


#59604 Close the stream when Action Cable's event loop fails to attach it ​

マージ日: 2026/11/4 | 作成者: @sampokuokkanen

  1. 概要 (1-2文で)
    Action Cable の StreamEventLoop で NIO::Selector#register などの失敗時にストリームが閉じられずクライアントがハングする問題を修正し、失敗時でも確実に IO をクローズして接続を終了するようにした PRです。あわせてこの挙動を保証するテストが追加され、Action Cable のイベントループの堅牢性が向上しています。

  1. 変更内容の詳細

問題の背景 ​

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 され、クライアントがハングしない

という動作に変更されています。

(疑似コードイメージ)

ruby
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 など他実装での安全性が向上
  • deregister が例外を投げても、IO 自体は「必ず閉じる」
    • これにより、イベントループスレッドは依然として例外で死ぬ可能性があるが
    • IO が閉じられないまま残る、という最悪の状態は避けられる
    • 次の wakeup 時にスレッドは再生成され、キューに溜まっていたタスクが処理される

疑似コードイメージ:

ruby
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
end

3. テストの追加・変更 ​

  • actioncable/test/server/socket/client_socket_test.rb
    • 「hijacked IO ソケットを shutdown 時に閉じる」テストが、永遠に event.wait し続けるのではなく、最大 5 秒まで待つように変更
      • イベントループが詰まった場合でもテスト全体が延々とハングしないようにするため
  • actioncable/test/server/stream_event_loop_test.rb
    • register が失敗するケースを再現するテストを新規追加
      • 例えば、テスト用に selector.register をモックして例外を投げさせ、それでも IO が閉じられることを確認する
    • deregister が失敗するケースをカバーするテストも追加
      • 登録済み IO に対して deregister が例外を投げても IO は閉じられることを確認

4. CHANGELOG の更新 ​

actioncable/CHANGELOG.md に今回の挙動変更が追記され、ユーザー/開発者に

  • 「登録失敗時や取り外し失敗時にも IO をクローズするようになった」

という振る舞いの変化が明示されています。


  1. 影響範囲・注意点
  • Action Cable の接続まわりの堅牢性向上

    • Selector への登録/解除が失敗しても、クライアントソケットが閉じられずにハングする、という状態は起きにくくなります。
    • 特に JRuby + nio4r + UNIX ソケットのような特殊環境で「テストが固まる」「Action Cable が固まる」現象のリスクが減ります。
  • イベントループスレッドは依然として例外で死ぬ可能性がある

    • PR 説明にもある通り:
      • 登録済み IO に対する deregister が例外を投げた場合、イベントループのスレッド自体は以前と同様に死ぬ
      • ただし、IO は確実に閉じられ、次回の wakeup でスレッドは再生成され、キュー内の作業も処理される
    • アプリ側で StreamEventLoop を直接いじっている場合(通常はない)は、この挙動を前提にしないよう注意が必要です。
  • 独自実装の 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 には変更はなく、「内部的にエラー時にソケットをより確実に閉じるようになった」という性質の修正です。
    • ただし、いままで「バグによりいつまでも切断されなかった」ようなクライアントは、今後はきちんと切断されるようになります。その意味では、バグ依存の挙動に頼っていた場合は影響があります。

  1. 参考情報 (あれば)

今回の修正は、「Selector への登録・解除が失敗するようなエッジケースでも、Action Cable がクライアントを宙ぶらりんにしない」ことに主眼があります。特にマルチプラットフォーム対応や JRuby サポートを重視する場合は、この変更を把握しておくとよいです。


#59626 Skip nested values that serialize to an empty query in to_query ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    Rails の to_query(クエリ文字列生成)で、ネストした配列・ハッシュが「結果として空文字列にしかならない場合」はクエリから完全にスキップするようにし、「?&page=2」のような余計なセパレータ(&)が出ないようにする修正です。
    同時に、nil・false・空文字列はこれまで通り有効な値としてクエリに残るようにテストで明示されています。

  1. 変更内容の詳細

背景 ​

関連 PR の関係は以下の通りです:

  • #57500
    • 「生成されるクエリ文字列が空なら、その要素をスキップする」という挙動に変更
  • #57677
    • その後、「要素の「型」によって」空配列・空ハッシュをスキップする実装に変更
    • 副作用として、「自分自身は空でないが、中身が全部空コレクション」というケースが漏れる

問題となった例:

ruby
{ 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_query
  • Hash#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)。

  1. 配列/ハッシュが「空コレクションのみ」を含むケースはクエリから消える

    想定される例:

    ruby
    { q: [[]], page: 2 }.to_query
    # 修正後の期待値(イメージ):
    # => "page=2" もしくは "page=2&q=" のような余計な `&` が入らない形

    また、[{}, []] といった「要素としてハッシュや配列を持つが、それらがすべて空」というケースでも、結果のクエリ断片は空とみなされてスキップされます。

  2. nil・false・空文字は引き続き維持される

    例として(テスト内容から推測される形):

    ruby
    { filters: [nil, false, ""] }.to_query
    # => "filters[]=&filters[]=false&filters[]="
    # ※ 具体的な順序やエンコードは実装依存だが、
    #    「要素が落ちない」ことを確認するテストが追加されている

    ここで意図されているのは:

    • 「コレクションが“空”かどうか」ではなく
    • 「(再帰的に)シリアライズ結果が完全に空文字列かどうか」

    を基準にスキップするため、nil や空文字・false は「値として意味がある」ものとして保持する、という区別です。


  1. 影響範囲・注意点
  • 対象となる機能

    • 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・空文字は保持されるため、影響範囲はかなり限定的
  • デバッグ・確認ポイント

    • 独自で to_query を直接使っている場合は、ネストした配列/ハッシュで、空コレクションしか入っていないケースの出力が変わっていないか確認すると安心です。
    • API クライアントなどで「空の配列を明示的に送りたかった」ケースがある場合は、
      • それが本当に空配列としてシリアライズされているか (param[]=)
      • もしくは完全に削除されてしまっていないか
        を確認してください。

  1. 参考情報 (あれば)
  • 元 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. 概要 (1-2文で)
    Rails 7系(?) で導入された「最適化された URL ヘルパー」が、空文字 "" を返す to_param を持つレコードに対して UrlGenerationError を投げるようになっていた回 regress を修正する PRです。default_url_options(特に host)が存在する場合でも、従来どおり URL を生成できるように、最適化パスが失敗したときに従来の一般パスにフォールバックするようになりました。

  1. 変更内容の詳細

問題となっていた挙動 ​

  • 以前のPR #59479 で、URL ヘルパーの「最適化版」が使われる条件が拡張され、default_url_options が予約済みオプション(host など)だけを設定している場合にも、最適化版ヘルパーが使われるようになりました。

  • しかし、この最適化版ヘルパーは、

    • 引数が空文字 "" のとき
    • もしくはレコードの to_param が空文字 "" を返すとき
      にパス部分を組み立てられず、ActionController::UrlGenerationError を発生させます。
  • 一方、最適化が導入される「以前の一般的なルート生成パス」は、こうした場合でも URL を生成しており、例えば:

    ruby
    default_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/ など)。

  1. 影響範囲・注意点
  • 影響を受ける主なケース

    • default_url_options[:host] 等を設定しており
    • *_url / *_path ヘルパーに対して、to_param が空文字を返すような AR モデル・リソースを渡しているコード。
    • 実務上は、メーラーや外部向けURL生成処理(url_for / *_url)で影響する可能性が高いです。
  • 既存コードへの影響

    • 「以前のバージョンでは正常に動いていたが、#59479 以降で UrlGenerationError が出るようになっていたコード」は、このPRで元通り動くようになるはずです。
    • 逆に、「#59479 以降の挙動(空の to_param で必ず例外)を前提にした」ようなコードがもしあれば、その前提は崩れます。ただし、説明文からも分かる通りそれは regress 挙動と見做されており、依存しているほうが危険と考えられます。
  • セマンティクス上の注意点

    • default_url_options の有無で、空文字・空 to_param の扱い(例外 vs URL 生成)が変わるため、URL が生成されるかどうかを厳密に検査したい場合には、その点を考慮する必要があります。
    • とはいえ、これは「以前の Rails から存在していた挙動」に回帰しているため、実運用上は問題を減らす方向の変更と見てよいです。

  1. 参考情報 (あれば)
  • 関連PR:
    • #59479: 「最適化された URL ヘルパー」の適用条件を広げた変更。このPRはその副作用(regression)を修正している。
  • 実例:
    • Lobsters プロジェクトの check_url_dupe に関するリクエストスペックが、この regression により main ブランチで落ちており、本PRの修正により通るようになっていると説明されています。
  • 実装箇所:
    • 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. 概要 (1-2文で)
    PostgreSQL 18.4以降で外部キー検証タイミングが変わったことにより、FieldOrderedValuesTest が authors フィクスチャだけを読み込んだ場合に外部キー制約エラーが出ていた問題を解消するPRです。authors と外部キーで結びつく author_addresses フィクスチャも同時にロードするようにして、テストが単体実行されても安定して通るようにしています。

  1. 変更内容の詳細

何をしているPRか ​

  • 対象ファイル: activerecord/test/cases/relation/field_ordered_values_test.rb
  • 変更内容: フィクスチャのロード対象に author_addresses を追加(あるいは authors 単体ロード → authors, author_addresses のように変更)しただけの 1 行変更です。

イメージとしては、以下のような変更です(実際の記法はテストの他ファイルと揃えてありますが、概念的にはこんな感じです):

ruby
# 変更前(例)
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 も一緒にロードするようにして、外部キーを満たすデータを用意した状態で検証が行われるようにしています。

  1. 影響範囲・注意点
  • 影響範囲:
    • Rails本体の動作ではなく、Active Record のテスト (FieldOrderedValuesTest) のみ。
    • 本番コードには影響しない、テスト安定性向上のための変更です。
  • 確認状況:
    • PostgreSQL 18.6 で FieldOrderedValuesTest ファイル単体実行時:
      • 変更前: 21件の PG::ForeignKeyViolation で失敗
      • 変更後: 正常にパス
    • PostgreSQL 15 および SQLite:
      • 変更前後ともテストはパス(既存環境へのマイナス影響なし)。
    • 他のテストファイル:
      • authors を読み込む他のテストファイルは、author_addresses を空にした状態でも単体実行で通っており、このPRの対象は FieldOrderedValuesTest のみで十分と判断されています。
  • 注意点:
    • 似たように外部キーを持つフィクスチャを不完全な組み合わせで読み込んでいるテストが他にないか、PostgreSQLの新しいバージョンを使う環境では注意が必要です。
    • 今後フィクスチャを追加・変更する際は、
      • 「外部キーを張っているテーブルのペアを同時にロードしているか」
      • 「DBバージョン差で外部キー検証タイミングが変わらないか」 を意識しておくと同種のテスト不安定を防げます。

  1. 参考情報 (あれば)
  • 関連コミット:
    • 63cf73e451 "Extract order references from the built Arel nodes"
      → このコミット以降、FieldOrderedValuesTest が authors フィクスチャを利用するようになり問題が顕在化。
  • 関連PR:
    • #28991: 他のテストに対して、同様に関連フィクスチャを合わせてロードする対応を行ったPR。
  • DB挙動:
    • PostgreSQL 18.4 以降の「外部キーを一旦 NOT ENFORCED で挿入 → 後から ENFORCED で既存行を検証」という仕様変更が、従来見えていなかったフィクスチャ間の依存関係問題を炙り出している形です。

#59620 Don't build a Set of renderers on every render ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    コントローラにローカルな renderer 設定がない場合に、毎回 Set を新規生成していた処理を共通の Renderers.all をそのまま返すようにし、レンダリング時のオーバーヘッドを削減する最適化です。Ractor セーフな実装方針を維持しつつ、render 呼び出しあたりの時間・オブジェクト数を削って高速化しています。

  1. 変更内容の詳細

何をしているか ​

  • 対象ファイル: 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 対応のスナップショット方式)。

背景: #57830 と Ractor safety ​

  • #57830 での変更点:
    • renderer のレジストリは、add / remove のたびに 新しい凍結済み Set を再生成する実装に変更された。
    • これにより:
      • イテレーション中の内容変更が起こらない(スナップショット性)。
      • Ractor 間で共有しても安全(凍結されている)。
  • 今回の PR はその方針に沿いつつ、
    • 「毎回 _renderers + Renderers.all という同じ内容の Set を新規生成している」ケース(ローカル renderers なし)を最適化し、
    • 共有されている凍結済みの Set (Renderers.all) をそのまま返すようにしたものです。

パフォーマンスの効果(Ruby 4.0.9) ​

コントローラにローカル renderers がないケースの 1 回あたり:

項目mainthis(本PR)
テンプレート render 時の renderer チェック (インタプリタ / YJIT)1,354 / 1,165 ns801 / 552 ns
render json: (インタプリタ / YJIT)3,928 / 2,658 ns3,274 / 2,001 ns
オブジェクト数, チェック / render json:3 / 92 / 8
  • セットの union をやめて共有 Set を使うことで、
    • 時間: renderer チェックで ~40% 前後短縮
    • オブジェクト数: 毎回 1 オブジェクト削減(3→2 / 9→8) といった削減になっています。

テスト ​

  • 追加テストは以下をカバー:
    • ActionController::Base / API コントローラでローカル renderers がない場合。
    • 親クラスで use_renderers しているケースと、そのサブクラスの挙動。
    • renderers が render の合間に add / remove されるケース(スナップショット更新の整合性)。

  1. 影響範囲・注意点

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 が発生します。
  • Rails のコアコードでは _all_renderers の結果はイテレートのみで利用しており、この変更による動作影響はありません。

想定される問題パターンと対策 ​

もし既存アプリ・ライブラリで _all_renderers をカスタマイズしている場合は、以下を確認してください:

  • アンチパターン例(NG):
    ruby
    def _all_renderers
      super.delete(:json)
      super
    end
  • 推奨パターン(OK):
    ruby
    def _all_renderers
      super - [:json]  # 非破壊的な演算で新しい Set を作る
    end
    もしくは:
    ruby
    def _all_renderers
      super.dup.tap { |set| set.delete(:json) }
    end
  • ポイント:
    • 「super が返す Set は凍結されていてもよい(= 破壊的変更してはいけない)」という前提で扱うべきになった、という契約変更です。

影響範囲のまとめ ​

  • 直接影響するのは renderers 周りを内部 API レベルでカスタマイズしている一部の高度なユーザ / ライブラリのみ。
  • 通常の render, respond_to, use_renderers の利用だけなら、挙動は同じでパフォーマンスだけ良くなります。
  • Ractor / 並行実行環境でも、renderer レジストリのスナップショット性・凍結状態は維持されます。

  1. 参考情報 (あれば)

#59619 Join adjacent literals when building a route's path format ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1–2文で)
    Rails のルーティング内部で使われる Journey::Format の実装を改善し、ルート生成時に隣接するリテラル文字列を一度だけ結合するようにした PR です。これにより URL の実際の文字列出力は変えずに、ルートヘルパー呼び出し時のオブジェクト生成・命令数が削減され、軽微なパフォーマンス改善とメモリ節約が行われています。

  1. 変更内容の詳細

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 生成時の内部的な append
    ruby
    path << "/"
    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 を直接触ってカスタムフォーマットを作っている」ような高度なメタプログラミングをしている場合は意識が必要です。


  1. 影響範囲・注意点

3-1. 一般的な Rails アプリへの影響 ​

  • URL の文字列出力は変わらない
    • ASCII の / と .、あるいはルートパスのスライスをそのまま利用しているため、生成される URL は以前と同じバイト列・同じエンコーディングになります。
  • 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) しか使っていない場合は、互換性問題は基本的に発生しません。

  1. 参考情報

#59614 Iterate Hash#extract! keys with each instead of each_with_object ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    Hash#extract! の実装を each_with_object ベースから単純な each + ローカル Hash 生成に変更し、余分なオブジェクト生成と命令数を減らすマイクロ最適化のPRです。これにより stylesheet_link_tag / javascript_include_tag や ActionController::Parameters#extract! 経由の呼び出しがわずかに軽量化されます。

  1. 変更内容の詳細

何をしているか ​

  • 変更ファイル: activesupport/lib/active_support/core_ext/hash/slice.rb
  • 対象メソッド: Hash#extract!

従来の実装は概ね次のような形でした(イメージ):

ruby
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 を用いた実装に切り替えています。ざっくり言うと以下のような形です(イメージ・実際の細部は多少異なる可能性あり):

ruby
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, Array and Enumerable implementations 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
オブジェクト数32
命令数(インタプリタ)7,6946,250 (−18.8%)
命令数(YJIT)6,4043,600 (−43.8%)

さらに stylesheet_link_tag("application") レベルの処理では:

  • インタプリタ: 命令数 −1.6%
  • YJIT: 命令数 −5.1%

といった程度の改善が観測されています。

ただし、リクエスト全体のベンチマーク (ruby-bench) ではノイズ範囲内のため、リクエスト単位で体感できるスピードアップがあるとは主張していません。


  1. 影響範囲・注意点

影響範囲 ​

ActiveSupport の Hash#extract! を利用している全ての箇所に影響します。代表例:

  • ActionView:
    • stylesheet_link_tag
    • javascript_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 そのものをトレースしているような極めて特殊なコードでは、トレース対象メソッドが変わる可能性があります。

  1. 参考情報 (あれば)

#59618 Append the default star projection without a rest Array ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    build_select がデフォルトの table.*(星投影)を project 経由ではなく、SelectManager の projections に直接追加するように変更され、余計な配列割り当てが1つ減りました。Rails 標準の SelectManager を使う限り、生成される SQL と挙動は従来どおりです。

  1. 変更内容の詳細 (サンプルコード含む)

これまでの挙動 ​

ActiveRecord::Relation#build_select は、デフォルトのカラム指定(例: posts.*)を付与するときに project を呼んでいました。

擬似コードイメージ:

ruby
def build_select(arel_table)
  # デフォルトの星投影を付ける
  manager.project(arel_table[Arel.star])
end

project は可変長引数(splat)を受け取るため、内部で1要素だけを持つ配列(rest Array)が毎回生成されます:

ruby
def project(*projections)
  @ctx.projections += projections
end

このため、デフォルトの table.* を足すたびに「1要素の配列」が1つずつ割り当てられていました。

変更後の挙動 ​

今回の PR では、build_select が project を呼ばずに、直接 manager.projections にデフォルトの星投影を << するようになりました。

イメージ:

ruby
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つ減る

ベンチマーク中の例:

ruby
Post.all.to_a

で走るクエリ生成中に SELECT "posts".* FROM "posts" の posts.* を追加する箇所が、今回の変更の対象です。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • 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% 程度の改善(誤差レベルだが一貫して減少)

注意点(カスタマイズしている場合) ​

以下に該当する場合のみ注意が必要です:

  1. 独自の SelectManager 実装を使っている
  2. かつ、その SelectManager#project をオーバーライドして「デフォルトの table.* も含めて」何らかの特別な処理をしていた

今回の変更により:

  • デフォルト星投影は project 経由せず直接 projections に積まれるようになったため、
  • 独自 project 実装では、もはや「デフォルト星投影」をフックできません

そのような場合は、例えば:

  • projections をラップする / 書き込みをフックする
  • あるいは build_select を上書きする

などの形で、意図した処理を移植する必要が出てくる可能性があります。

標準的な用途(ActiveRecord + 標準 Arel だけを利用)では、特に対応は不要です。


  1. 参考情報 (あれば)
  • 関連 PR:
    • #45864: 同じ行(build_select 周り)を書き換える別の変更
    • #59579: where! から同様の rest Array を除去したパフォーマンス改善 PR
  • ベンチマーク環境:
    • SQLite in-memory, GC 無効
    • Post.all.to_a で 20レコードを 500回ロード
    • Ruby 4.0.9 / ZJIT は Ruby master (9aa0898240)
  • 低レベルな AR のパフォーマンス研究プロジェクト:

#59616 Read the update timestamp only when it is written ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    record_update_timestamps が、実際にタイムスタンプを書き込む直前にだけ現在時刻を取得するように変更され、不要な時計アクセスと接続取得を減らすことで、update 時のパフォーマンスと挙動の明瞭さが改善されました。updated_at/updated_on などに書き込まれる値そのものは、従来の標準的なタイムスタンプ処理と同じです。

  1. 変更内容の詳細

2-1. 何が変わったか ​

これまでの record_update_timestamps の挙動:

  • 「更新時にタイムスタンプを付与する」処理が、複数のパス(少なくとも 2 回)に分かれて行われるケースがある
    • 1回目のパス: 通常の updated_at / updated_on など
    • 2回目のパス: before_update コールバック等により、再度「更新対象カラム」が増えた/変わった場合の追い書き
  • 各パスの「開始時」に現在時刻を読む(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% 以内に収まることも確認
  • この PR による変化(main → この PR):

    post.update!, Callgrind instructionsInterpreterYJITZJIT
    main → this PR−1.4%−0.9%−1.4%
  • メモリアロケーション: このベンチマークで 190 → 189 allocations に減少。

つまり小さいながらも一貫した改善が見られており、不要な時計読み出し・接続コストが削れたことと整合的です。


  1. 影響範囲・注意点

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 や設定オプションは一切変わっていないため、通常のアプリケーションはそのまま動作します。
  • タイムスタンプの実際の値の違いも、あってもサブミリ秒レベル/パス内の順序の違いにすぎず、依存しているコードはほぼないと考えられます。

  1. 参考情報 (あれば)

総じて、「不要な時計読み出しと接続オーバーヘッドを削った、挙動互換性の高いマイクロ最適化」という位置づけの変更です。


#59615 Read the blob filename's extension once when choosing a variant format ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1–2文で)
    Active Storage の blob.variant(...) がデフォルトの画像フォーマットを決める際に、ファイル名拡張子を何度も解析していた処理を1回だけにまとめ、オブジェクト生成と処理時間を削減するパフォーマンス改善です。機能的な挙動は変えずに、画像バリアント生成時のオーバーヘッドを削っています。

  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 側の「拡張子の解釈ロジック」は変えていないため、フォーマット選択の挙動は従来と同じです。

イメージ的なコード差分(疑似コードレベル):

変更前(イメージ)

ruby
def format
  if filename.extension == "png"
    "png"
  elsif filename.extension == "jpeg" || filename.extension == "jpg"
    "jpeg"
  elsif filename.extension == "gif"
    "gif"
  else
    # 何らかのフォールバック
  end
end

変更後(イメージ)

ruby
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 個削減)

  1. 影響範囲・注意点

影響範囲:

  • Active Storage で画像バリアント (blob.variant(...)) を作成するときの内部処理(Representable#format)に限定されます。
  • 対象となるのは主に Web 画像(PNG / JPEG / GIF)のデフォルト format: 決定ロジックです。
  • API や外部から見える挙動(生成される画像形式・ファイル名・URL など)は変わりません。

期待される効果:

  • アップロード済み画像の縮小・サムネイル生成などが多いアプリケーションで、CPU 使用量とオブジェクト割り当てがわずかに減少。
  • 高トラフィック環境やバッチで大量にバリアントを生成する環境では、累積的に無視できないコスト削減につながる可能性があります。

注意点:

  • 挙動は変わらない前提で書かれていますが、「ファイル名拡張子に依存した独自の挙動」をパッチ当てなどで Representable#format へ追加している場合、今回のローカル変数化を前提にしたコードとコンフリクトする可能性はあります(とはいえ通常の Rails アプリではまず問題になりません)。
  • テスト結果として、Active Storage のモデル・コントローラテストのラン数は同じで、PDF プレビューア未インストールに起因するエラー件数も完全一致とのことで、既存のテスト観点では後方互換とみなせます。

  1. 参考情報 (あれば)

この PR は「ファイル名拡張子を何度もパースしない」という、ごく小さいが筋のよいマイクロ最適化で、Active Storage を多用するアプリケーションには素直なメリットがあります。


#59613 Build notification handle groups with each instead of Hash#map ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    ActiveSupport::Notifications::Fanout#build_handle 内で、通知ハンドルの生成に Hash#map(実際は Enumerable#map)を使うのをやめ、each + << に書き換えることで、余計なオブジェクト生成を減らし、特に SQL 実行など高頻度イベントでのオーバーヘッドを削減するパフォーマンス改善 PR です。挙動は原則として同じまま、呼び出しトレース上のフレーム位置のみ変わる可能性があります。

  1. 変更内容の詳細

背景 ​

  • 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(イメージ):

ruby
def build_handle(groups)
  # groups は Hash
  handles = groups.map do |group_klass, args|
    group_klass.new(*args)
  end

  handles.empty? ? NullHandle : SomeHandleClass.new(handles)
end

Hash#map(実体は Enumerable#map)により以下が発生:

  • map 内部のブロック呼び出しに伴うコールバックオブジェクト
  • 中間 Array(handles)の生成

After(イメージ):

ruby
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 オブジェクトの順序は同じです。
  • コンストラクタへの引数も同じ形で渡されます。
  • groups が空の場合に NullHandle を返す挙動も維持されています。
  • 違いが出る可能性があるのは「group のコンストラクタ内部から caller_locations や TracePoint でスタックを覗いた場合に、見えるフレーム構造が僅かに変わる」点だけ、と明示されています。

  1. 影響範囲・注意点

影響範囲 ​

  • 影響を受けるのは 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 がなければ基本的にそのまま使えます。

  1. 参考情報 (あれば)
  • PR本文で挙げられているスタンドアロン計測スクリプト(objects per call と ns per call を出力):
ruby
# 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. 概要 (1-2文で)
    ActionView::TemplateRenderer#render 内で毎回行われていた prepend_formats の不要な配列ユニオン処理を、既にフォーマットが先頭にある場合はスキップする最適化です。これにより、特にリクエスト外 (ApplicationController.render など) のレンダリングで命令数・オブジェクト数が削減され、若干のパフォーマンス向上が得られます。

  1. 変更内容の詳細

背景: これまでの prepend_formats の挙動 ​

TemplateRenderer#render はレンダリングごとに、テンプレートのフォーマットを優先するため prepend_formats(template.format) を呼んでいます。
ここで prepend_formats はざっくり言うと:

rb
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 を実行している点です。

例:

rb
@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 と同じ場合」はユニオン計算をスキップします。

イメージとしては:

rb
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
interpreter4,667 ns3,217 ns
YJIT4,226 ns2,631 ns

ruby-bench の erubi-rails ベンチでは:

  • インタプリタ: 命令数 2.2% 減
  • YJIT: 命令数 4.1% 減
    (Ruby master ではそれぞれ 1.5%, 2.5% 減)

さらに、1レンダリングあたりオブジェクトを2つ少なく確保するようになっています。


  1. 影響範囲・注意点

影響があるケース ​

説明文より、挙動が変わる(=最適化の恩恵を受ける)のは以下のケースのみです:

  • リクエスト外のレンダリング
    • 例: ジョブ内から ApplicationController.render(:show) を呼ぶ場合
  • かつ formats: オプションを明示的に渡さない場合
  • かつ lookup_context が 36 個のデフォルトフォーマットを持っていて、
  • かつ「テンプレートの format がすでに lookup_context.formats の先頭」である場合

この条件が揃う場合、これまで無駄に行っていた配列ユニオンをスキップするだけなので、機能的な挙動は変わらず、パフォーマンスのみが向上します。

変わらないケース ​

以下は挙動が変わりません:

  • 通常の HTTP リクエストでのレンダリング
    • 例: ブラウザからの text/html リクエストでのコントローラアクション
  • パーシャルレンダリング(テンプレート内の render)
  • formats: オプションを明示的に指定したレンダリング
    • 例: Turbo Stream の broadcast などで formats: [:turbo_stream] を渡す場合

理由: これらのケースでは lookup_context.formats がデフォルト36個フルの状態ではなく、すでにリクエストのフォーマット([:html] など)に絞り込まれていることが多く、今回の「すでに format が先頭か?」のチェックに当てはまらないためです。
また、formats: を明示的に指定する場合は prepend_formats の呼び出し方や formats= への引き渡し方が異なり、この最適化パスは通りません。

互換性上の注意点 ​

  • formats= メソッドの意味や挙動自体は変わっていないため、アプリケーションコードで lookup_context.formats を直接参照しているような場合も、従来と同じ結果が得られます。
  • TemplateRenderer / AbstractRenderer をモンキーパッチしているコードがある場合は、prepend_formats まわりの実装差分に注意が必要です。
  • フォーマット解決の仕様(*/* 展開や JS→HTML フォールバック)が変わるものではないため、レンダリング結果の互換性に影響はほぼありません。

  1. 参考情報 (あれば)
  • 変更ファイル:
    • actionview/lib/action_view/renderer/abstract_renderer.rb
      • prepend_formats まわりに8行追加・1行削除の小変更
    • actionview/test/template/lookup_context_test.rb
      • 新しい最適化パスをカバーするテストコードを14行追加
  • パフォーマンス効果は小さいものの、レンダリングが多いジョブ処理(メール・バッチ・バックグラウンドレンダリング)などでは積み重なって効くタイプの最適化です。

#59612 Avoid reprocessing with values when merging relations ​

マージ日: 2026/11/4 | 作成者: @kamipo

  1. 概要 (1-2文で)
    Relation#merge 時に with で指定した値を二重に処理していた問題を解消し、既に処理済みの with_values をそのままマージするようにした PR です。これにより不要なオブジェクト生成が減り、with を多用したクエリ合成でメモリアロケーションが削減されます。

  1. 変更内容の詳細

背景: with と Relation#merge ​

Active Record の with は、CTE (Common Table Expression) などの「追加のクエリ情報」を Relation に付与するためのメソッドで、内部的には with_values という配列/ハッシュのような構造に加工されています。

ruby
Post.with(a: Post.all, b: Post.all)

のような呼び出しでは、process_with_args によって引数が内部表現に変換され、relation.with_values として保持されます。

一方、Relation#merge は

ruby
Post.all.merge(Post.with(a: Post.all, b: Post.all))

のように 2 つの Relation を合成する API ですが、このとき with_values の扱いに問題がありました。

問題点: 既に処理済みの値に process_with_args を再適用していた ​

従来の Merger は、with_values をマージする際に、次のような流れになっていました:

  1. マージ対象の relation (Post.with(...) 側) では、既に process_with_args を通して with_values に加工済み
  2. Merger が with_values をマージする際、再度 with! を呼び出し
  3. with! の中でまた process_with_args が走る

つまり、一度処理済みの内部表現に対して、再び「引数としての処理」をしようとしており、以下のような無駄が発生していました。

  • 余計なオブジェクト生成(メモリアロケーション)
  • 不要な変換処理によるオーバーヘッド

PR の説明にあるとおり、次のコードのアロケーション数が 17 から 8 に減っています。

ruby
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 の微調整)。


  1. 影響範囲・注意点
  • 対象:
    • ActiveRecord::Relation に対して with を使い、それを他の Relation と merge している箇所
    • 特に、with に別の Relation (Post.all など) を渡しているケースで効果が出やすい
  • 期待できる効果:
    • 不要なオブジェクト生成の削減(アロケーション数減少)
    • 軽微なパフォーマンス向上(特に with を多用した複雑なクエリ合成で)
  • 互換性:
    • 外部から見える API (with, merge) の挙動は変えておらず、あくまで内部実装の最適化
    • すでに with を使っているアプリケーションで、挙動上の非互換は基本的に発生しない想定
  • 注意点:
    • もしアプリ側で with_values を直接触っているようなメタプログラミングをしている場合は、念のためテスト確認推奨(ただし、このような使い方自体が内部実装依存で非推奨)

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59612
  • 関連する内部 API:
    • ActiveRecord::Relation#with
    • ActiveRecord::Relation::Merger
    • ActiveRecord::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. 概要 (1-2文で)
    Active Record がカラム型を取得する際に使う type_for_attribute 内の属性エイリアス解決ロジックを、ブロック付き Hash#fetch から「デフォルト値付き fetch」に変更し、Symbol#to_proc 呼び出しのオーバーヘッドを削減する最適化です。これにより type_for_attribute 呼び出しが 10〜20% 程度高速化され、where の条件評価などでの累積コストを下げています。

  1. 変更内容の詳細

背景となる処理 ​

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 のようなもの)を解決するために、概ね以下のようなコードパスを通っています:

ruby
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引数にデフォルト値を渡す形」に書き換えています。

変更前(イメージ):

ruby
def type_for_attribute(name)
  name = attribute_aliases.fetch(super, &:itself)
  # ...
end

変更後(PR の要点):

ruby
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呼び出しあたりは小さい最適化でも、頻繁に呼ばれるためトータルでは一定の効果が確認されています。


  1. 影響範囲・注意点
  • 影響範囲:

    • Active Record の type_for_attribute を経由する全てのコードパス:
      • where 条件の型変換
      • association の読み込み / 作成 / 更新
      • その他、型情報を参照する内部処理
    • 属性エイリアス (alias_attribute) を利用する場合/しない場合どちらも対象だが、特に「エイリアスを使っていない通常の属性名」で効いてくる最適化です。
  • 挙動面の互換性:

    • Hash#fetch の使い方を「ブロック付き」から「デフォルト値付き」に変えただけであり、
      • エイリアスが存在する場合 → その解決先を返す
      • エイリアスが存在しない場合 → 元の属性名をそのまま使う というロジックは変わっていません。
    • デフォルト値や nil の扱いも fetch の既存仕様通りで、以前と同じです。
    • したがって、アプリ側で attribute_aliases を独自にいじっていない限り、挙動の変更は発生しないはずです。
  • パフォーマンス観点での注意:

    • 単体機能としては非常に小さな最適化ですが、type_for_attribute は CPU プロファイル上で 0.18〜0.39% 程度を占めうる箇所であり、大規模アプリや高トラフィック環境ではじわじわ効くタイプの改善です。
    • 実アプリ全体のスループットが体感で変わるほどではないものの、ボトルネック分析において「type_for_attribute が無駄に重い」という状況は緩和されます。

  1. 参考情報 (あれば)

#59606 Restore three ActionView::Template handler methods removed in #57874 ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で) Rails 8.1 までは存在していた ActionView::Template の3つのハンドラ関連メソッドが、#57874 で削除されたことによる互換性問題を解消するため、それらを ActionView::Template::Handlers への委譲として復活させた PR です。既存のテンプレートハンドラ(例: maildown の Markdown ハンドラ)が Rails main で動かなくなっていた問題を、8.1 と同等の挙動に戻す形で修正しています。

  1. 変更内容の詳細

復活した3つのメソッド ​

#57874 で削除されていた以下のメソッドが、ActionView::Template 上に再導入されました。

  • registered_template_handler
  • template_handler_extensions
  • register_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 でも再び動作するようになります。

ruby
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 なハンドラを戻り値として返す
ruby
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 ではそのまま残されています。

  1. handler_for_extension が registered_template_handler を呼ばなくなった

    • 以前は handler_for_extension → registered_template_handler という委譲があったが、今は直接 Handlers を見る形になっている。
    • これに依存して handler_for_extension / registered_template_handler をオーバーライドしているような高度なメタプログラミングコードがない限り影響はない。
  2. register_default_template_handler が register_template_handler を呼ばなくなった

    • 内部実装上の違いであり、通常ユース (拡張子と Proc/クラスを登録する) の挙動は維持される。
    • ただし、これらのメソッドをオーバーライドしているフレームワーク拡張などがある場合は注意が必要。

  1. 影響範囲・注意点

主な影響範囲 ​

  • 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 と同じ挙動への揃えなので、通常はプラスの変更ですが、返り値のオブジェクト同一性を厳密に見ているテストなどがあれば影響の可能性があります。

  1. 参考情報 (あれば)

この PR 自体はファイル変更も 2 件・追加のみで、挙動も 8.1 への回帰+Proc の取り扱いの微修正にとどまるため、基本的には「安心して main を試しやすくする互換性改善」と考えてよいです。


#59605 Look up the SQLite3 type map once per result ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    SQLite3 アダプタがクエリ結果を組み立てる際に、これまでカラムごとに呼び出していた type_map を「結果セットごとに1回だけ」取得するようにし、CPU命令数を削減してパフォーマンスを改善する変更です。挙動は基本的に従来と同じですが、type_map をオーバーライドしているサブクラスのみ呼び出し頻度が変わります。

  1. 変更内容の詳細

以前の動作 ​

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 を一度だけ呼んで、その戻り値をローカル変数にキャッシュする」ことです。

擬似コードで表すと:

ruby
# 変更前(イメージ)
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% 程度の命令数削減が確認されており、ノイズを超える改善になっています。


  1. 影響範囲・注意点
  • 機能的挙動

    • type_map の戻り値が接続設定にのみ依存するという前提のもとでの最適化なので、通常の使い方ではクエリ結果の型変換挙動は変わりません。
    • カラムごとに type_map が再評価されなくなったため、「同じ結果セットの中で、カラムごとに異なる type_map を返したい」といった非標準なカスタマイズがもしあれば、そこは動作が変わります(ただし、そのような前提で書くのはもともと危険な設計と言えます)。
  • type_map をオーバーライドしているアダプタ・サブクラス

    • 変更前: カラムごとに type_map が呼ばれていた
    • 変更後: 結果セットごとに1回だけ呼ばれる
    • もし type_map を副作用付きでオーバーライド(例: 呼ばれた回数をカウントしたり、呼び出しごとに状態を変える)している場合は、呼び出し回数の減少によって観測される挙動が変わる可能性があります。
    • 正しくは、type_map は純粋関数的(設定のみ依存・副作用なし)に実装すべきで、その前提に沿っていれば影響はありません。
  • 対象

    • ActiveRecord SQLite3 アダプタにのみ影響(PostgreSQL/MySQL など他アダプタは無関係)。
    • 特に SQLite + default_timezone を設定しているアプリでメリットが大きいです。

  1. 参考情報 (あれば)

#59603 Check for String tag attribute values first ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    tag.*, content_tag, link_to などが HTML 属性を組み立てる際の内部処理で、「属性値が String であるケース」を最優先で判定するようにして、典型的なパスを高速化する最適化 PR です。主に文字列属性を大量にレンダリングするビューで、わずかながら CPU 命令数と実行時間が削減されます。

  1. 変更内容の詳細

変更対象 ​

  • ファイル: actionview/lib/action_view/helpers/tag_helper.rb
  • クラス: ActionView::Helpers::TagHelper::TagBuilder
  • メソッド: append_tag_option(推定:PR 説明より)

Rails の以下のようなヘルパーで使われる「属性の組み立て処理」に影響します。

  • tag.* (tag.input, tag.div など)
  • content_tag
  • link_to
  • 各種 form ヘルパー (form_with, form_for, text_field など)

これらのヘルパーは、最終的に TagBuilder#append_tag_option によって、HTML 属性のキー・値を文字列化し、エスケープしながら class="..." や data-* 属性などを生成します。

もともとの処理フロー ​

append_tag_option 内では、属性値 value に対してだいたい次のような case 文で型を見ています(簡略イメージ):

ruby
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 に

ruby
when String
  # else と同じ処理

というブランチを「最初に」追加しています。イメージとしては:

ruby
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 付近):

文字列属性だけのケース ​

ruby
tag.input(type: "text", name: "q", value: "rails")
  • インタプリタでの命令数: 約 6.2% 減
  • YJIT での命令数: 約 2.2% 減
  • ZJIT での命令数: 約 2.0% 減
  • 実行時間(インタプリタ): 約 4.7% 短縮
  • 実行時間(YJIT): 約 2.4% 短縮

Symbol 属性を含むケース ​

ruby
tag.div("x", dir: :ltr)
  • インタプリタ命令数: +1.2%
  • YJIT 命令数: +0.5%
  • 実行時間も +1〜2% 程度の増加

ただし全体として非常に小さい差であり、ベンチマーク全体では「ノイズの範囲」という評価です。


  1. 影響範囲・注意点

影響範囲 ​

  • 影響を受けるのは、
    • tag.*
    • content_tag
    • link_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 に切り出しただけです。
  • したがって、ユーザーコードの互換性問題や HTML 出力の変化は原理的に発生しないと考えられます。

パフォーマンス上の意味合い ​

  • String 属性値が多い一般的なアプリでは:
    • ページ全体で見ると、レンダリング時間の 0.1% 程度の改善が期待できる、という評価になっています。
    • 例: Fizzy や Lobsters で、約 1350 個の String 属性を描画するページをインタプリタでレンダリングした場合、60µs 程度の削減(44–63ms のうちの 0.1%)。
  • ruby-bench の railsbench のようなマクロベンチでは、「測定ノイズの範囲内」であり、大きな違いは観測されていません。
  • まとめると、
    • ホットパスのマイクロ最適化であり、
    • 影響は非常に小さいが、よく出るコードパスの効率が少し良くなった、 という位置づけです。

開発者が意識すべき点 ​

  • アプリケーションコード側で 特別な対応や修正は不要です。
  • タグヘルパー周りの挙動が変わったわけではないので、「この PR によってテストが壊れる・HTML が変わる」といったことは想定されません。
  • パフォーマンス計測環境で Rails main / Rails 9(想定)を追っている場合は、微妙な数値変化の原因としてこの変更を知っておくとよい、という程度です。

  1. 参考情報 (あれば)

#59405 Index existing attachments once when attaching to a collection, 1.6x faster alone and 63x with #58679 ​

マージ日: 2026/11/4 | 作成者: @czj

  1. 概要 (1-2文で)
    has_many_attached に対して既に大量の添付ファイルがあるレコードにさらに attach する際の計算量の悪さ(O(N²))を解消し、添付処理を実質線形時間に近づけるパフォーマンス改善 PR です。既存の添付レコードを一度だけインデックス化して再利用することで、単体でも約 1.6x、高速化 PR #58679 と併用すると最大 63x 程度の高速化が報告されています。

  1. 変更内容の詳細

背景となる問題 ​

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 連想をそもそもロードしない

擬似コードイメージ:

ruby
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 から取得するようにしています。

イメージ:

ruby
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 つのテストが追加されています。どちらも「挙動は変えずに速度だけ上げる」ことを保証するものです。

  1. 「複数添付のうち同じ blob を再割り当てしたときに、正しい attachment を保持する」テスト

    • 複数の attachment が同じ blob を共有している場合でも、従来と同じ attachment が選ばれること(最初のもの)が保証されています。
  2. 「新規アップロードのみを割り当てる場合には、既存 attachments 連想をロードしない」テスト

    • この挙動は main でも成立しており、今回の最適化でも壊してはいけない前提条件です。
    • インデックス構築を eager にやるとここが壊れるため、「インデックスは lazy に構築する」という実装方針をテストで固定しています。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象: 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 の中央値):

Nmainこの PR 単体#58679 単体両方適用
1K247 ms161 ms105 ms18.6 ms
5K5,796 ms3,672 ms2,220 ms91.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)では非互換はありません。

  1. 参考情報 (あれば)
  • 元 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. 概要 (1-2文で)
    Rails の asset_path などで使われる URI_REGEXP の正規表現を、「オルタネーションごとに ^ を付ける」形から「先頭に一度だけ ^ を付ける」形に変更し、特に相対パス(例: "application.css")に対する match? のコストを削減するパフォーマンス改善です。
    マッチ結果は(広範なテストで確認された範囲で)完全に互換であり、挙動を変えずに 10〜15% 程度 asset_path 呼び出しの命令数・時間を削減します。

  1. 変更内容の詳細

何をしているか ​

asset_path は引数が「すでに完全な URL であるか」を判定するために URI_REGEXP を match? しています。
この PR は、その URI_REGEXP の「アンカー(^)」の付け方だけを変更しています。

元のイメージ(簡略化):

ruby
URI_REGEXP = /^https?:|^\/\/|^data:/i
# 実際にはもっと複雑ですが、趣旨としてはこういう形

変更後のイメージ:

ruby
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_path
  • compute_asset_host
  • Action Text: RemoteImage.remote_url?

このため、MatchData への依存やグルーピングの変化による影響はありません(グループも非キャプチャ (?:...) のみ)。


  1. 影響範囲・注意点

影響範囲 ​

  • 直接の変更ファイルは 1 個のみ:
    • actionview/lib/action_view/helpers/asset_url_helper.rb
  • 影響する高レベルヘルパー:
    • asset_path
    • image_tag
    • stylesheet_link_tag
    • javascript_include_tag
    • asset_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 をパースしているような「メタ」な使い方をしていない限り、挙動の差異は出ないはずです。

  1. 参考情報 (あれば)
  • 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 無効
  • 作者のプロファイル/ベンチ環境:

実務的には、「挙動は変えずに asset_path 周辺のコストを少し削ったマイクロ最適化」であり、アップグレード時に特別な対応は不要ですが、大量のアセットを扱うアプリでは「こうした細かい積み重ね」が全体のレスポンス時間・CPU コスト削減につながる、という位置づけの変更です。


#59597 Build allow_browser's named version sets once ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    allow_browser versions: :modern などの「名前付きバージョンセット」を、これまでリクエストごとに HashWithIndifferentAccess へ変換していた処理をクラスロード時に一度だけ行うようにし、ブラウザチェックのオーバーヘッドを削減した PR です。アプリ側で渡す任意のバージョン Hash の挙動は変えず、生成アプリの共通リクエストパスをわずかに高速化しています。

  1. 変更内容の詳細

背景 ​

  • 新規生成された 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 を前提としたコード(設定など)には影響しません。

イメージ(疑似コード):

ruby
# 既存: Symbol キーの Hash 群
SETS = {
  modern: {
    chrome: 120,
    firefox: 132,
    # ...
  },
  # ...
}

# 追加: クラスロード時に一度だけ IndifferentAccess に変換し凍結
EXPANDED_SETS = SETS.transform_values { |h|
  HashWithIndifferentAccess.new(h).freeze
}.freeze

ブラウザ判定時のロジックは:

ruby
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
end

2) アプリ側が渡すカスタム 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 が使われること
    • 挙動の後方互換性
      を検証しています(差分から見るに、そのあたりの確認用と思われます)。

  1. 影響範囲・注意点

性能面の影響 ​

  • ベンチマーク対象: 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 という研究用ベンチで測定する中でこのボトルネックが発見されています。

  1. 参考情報 (あれば)

#59596 Freeze a cached statement's SQL once instead of copying it per execution ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    find, find_by, association 読み出しなどで使われる ステートメントキャッシュの SQL 文字列を「キャッシュ時に一度だけ freeze」することで、実行ごとの文字列コピー・freeze を削減し、割り当てオブジェクト数と命令数をわずかに減らす変更です。
    その結果、prepared statement 利用時に payload[:sql] が毎回別インスタンスではなく 同じ frozen String になり、SQL をインプレースで書き換えるような独自拡張は FrozenError となる可能性があります。

  1. 変更内容の詳細

何をしているか ​

  • 対象:
    • Active Record のステートメントキャッシュ経由で実行されるクエリ
      • Model.find(id)
      • Model.find_by(...)
      • association の読み出し (user.posts, user.posts.to_a など)
  • これらは内部的にキャッシュされた 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 行だけですが、概念的には以下のようなことをしていると考えられます:

ruby
# 以前: キャッシュには非 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 では差は誤差範囲レベル

大幅な改善ではありませんが、「頻繁に呼ばれる共通パスの細かな無駄を削る」タイプのマイクロ最適化です。


  1. 影響範囲・注意点

挙動が変わるケース ​

  1. prepared statements を使っている場合のみ影響

    • SQLite, PostgreSQL ではデフォルトで prepared statements 有効。
    • それ以外や、prepared statements を無効にしている場合は挙動は変わりません。
  2. 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 として表面化するようになった、という性質の変更です。

  1. 参考情報 (あれば)

#59590 Extract order references from the built Arel nodes ​

マージ日: 2026/11/4 | 作成者: @kamipo

  1. 概要 (1–2文で)
  • order 系メソッドが、生の引数からではなく Arel で組み立てたノードから参照テーブル情報(references)を抽出するように変更された PR です。
  • これにより、order/reorder/default_order の前処理タイミングが spawn 前に移り、副作用が減って、merge 時の余計な再処理もなくなり、オブジェクト割り当て数が減少しています。

  1. 変更内容の詳細

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 する形になっています。

これにより:

  • relation インスタンスの状態変化の場所・順序が明確になる
  • spawn 後の relation がより「完成された値」を持つようになる

relation 自身のテーブルは references から除外 ​

  • order 引数として :id や :"posts.id" を渡した場合の扱いが整理されています:

    ruby
    Post.order(:id)
    Post.order(:"posts.id")
  • これらはどちらも最終的に posts.id という Arel ノードになりますが、

    • relation 自身のテーブル(ここでは posts)は references に追加されなくなりました。
  • もともと 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 説明にある例:

ruby
Post.order(:"posts.id") # 39 -> 19 allocations
Post.all.merge(Post.order(:id, title: :desc)) # 26 -> 8 allocations
  • Post.order(:"posts.id")
    • 1 クエリ発行あたりのオブジェクト割り当てが 39 から 19 に削減
  • Post.all.merge(Post.order(:id, title: :desc))
    • 26 から 8 に削減

order 引数の解析や merge 時の二重処理が削減された効果が確認できます。


  1. 影響範囲・注意点

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)が含まれることを前提にしていた
  • order! / reorder! 呼び出しにより
    • 「生の引数」状態の order_values を期待していたり、
    • 副作用的に references が更新されることを前提にしていた
  • Merger の挙動を書き換える monkey patch に依存している

今回の変更で:

  • references:
    • relation 自身のテーブルは含まれなくなりやすい(order 経由では入らない)
    • Arel ノード解析後のテーブル名に基づく形に統一
  • order_values:
    • より早い段階でArel化・前処理済みであることが前提になり、
    • Merger はそれをそのまま引き継ぐ

そのため ActiveRecord::Relation の内部配列構造に依存する実装は、挙動確認・修正が必要になるケースがあります。

3-3. カスタム Arel / order 拡張との相性 ​

  • 自前で Arel ノードを組み立てて order(Arel.sql(...)) や order(my_arel_node) のように渡している場合:
    • 今回は「生成済み Arel ノードから参照を抽出する」方向に統一されているので、むしろ相性は良くなっています。
  • 一方で、「order の生引数解析にフックするような metaprogramming」をしている場合は、
    • 解析位置・タイミングの変更(spawn 前)が影響する可能性があります。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    JRuby 向けにだけ存在していたコントローラテストの分岐(JRuby + NekoHTML 由来の挙動差対応)を削除し、CRuby/JRuby を問わず同じ期待値でテストするようにした PR です。Nokogiri が JRuby で使用する HTML パーサを切り替えたことで挙動差が解消され、JRuby 専用アサーションがむしろ不正になったため、その対応を整理しています。

  1. 変更内容の詳細

背景となる挙動差 ​

対象テストは 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 + proot > map > area + p
1.13.10(JRuby+NekoHTML)<root><map><area><p>content</p></map></root>01
1.14.0 以降<root><area><p>content</p></root>10

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 構造」を前提に、単一のアサーションだけを残す

という形でテストコードを整理しています。

疑似コードで表すと:

ruby
# 変更前(イメージ)
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 件)のみを対象とした最小限の整理です。


  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)にしておくことが望まれます。

  1. 参考情報 (あれば)

この PR は、パーサ実装の統一に伴う過去の互換性ハックを取り除く「テストの正常化・簡素化」であり、今後 JRuby を含めた Rails の挙動検証がシンプルになる方向の変更です。


#59585 Freeze TimeZoneConverter and EnumType when what they wrap is frozen ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    Active Record の TimeZoneConverter と EnumType について、内部で包んでいるオブジェクトがすでに freeze 可能な場合は、それらラッパー自体も freeze するようにした PRです。これにより、モデルのデフォルト属性が Ractor 間で共有されやすくなり、attribute_types 取得やレコード生成・読み込みのパフォーマンスが改善されます。

  1. 変更内容の詳細

背景: 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 するように変更。

サンプルイメージ(概念的なもの):

ruby
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 など)でも、ほぼ全体として命令数減少

  1. 影響範囲・注意点

影響範囲 ​

  • すべての 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 など)、
    アプリ全体としてはほぼ確実にプラス とみてよい変更です。

  1. 参考情報 (あれば)

この 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. 概要 (1-2文で)
    ServerTest#test_restart_rails_server_with_custom_pid_file_path が CI でときどき失敗していた問題を、待ち条件を変更することで解消した PR です。Puma の起動ログのどのタイミングを待つかを見直し、サーバ再起動テストのフレークを減らしています。

  1. 変更内容の詳細

対象テスト:
railties/test/application/server_test.rb の ServerTest#test_restart_rails_server_with_custom_pid_file_path

やっていることは 1 行の変更で、Rails サーバ起動時に どのログが出るまで待つか を変えています。

元の挙動(問題点):

  • テストは、Rails サーバを起動し、Puma のログに "Listening" が出たら、すぐに rails restart を叩いていました。
  • Puma の挙動概要:
    1. バインド処理中に "Listening" をログ出力
    2. tmp_restart プラグインのポーリングスレッドを起動
    3. サーバを生成して実際にリクエスト受付を開始
  • tmp_restart プラグインは:
    • 2秒間隔でファイル変更を監視し、
    • 変更を一度検知すると restart を試みたあとポーリングを止める

このため、次のようなレースコンディションが発生していました:

  1. "Listening" は出たが、まだサーバ本体は走っていない状態
  2. tmp_restart が「再起動すべき変更」を検知 → restart を呼ぶが、サーバがまだ実行されていないので何も起こらない
  3. ポーリングは停止するので、以後変更があっても再起動されない
  4. テストが rails restart を実行しても、再起動が行われず "Restarting" ログが出ない
  5. その結果、テストが "Restarting" を待ち続けて失敗(flaky failure)

変更後の挙動:

  • "Listening" ではなく、"Use Ctrl-C to stop" がログに出るまで待ってから rails restart を実行するように修正しています。
  • "Use Ctrl-C to stop" はサーバ本体が起動し、実際にリクエストを処理できる状態になってから出力されるログです。
  • これにより:
    • tmp_restart プラグインが監視・検知・再起動を行う前提となる「サーバが実行中である」という条件が満たされたあとに、テスト側から rails restart を実行できる
    • プラグインが「空振りリスタート」してポーリングを止めてしまうタイミングと、テストからの rails restart 実行タイミングがかぶる可能性を下げられる

コードイメージ(擬似差分):

ruby
# 変更前(イメージ)
wait_for_server_output("Listening")
run_rails_command("restart")

# 変更後(イメージ)
wait_for_server_output("Use Ctrl-C to stop")
run_rails_command("restart")

実際の差分は 1 行の文字列変更のみです。


  1. 影響範囲・注意点
  • 影響範囲:
    • Rails 本体のランタイム挙動や Puma の挙動には一切影響せず、テストコードのみの変更です。
    • ServerTest#test_restart_rails_server_with_custom_pid_file_path の安定性が向上し、CI のフレークが減ります。
  • 注意点:
    • "Use Ctrl-C to stop" というログ文言に依存しているため、将来 Puma 側や Rails のサーバ起動メッセージが変更された場合、このテストが再度壊れる可能性があります。
    • ログベースの同期(待ち)は本質的に brittle なので、今後テストの設計を見直す際には、より構造化されたシグナル(ポートオープン確認など)での待機に置き換える余地があります。

  1. 参考情報 (あれば)

#59583 Read the through foreign key without wrapping it in an Array ​

マージ日: 2026/11/4 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    has_one :through / has_many :through が belongs_to を経由する関連を読み出す際の stale_state 計算を最適化し、単一カラムの外部キー読み出しで不要な Array 生成などを避けてオブジェクト数・命令数を削減する PR です。挙動(いつ関連をリロードするか)は変えずに、Ruby インタプリタ / YJIT / ZJIT すべてで実行コストを大きく削減しています。

  1. 変更内容の詳細

背景: stale_state とは何か ​

has_one :through / has_many :through が「既にロード済みの関連が古くなっていないか」を判定するために、所有者(owner)の外部キー値を stale_state としてキャッシュしています。

  • owner 側の外部キーが変化したら stale_state が変わる → 関連を再ロード
  • 変化していなければ → 既存の関連オブジェクトを再利用

このとき、belongs_to の外部キーを読む実装が、単一カラムの場合でも配列を経由する形になっており、オブジェクト生成やメソッド呼び出しが多くなっていました。

従来のイメージ(単一カラムキーでもだいたいこんな感じ):

ruby
# 疑似コード
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 キーの新実装(概念図) ​

ruby
# 旧: 
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 がこれまで通りの値になる
    • 外部キー変更時に関連が再ロードされること
  • 新しい実装によって「キャッシュが壊れる / リロードされない」等が起きないことの検証

テストレベルで「挙動非互換は起きていない」ことを担保しています。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • 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 命令数
main315,4478,2837,879
この PR112,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 によってアプリケーションコード側で何か修正が必要になるケースは基本的にありません。


  1. 参考情報 (あれば)

#59411 Retry ActiveRecord::SerializationFailure errors on safely-retryable queries ​

マージ日: 2026/11/3 | 作成者: @adrianna-chang-shopify

  1. 概要 (1-2文で)
    ActiveRecord::SerializationFailure(主にDBのトランザクション分離レベルに起因するシリアライゼーションエラー)発生時に、「安全にリトライできるクエリ」であれば自動リトライするようにしたPRです。すでに行っている ActiveRecord::Deadlocked の自動リトライと同じ扱いを、トランザクション外のシリアライゼーションエラーにも適用します。

  1. 変更内容の詳細

背景・モチベーション ​

  • シリアライゼーションエラー(SerializationFailure)は、発生したステートメントを含むトランザクション全体をロールバックします。
  • ただし、そのステートメントが「トランザクション外」(autocommitモード)で実行されていた場合、接続の状態は正常なままで、その同じステートメントを同じ接続上で安全に再実行できます。
  • これは既に ActiveRecord::Deadlocked に対して行っている戦略(一定回数まで自動リトライ)と同じであり、同様に SerializationFailure も扱える、という判断です。

実際の変更点 ​

1) retryable_query_error? に SerializationError を追加 ​

activerecord/lib/active_record/connection_adapters/abstract_adapter.rb で、クエリエラーが「リトライ可能かどうか」を判定する retryable_query_error? に SerializationError を含めました。

イメージとしては以下のような変更です(簡略化):

ruby
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説明文より):

ruby
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 になることを検証。

4) CHANGELOGの更新 ​

  • activerecord/CHANGELOG.md に、この挙動変更が記載されました。
  • これにより、ユーザー視点でも「SerializationFailure が特定条件で自動リトライされる」という仕様変更が明示されています。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象:

    • ActiveRecord を利用していて、
    • トランザクション外(autocommit)のクエリで、
    • DBのトランザクション分離レベルなどにより ActiveRecord::SerializationFailure が発生しうるアプリケーション
  • これらのケースでは、今後は ActiveRecord::Deadlocked と同様に、ActiveRecord側で自動的に一定回数(アダプタ実装依存)リトライされる可能性があります。

期待されるメリット ​

  • 一時的な競合により発生した SerializationFailure に対して、アプリケーションコードを書き換えずに耐障害性が向上します。
  • これまで Deadlocked のみ自動リトライに頼っていたコードでも、同種の一時的エラーである SerializationFailure を吸収できるようになります。

注意点 ​

  1. トランザクション内ではリトライされない

    • トランザクションを手動で張っているコード(transaction do ... end など)での SerializationFailure は、これまで通りエラーとしてアプリケーション側に返されます。
    • その場合は、アプリケーション側で「トランザクション全体をリトライする」ロジックを実装する必要があります。
  2. idempotent(副作用が安全)なクエリ設計が前提

    • 自動リトライの対象になるクエリは、再実行しても整合性が崩れないように設計する必要があります。
    • 通常、単純なSELECTや、同じ結果になるように設計されたUPDATE/INSERT(UPSERT的な操作など)は問題になりにくいですが、副作用のある処理や外部サービス連携がクエリ実行と密に結合している場合は設計を見直す必要があります。
  3. パフォーマンス・レイテンシ

    • 競合が頻発する環境では、シリアライゼーションエラー → リトライ → 再度エラー…という形でレイテンシが伸びる可能性があります。
    • ただしこれは既存の Deadlocked リトライと同様の懸念であり、SerializationFailure が追加されたことでその対象が少し広がるイメージです。

  1. 参考情報 (あれば)
  • エラー種別:
    • 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. 概要 (1-2文で)
    in_order_of が既存の Relation(特に既にロード済みのもの)を不正に破壊的変更してしまい、ActiveRecord::UnmodifiableRelation を発生させていたバグを修正した PR です。
    他のクエリメソッドと同様に、in_order_of の処理を spawn 済みの Relation 上で行うことで、元の Relation を変更しないようにしています。

  1. 変更内容の詳細

問題点 ​

in_order_of が実装上、spawn する前に self に対してカラム解決を行っていたため、

  • column_references
  • order_column(内部で arel_column_with_table を経由)

といった処理が、受け取り側の Relation (self) に対して references_values を追加してしまう状態になっていました。

この結果、以下のようなケースで例外が発生します:

ruby
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 を呼び出しても例外が出ないことを検証するケースを追加

(疑似的なイメージ)

ruby
# 修正後のイメージ(概念)
def in_order_of(column, values)
  spawn._in_order_of!(column, values)
end

def _in_order_of!(column, values)
  # ここでカラム解決 / references_values の追加などを行う
end

CHANGELOG ​

activerecord/CHANGELOG.md に、in_order_of が受け手の Relation を変更していた問題を修正した旨が追記されています。
Rails のバージョンアップ時に、この挙動修正が含まれていることが明示されます。


  1. 影響範囲・注意点
  • 既存コードの挙動への影響

    • 正常系でのクエリ結果(返ってくるレコードの順序)は変わりません。
    • 過去に「ロード済み Relation に対して in_order_of を呼ぶと例外が出る」状態だった箇所は、例外が出なくなり、意図した順序付けが行われるようになります。
    • これまで「バグを前提にしたワークアラウンド」(例: わざと load 前に in_order_of を呼ぶ、to_a に逃がす等)を書いていた場合、そのような回避コードは不要になります。
  • パフォーマンスやクエリ内容

    • spawn を使うのは他のクエリメソッドと同じパターンで、追加コストは他のメソッドと同程度です。
    • 生成される SQL の構造や ORDER BY の内容に大きな変更はありません(目的は副作用の排除のみ)。
  • Relation の不変性モデルとの一貫性

    • これにより、in_order_of も他のクエリビルダメソッド同様、「元の Relation を変更せずに、新しい Relation を返す」という Rails 的な一貫した挙動になります。
    • 「ロード済み Relation は変更不可」というルールに完全に従うようになりました。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59586
  • 関連するクラス/メソッド:
    • ActiveRecord::Relation#in_order_of
    • ActiveRecord::Relation#spawn
    • ActiveRecord::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. 概要 (1-2文で)
    with_recursive を使った ActiveRecord::Relation を merge すると、生成される SQL から RECURSIVE が抜け落ちるバグを修正した PRです。with_recursive のフラグ管理を通常の relation 値として扱うように変更し、merge・spawn・only・or などの演算で正しく伝播するようになりました。

  1. 変更内容の詳細

問題の内容 ​

これまで with_recursive は内部的に以下のような状態になっていました。

  • CTE 自体は @values[:with] に格納
  • 「これは再帰的 CTE である」というフラグは @with_is_recursive というインスタンス変数で別管理

一方、Relation::Merger は @values だけを見てマージする実装のため、merge 時に CTE 本体は引き継がれるものの、「再帰的である」というフラグは失われていました。

そのため、例えば:

ruby
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 フラグも他の値と同様に扱えるようになりました。

推測されるイメージとしては、以下のような状態になっています(実装イメージ):

ruby
# 従来 (イメージ)
@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 が正しく保持されることを検証しています。

  1. 影響範囲・注意点
  • 影響を受けるのは、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 の有無にも依存しているようなメタ的な処理を書いていた場合は挙動を確認した方がよいです(通常のアプリではまず問題にならないレベル)。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59580
  • 関連クラス/モジュール:
    • ActiveRecord::Relation
    • ActiveRecord::Relation::Merger
    • ActiveRecord::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. 概要 (1-2文で)
    ActiveSupport::Subscriber がイベントを受け取るたびに行っていた文字列スライスをやめ、あらかじめ計算したハンドラ名マッピングを使うことで、オブジェクト生成を削減しパフォーマンスを改善する変更です。通常の「attach_to でクラスを紐付けるタイプ」のサブスクライバで特に恩恵があり、既存のインターフェースは維持されています。

  1. 変更内容の詳細

これまでの挙動 ​

ActiveSupport::Subscriber#call は通知イベントを受け取ると、
event.name(例: "sql.active_record")から「最初のドットまで」を取り出して、
対応するインスタンスメソッド(ハンドラ)名を決めていました。

擬似コードで表すとイメージはこんな感じです:

ruby
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: なクラスリーダーで公開):

ruby
# イメージ:実際のコードは多少異なる可能性あり
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 します。

擬似コードで表すと:

ruby
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 やインスタンス側から見える挙動は変わらない想定
  • つまり、通常の利用者から見えるインターフェースはそのままです

  1. 影響範囲・注意点

影響範囲 ​

  • 主に影響するのは ActiveSupport::Notifications をベースにした ActiveSupport::Subscriber のサブクラスおよびそれを attach_to しているコードです。
    • 例: ActiveRecord の "sql.active_record"、 ActionView の "render_template.action_view" など
  • Rails 本体のログ・計測系 subscriber に広く適用されるため、 Rails アプリ全般で恩恵があります。

ベンチマーク結果(PR 説明より):

Subscriber#call, per callmainこの PR
objects10
ns (interpreter / YJIT / ZJIT)412 / 244 / 322247 / 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: と明示されているクラスリーダーに依存したコードを書くのは推奨されません
    • 将来の互換性が保証されないため

  1. 参考情報 (あれば)

#59576 Compile a single-predicate where clause without a nodes Array ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    where 句に単一の述語しか無い場合に、内部で不要な Array を生成しないようにして、Active Record のクエリコンパイル時のオブジェクト生成数を削減する最適化です。クエリの意味や生成される SQL/AST は変えずに、メモリ効率とわずかなパフォーマンス向上を狙った変更です。

  1. 変更内容の詳細

背景: これまでの挙動 ​

ActiveRecord::Relation::WhereClause#ast は、where(...) で指定された述語(predicates)を AST ノードの配列として組み立てています。

内部的には:

  • 述語ごとに AST ノードを生成
  • それらを新しい Array に詰める
  • 述語が 1 個しかない場合は「配列の先頭要素だけ」を返す

という流れになっており、例えば:

ruby
Post.where(id: 1)

のように述語が1つしかないケースでも、毎回一旦 Array を作ってから array.first だけを返していました。この配列は最終的に使われないにもかかわらず、オブジェクトとしては毎回確保されてしまいます。

今回の変更点 ​

  • 述語が 1つだけ の WhereClause かつ、
  • その述語が 空文字列ではない("" ではない)

という条件を満たす場合には、配列を作らず、その単一述語だけを直接ラップして返すように実装を変更しています。

それ以外のケース:

  • 述語が複数個ある
  • 述語が1つだが、それが空文字列である(where("") のようなケース)

これらは従来通りのコードパス(配列を作る処理)を通すようにしてあり、AST の構造や動作の互換性を維持しています。

擬似コードイメージ ​

以前:

ruby
def ast
  nodes = []
  predicates.each do |predicate|
    nodes << wrap_predicate(predicate)
  end

  nodes.one? ? nodes.first : nodes
end

今回:

ruby
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 が期待通り構築されること
  • 複数述語や空文字列を含むケースでは従来通りの挙動であること

  1. 影響範囲・注意点

パフォーマンスへの影響 ​

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 に触れることはまずないため、多くの利用者には影響ありません。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    Rails の ActiveRecord::Relation のクエリメソッド (order!, where!, having! など) が引数配列を二重に生成していた問題を解消し、不要な配列アロケーションを削減してパフォーマンスを改善する変更です。結果として、一般的なクエリチェーンでのオブジェクト生成数が減ります。

  1. 変更内容の詳細

背景となっていた問題 ​

spawn.order!(*args) のように「メソッドに配列をスプラットで渡し、その受け側メソッドも *args で受ける」構造だと、Ruby は内部的に配列を再構築するため、余分な配列アロケーションが発生します。

例(イメージ):

ruby
def order!(*args)
  # args はここで新しく配列として組み立てられる
end

spawn.order!(*args_from_caller)
# args_from_caller -> 新しい args 配列 にコピーされる

この PR は、この「二重配列アロケーション」を避けるのが主目的です。


_xxx! メソッドの追加とラッパー化 ​

order! を直接 *args で実装するのをやめて、配列をそのまま受け取る「内部用メソッド」を追加し、order! 自体はラッパーにしています。

イメージコード:

ruby
# 外部インターフェイス(従来どおり *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! にも配列アロケーション削減が入っています。

以前のイメージ ​

ruby
def where!(*rest)
  # 引数がなくても rest = [] の新しい配列が毎回できる
end

変更後のイメージ ​

ruby
FROZEN_EMPTY_ARRAY = [].freeze

def where!(predicates = nil, rest = FROZEN_EMPTY_ARRAY)
  # 第二引数を省略した場合、毎回新しい [] を作らず
  # 共有の FROZEN_EMPTY_ARRAY を参照する
end

ポイント:

  • 引数が 0 個や 1 個のケースで、不要な空配列の生成をしない
  • FROZEN_EMPTY_ARRAY を使うことで、「読み取り専用の共有空配列」を再利用する

効果の例 ​

PR 説明にあるサンプル:

ruby
Post.where(title: "x").order(:id).includes(:comments)
    .joins(:comments).select(:id).limit(10)

このクエリを一度実行するまでに生成されるオブジェクト数:

  • 変更前: 52 allocations
  • 変更後: 48 allocations

クエリビルド時の配列割り当てが削減されていることが分かります。
1 回あたりは小さな差ですが、リクエストあたり大量のクエリ生成が行われるアプリや、バックグラウンドジョブなどで効いてきます。


  1. 影響範囲・注意点
  • 対象クラス/モジュール

    • ActiveRecord::Relation::QueryMethods
    • ActiveRecord::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 回数・時間がわずかに改善される可能性があります。
    • 一回一回は微小だが、トラフィックが多いアプリやバッチ処理で蓄積効果が出るタイプの最適化です。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59579
  • 関連しそうなトピック:
    • Ruby における *args / スプラット演算子と配列アロケーションのコスト
    • Rails 内部の frozen な空配列 (FROZEN_EMPTY_ARRAY) パターンによるマイクロ最適化
    • ActiveRecord::Relation のクエリビルドチェーンの実装 (QueryMethods, Merger)

#59571 Define parameterless scope methods for lambda bodies without parameters ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    Rails の scope 定義で、引数を取らない lambda を使っている場合に限り、そのスコープ用メソッドを「引数なしメソッド」として定義するようにして、余計な配列・ハッシュの生成を減らすパフォーマンス改善が行われました。挙動(引数を渡したときに ArgumentError が出ることなど)は維持しつつ、1リクエストあたりのオブジェクト生成数を削減しています。

  1. 変更内容の詳細

これまでの挙動 ​

ruby
class Post < ApplicationRecord
  scope :published, -> { where(published: true) }
end

上記のようなスコープ定義では、内部的に以下のようなメソッドが定義されていました(イメージ):

ruby
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 オブジェクト

例 ​

ruby
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) }
end

published スコープについては、内部的には以下のようなイメージになります:

ruby
# 変更前(イメージ)
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.published1917
Post.where(n: 1).published2018
Post.min_n(1)1616

また、Rails チームが計測した 21 種類の構成のうち 20 でこの種のスコープ呼び出しがあり、そのうちの中央値では 1 リクエストあたり 13 オブジェクト減 という結果が出たとのことです。


  1. 影響範囲・注意点
  • 大半のアプリでは互換性上の問題はほぼない想定
    • 引数を取らない lambda を scope 本体にしているだけなら、挙動は同じです。
    • 「引数を渡すと ArgumentError になる」という性質も維持されます。
  • メソッドシグネチャに依存しているメタプログラミングは影響しうる
    • method(:published).arity や parameters を使って挙動を変えているコードがある場合、挙動が変わる可能性があります。
  • Proc ベースや引数つき lambda のスコープは影響なし
    • これらはこれまで通り *args, **kwargs を取るメソッドとして定義されます。
  • パフォーマンス上の効果
    • スコープを多用しているコードパス(特に関連経由でのチェイン)で、GC 負荷やオブジェクト生成数がわずかに減る可能性があります。
    • 効果自体は 1 呼び出しあたり数オブジェクトの削減ですが、大規模トラフィック下では積み上がります。

挙動確認の観点で見ると:

  • 以下を行うと差分を実感できます(コンソールなどで):
    • Post.method(:published).arity
    • Post.method(:published).parameters
  • 逆に、スコープを普通に呼んでいるだけのアプリケーションコードは、そのまま動作する想定です。

  1. 参考情報 (あれば)
  • 対象ファイル:
    • activerecord/lib/active_record/scoping/named.rb
      • scope 定義時のメソッド生成ロジックに変更。
    • 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. 概要 (1-2文で)
    PostgreSQLでときどき落ちていた EagerAssociationTest#test_preload_has_many_through_with_source_type_ignores_other_types_in_loaded_through_records を、レコードの順序に依存しないように修正したPRです。テストの本質である「型のフィルタリング」を確認しつつ、ORDER BY のないクエリに起因する順序の違いで失敗しないようにしています。

  1. 変更内容の詳細

問題となっていたテストは「has_many :through + source_type を使った関連の eager load が、別タイプのレコードを無視していること」を検証するものです。

失敗のパターンとしては、期待値と実際の値の“順序”だけが違っていました:

diff
- [#<SpecialPost id: 2, ...>, #<Post id: 1, ...>]
+ #<ActiveRecord::Associations::CollectionProxy [#<Post id: 1, ...>, #<SpecialPost id: 2, ...>]>

PR説明から分かるように:

  • 期待値側は次のクエリで取得 (ORDER BY なし)

    sql
    SELECT "posts".* FROM "posts"
    INNER JOIN "taggings" ON "posts"."id" = "taggings"."taggable_id"
    WHERE "taggings"."tag_id" = $1
      AND "taggings"."taggable_type" = $2
  • 実際値側はプリロード経由で取得 (どちらのクエリも ORDER BY なし)

    sql
    SELECT "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)
実際の修正イメージとしてはおおよそ次のような形になります (擬似コード):

ruby
# 変更前(順序も含めてそのまま比較)
assert_equal expected_records, post.tags

# 変更後(id でソートして集合比較)
assert_equal expected_records.sort_by(&:id), post.tags.sort_by(&:id)

実際には、左辺(通常ロード)と右辺(プリロード済み関連・CollectionProxy)の両方を sort_by(&:id) する形で比較しています。


  1. 影響範囲・注意点
  • 影響範囲

    • 変更はテスト (eager_test.rb) のみであり、本体の Active Record の動作・API には影響しません。
    • CI 上での flakiness(ときどき落ちるテスト)を解消することが主目的です。
  • 注意点

    • このテストに限らず、ORDER BY を伴わないクエリ結果の順序に依存したテストは、DB 実装や実行計画の違いで不安定になりうるため、同様に「id等による明示的なソート」か「集合としての比較」にすべき、という示唆があります。
    • 実運用コード側に ORDER BY が不要であるという前提 (順序を保証しない仕様) はこのPRで変わっていません。必要であればアプリケーション側・ライブラリ側で明示的に ORDER BY を付与する必要があります。

  1. 参考情報 (あれば)

#59575 Use count(:all) for one? and many? ​

マージ日: 2026/11/3 | 作成者: @kamipo

  1. 概要 (1-2文で)
    ActiveRecord::Relation#one? / many? が select 句付きのクエリで ActiveRecord::StatementInvalid を起こすケースがあり、それを内部で count(:all) を使うようにして修正したPRです。レコード数判定のためのカウント処理を「SELECT リスト」ではなく「行数ベース」に統一し、distinct 付きクエリでも正しく動くようにしています。

  1. 変更内容の詳細

問題の背景 ​

元々 one? と many? は、内部的に limited_count → count を呼んで件数を判定していましたが、count の呼び出しが「引数なし」だったため、以下のような振る舞いになっていました。

  • count に引数なし:
    • select が指定されていない場合: COUNT(*) 相当で問題なし
    • select が指定されている場合: COUNT(SELECT で指定した式) になろうとする

select に複数のカラムや集約関数を含めている場合、COUNT(...) の中身として不正な式が生成され、DB から StatementInvalid が返されていました。

PRの説明にあるような例:

ruby
# 集約を含んだ 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.rb

    • limited_count 内の count 呼び出しが count(:all) に置き換えられた (実質 1 行の変更)
  • activerecord/test/cases/relations_test.rb

    • 上記のような select 付きの Relation に対して one? / many? が正常に動作することを確認するテストが追加された(約 29 行)

distinct 付き Relation への対応 ​

説明文に

distinct relations are still counted correctly, via a subquery.

とある通り、distinct が指定されている Relation に対しても、既存の limited_count ロジック(内部でサブクエリを用いたカウント)により正しく件数が取得されます。

count(:all) にしても、distinct の扱いは ActiveRecord 内部の count 実装側でケアされるため:

ruby
Model.distinct.one?
Model.select(:name).distinct.many?

といったクエリも引き続き期待通りに動作します。


  1. 影響範囲・注意点
  • 影響を受けるメソッド:

    • 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? に戻せる可能性があります。

  1. 参考情報 (あれば)

#59559 Fix one? and many? on grouped relations ​

マージ日: 2026/11/3 | 作成者: @b84330808

  1. 概要 (1-2文で)
    Grouped Relation(group を使ったクエリ)に対して one? / many? が正しく動作していなかったバグを修正し、グループごとの件数に基づいて one? / many? を判定できるようにした PR です。count がハッシュを返す場合でも、追加のクエリをほぼ増やさずに正しいブール値が返るようになっています。

  1. 変更内容の詳細

背景となる問題 ​

group を含む Relation に対して one? / many? を呼ぶと、以下のような不整合・エラーが起きていました。

ruby
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 は「グループごとの件数ハッシュ」を返す
ruby
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(グループ数)を件数として扱う

これにより、例えば以下のような挙動になります。

ruby
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 にこの挙動修正が記載

  1. 影響範囲・注意点
  • 影響対象:
    • group を含む ActiveRecord Relation で one? / many? を使っているコード
    • 特に joins(...).group(...) や集計系のクエリでの利用箇所
  • 互換性:
    • これまで「常に false を返していた」「例外を投げていた」ケースが、正しく true/false を返すようになります。
    • 正しい動作への修正なので「仕様として依存していた」ケースはほぼないはずですが、
      • これまで rescue していた TypeError が発生しなくなる
      • 「group 付き relation では one?/many? は壊れている」と想定して、独自ロジックを組んでいた場合は挙動が変わる可能性があります。
  • パフォーマンス:
    • これまでと同様、one? / many? は LIMIT 2 の count クエリのみで判定するため、性能面の悪化はほぼありません。
    • グループ数を数えるだけなので、大量レコードでも負荷は従来同等です。

  1. 参考情報 (あれば)
  • 元 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. 概要 (1-2文で)
    Rails のローカルキャッシュ (Strategy::LocalCache) が、バッチ書き込み・削除 (write_multi, delete_multi) を使った場合でも一貫性を保つように修正された PR です。あわせて MemCacheStore のオプション処理(unless_exist, compress, race_condition_ttl)まわりの重複と漏れも整理・修正されています。

  1. 変更内容の詳細

2-1. LocalCache がバッチ API を直接フックするように変更 ​

従来の Strategy::LocalCache は、以下の単一キー向けのメソッドにフックすることでローカルキャッシュの整合性を保っていました。

  • write_serialized_entry
  • delete_entry

一方で、Rails の CacheStore には以下の複数キー向けのバッチメソッドがあります。

  • write_multi → 内部的に write_multi_entries
  • delete_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 を呼んでもローカルキャッシュ上は値が残り続けるという不整合が起こっていました:

ruby
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 を呼ぶ。

これにより、下記のようなコードでも期待通りに動きます:

ruby
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. テストの追加・整理 ​

以下のテスト追加・移動が行われています。

  1. activesupport/test/cache/behaviors/cache_store_behavior.rb

    • write_multi + :unless_exist の挙動を、共通の CacheStoreBehavior に移動。
    • これにより全てのキャッシュストア実装がこの期待値を満たしているか検証される。
  2. activesupport/test/cache/behaviors/local_cache_behavior.rb

    • delete_multi が LocalCache と整合性を保てているかを検証するテストを追加。
    • with_local_cache ブロックで write → delete_multi → read のパターンをカバー。
  3. activesupport/test/cache/stores/mem_cache_store_test.rb

    • MemCacheStore に対して、以下のパスをテストするケースを追加:
      • write_multi + compress が効くか
      • write_multi + race_condition_ttl の期限計算が正しいか
    • 既存のテストのうち、共通化されたものは CacheStoreBehavior 側に移行。

  1. 影響範囲・注意点
  • 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 している」ような高度な実装では呼び出し順に注意してください。

  1. 参考情報 (あれば)
  • この 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 が効かない問題
  • キャッシュ戦略:
    • 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. 概要 (1-2文で)
    ActiveRecord::Relation 同士のマージ処理で、同じ Relation を自分自身に対して merge!/Merger した場合にクエリ条件が失われうる不具合が修正されました。自分自身をマージ対象とした場合は、何も変更せず同じ Relation をそのまま返すようにしています。

  1. 変更内容の詳細

背景: Merger と merge / merge! の挙動 ​

ActiveRecord には以下の2つのマージ API があります。

  • relation.merge(other)
    • 内部的に relation.spawn した新しい Relation に other をマージ
    • 元の Relation は変更されない(非破壊)
  • relation.merge!(other)(private)
    • 呼び出し側 Relation (self) に other の条件等を直接書き込む(破壊的)

merge! は内部的に ActiveRecord::Relation::Merger を使って、@relation に @other の各種値(where, order, joins など)をコピー・統合しています。

ここで問題となっていたのは、「relation.merge!(relation) のように、同じ Relation を self と other の両方に渡した場合」です。

問題点: self を other にマージすると条件が消える ​

PR 説明にあるように、次のようなコードで不具合が発生していました。

ruby
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 の場合の早期リターン」を追加しています。

イメージとしては以下のようなチェックが追加されています(厳密なコードは多少異なる可能性あり):

ruby
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 などが消えないことが検証されています。


  1. 影響範囲・注意点
  • 対象:
    • ActiveRecord::Relation の merge!(およびそれを利用する内部処理)
    • 特に、同一の Relation を self/other に渡しているケースのみが挙動変更の対象
  • 挙動の変化:
    • 以前: relation.merge!(relation) で where/order などが失われることがあった(バグ)
    • 現在: relation.merge!(relation) は何も変更せず、relation をそのまま返す
  • 後方互換性:
    • 期待される正しい挙動に修正されただけなので、多くのアプリケーションにとっては「バグ修正」であり、意図的にこのバグを利用しているコードでもない限り問題は起こりにくいです。
  • 注意点:
    • ライブラリやメタプログラミング的なコードで merge! 相当を直接呼び出している場合(あるいは Relation 内部構造に依存している場合)、
      「self を other にそのまま渡したら条件がリセットされる」といった誤った前提を置いていると挙動が変わります。
    • ただし merge! は public API ではなく内部向けメソッドのため、通常のアプリケーションコードへの影響はほぼありません。

  1. 参考情報 (あれば)
  • 対応 PR: https://github.com/rails/rails/pull/59572
  • 関連概念:
    • ActiveRecord::Relation#merge
    • ActiveRecord::Relation#spawn
    • ActiveRecord::QueryMethods#unscope / unscope!
  • 類似のバグパターン:
    • 同一オブジェクトを「入力」と「出力」に同時に使う合成処理における、副作用による状態破壊(in-place 処理と共有オブジェクトの組み合わせ起因)

#59568 Read attribute_types once when loading records ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    Active Record が SQL 結果からレコードをロードする際に、attribute_types を毎カラムごとに何度も呼んでいた箇所を、事前に一度だけ読み出すようにして無駄なメソッド呼び出しを削減し、レコード生成処理のパフォーマンスを向上させる変更です。
    機能的な挙動は変えずに、主に SQLite / PostgreSQL 利用時のクエリ結果インスタンス化コストを数 %〜最大 20% 程度削減しています(命令数ベース)。

  1. 変更内容の詳細

背景 ​

  • 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 instructions413k / 213k−6.6% / −3.8%
Post.where(...).limit(5).to_a747k / 360k718k / 351k−3.9% / −2.5%
1 行インスタンス化だけ143k / 84k114k / 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 行の追加」のような極小変更です。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象: Active Record が SQL 結果 (_load_from_sql) からレコードをインスタンス化する全てのパス
    • 主に SQLite / PostgreSQL 利用時に関係(型情報を Result#column_types から取得する DB アダプタ)
    • Model.find, where(...).to_a, pluck 絡みなど、通常のクエリによるレコード生成全般
  • 影響内容:
    • 機能的な挙動(返ってくるオブジェクト、型付けの結果)は変わりません。
    • レコードインスタンス生成時の CPU 使用量・命令数が削減され、全体としてクエリ処理のスループットとレイテンシがわずかに改善されます。

注意点 ​

  • 互換性:
    • 挙動を変える変更ではなく、「同じ Hash をより効率よく計算する」だけなので、アプリケーションコードへの互換性リスクはほぼありません。
  • 他 PR との関係:
    • PR 説明にもある通り、#59141 でこの reject ブロック自体が削除される予定であり、そちらが先にマージされていれば今回の最適化は不要(moot)になる設計です。
    • すでにこの PR (#59568) はマージ済みなので、今後 #59141 がマージされれば、さらに当該コードパスごと簡素化される見込みです。
  • attribute_types のキャッシュ前提:
    • ブロック内で attribute_types が変更されない(=モデルのスキーマ定義が途中で変化しない)ことを前提にしていますが、通常の Rails アプリケーションでは要求される前提条件であり、一般利用では問題ありません。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59568
  • 関連 PR:
    • #58437: attribute_types の取得経路を schema_context ベースに変更したため、attribute_types 呼び出しコストが増えた。
    • #59141: _load_from_sql 内での column_types フィルタリングロジック自体を削除予定の PR。
  • 関連コード:
    • ActiveRecord::Querying
    • ActiveRecord が内部で利用する _load_from_sql / レコードインスタンス化ロジック(通常はアプリから直接呼ばない内部 API)

#59563 Serialize Active Job Hash arguments without pair Arrays ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    Active Job でジョブ引数の Hash/キーワード引数をシリアライズする処理から、不要な [key, value] の一時配列生成を取り除き、オブジェクト生成数と実行時間を削減した変更です。動作仕様(キー・値・順序・_aj_* マーカー)は一切変えずに、内部実装のみを高速化しています。

  1. 変更内容の詳細

何を変えたか ​

対象メソッドは ActiveJob::Arguments#serialize_hash です。
従来は次のような「each_with_object + ブロック引数の多重代入」で Hash を組み立てていました:

ruby
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 を直接更新する形に変更しています(イメージ):

ruby
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 (変更後)
典型的なキーワード引数生成オブジェクト数136
実行時間5.1 / 3.1 µs4.2 / 2.4 µs
ネストしたイベント Hash生成オブジェクト数3715
実行時間14.3 / 7.9 µs11.8 / 5.6 µs

※時間は「Interpreter / YJIT」の 2 列をまとめた形だと思われます。

Chatwoot での実利用例では、「ステータス切り替え時に複数ジョブを enqueue する処理」で、238 個のオブジェクト削減(約 1.3% 減) が確認されています。


  1. 影響範囲・注意点

影響する箇所 ​

  • Active Job 経由でジョブを enqueue する際に:
    • 引数に Hash を渡した場合
    • キーワード引数として渡した場合
      これらを内部でシリアライズする処理のパフォーマンスが向上します。

仕様面の影響 ​

  • Hash のキー・値・挿入順序はこれまで通り保持されます。
  • Active Job が内部的に使用する _aj_* 系のマーカーもこれまで通り付与されます。
  • 引数のシリアライズ結果の形式(JSON などに落としたときの形)は変わりません。

つまり、アプリケーション側での互換性問題は基本的にありません。
テストを書き換える必要も、通常は発生しない想定です。

注意点(とはいえ) ​

  • 非公開 API(ActiveJob::Arguments の内部実装)に依存しているようなメタプログラミングをしている場合は、挙動を念のため確認してください。
  • 大量にジョブを enqueue するバッチやイベント駆動処理では、パフォーマンス改善の影響が出やすいので、メモリ・スループットをウォッチしておくと良いです(良い意味で変化するはずです)。

  1. 参考情報 (あれば)
  • この PR: Serialize Active Job Hash arguments without pair Arrays (#59563)
  • 類似の最適化 PR:
    • #59236: 同様の「pair Array を避ける」パターンを他所で適用したもの
    • #55544: 今回と同じコードパスを過去に高速化した PR
  • 関連クラス:
    • ActiveJob::Arguments
    • ActiveJob::Base(ジョブの引数はここから Arguments に渡される)

#59566 Fix stale reads from the local cache after ActiveSupport::Cache::MemCacheStore#write_multi ​

マージ日: 2026/11/3 | 作成者: @kehra

  1. 概要 (1-2文で)
    ActiveSupport::Cache::MemCacheStore#write_multi 実行後に、同一リクエスト内のローカルキャッシュから古い値が読まれてしまう不具合を修正するPRです。set_multi 経由の書き込み時にローカルキャッシュを適切に無効化することで、一貫した読み取り結果を保証します。

  1. 変更内容の詳細

問題の背景 ​

Rails 8.2 以降(#58066 以降)、MemCacheStore#write_multi は Dalli の set_multi を利用するようになりましたが、この経路では write と異なりローカルキャッシュ(with_local_cache)が更新されていませんでした。

その結果、同一リクエスト内で次のようなコードを書くと:

ruby
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 から取得した値が改めてローカルキャッシュに保存されるため、以降の読み取りは新しい値になる

疑似コードイメージ:

ruby
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 にならない)

  1. 影響範囲・注意点
  • 対象:

    • 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 後にすぐ新しい値が見える」挙動に近づく形になります

  1. 参考情報 (あれば)
  • 関連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. 概要 (1-2文で)
    Rails のテストランナーで bin/rails test -n ClassName#test_name 形式のフィルタが動作しなくなっていたバグを修正し、Minitest 本来のクラス名+テスト名フィルタが正しく動くようにした PR です。これにより、ActiveSupport::Testing::Isolation が -n を使ってサブプロセス実行するケース(JRuby / Windows など fork 非対応環境)でもテストが正しく実行されます。

  1. 変更内容の詳細

不具合の内容 ​

背景:

  • 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 説明にある再現例:

bash
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" のような名前になるので、従来のプレフィックスロジックが必要

イメージとしては擬似コードでこんな条件分岐が追加された形です(実装はもう少し具体的な正規表現等ですが):

ruby
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 フィルタが再び動くようになった旨が追記されています。


  1. 影響範囲・注意点

影響範囲:

  • Rails のテスト実行:
    • bin/rails test -n ClassName#test_name
    • bin/test -n ClassName#test_name
  • fork 非対応環境(JRuby, Windows, または NO_FORK=1)で ActiveSupport::Testing::Isolation::Subprocess を用いるテスト:
    • これまで isolated テストが SubprocessCrashed になっていたケースが正常に動作するようになります。

互換性・注意点:

  • 既存の -n test_foo フィルタや、test "..." / it "..." など宣言的テスト名に依存したフィルタは挙動維持のままです。
  • Isolation::Subprocess は引き続き -n を子プロセスに渡しますが、Minitest 6 では「-n/--name ではなく -i/--include を使ってほしい」という警告が出るとのことです。
    • この PR では Minitest 5 との互換性を優先して -i への移行はしていません。
    • 将来的に Minitest 6 前提になるときに -i への切り替えが別途必要になる可能性があります。

運用面でのポイント:

  • これまで bin/rails test -n ClassName#test_name が動かずに諦めていた場合、今後は積極的にクラス単位+メソッド指定のフィルタを利用できます。
  • JRuby / Windows 環境で isolation を使うテスト(ActiveModel, ActiveRecord の一部など)が一斉にグリーンになる可能性が高いので、CI の挙動が変わる (「5 errors → 5 passed」など) 点に注意してください。

  1. 参考情報 (あれば)
  • この不具合は 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. 概要 (1-2文で)
    thread_mattr_accessor がスレッド/ファイバごとの状態管理に使うキー文字列を、毎回動的に生成するのをやめ、モジュールごとに1回だけ生成した凍結リテラルを使い回す最適化です。これにより、多くのリクエストで発生していた不要な文字列オブジェクトの生成が削減され、パフォーマンスが向上します。

  1. 変更内容の詳細

背景: thread_mattr_accessor とキー文字列 ​

thread_mattr_accessor は、モジュール/クラスに対して「スレッド(および Fiber)ごとに独立したクラス属性」を定義するためのヘルパーです。内部的には IsolatedExecutionState に対して、

ruby
"attr_#{name}_#{object_id}"

という文字列をキーにして値を格納・取得しています。

従来の実装では、読み取り・書き込みのたびにこのキー文字列を組み立てていたため、呼び出し1回あたり 2 個の String オブジェクトが生成されていました(1回のアクセスで reader / writer ともに同様の仕組みを使う場合など)。

この PR では、このキー文字列を「定義元モジュール単位で一度だけ」生成し、その後は凍結されたリテラルとしてメソッドに埋め込むように変更しています。

具体的な変更イメージ ​

(実際のコードは簡略化していますが、概念的には以下のような違いです)

従来:

ruby
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
end

PR 後(定義時に一度だけキーを生成し、リテラルとして埋め込み):

ruby
# 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 callmainthis PR
objects20
ns (interpreter / YJIT)364 / 299198 / 109
  • オブジェクト数: 2 → 0 (String の一時オブジェクトを生成しない)
  • 実行時間: インタプリタ / YJIT ともに短縮

さらに、9 つの OSS アプリでの 97 workload 中 83 で、1 リクエストあたりのオブジェクト数が削減され(中央値 −11/req)、実アプリでも効果が確認されています。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象機能: 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 を乱用する」ような極端なケースでは、最適化効果が限定的になる可能性があります。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59561
  • 対象コード:
    activesupport/lib/active_support/core_ext/module/attribute_accessors_per_thread.rb
  • 関連概念:
    • IsolatedExecutionState: Rails がスレッド/ファイバごとの状態を安全に扱うための内部 API
    • thread_mattr_accessor: mattr_accessor の「スレッド/ファイバごとに分離された」版で、フレームワーク内部でも広く使用される仕組み

#59560 Reuse type map cache keys for PostgreSQL result columns ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    PostgreSQL のクエリ結果の型解決で、毎カラムごとに新しい配列オブジェクトを生成していたキャッシュキーを再利用することで、オブジェクト生成数を削減する最適化です。型解決ロジックの挙動(どの型になるか)は変えずに、メモリ・GC 負荷を下げる変更になっています。

  1. 変更内容の詳細

これまでの問題点 ​

PostgreSQL アダプタは、クエリ結果の各カラムについて、次のように型を引いています:

ruby
type_map.fetch(oid, fmod, sql_type)

ここで *args([oid, fmod, sql_type])がハッシュのキーになっており、

  • 各カラムごとに Array が1つ生成される
  • キャッシュヒットしても、毎回新しい配列オブジェクトが生成される

という状態でした。

さらに、結果セットのカラムに関しては

  • sql_type は常に空文字列 ""
  • 実際のキーは [oid, fmod, ""] 相当

となるため、かなりパターンが限られています。それにもかかわらず、都度 Array を生成していました。

この PR の主なアイデア ​

「結果カラム用の type_map 参照では、[fmod, ""] のキーオブジェクトを使い回す」ことで、配列の生成数を削減しています。

大きく分けて 2 点の変更があります:

  1. HashLookupTypeMap#fetch_result_type の追加
  2. 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 でキャッシュを破棄する際に、これらのキーもまとめて破棄する
  • 失敗した(該当する型がなかった)場合には何も保持しない

イメージ的には次のような動きです(擬似コード):

ruby
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 説明にあるとおり:

mainthis PR
cast_result, 10-column select_all27 objects17 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.rb
  • activerecord/test/cases/type/type_map_test.rb

で次のような点を検証しています:

  • fetch_result_type による型解決が、従来の fetch と同じ結果になること
  • register_type / clear を行った場合に、結果用キーキャッシュも正しく破棄されること
  • 失敗した lookup(見つからなかった型)の場合に余計なキーが残らないこと

  1. 影響範囲・注意点

影響範囲 ​

  • 対象: 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) パスに少しだけ追加処理がありますが、頻度が低いため通常は無視できるオーバーヘッドです。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59560
  • 関連箇所:
    • ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
    • ActiveRecord::Type::HashLookupTypeMap
  • 類似の最適化パターン:
    • 頻出するハッシュキー・配列・文字列を「凍結・共有」して GC 対象オブジェクトを減らすテクニック
      • ex. Symbol の利用、"".freeze、FrozenRecord / frozen hash の活用 など

#59558 Avoid scratch Arrays when casting PostgreSQL results ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    PostgreSQL 用アダプタで、クエリ結果をキャストする際に毎回作成していた一時的な Array(field_types と missing_oids)の生成を避け、オブジェクト割り当て数を削減する最適化です。ほぼすべてのカラム型が既知という前提で、実行時の動作は維持しつつメモリアロケーションを減らしています。

  1. 変更内容の詳細

背景: 既存コードの問題点 ​

ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#cast_result(あるいはそれに準じたメソッド)では、PostgreSQL の PG::Result から Ruby オブジェクトへ変換するときに、以下のようなことをしていました:

  • 各クエリ結果について:
    • 全カラム分の OID を field_types Array に一度まとめて読み込む
    • 未知の OID を集めるための missing_oids Array を作る
    • その後、型テーブルを引きながら変換を行う

しかし、

  • result.ftype(index) は PG::Result 上の非常に軽い Integer アクセサである
  • ほとんどのアプリケーションでは、全ての OID が事前に登録済み(=未知の OID が無いケースが「ほぼ常に」)

という事情から、「わざわざ全カラム分の OID を一括で Array に取り出す」コストと、「未知の OID がないのに毎回 missing_oids Array を作る」コストが無駄になっていました。

今回の変更のポイント ​

  1. field_types Array の削除

    • 各カラムの型を調べるとき、
      • 以前: 一度 field_types = (0...nfields).map { |i| result.ftype(i) } のように Array を作成してからそれを参照
      • 変更後: 各ループ内で都度 result.ftype(i) を呼び出す
    • これにより、クエリ結果ごとに 1 つ作られていた field_types Array の割り当てが不要になります。
  2. missing_oids Array の遅延生成

    • 以前: 「未知の型があるかどうか」に関わらず、毎回 missing_oids = [] を用意してからループ
    • 変更後:
      • まず missing_oids を nil のままにしておき、
      • ループ中に「未知の OID に遭遇したタイミング」で初めて missing_oids = [] を生成して push する
    • そのため、全カラム型が既知の場合は missing_oids Array 自体が一切作られません。
  3. load_additional_types への引き渡しの挙動維持

    • 未知 OID が見つかった場合に、load_additional_types(missing_oids) のようなメソッドを呼んで追加ロードする処理があります。
    • このメソッドに渡される OID リストについて:
      • 並び順は従来と同じ
      • 重複もこれまで通り許容(仕様を変えない)
    • 追加ロード後に依然として不明な型は、従来通り「still-missing types」として処理・登録されます。
  4. コードイメージ(擬似的な Before / After)

※ 実際のコードは多少異なりますが、意図が分かるレベルの擬似コードです。

Before(イメージ):

ruby
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?
end

After(イメージ):

ruby
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 行と非常に小さくまとまっています。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象: PostgreSQL アダプタ (activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb) の、クエリ結果を Ruby オブジェクトへキャストする処理全般。
  • 利点:
    • 既知の型のみで結果が返るケースがほとんどのアプリケーションでは、クエリ結果ごとに 2 個(field_types と missing_oids)の Array アロケーションが減少
      • 未知 OID が存在するケースでも、少なくとも field_types 1 個分は削減
    • PR の計測では、9 つの PostgreSQL アプリ(Mastodon, Discourse, Docuseal, Lago, Loomio, Sure, CASA, CodeTriage, PingCRM)と 124 の workload(リクエスト、ジョブ、メーラー)において、121 workload でオブジェクト割り当て数が減少(中央値 −23 個)。
    • 計測はオブジェクト数のみで、パフォーマンスの時間的な改善を直接主張してはいないものの、GC 負荷軽減などの面でメリットが見込まれます。

挙動互換性・注意点 ​

  • 機能的な挙動(型解決の結果、未知 OID の扱い、load_additional_types の呼び出しタイミングなど)は従来と同じになるよう配慮されています。
  • OID リストの順序・重複も既存と同じで、これに依存したコードへの影響はありません。
  • result.ftype(index) をループ内で繰り返し呼ぶ形になりましたが、PG::Result 上では軽量な整数アクセサであり、かつ field_types の Array 構築コストが無くなるため、実質的には総コスト減の方向です。
  • #57517 でこの周辺がロックでラップされているため、片方の PR がマージされた後、もう一方は trivial なリベースが必要になる(=コード位置が近い)ことが明記されています。
    → 既に #59558 がマージ済みなので、#57517 側で小さなコンフリクト解消が必要になる可能性があります。

  1. 参考情報 (あれば)
  • 該当ファイル:
    activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb
  • 関連 PR:
    • #57517: 同じ cast_result 付近のコードをロックで保護する変更(競合する可能性)
  • 計測対象アプリケーション:
    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. 概要 (1-2文で)
    Relation#merge 実行時に、マージ元の Relation が持つ values ハッシュを不要にコピーしていたのをやめ、読み取り専用で直接参照するようにして、オブジェクト生成数を削減する最適化です。unscope! の影響を受け得る「自分自身の values」を扱う場合のみ、これまで通りコピーします。

  1. 変更内容の詳細

なにを改善したか ​

従来の挙動:

ruby
# 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
end
  • Relation#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 からキーを破壊的に削除するため、読み取り中に状態が変わるのを避けるためです。

擬似コードイメージ:

ruby
# 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! による破壊的変更が安全に動きつつ、不要なオブジェクト生成が減ることを確認しています。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象となるのは 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_many 302 → 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 と干渉しないか一応確認した方が安全です(とはいえ、ほとんどのアプリには無関係な領域です)。

  1. 参考情報 (あれば)
  • この PR は #59212 とは独立に動作し、#59212 適用後でも依然として多くのワークロードで割り当て削減効果が残るとされています(97 のうち 68 がまだ改善)。
  • 関連ファイル:
    • activerecord/lib/active_record/relation.rb
      values_for_merge の追加。
    • activerecord/lib/active_record/relation/merger.rb
      values の扱いを dup 前提から「条件付き dup + 読み取り専用参照」に変更。
    • activerecord/lib/active_record/associations/collection_proxy.rb
      values_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. 概要 (1-2文で)
    Action Text の Trix 添付ファイル関連テストが Nokogiri 1.19.5 以降で落ちていた問題に対し、HTML 比較方法を文字列比較から assert_dom_equal に変更して、Nokogiri のシリアライズ仕様変更に依存しないようにした PR です。これにより、Nokogiri のバージョン差異に関係なく同じテストが通るようになります。

  1. 変更内容の詳細

背景となる問題 ​

  • 失敗していたテスト:
    • ActionText::ContentTest#test_sanitizes_attachment_markup_for_Trix
    • ActionText::AttachmentTest#test_to_trix_html_sanitizes_action-text_HTML_content_attachment
  • Nokogiri 1.19.5 以降の HTML5 シリアライズ仕様変更により、属性値中の < と > が &lt; / &gt; にエスケープされるようになった(WHATWG HTML仕様に準拠した変更)。
  • 既存テストは、data-trix-attachment 属性を「シリアライズ後の文字列そのもの」として比較していたため、Nokogiri のバージョンごとに生成される文字列が異なり、CI(Nokogiri 1.19.5+)ではテストが落ちていた。

対応内容 ​

テストの HTML 比較方法を、文字列の完全一致から DOM ベースの比較に変更しています。

具体的には、対象テストで:

  • 変更前(イメージコード):

    ruby
    assert_equal expected_html, actual_html

    あるいは、data-trix-attachment などを文字列としてそのまま比較していた。

  • 変更後:

    ruby
    assert_dom_equal expected_html, actual_html

assert_dom_equal は内部的に HTML をパースし、DOM として構造・属性値を比較するため、

  • < / > が文字列中で &lt; / &gt; としてシリアライズされていても、
  • 同じノード構造・属性値であれば「同じ DOM」とみなしてパス

するようになります。

これにより、以下の両方でテストが通る:

  • 古い Nokogiri(例: Gemfile.lock に入っている 1.19.1)
  • 新しい Nokogiri(CI でインストールされる 1.19.5 以降)

対象ファイル ​

  • actiontext/test/unit/attachment_test.rb
  • actiontext/test/unit/content_test.rb

の 2ファイルで、それぞれ関連テストに対して assert_dom_equal を使うように +3/-1 行程度の小変更が行われています。


  1. 影響範囲・注意点
  • 影響範囲

    • 実際の Action Text 機能・本番コードには変更なし。
    • 影響するのは Trix 添付ファイル(data-trix-attachment)に関するユニットテストのみ。
    • これにより、Rails の Action Text テストスイートが Nokogiri バージョンに依存せず安定する。
  • 注意点 / 開発者向け示唆

    • HTML/DOM をテストする際、Nokogiri などの HTML シリアライザの仕様変更に影響されやすい文字列比較 (assert_equal での raw HTML 比較) は脆くなりがち。
    • 今回のように:
      • DOM 構造や属性値の論理的な同一性を検証したい場合は assert_dom_equal
      • 単純文字列比較が本当に必要な場合は、ライブラリのシリアライズ仕様に追従する覚悟が必要
    • 自前のテストでも、属性値中の <, >, & などが関わる場合、assert_dom_equal や属性値の個別取得 + 比較を検討した方がよいです。

  1. 参考情報 (あれば)

#59552 Use the scopes as constraints of a direct association reflection ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    belongs_to / has_one / has_many のような「直接の関連(through を介さないもの)」について、関連スコープ構築時に余分な配列オブジェクトを作らないようにして、オブジェクトアロケーションを減らす最適化が入っています。挙動は変えずに、メモリ使用量を少し改善する内部的なパフォーマンスチューニングです。

  1. 変更内容の詳細

背景: constraints と scopes ​

Active Record では、関連スコープを組み立てる際に「リフレクション(関連定義)」のチェーンを辿り、それぞれの constraints を集めてスコープを構築します。

  • チェーン: chain
  • 各リフレクションの constraints: 通常は chain.flat_map(&:scopes) を通じて取得
  • scopes: その関連に紐づくスコープ情報(プロックや Arel 条件など)

belongs_to / has_one / has_many のような「through を使わない直接の関連」の場合、チェーンは 1 要素だけになります:

ruby
chain # => [self] (self はその関連の Reflection)

このとき constraints を求めるために

ruby
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 行と小さな変更です。
コードイメージとしては以下のような形になります(擬似コードレベルのイメージ):

ruby
# もともと: 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)
  • 実行時間については「改善を主張するには小さすぎる」ということで、タイムの主張は明示的にしていない

  1. 影響範囲・注意点
  • 挙動の互換性
    • 公開 API レベルでの挙動変更は意図しておらず、返されるスコープロジック自体は従来どおりです。
    • constraints が返す配列インスタンスそのものに依存しているようなメタプログラミング(例: object_id や破壊的変更)をしていなければ、通常のアプリケーションには影響ありません。
  • 対象となる関連
    • belongs_to
    • has_one
    • has_many
    • ただし「through を介さない直接関連」のみ。
    • has_many :through やポリモーフィック関連は従来のロジック継続。
  • パフォーマンス観点
    • 1呼び出しあたりの削減は小さいですが、アプリ全体のトラフィックや複雑な関連を多用するケースでは、GC 負荷の軽減にわずかに寄与する可能性があります。
    • 計測上、多くのワークロードでオブジェクト数が減っているが、劇的な差ではないため、性能改善目的でこれ単体に依存するのは現実的ではありません。

  1. 参考情報 (あれば)
  • この変更は 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. 概要 (1-2文で)
    Rails 8.1 以降で Enumerable#index_with が Hash や Struct に対して挙動退行していた問題を修正し、「each が yield する値」をインデックス対象として再び使うように戻した PRです。Array に対してはパフォーマンス改善のための to_h 利用を維持しつつ、Enumerable 全体では従来どおり each に基づいた挙動を保証します。

  1. 変更内容の詳細

何が問題だったか ​

もともと Enumerable#index_with は、each が yield する値をキーとしてハッシュを作っていました。

ruby
# Rails 8.1 以前のイメージ
enum.index_with { |elem| ... }  # elem は each が yield するもの

しかし、コミット 2b88dc1995 で「事前に配列サイズを見積もれる」という理由から内部実装を to_h { ... } ベースに変更した結果、

  • to_h が each と同じ形で yield しない Enumerable で挙動が変わってしまった

具体例:

  1. Hash の場合
ruby
# 元々(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 するか」に依存してしまった

という仕様的なねじれが発生していました。

  1. Struct の場合

Struct#to_h は「メンバ名と値のペア」ではなく「メンバ名をキーにしたハッシュ」として扱われる関係で、これも each とは異なる形で yield します。そのため、Struct#index_with でも従来と違うキーが使われるようになってしまっていました。

何を直したか ​

PR では以下のように整理し直しています。

  1. Enumerable#index_with は再び each を使う

    • 内部実装を to_h { ... } ベースから、従来どおりの each ループベースに戻した
    • これにより、Hash・Struct・HashWithIndifferentAccess など、to_h が each と違う振る舞いをするクラスでも、「each が yield する値をインデックスにする」という本来の意味を回復

    イメージ:

    ruby
    module Enumerable
      def index_with
        # 擬似コード
        result = {}
        each do |elem|
          result[elem] = yield(elem)
        end
        result
      end
    end
  2. 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 で問題なく機能するよう配慮されている旨が説明されています
  3. テストの追加

activesupport/test/core_ext/enumerable_test.rb に回帰テストが追加され、以下のケースで「以前と同じ挙動」であることが確認されます。

  • Hash
    • ブロック引数 1 つのケース
    • ブロック引数 2 つのケース
    • ブロック省略など「デフォルトの形」のケース
  • HashWithIndifferentAccess
  • Struct

PR 説明では「このテストは main ブランチでは失敗する(= 回帰を検出できる)」と述べられています。


  1. 影響範囲・注意点
  • 対象バージョン

    • 回帰を引き起こした変更は 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 で見える単位」と同じであることが仕様として再確認された、と理解しておくとよいです

  1. 参考情報 (あれば)
  • 該当 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. 概要 (1-2文で)
    Relation#merge 内で常に呼ばれていた extending! を、「拡張モジュールが存在する場合だけ」呼ぶようにし、余分な配列オブジェクトの生成を避けてメモリ割り当てをわずかに削減する変更です。動作仕様は変えずに、Active Record の関連操作(author.posts.count や merge(Post.published) など)のオブジェクト数を微減させるマイクロ最適化です。

  1. 変更内容の詳細

何が問題だったか ​

ActiveRecord::Relation#merge は、最終的に以下のような処理をしていました(概念的なイメージ):

ruby
# 擬似コードイメージ
def merge(other)
  # ... いろいろなマージ処理 ...
  relation.extending!(*other.extensions)
end
  • other.extensions は、Relation#extending などで付与された「拡張モジュール」のリストです。
  • 多くの場合、この拡張は使われず other.extensions は空の配列です。
  • extending! 自体は「引数がなければすぐ return」する軽いメソッドですが、*other.extensions のスプラット展開のために毎回「配列を 1 個」確保するオーバーヘッドが発生していました。

このため Relation#merge を呼ぶたびに、ほぼ無駄な Array オブジェクトが 1 つずつ増える形になっていました。

今回の変更点 ​

extensions が空のときには extending! 自体を呼ばないようにしただけの 1 行変更です。イメージとしては次のような条件分岐が追加された形です:

ruby
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 ワークロードでなおオブジェクト数に変化があるとのことです。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象: 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 負荷やオブジェクト割り当てをとにかく減らしたい場合に、ほんの少しだけ効く」レベルのマイクロ最適化です。


  1. 参考情報 (あれば)

#59554 Don't build bang method names when merging relation values ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    ActiveRecord::Relation#merge が内部で limit! や offset! などのメソッド名を毎回動的に生成していたのを、事前に定義したシンボルを使うように変更し、オブジェクトアロケーション数をわずかに削減するパフォーマンス改善です。挙動や公開APIは変えずに、Relation マージ時の文字列生成コストを減らしています。

  1. 変更内容の詳細

何をしていたか ​

Relation#merge は、マージ対象の Relation が持つ各種値(limit, offset, distinct, readonly など)を、自身に反映するために以下のようなことを行っています。

  • 値の種類ごとに対応する「破壊的メソッド」(limit!, offset!, distinct!, readonly! など)を呼び出す
  • これまではそのメソッド名を毎回 :"#{name}!" のようにして動的に組み立てていました

疑似コードで表現すると、以前はだいたいこんなイメージです:

ruby
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 メソッド名の一覧」をあらかじめ定義し、それを使ってメソッドを呼び出すようにしました。

イメージとしては、例えば以下のような形です(実際の定義名や構造は多少異なる可能性がありますが、本質は同じです):

ruby
# 事前に 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) はコンフリクトしないように設計されています。


  1. 影響範囲・注意点
  • 対象: 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 圧のごくわずかな低減が期待できる程度

  1. 参考情報 (あれば)
  • 該当 PR:
  • 関連 PR(association reader 側の limit(1) マージをなくす改善):
  • 背景となる一般的な話:
    • Ruby では :"#{name}!" のように毎回文字列補間を行うと、その String が都度生成され GC 対象になるため、ホットパスでは事前定義したシンボルを使うのが定石
    • Rails 本体はこの種の「小さなアロケーション削減」を継続的に行うことで、トータルの GC 負荷を下げる方針をとっている

#59553 Return a frozen empty Array from default_extensions ​

マージ日: 2026/11/3 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    ActiveRecord::Scoping::Named の default_extensions が、毎回新しい空配列を返すのではなく、共有された frozen な空配列を返すように変更されました。これにより、関連オブジェクトの生成時のオブジェクト割り当てがわずかに減り、メモリアロケーションが最適化されています。

  1. 変更内容の詳細 (サンプルコード含む)

背景 ​

  • 各コレクション関連 (author.posts など) のプロキシは、内部で Association#extensions を呼び出します。

  • Association#extensions は、以下のようなイメージで

    • モデルクラス側の klass.default_extensions
    • リフレクション (has_many ... do ... end などで指定した extension)

    を union(和集合)して使っています。

    擬似コードイメージ:

    ruby
    def extensions
      klass.default_extensions | reflection.extensions
    end
  • 「デフォルトスコープを持たないモデル」では、klass.default_extensions は「何もない」ので、実際には空配列を返していましたが、そのたびに 新しい Array オブジェクト が生成されていました。

変更点 ​

  • default_extensions が「空の場合」に、毎回 [](新規配列)を返すのではなく、共有された frozen な空配列を返すように変更されました。

    変更前(イメージ):

    ruby
    def default_extensions
      []
    end

    変更後(イメージ):

    ruby
    EMPTY_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”)。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象は 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: オプションを使う形になります:

    ruby
    class Author < ApplicationRecord
      has_many :posts do
        def published
          where(published: true)
        end
      end
    end

    もしくは:

    ruby
    module PostsExtensions
      def published
        where(published: true)
      end
    end
    
    class Author < ApplicationRecord
      has_many :posts, extend: PostsExtensions
    end
  • 内部的には「共有された frozen 配列 + union 演算」で扱うため、機能的な挙動(関連に定義された extension が呼び出せるかどうか)は変わりません。


  1. 参考情報 (あれば)
  • 対象ファイル:
    activerecord/lib/active_record/scoping/named.rb (+2 / -2 の非常に小さな変更)
  • PR 番号: #59553
    タイトル: "Return a frozen empty Array from default_extensions"
  • パフォーマンス効果:
    • 「オブジェクト割り当て削減」による 軽微なメモリ効率改善 が主目的
    • 実行時間については「改善を主張しない」と明記されているため、マイクロ最適化レベルの変更と見なすのが妥当です。

#59544 Only extract Ranges in ArrayHandler when there are any ​

マージ日: 2026/11/2 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    where(column: array) などで使用される ArrayHandler が、配列内に Range オブジェクトが含まれない場合には、Range 抽出用の一時配列を生成しないよう最適化されました。SQL やバインドパラメータの内容・挙動は変えずに、不要なオブジェクト割り当てをわずかに削減する変更です。

  1. 変更内容の詳細

何をしているクラスか ​

activerecord/lib/active_record/relation/predicate_builder/array_handler.rb は、以下のようなクエリで使われる

ruby
Model.where(column: array)
# => column IN (...)

のような配列を使った条件 (IN 句や範囲指定) を、SQL とバインド値に変換するためのハンドラです。
配列に Range オブジェクトが含まれていると、BETWEEN などに展開する処理があります。

変更のポイント ​

元の実装では、配列の中に Range が含まれているかどうかに関わらず、毎回「Range だけを集めた新しい配列」を作成していました。その結果、

  • where(id: [1, 2, 3]) のように Range を一切含まない配列でも
    毎回空の配列を割り当てて捨てる、という無駄なオブジェクト生成が発生していました。

今回の変更では:

  1. まず array.any? { |v| v.is_a?(Range) } のような形で、配列に Range が含まれているかを事前チェック
  2. Range が1つも無ければ、Range 抽出処理そのものをスキップ
    (=新しい配列を作らない)
  3. Range が含まれている場合のみ、従来通り Range を抽出した配列を作成し、SQL に反映する

というフローになっています。

コードイメージ(概念的な擬似コード):

ruby
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 → 171
  • Post.preload(:author, :comments)
    オブジェクト割り当て数: 691 → 689
  • 9つのアプリ・97ワークロード中、60ワークロードでオブジェクト割り当てが減少

あくまで「割り当て数の削減」の話であり、PR では 処理時間の改善を主張していない 点が明示されています。


  1. 影響範囲・注意点
  • 機能・挙動の変更はなし
    生成される SQL 文およびバインド値は「完全に従来通り」であり、アプリケーションから見える振る舞いは変わりません。
  • 影響範囲は ArrayHandler を通る場面全てですが、実質的には:
    • Model.where(column: array) 形式のクエリ
    • includes / preload などが内部で発行する IN クエリ における内部実装のメモリアロケーションがわずかに軽くなっただけです。
  • Range を含むクエリ(where(id: 1..10) や [1, 2..5, 10] など)の動作・SQL 生成ロジックも変わっていません。チェック条件も以前と同じ is_a?(Range) ベースなので、ここでの判定仕様にも変更はありません。
  • したがって、この PR によって既存コードが壊れるリスクは非常に低く、通常のアプリ開発者が特別な対応をする必要はありません。

  1. 参考情報 (あれば)
  • 対象ファイル:
    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. 概要 (1-2文で)
    ActiveRecord::Relation の except / only が内部で余計な Hash のコピーを作らないようにし、オブジェクト生成数をわずかに削減する変更です。挙動は変えずにメモリアロケーションを少し最適化するマイクロパフォーマンス改善です。

  1. 変更内容の詳細

これまでの実装 ​

Relation#except / Relation#only は内部状態を持つ @values(クエリ条件などを保持する Hash)に対して、いったん values メソッド経由で dup 済み Hash を取り出し、それに対して except / slice を呼んでいました。

イメージとしては次のような形です(実際のコードを簡略化):

ruby
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 を直接参照するように変更しました。

擬似コードで表すと次のような違いです:

diff
- 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 は 割り当て数のみ を根拠にしており、「パフォーマンスが必ず速くなる」という時間計測上の保証をしているわけではありません。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象メソッド:
    • ActiveRecord::Relation#except
    • ActiveRecord::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 を経由する形に戻す

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    このPRは、接続チェックイン時(リクエストやジョブ終了時など)に「保留中の intent」が何もない場合は処理を早期リターンし、不要な配列オブジェクトの割り当てを避ける最適化です。機能的な挙動は変えずに、オブジェクトアロケーションをわずかに削減します。

  1. 変更内容の詳細

対象ファイル:

  • 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 処理も配列の再割り当ても行わず、そのまま抜ける
      という挙動になります。

擬似コードイメージ(あくまで説明用):

ruby
def finalize_pending_intents
  # 【今回追加】空なら何もしない
  return if @pending_intents.empty?

  # ここから下は従来通り
  @pending_intents.each(&:finalize!)
  @pending_intents = []
end

PRの説明にある通り、

  • Post.first を(リース非保持で)実行するケースで、checkin あたりの Array アロケーションが 118 → 117 に減少
  • 9つのアプリ、97のワークロードを計測したところ、82ワークロードでアロケーション数が減少(中央値 -7個/ワークロード)
    という効果が報告されています。
    なお、あくまで「オブジェクト割り当て数」だけの改善であり、「パフォーマンス時間の改善」を主張するものではないと明記されています。

  1. 影響範囲・注意点
  • 機能面:

    • intent の finalize 処理がスキップされるのは「そもそも pending intent が1件もない場合」に限られます。
    • pending intent が存在する場合の処理フローは従来と変わりません。
    • そのため、アプリケーションの観測可能な挙動(クエリ結果、トランザクション挙動など)は基本的に変わりません。
  • パフォーマンス面:

    • 1回あたり配列1個分のアロケーション削減のような、かなり細かいレベルの最適化です。
    • リクエスト数・ジョブ数が多い環境では、総和として GC 負荷軽減にごく僅かに寄与する可能性がありますが、すぐに体感できるレベルではないと想定されます。
    • Ruby インタプリタ本体、YJIT いずれでも同様のアロケーション削減が確認されています。
  • 互換性/マイグレーション:

    • 破壊的変更は含まれていません。
    • API 仕様変更や設定変更の必要もありません。
    • 既存のコード、特に ActiveRecord::Base / コネクションプール / トランザクションまわりの呼び出し側に修正は不要です。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    ActiveRecord::Relation::WhereClause#merge が、空の WhereClause に他の条件をマージする際の不要な配列生成や分割処理をスキップする最適化が入りました。結果として、関連の読み取り(post.author, author.posts, スコープ付き has_many など)でオブジェクトアロケーション数が減少します。

  1. 変更内容の詳細

何が問題だったか ​

関連を読むたびに、内部的には「関連に紐づく where 条件などを既存の relation にマージする」処理が走ります。
WhereClause#merge は、このマージ処理で次のような無駄をしていました:

  • 受け側(self)の WhereClause に まだ1つも predicate がない 場合でも、
    • 相手側の WhereClause の属性情報を一旦 Array に展開
    • except_predicates 内でさらに2つの Array に分割
    • しかし、実際には「フィルタされる predicate が 0 件」なので分割した意味がない

このため、「空の WhereClause に条件をくっつけるだけ」のケースでも、配列生成と分割というコストのかかる処理が毎回発生していました。

今回の最適化内容 ​

説明文から読み取れるロジック上の変更点は2つです。

  1. self に predicate が無いときは配列展開をスキップ

    • 「元が空」の場合はフィルタ処理の余地がなく、相手の条件をそのまま使えばよいので、
      • 相手側の属性を配列化して
      • except_predicates でさらに分けて… といった処理を丸ごと飛ばします。
  2. すべてのカラムが 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 オブジェクトの削減

いずれも「オブジェクトアロケーション数のみの測定」であり、「速度向上を数値として保証するものではない」と明確にされている点もポイントです。


  1. 影響範囲・注意点

影響範囲 ​

  • 影響を受けるのは 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 ではなく内部ロジックの最適化なので、互換性リスクはかなり低いと考えられます。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    Rails の Arel において、SelectManager#project / #order と Window#order / #partition の可変長引数の扱いを「その場変換」に変えることで、余分な Array の生成を減らし、オブジェクトアロケーションを削減する最適化が行われました。機能的な挙動は変えずに、SELECT クエリのコンパイル時に発生するメモリアロケーションを抑える変更です。

  1. 変更内容の詳細

変更の背景 ​

対象メソッドは以下の4つです:

  • Arel::SelectManager#project
  • Arel::SelectManager#order
  • Arel::Nodes::Window#order
  • Arel::Nodes::Window#partition

これらは Ruby の可変長引数 (*args) を受け取り、既存の内部配列に結合する処理を行っています。
以前の実装では、以下のような構造になっていたと考えられます(擬似コード):

ruby
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) し
  • それを既存の配列に結合する

形に変更しています。

擬似コードにすると:

ruby
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)

  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • Arel の SELECT ビルド処理 (SelectManager#project, #order)
    • ウィンドウ関数の ORDER / PARTITION 指定 (Window#order, #partition)
  • 実質的には、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 回数やヒープの断片化に対して良い影響が見込めます

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59534
  • 関連クラス:
    • Arel::SelectManager
      • 主に Active Record のクエリビルダーが内部で使用
    • Arel::Nodes::Window
      • OVER (PARTITION BY ... ORDER BY ...) 等のウィンドウ関数構築に使用
  • 計測環境の一例:
    • Basecamp のアプリケーション群:
      • Fizzy: board/cards/notifications におけるアロケーション削減数
      • Writebook: 1リクエストあたり 6〜21 オブジェクト削減

#59539 Use drop instead of an endless Range for the join chain tail ​

マージ日: 2026/11/2 | 作成者: @thomasklemm

  1. 概要 (1-2文で)
    joins / left_joins / eager_load(および JOIN に変換される includes)で内部的に使っている「関連リフレクションのチェーン処理」において、endless Range (array[index..]) の生成をやめて drop(index) に置き換えることで、オブジェクト割り当てを 1 個だけ削減するマイクロ最適化です。
    振る舞いは一切変わらず、内部実装のメモリアロケーション削減のみを目的とした変更です。

  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 説明で挙げられている例:

ruby
Post.joins(:author).load

この 1 回の呼び出しにおけるオブジェクト割り当て数が:

  • 変更前: 153
  • 変更後: 152

と、joined reflection 1 つにつき 1 オブジェクト削減されています(through association が多いと、より多く削減される可能性あり)。

実際のコード変更イメージ ​

※PR本文からの推定ですが、変更は 1 行のみで、ほぼ次のような置き換えです:

ruby
# 変更前(例)
reflection_chain[index..]

# 変更後
reflection_chain.drop(index)

Ruby の挙動としては:

ruby
ary = [1, 2, 3, 4]

ary[2..]      # => [3, 4]  # endless Range を生成してからスライス
ary.drop(2)   # => [3, 4]  # Range 不要で同じ結果

となり、返る配列の内容は同じでありつつ、Range オブジェクトの生成だけがなくなります。


  1. 影響範囲・注意点
  • 対象機能

    • Active Record の:
      • joins
      • left_joins
      • eager_load
      • includes(JOIN ベースに最適化されるケース)
    • これらが内部で使う JoinDependency::JoinAssociation 周りのみ。
  • 互換性 / 振る舞い

    • array[index..] と array.drop(index) はこの文脈では同等の配列を返すため、
      SQL の内容や返ってくるレコードが変わることはありません。
    • 公開 API の挙動変更はなく、完全に後方互換な内部最適化と見なせます。
  • パフォーマンスへの影響

    • 「オブジェクト割り当て数の削減」のみを主張しており、実行時間の改善は主張していない点に注意が必要です。
    • ただし、大量の join・ネストした through association を多用する大規模クエリ生成では、 GC プレッシャーがごくわずかに減る可能性はあります。
  • Ruby バージョン依存性

    • drop は古くからある Array メソッドなので、サポート Ruby バージョン間での互換性問題は事実上ありません。
    • endless Range (index..) は Ruby 2.6 以降の構文ですが、Rails 本体はすでにそれを利用しており、 今回はむしろそれを避ける方向の変更なので、互換性は向上方向といえます。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    Relation をロードするときに毎回新しく空配列を生成していた箇所を、共通の FROZEN_EMPTY_ARRAY を渡すように変更し、オブジェクトアロケーションを削減したパフォーマンス微改善の PR です。SQL 実行時の binds や association の joins 用デフォルト値として、内容も変更されない空配列であることを明示しています。

  1. 変更内容の詳細

何をしているか ​

変更対象: activerecord/lib/active_record/relation.rb のみ(+2 / -2 行)

Relation#load がクエリを実行する際に、以下のような場面で都度 [](新しい空の配列)を生成していました。

  • exec_main_query に渡す binds(プレースホルダーに対するバインド値)
  • association の scope 構築時に alias_tracker が使う joins のデフォルト値

これらは:

  • 中身を読むこともなく
  • 変更(push など)されることもない

という完全に読み取り専用の用途で使われているため、新しい空配列を毎回生成するのではなく、既に定義済みの共有オブジェクト FROZEN_EMPTY_ARRAY を渡すように変更しています。

疑似的には以下のような変化です(実際のコードはもう少し込み入っている想定):

ruby
# 変更前(イメージ)
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 いずれでも同じアロケーション削減効果を確認
  • あくまで「アロケーション数」の話であり、時間的な高速化については主張しない(計測してもいない)と明記

  1. 影響範囲・注意点

影響範囲 ​

  • 関連する箇所:

    • 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? のように読み取り専用であれば、挙動は変わりません。

  1. 参考情報 (あれば)
  • この変更は ActiveRecord の内部実装レベルでの小さなメモリアロケーション削減であり、機能的変更(SQL の内容やクエリ結果)ではありません。
  • Rails 本体では FROZEN_EMPTY_ARRAY / FROZEN_EMPTY_HASH などの定数が複数箇所で使われており、「読み取り専用のデフォルト値には共有の frozen オブジェクトを使う」という方針に沿った一貫性のある変更です。

#59537 Take tag_string options before the optional content ​

マージ日: 2026/11/2 | 作成者: @thomasklemm

  1. 概要 (1–2文で)
    Rails の ActionView::Helpers::TagHelper 内部で使われる TagBuilder#tag_string の引数順を見直し、「任意引数の後ろに必須引数が来る」状態を解消して、毎回発生していた不要な配列オブジェクトの割り当てを削減するパフォーマンス改善です。外部 API の挙動(タグ出力・エスケープ・ブロックの扱い・エラー内容)は変わりません。

  1. 変更内容の詳細

問題となっていた点 ​

TagBuilder#tag_string は内部的に次のようなシグネチャ(イメージ)になっていました:

ruby
# 旧イメージ(説明用)
def tag_string(name, content = nil, options, escape = true)
  ...
end

Ruby では「デフォルト値を持つ引数(任意引数)」の後ろに「必須引数」があると、呼び出し時に引数の並びを合わせるために配列の割り当て等が発生します。
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 など)の呼び出し方や挙動は一切変わっていません。

利用イメージ(外部からの呼び出しは従来どおり) ​

ruby
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 への渡し方だけが変わっています。


  1. 影響範囲・注意点
  • 影響範囲:
    • Rails の ActionView::Helpers::TagHelper::TagBuilder を内部的に利用しているタグ生成ロジック全体。
      • 標準の tag.div, tag.span, tag.li など
      • method_missing によるカスタムタグ (tag.my_widget など)
  • 公開 API のシグネチャは変わっていないため、通常のアプリケーションコードは修正不要です。
  • ただし、TagBuilder#tag_string を直接呼び出している独自拡張コード(プライベート API への依存)は引数順変更の影響を受けます。
    • 例: モンキーパッチや、自前の TagBuilder サブクラスで super 呼び出ししている場合など。
    • このようなコードは基本的に非推奨の使い方ですが、もし行っている場合は新しいシグネチャに合わせて修正が必要になります。
  • PR 説明にもあるように、#54042 も同じ tag_string を触っているため、この PR をマージすると #54042 は trivial な rebase が必要です。Rails コア開発者やパッチ投稿者はコンフリクトに注意する必要があります。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    このPRは、クエリ実行時に「警告がひとつも無い場合」に毎回新しい Array オブジェクトを生成していた処理を、共有の「凍結された空配列」を返すように変更し、オブジェクトアロケーションを1つ減らす最適化です。Rails本体内の挙動は変えずに、SQLite/PostgreSQL/MySQLで「警告なし」のケースのメモリ効率を改善しています。

  1. 変更内容の詳細

背景 ​

Active Record では、あらゆるクエリ実行時(find, pluck, 書き込み系クエリ、関連ロードなど)に collect_warnings が呼ばれます。

  • これまでの挙動:
    • 抽象アダプタ (Abstract Adapter): collect_warnings で毎回新しい空配列 [] を返す
    • PostgreSQL アダプタ: 通知が無くても毎回新しい Array に差し替え
    • MySQL アダプタ: 早期リターン時に新しい [] を返す

どのケースでも「警告が何も無い」のに、毎回新しい配列オブジェクトが生成されており、単純なクエリでもオブジェクト数が 1 つ余分に増えていました。

  • PRの説明中の例:
    Post.find(1) 実行時のオブジェクト数が 62 → 61 に減少(警告なしの場合)。

具体的な変更点 ​

3ファイルに対して、合計7行追加・2行削除の小さな変更です。

1) 抽象アダプタ (database_statements.rb) ​

  • これまで:
    • collect_warnings は [] のように、毎回「新しい配列インスタンス」を返していた。
  • 変更後:
    • 「警告がない」場合は、「共有の凍結された空配列」を返すように変更。

イメージとしてはこんな感じの変更です(実際のコードは多少異なるかもしれませんが、意図として):

ruby
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回あたり、警告が無い場合のオブジェクトアロケーションが1つ減少します。
    • SQLite, PostgreSQL (notice なし), MySQL (警告なし or 警告無効) に適用。
    • PR本文でも、YJIT/インタプリタ双方で「アロケーション数の減少」が確認されています。
  • 注意:
    • PRは「アロケーション数のみの話」であり、「パフォーマンス(時間)」については明示的に「主張しない」とされています。ただし、GC頻度低減などの観点で微小な改善が見込めます。

互換性・破壊的変更の可能性 ​

Rails本体のコードは「警告配列を読むだけ」であり、返ってきた配列に対して << したり push するようなコードは存在していません。そのため、Rails本体の挙動は変わりません。

ただし、サードパーティアダプタで collect_warnings をオーバーライドしている場合に注意点があります。

  • 想定外に影響を受けるパターン:
    • super を呼び、
    • 返ってきた配列に対して 破壊的操作(例: <<, push, concat など) を行っている場合。

このPRにより、「警告なし」のときに返ってくる配列は凍結されているため、以下のようなコードは FrozenError を起こします:

ruby
def collect_warnings(...)
  warnings = super
  # 以前: warnings は毎回新しい配列インスタンスで、破壊的変更も可能だった
  # 今回の変更後: 警告なしの場合は warnings が凍結されており、以下は FrozenError に
  warnings << "some custom warning"
  warnings
end

PR本文では「Rails本体内にはそのようなコードは存在しない」と明言されていますが、独自アダプタや monkey patch で同様のパターンを取っている場合には、今後の Rails バージョン更新時にエラーが発生する可能性があります。

  • 対策の例:
    • super の戻り値は「読み取り専用」と見なす

    • 変更が必要であれば自前で配列を生成する:

      ruby
      def collect_warnings(...)
        super_warnings = super
        warnings = super_warnings.dup  # 凍結されていても dup で可変配列に
        warnings << "some custom warning"
        warnings
      end
    • もしくは常に新しい配列に詰め直す:

      ruby
      def collect_warnings(...)
        [*super, "some custom warning"]
      end

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    HashWithIndifferentAccess#fetch のシグネチャを見直し、毎回余計な配列オブジェクトが確保されていたのをやめて、params や enum などでのメモリアロケーションを削減する最適化です。機能的な挙動は(例外内容やブロック優先などを含めて)Hash#fetch と同等を維持しつつ、パフォーマンス改善のみを行っています。

  1. 変更内容の詳細

これまでの問題点 ​

従来の定義は概ね以下のようになっていました(PR説明からの推測):

ruby
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つ」に変更しています。イメージとしては次のような形です:

ruby
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 と同じ動作:

ruby
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個 だったので、実行時の挙動としては変化なしで、エラーメッセージ表記だけが揃った形です。


  1. 影響範囲・注意点

影響範囲 ​

この最適化により恩恵を受ける代表的な箇所:

  • ActionController::Parameters (params) を通じた HashWithIndifferentAccess#fetch の全呼び出し
    • params.fetch(:q)
    • params.fetch(:page, nil) など
  • Active Record の enum 実装における内部的な fetch
    • Post.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 と同等になるようテストで担保されており、実務コードへの影響はほぼゼロと考えてよいです。

  1. 参考情報 (あれば)
  • 対象クラス: 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. 概要 (1-2文で)
    WhereClause#ast 生成時に、空文字列の述語("")を除外する処理を「配列を減算してから map する」二段階から「一度の走査で判定して構築する」形に変更し、余分な配列生成・オブジェクト確保を削減するパフォーマンス改善です。挙動(どの述語が残るか、順序)は従来と同一に保ったまま、1リレーションあたり1配列分のアロケーションを削っています。

  1. 変更内容の詳細

元の処理の概要 ​

ActiveRecord::Relation::WhereClause#ast 内にある predicates_with_wrapped_sql_literals というメソッド(もしくはローカル処理)で、

  1. まず predicates(where 条件を表す配列)から空文字列 "" を Array#- で取り除く
  2. その結果に対して map して AST ノードを構築する

という二段階の処理を行っていました。

イメージとしてはこんなコードです:

ruby
# 旧イメージ(擬似コード)
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 を構築する」スタイルに変更しています。

擬似コードとしては次のような変化です:

ruby
# 新イメージ(擬似コード)
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 負荷のごくわずかな低減には寄与する変更です。


  1. 影響範囲・注意点
  • 対象:
    • Active Record の WhereClause#ast 生成処理(内部 API)。
    • 実行時に where を含むクエリがコンパイルされる際に利用されます。
  • 挙動:
    • 空文字列 "" を where 述語として与えた場合に無視される、という既存の挙動は維持されています。
    • 述語の順序も維持されており、SQL 生成結果に論理的な変更はありません。
  • 後方互換性:
    • Public API (Model.where(...) の使い方など) に対する互換性上の影響は想定されません。
    • もしアプリケーションが内部的に WhereClause の AST 表現に依存している場合(かなりレアですが)、空文字述語がスキップされる前提自体は従来通りです。
  • テスト:
    • Active Record の SQLite スイートが従来通り全て通過しているため、SQL 生成・実行レベルでの互換性は確認済みです。

注意点として、挙動的には変更なし・純粋に配列アロケーションを減らすマイクロ最適化と見なして差し支えありません。独自に WhereClause#ast をフックしているような高度に内部依存したコードを書いていない限り、アプリ側で意識すべき変更点はほぼありません。


  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    RDoc マークアップを使っている API ドキュメント内で、本来コードとして表示したい箇所がバッククォート(`...`)で囲まれていたため正しく等幅コード表示されていなかった問題を修正し、RDoc 推奨の +...+ や <tt>...</tt> に置き換えた PRです。挙動変更は一切なく、API ドキュメントの表示のみが改善されます。

  1. 変更内容の詳細

背景・問題点 ​

Rails の多くのコメントは「RDoc デフォルトマークアップ」を前提としており、# :markup: markdown がないファイルでは Markdown 記法(`code`)はコードとして扱われません。
その結果、例えば以下のようなコメントは:

rdoc
# 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> を使用

概念的な差分イメージ:

diff
- # 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#associated
  • ActiveRecord::FinderMethods#find
  • ActiveRecord::Relation#upsert_all(:on_duplicate オプションの説明)
  • ActiveRecord::TestFixtures::ClassMethods
    • #skip_transactional_tests_for_database
    • #use_transactional_tests_for_database
  • ActiveSupport::Cache::RedisCacheStore.new
  • Rails.autoloaders
  • Rails::Application#dotenvs
  • Rails::Generators::Testing::Assertions#assert_initializer

なお次は変更対象外です:

  • コメント内のコードブロックの中にあるバッククォート
  • :nodoc: が付いたメソッド
  • メソッド本体内のコメント

実施確認 ​

PR 作成者は、変更前後で該当ファイルのみを RDoc 6.15.0 で HTML 生成し比較:

  • 変更前: 15 箇所が “curly quotes” 付きテキストとして表示
  • 変更後: すべて <code> としてレンダリングされることを確認

rake rdoc による全体ビルドは実行していませんが、局所的な確認は行われています。


  1. 影響範囲・注意点
  • 実行時挙動・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 記法を混ぜないほうが安全です。

  1. 参考情報 (あれば)

#59524 Let a frozen CachingKeyGenerator work on Ruby 3.3 ​

マージ日: 2026/11/2 | 作成者: @czj

  1. 概要 (1-2文で)
    Ruby 3.3 で ActiveSupport::CachingKeyGenerator#freeze を呼ぶと Ractor.[]= の未定義エラーで落ちていた問題を修正する PR です。Ractor ローカルなキャッシュへのアクセスに ActiveSupport::Ractors を経由するよう変更し、Ruby 3.3 でも凍結済みジェネレータが正しく動くようになりました。

  1. 変更内容の詳細

問題の背景 ​

  • Rails は Ruby 3.3 をサポートしているが、Ruby 3.3 には Ractor.[] / Ractor.[]= が存在しない。
  • 一方、ActiveSupport::CachingKeyGenerator#freeze の実装が Ractor.[]= に依存していたため、Ruby 3.3 上で以下のようなエラーが発生していた:
text
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.[] を経由するように変更しています。

概念的には、以下のようなイメージです (実際のコードは行数的にこの程度の差分):

ruby
# 変更前 (イメージ)
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行追加)。

追加されたテストの内容(要約):

  1. ActiveSupport::CachingKeyGenerator でキーを一度生成してキャッシュを作る。
  2. その CachingKeyGenerator インスタンスを freeze する。
  3. もう一度同じ条件でキー生成を呼ぶ。
  4. 2回目もキャッシュされた同じキーが返ることを検証する。

このテストは:

  • 修正前: Ruby 3.3 では #freeze 内で Ractor.[]= を呼んでエラーになり失敗。
  • 修正後: Ruby 3.3 と Ruby 3.4 / 3.5 / 4.0 など両方でグリーン。

ということを確認するためのものです。


  1. 影響範囲・注意点
  • 影響範囲
    • ActiveSupport::CachingKeyGenerator を利用しているコード全般 (主にクレデンシャル、Cookie/Session 暗号化キーの派生、署名付き URL などで内部的に利用されうる)。
    • 特に、CachingKeyGenerator インスタンスを freeze して不変オブジェクトとして扱うようなコードパスに影響します。
  • 挙動の変化
    • 期待される挙動 (「Ractor ごとにローカルなキャッシュを持ち、その後 freeze してもキャッシュは有効のまま」) は Ruby バージョンに依存せず一貫します。
    • 既存のパブリック API や返り値の形式・意味は変わりません。あくまで内部で使う Ractor ストレージの実装をリファクタリングしたものです。
  • 互換性
    • Ruby 3.3 でも #freeze による NoMethodError が発生しなくなり、サポート範囲内の Ruby バージョンでテストが通るようになります。
    • Ractor を積極的に使っているアプリでも、Ractor ローカルの意味論は ActiveSupport::Ractors によって保持されるため、挙動変更はほぼありません (ActiveSupport 側の実装に依存する部分はありますが、もともとその前提で利用されるクラスです)。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    in_batches(use_ranges: true) を使ったバッチ処理で、本来無視されるはずの default_order が残り続けてしまい、不要な ORDER BY が付いたクエリが発行されていた不具合を修正するPRです。ranged batching(use_ranges: true)の場合に限り、default_order も明示的な order と同様に削除されるようになりました。

  1. 変更内容の詳細

問題の背景 ​

もともと Active Record のバッチ処理 (in_batches) は、パフォーマンスと安定性のために「独自の order/id 範囲制御」を行うため、既存の並び順を無視する仕様になっています。

ただし、default_order を使っている場合に in_batches(use_ranges: true) を行うと、次のような違いが発生していました。

ruby
# 明示的な 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 に本修正の挙動変更を記述。

  1. 影響範囲・注意点
  • 対象となるのは以下の組み合わせです:
    • Active Record の default_order を利用している
    • in_batches(use_ranges: true) を使っている
  • これまでは、バッチ内の 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 がこれまでどおり保持されます。

  1. 参考情報 (あれば)

#59522 Don't load Herb when compiling with another ERB implementation ​

マージ日: 2026/11/2 | 作成者: @sampokuokkanen

  1. 概要 (1-2文で)
    Rails の ERB テンプレートコンパイル時に、別実装(デフォルトの Erubi など)を使っている場合でも常に Herb が autoload されてしまう問題を修正し、Herb を明示的に選んだときだけロードされるようにした PR です。これにより MRI では無駄な gem ロードが避けられ、JRuby では C 拡張の herb ロード失敗によるレンダリングエラーが解消されます。

  1. 変更内容の詳細

問題の背景 ​

Rails には ERB テンプレートエンジンとして複数実装を切り替える仕組みがあります(デフォルトは Erubi)。
ERB#implementation_for で現在の実装が Herb かどうかを判定する際に implementation == Herb という比較を行っていましたが、この「Herb という定数に触れる」こと自体が autoload をトリガし、herb gem がロードされていました。

  • MRI: 使っていないのに herb gem が毎回ロードされるだけ(無駄だが致命的ではない)
  • JRuby: herb は C 拡張で Java 版がないため require 'herb' が失敗し、すべての ERB コンパイルが落ちる

変更のポイント ​

1) Herb がまだ autoload されていない場合は比較をスキップ ​

actionview/lib/action_view/template/handlers/erb.rb の implementation 判定部にガードが追加されています。

概念的には以下のような処理になります(正確なコードではなくイメージです):

ruby
# 変更前(イメージ)
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 を起こさない
  • したがって herb gem を暗黙にロードしない

という挙動を実現しています。

ここでの重要な前提は 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 をロードしたかどうか」に依存してしまうため、「サブプロセスを使ってクリーンな状態で確認する」構成になっています。


  1. 影響範囲・注意点
  • 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
      まで減少しており、大半が herb autoload 由来のエラーだったことが分かります。残りは別の JRuby 固有の問題とのことです。
  • Herb を本当に使いたい場合
    erb_implementation = :herb のように明示的に設定している場合は、その時点で Herb がロードされるため今回のガードには引っかかりません。
    つまり、「Herb を選択したのに Herb がロードされない」という事態にはなりません。

  • 挙動変更の影響の種類
    まだリリースされていない Herb サポートに対するバグ修正であり、「既存リリース版の挙動が変わる」という類いのものではありません。このため CHANGELOG は更新されていません。


  1. 参考情報 (あれば)
  • PR 本文にある通り、Herb サポート自体はまだリリース前の機能であり、CHANGELOG への記述は見送られています。
  • 今回の修正は「autoload されていない定数を不用意に比較しない」という Ruby の常套パターンでもあり、他のオートロードされるコンポーネントについても似た問題が起こり得るため、設計・実装上の参考になるケースと言えます。

#59520 Don't gsub preload Link hrefs that need no encoding ​

マージ日: 2026/11/2 | 作成者: @amatsuda

  1. 概要 (1-2文で)
    preload_link_href が毎回 String#gsub を実行して余計なオブジェクトを生成していた問題に対し、「エンコードが不要な href 文字列はそのまま返す」ことで、stylesheet_link_tag / javascript_include_tag / preload_link_tag のメモリアロケーションを削減する最適化です。振る舞い(出力される HTML)の変更はなく、パフォーマンス改善のみを目的とした変更です。

  1. 変更内容の詳細

背景 ​

  • preload_links_header が有効(デフォルト)な場合、
    • stylesheet_link_tag
    • javascript_include_tag
    • preload_link_tag
      呼び出し時に preload_link_href が毎回実行されます。
  • 旧実装では、href に含まれる「エンコード対象文字」があるかどうかに関係なく String#gsub を実行しており、マッチがなくても新しい String オブジェクトが生成されるため、各タグ呼び出しごとに余計なオブジェクトが 2 個増えていました。
  • 実際のアセットURL(例: /assets/application.css など)には、preload_link_href がエンコード対象としている文字(<、>、空白類、制御文字など)が含まれることはほとんどありません。

今回の変更の核心 ​

preload_link_href の中で、次のような「ショートサーキット」を入れた形になります(イメージの擬似コード):

ruby
def preload_link_href(href)
  # エンコード対象文字が 1 つも含まれていない場合は gsub を呼ばず、そのまま返す
  unless href.match?(/[<>\s[:cntrl:]]/)
    return href
  end

  # 1つでも含まれている場合は従来通り gsub でエンコード
  href.gsub(/[<>\s[:cntrl:]]/) do |char|
    # 既存のエンコードロジック(実際のコードでは &lt; 等または %xx 等に変換)
  end
end

ポイント:

  • 文字列を走査するのは同じですが、「エンコードが不要」であれば gsub 自体を呼ばないため、新しい文字列オブジェクトが作られません。
  • <・>・空白(スペース、タブ、改行など)・制御文字を含む href の場合のみ、従来通りの gsub によるエンコードを行います。
  • これにより、通常のアセットURL(ほぼ全てエンコード不要)では、余計な 2 オブジェクトの割り当てがなくなります。

効果(PR記載の測定結果) ​

Ruby 4.0.9、preload_links_header 有効時の 1 呼び出しあたりのオブジェクトアロケーション数:

呼び出し変更前変更後
stylesheet_link_tag("application")3432
javascript_include_tag("application")3331
preload_link_tag("/x.woff2")3028

各呼び出しで 2 オブジェクトずつ削減されており、大量アクセスのアプリケーションでは GC 負荷軽減が期待できます。


  1. 影響範囲・注意点

振る舞いの互換性 ​

  • 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 を素通ししてしまう」バグになるので注意が必要です。

  1. 参考情報 (あれば)
  • 対象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. 概要 (1–2文で)
    ActiveRecord::Relation#unscope に :default_order を指定できるようになり、関連付けられた「デフォルトの ORDER 句」だけを取り除きつつ、明示的に指定した order はそのまま残せるようになりました。これにより、unscope(:order) と対になる挙動として「デフォルト順のみ解除」がサポートされます。

  1. 変更内容の詳細

何ができるようになったか ​

これまで:

  • unscope(:default_order) を呼ぶと ArgumentError が発生して使えなかった
  • unscope(:order) は「明示的な order を削除して、default_order は残す」動き

今回の変更後:

  • unscope(:default_order) が有効な指定として扱われる
  • Relation に設定されている default_order だけ を外し、明示的な order は保持する

内部的には、VALID_UNSCOPING_VALUES に :default_order が追加され、unscope で指定可能なキーとして認識されるようになっています。

動作イメージ / サンプルコード ​

ruby
# 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

比較のため:

ruby
# こちらは明示的 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 として扱われます。


  1. 影響範囲・注意点
  • 対象バージョン: この PR がマージされた以降の Rails (Active Record) バージョンで利用可能になります。既存のバージョンでは unscope(:default_order) は引き続き ArgumentError になるため、バージョン依存コードに注意が必要です。
  • 既存コードへの互換性:
    • これまで unscope(:default_order) はエラーになっていたため、「呼び出すと挙動が変わってしまう」既存コードは基本的に存在しない想定です(あっても今は落ちているはず)。
    • したがって、この PR による後方互換性の問題はほぼありません。
  • 明示的 order と default_order を両方使っているコード:
    • これまでは default_order だけをピンポイントに消す手段が事実上なかったため、reorder や unscope(:order) で両方まとめてクリアしていたような箇所では、より意図に沿った実装として unscope(: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 の解除機能」だけを独立して提供するものです。

  1. 参考情報 (あれば)
  • 対象 PR: https://github.com/rails/rails/pull/59516
  • 関連ディスカッション:
  • 実際に利用する場合のパターン例:
    • モデル側で 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. 概要 (1-2文で)
    ActionDispatch::FileHandler#root_entry? で、毎回文字列をバイナリコピーしていた処理を見直し、「ASCII だけのパス名」に対してはコピーを行わず、そのまま join するようにしたパフォーマンス改善の PR です。静的ファイル以外へのリクエストでの不要なオブジェクト生成を減らし、わずかながらアロケーション削減・高速化を図っています。

  1. 変更内容の詳細

背景 ​

ActionDispatch::FileHandler#root_entry? は、
「リクエストのトップレベルパス(例: /posts/1 なら posts)を静的ファイル用 root と結合し、そのファイルが存在するかどうかを調べる」
という処理を行っています。

以前の PR (#59053) では、このときに name.b を呼んでおり、

ruby
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 行の置き換え程度ですが、イメージとしては以下のような処理になります(擬似コード):

ruby
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 のパス名での挙動
    が壊れていないことを確認する目的と考えられます。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象: ActionDispatch::FileHandler 経由での静的ファイルの存在チェック(ActionDispatch::Static ミドルウェア)
  • 特に、「静的ファイルではないパス」へのリクエスト(通常のコントローラへのルーティングになるリクエスト)でのオブジェクトアロケーションがメインの影響箇所です。

計測結果では、単一ファイル構成のプロダクションアプリ (Ruby 4.0.7) におけるアロケーション数が 1 個減っており:

requestbeforeafter
GET /posts/1 (HTML)352351
ActionController::API JSON show312311
404341340

というように、リクエストごとに 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 アプリ開発者が意識すべき互換性リスクはほぼなく、
「静的ファイル配信周りがほんの少しだけ軽くなった」程度に捉えて問題ありません。


  1. 参考情報 (あれば)

#59511 Percent-encode href in preload Link header entries ​

マージ日: 2026/11/2 | 作成者: @fuzzyrichie

  1. 概要 (1-2文で)
    Rails の preload_link_tag / stylesheet_link_tag / javascript_include_tag が返すレスポンスヘッダ Link(および 103 Early Hints)内の href を、RFC 8288 に沿ってパーセントエンコードする修正です。> など不正な文字が URL に含まれていても、Link ヘッダが壊れて複数のリンクとして誤解析されないようにします。

  1. 変更内容の詳細

問題点 ​

これまで preload_link_tag などは、Link ヘッダ値を以下のように素朴に組み立てていました:

ruby
preload_link = "<#{href}>; rel=#{rel}; as=#{as_type}"

ここで href に > などが含まれていると、<> でくくられた URI リファレンスが途中で閉じたように見えてしまい、その後ろが追加のパラメータや別の link-value として解釈されてしまいます。

PR 説明の例:

ruby
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_tag
  • stylesheet_link_tag (preload_links_header 有効時の Link/Early Hints の部分)
  • javascript_include_tag(同上)

ファイル単位では:

  • actionview/lib/action_view/helpers/asset_tag_helper.rb
    • href をヘッダに書き出す直前で、上記文字をパーセントエンコードする処理を追加
  • actionview/test/template/asset_tag_helper_test.rb
    • 不正文字を含む href を指定したときに、Link ヘッダ側だけが正しくパーセントエンコードされることを確認するテストを追加
  • actionview/CHANGELOG.md
    • 上記挙動変更を記載

(PR 本体には具体的なメソッド名は書かれていませんが、典型的には href を正規化する小さなプライベートメソッドを追加し、それを preload_link_tag 系のヘッダ生成コードから呼び出す形になります。)

参考となる挙動イメージ:

ruby
# 入力
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

  1. 影響範囲・注意点
  • 影響範囲
    • preload_link_tag, stylesheet_link_tag, javascript_include_tag を利用し、かつ
    • href に < / > / 空白 / 制御文字を含む(または含み得る)URL を指定している場合
    • 特に、外部 CDN 等へのフル URL を直接指定しているケースで影響があり得ます。
  • 仕様的には正当な挙動
    • これらの文字は元々「そのままの URI」としては不正かつ、Link ヘッダでも許されないものなので、パーセントエンコードすることは仕様上正当です。
    • 実際のところ、多くのブラウザは HTML からのリクエスト時にも同じようにエンコードして送るため、HTML 経由でアクセスされる URL と Link ヘッダ経由の URL がより一致する方向の変更になります。
  • 互換性・注意点
    • これまで「不正な URL をそのままヘッダに流していても、たまたま CDN 側で受け入れていた」ようなレアケースでは、ヘッダ側の URL が変わる可能性があります。
    • ただし、HTTP/URL の仕様に従う限り、本来期待されるのは今回の新挙動です。
    • as, type, crossorigin, integrity, fetchpriority などの他パラメータは今回の変更の対象外で、これまで通りそのまま挿入されます。これらは主に開発者がリテラルで与えるものなので、セキュリティ上の意味での「エスケープ強化」は href 部分のみに限定されています。
  • セキュリティ的観点
    • ヘッダインジェクション的な挙動(href 内の > や空白を悪用してパラメータや別リンクに紛れ込ませる)が抑制されるため、防御的にもプラスの変更です。

  1. 参考情報 (あれば)

この PR によって、Link ヘッダの href が RFC とブラウザの実挙動により忠実に揃うようになり、壊れたヘッダや予期せぬ複数 Link への分割を防げるようになります。


#59486 Honor unless_exist and avoid double compression in MemCacheStore#write_multi ​

マージ日: 2026/11/1 | 作成者: @kehra

  1. 概要 (1-2文で)
    MemCacheStore#write_multi の挙動が単体の write と揃うように修正され、unless_exist: true が正しく尊重されるようになったほか、Dalli 側での二重圧縮が起きないように :compress オプションの扱いが調整されました。これにより、既存キーの誤上書きと不必要な圧縮が防がれます。

  1. 変更内容の詳細

背景となる問題点 ​

MemCacheStore#write_multi から Dalli の set_multi を使うパス(#58066 で導入)が、単体の write/write_entry と比べて次の2点で挙動がずれていました。

  1. unless_exist: true が無視される
    本来は「キーが存在しない時だけ書き込む」べきですが、set_multi を通してしまうことで既存キーも上書きされていました。

    問題となる挙動の例:

    ruby
    cache.write("foo", "bar")
    cache.write_multi({ "foo" => "baz" }, unless_exist: true)
    cache.read("foo") # 実際: "baz" / 期待: "bar"
  2. 二重圧縮が起きうる
    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 する」ように切り替えています。

イメージとしては次のような感じです(実際のコードは多少異なりますが、ロジックの方向性として):

ruby
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

これにより、次のコードが期待どおり動作するようになります。

ruby
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 が再圧縮しようとすることを防ぐ

という効果があります。


  1. 影響範囲・注意点
  • 対象: 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 が元々持っていた) 正しい挙動に揃えた」修正であり、リリース間の後方互換性の問題はほぼありません。

  1. 参考情報 (あれば)

この 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. 概要 (1-2文で)
    ActiveRecord の Relation#sum にブロックを渡した場合の実装を見直し、中間配列 map を作らずに直接 sum するようにして最大 1.5 倍程度高速化した PR です。基本的に返り値・例外は変わりませんが、「each を独自にオーバーライドした Relation」に対する挙動だけが従来と変わる可能性があります。

  1. 変更内容の詳細

2-1. 何をしているか ​

現状(この PR 以前)の Relation#sum(ブロック付き)はざっくり以下のような流れでした:

ruby
# 擬似コードイメージ(旧)
records = load # or to_a
# ブロックを map で適用して値の配列を作る
values = records.map(&block)
# その配列に対して sum
values.sum(initial_or_column)

この PR では、Array#sum がブロックを直接受け取って要素ごとに処理できることを利用し、中間の map 用配列を作らずに一気に sum します:

ruby
# 擬似コードイメージ(新)
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. サンプルコードの挙動イメージ ​

対象となるのは、例えば次のようなコードです:

ruby
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 に未保存レコードが混じるケースで整数・浮動小数を足し合わせるテストが加わっています(これは旧実装でも通る=仕様として互換)。


  1. 影響範囲・注意点

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 の各パターン)。

Array#sum のブロック付き実装をそのまま使うだけなので、JIT にも優しい形になっています。

3-2. 挙動が変わる可能性があるケース ​

唯一の仕様差分は、「Relation の each を独自にオーバーライドしている場合」です。

従来:

ruby
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 での新実装:

ruby
# すでにレコードをロード済みの場合は、内部で保持している配列に対して
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」するパターンはわりと多い(例: 見積もり、合計金額、数量計算など)
  • よって多くのアプリでは
    • 性能面はわずかに改善
    • 互換性面の影響はほぼゼロ となる可能性が高い変更です。

  1. 参考情報 (あれば)
  • この 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. 概要 (1-2文で)
    Active Record のクエリキャッシュ (QueryCache::Store) において、キャッシュミス時にキーのハッシュ計算が1回少なくなるように最適化した PR です。機能的な挙動は変えず、キャッシュミス時のオーバーヘッドを削減してクエリ実行を高速化しています。

  1. 変更内容の詳細

背景・問題点 ​

対象メソッドは ActiveRecord::ConnectionAdapters::QueryCache::Store#compute_if_absent です。このメソッドは:

  • 引数のキー key がキャッシュに存在しなければ、yield した結果をキャッシュに入れて返す
  • 存在すれば、キャッシュ済みの値を返す

という、典型的な「存在しなければ計算して保存する」ヘルパです。

既存コードでは、おおよそ次のような処理が行われていました(擬似コード):

ruby
@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 行だけのシンプルな変更で、イメージとしては次のような修正です(擬似コード):

ruby
# 修正前
@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 ns3996 ns10/10
YJIT5102 ns3979 ns10/10
ZJIT5608 ns4260 ns10/10

すべてのモードで約 20~25% 程度の短縮が見られており、最適化としては十分有意な効果があります。


  1. 影響範囲・注意点

機能面の影響 ​

  • クエリキャッシュのロジック・外部挙動は変わりません。
    • 「キーが無ければ 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] ||= ... という実装細部に依存していない限り、互換性問題はほぼ考えられません。

  1. 参考情報 (あれば)
  • 対象ファイル:
    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. 概要 (1-2文で)
    Markdown 形式に変換された Rails の API ドキュメント内で、コードスパンとして使うはずのバッククォートがエスケープされてしまい文字どおり表示されていた問題を修正し、正しくコードとしてレンダリングされるようにした PR です。併せて、もともと Markdown だった Action Text のコメントが変換時に崩れていた箇所も原文の意図に沿うように復元しています。

  1. 変更内容の詳細

背景 ​

  • もともと 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` となるよう修正。

この修正が、以下を含む多くのファイルにまたがっています:

  • actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb
  • activejob/lib/active_job/continuation.rb
  • activejob/lib/active_job/queue_adapters/async_adapter.rb
  • activemodel/lib/active_model/attribute_assignment.rb
  • activemodel/lib/active_model/attribute_methods.rb
  • activerecord/lib/active_record/connection_adapters/abstract_adapter.rb
  • activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
  • activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb
  • activerecord/lib/active_record/core.rb
  • activerecord/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 のドキュメントを、変換前と同じ表示・意味になるように復元する」ことです。


  1. 影響範囲・注意点
  • 影響範囲:

    • 変更はすべてコメント(ドキュメント)に対するもので、Ruby の実行コード・挙動には影響しません。
    • Rails API ドキュメント (公式サイトや rails doc 生成物) を Markdown レンダラーで閲覧した際の見た目・可読性が向上します。
    • とくにメソッド名、クラス名、オプションキー、コードスニペットなどが正しくコードスパンとして表示されるようになります。
  • 実務的な注意点:

    • ライブラリ/アプリケーション側で、これらの「崩れた Markdown コメント」をコピー & ペーストして README や自前ドキュメントに転記していた場合、自分たちのドキュメント側にも同様の問題が残っている可能性があります。
      • 例: \perform_later` のような表記が残っている、
        ` が消えていて行の区切りが不自然、など。
    • 今後 Rails 本体に対してドキュメント PR を出す際は、「RDoc 時代の癖でエスケープされたバッククォート」を真似せず、Markdown として素直に `code` を書けばよい状況になっています。
  • 後方互換性:

    • コードそのものには変更がないため、互換性に関する心配は不要です。
    • CI や静的解析でコメントも検査しているような特殊なツールチェーンを組んでいる場合のみ、差分として検知される可能性がありますが、実害はありません。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    ActiveRecord の動的 finder (find_by_xxx / find_by_xxx!) 判定ロジックにおいて、respond_to? 時の無駄なマッチ処理をスキップする最適化が行われました。find_by_ で始まらないメソッド名については DynamicMatchers の詳細なパターンマッチを行わないことで、respond_to? が約 2 倍高速になっています。

  1. 変更内容の詳細

何が変わったか ​

対象: 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 のパターンマッチへ進む

という早期リターンが追加されています。

疑似コードにすると以下のようなイメージです:

ruby
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? を多用するため、この種のナノ秒レベルの最適化でも、アプリケーション全体では無視できない差になる場合があります。


  1. 影響範囲・注意点
  • 既存の動作に対する互換性

    • 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) のような呼び出しが大量に行われるコード(特にメインループやレンダリングパス付近)で、わずかにオーバーヘッドが減る可能性があります。
    • マイクロベンチマークでは差が出ていますが、実アプリで体感できるかどうかはワークロード次第です。

  1. 参考情報 (あれば)

#59189 Fix inaccurate examples in Active Record docs [ci skip] ​

マージ日: 2026/11/1 | 作成者: @bastin27

  1. 概要 (1-2文で)
    Active Record の API ドキュメントおよびガイド中の、実際の挙動と食い違っていたサンプル出力が修正された PR です。コードの挙動は一切変えず、ドキュメントのみを実装どおりの正しい内容に揃えています。

  1. 変更内容の詳細

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") と同じパターンになるように)ことで、実際の戻り値と一致させています。

イメージ(擬似的な例):

ruby
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 は nil
    • created_at, updated_at も nil
  • データベースに保存されていないので、タイムスタンプはまだ入りません

修正の方向性:

  • 例の出力から、created_at / updated_at に値が入っているような記述を削除し、未保存レコードにふさわしい出力に変更しています。

イメージ(擬似的な例):

ruby
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 と、ガイドの例を一致させています

イメージ(擬似的な例):

ruby
Book.lock.first
# 以前の記述 (例):
# SELECT "books".* FROM "books" FOR UPDATE LIMIT 1;
#
# 修正後 (例):
# SELECT "books".* FROM "books" ORDER BY "books"."id" ASC LIMIT 1 FOR UPDATE;

  1. 影響範囲・注意点
  • 影響範囲:
    • 実装コード・挙動には一切変更なし
    • 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 を通るため配列になることがある」 といった挙動を踏まえるとドキュメントとの整合性が保てます。

  1. 参考情報 (あれば)

#59497 Fix preloading has_many :through with a default_order scope ​

マージ日: 2026/11/1 | 作成者: @czj

  1. 概要 (1-2文で)
    has_many :through でスコープに default_order を使っている場合に、preload 時だけ並び順が無視されていた不具合を修正する PR です。これにより、怠惰ロード(通常の関連アクセス)と preload の結果の並び順が揃うようになります。

  1. 変更内容の詳細

問題の挙動 ​

前提となるモデル:

ruby
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

期待される挙動(怠惰ロード時)は:

ruby
Author.first.categorized_posts_by_title
# => title でソートされている

ところが、preload を使うと:

ruby
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)しか見ていませんでした。

  1. 各 owner(例: Author)に紐づくレコードを並び替えるとき
  2. 兄弟の関連(同じ source を共有する別の関連)で既にロードされているレコードを再利用する際、「同じ order か」を判断するとき
  3. through 関連が、条件付きで source レコードを eager load するときのクエリに order を反映するとき

この PR では、上記3か所すべてで default_order_values も考慮するように修正しています。挙動の優先順位は、怠惰ロード(通常の関連アクセス)と同じに揃えています:

  • 明示的な order が一箇所でも指定されていれば、それが最優先
  • reorder(nil) のように明示的に order を消した場合は、default_order にフォールバック
  • through 側の関連自身に order があれば、それは保持される(default_order に上書きされない)

これにより、たとえば:

ruby
has_many :categorized_posts_by_title,
         -> { default_order(:title) },
         through: :categorizations, source: :post

で定義した関連を:

ruby
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.rb
  • activerecord/test/cases/associations/eager_test.rb
  • テストモデル: Author, Categorization, Post

  1. 影響範囲・注意点

影響範囲

  • has_many :through を preload / includes(preload パスを通るケース)で事前ロードしており、かつその関連や source 側で default_order を利用しているアプリケーション
  • 上記のケースでは、「これまで preloading 時だけ並び順が崩れていた」状態が解消され、怠惰ロード時と同じ順序になります

注意点

  • default_order: オプションを has_many :through に直接指定した場合の「through 側の順序」は、この PR ではまだ無視されます
    • plain な has_many に対する default_order: の対応は #58667 で進行中であり、その影響範囲に踏み込まないよう、この PR ではあえて触れていません
  • eager_load は依然として default_order を一切考慮しません
    • この PR は preload の挙動を「怠惰ロードと同じにする」ものであり、eager_load の仕様はそのままです
  • order を明示しているコードは、今回の変更の影響を受けません(従来どおり、明示的な order が優先)

実務的には、「has_many :through + default_order を使っていて、includes/preload 時に順序が変だと感じていた」場合、それが修正される変更と考えておけば問題ありません。


  1. 参考情報 (あれば)
  • 関連 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. 概要 (1-2文で)
    Relation#compute_cache_version がコレクションキャッシュキー算出時のクエリから default_order を除外するようになり、PostgreSQL で発生していた PG::GroupingError(GROUP BY 不備)を解消する修正です。これにより default_order を使ったリレーションでも安全に cache_key / cache_version が取得できます。

  1. 変更内容の詳細

何が問題だったか ​

Relation#compute_cache_version は、コレクション用のキャッシュキーに利用するために、以下のような集約クエリを発行します。

  • レコード数: COUNT(*)
  • 最終更新時刻: MAX(updated_at) など

前のPR (#59436) で「明示的な order は削除」されていたものの、default_order は残ったままだったため、こんなケースで問題が起きました:

ruby
Developer.default_order(:name).cache_key

default_order(:name) によって内部的に ORDER BY developers.name が残ったまま、COUNT(*), MAX(updated_at) を計算するクエリが発行されると、PostgreSQL では次のようなエラーになります。

text
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 行のテストが追加され、例えば以下のようなケースがカバーされていると考えられます:

ruby
developers = Developer.default_order(:name)
developers.cache_key # または cache_version

# => PostgreSQL でも例外なく動くことを確認

このテストにより、default_order を指定したリレーションでも cache_key / cache_version の取得が例外を出さないことが保証されます。


  1. 影響範囲・注意点
  • 影響範囲

    • ActiveRecord の「コレクションキャッシュキー」関連のみが対象です。
      • 例: @developers = Developer.where(active: true)
        @developers.cache_key_with_version 等
    • default_order を利用するスコープやクエリで、cache_key / cache_version を呼んだ場合の挙動が安定します(エラーにならなくなる)。
  • 注意点 / 互換性

    • キャッシュキー算出時のクエリは、これまでも COUNT(*) と MAX(updated_at) のような集約値のみに依存しており、order に依存したキャッシュキーではありません。
      そのため、default_order を除外してもキャッシュキーの意味論は変わりません。
    • すでに本番で PostgreSQL を使っていて、
      • default_order を使ったスコープに対して
      • コレクションの cache_key / cache_version を呼んでいる
        場合、今まで落ちていた画面/エンドポイントがエラー無しで動くようになります。
    • order 情報を含めたキャッシュキーを期待していた場合(順序の違いで別キャッシュにしたい等)は、そもそもこの API はその用途に向いていないため、別途キー設計を行う必要があります(例: 並び順を含めた自前のキーを組み立てる)。

  1. 参考情報 (あれば)
  • 元PR: #59436
    Relation#compute_cache_version 周りのロジック変更のフォローアップとして今回の修正が入っています。
  • 関連メソッド:
    • ActiveRecord::Relation#cache_key
    • ActiveRecord::Relation#cache_key_with_version
    • ActiveRecord::Relation#compute_cache_version
      これらは Rails のフラグメントキャッシュ・テンプレートキャッシュなどで頻用されるため、キャッシュキー周りの安定性向上につながる変更です。

#59507 Fix flaky ChainedAliasAssociationsTest ​

マージ日: 2026/11/1 | 作成者: @kamipo

  1. 概要 (1-2文で)
    ChainedAliasAssociationsTest が他テストから残ったコメントデータの影響で不安定(flaky)になる問題を修正した PR です。テストの setup でコメントテーブルを明示的にクリアし、このテスト自身が作成したデータだけに依存するようにしました。

  1. 変更内容の詳細

問題の背景 ​

  • このテスト (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 行追加されました(概念的には以下のようなもの):

ruby
class ChainedAliasAssociationsTest < ActiveRecord::TestCase
  def setup
    super
    ChainComment.delete_all
  end

  # ... 既存のテスト ...
end

※ 実際のクラス名・モデル名は PR の文脈からの推測ですが、趣旨は「対象テストの setup でコメント用テーブルを空にする」ことです。

ポイント:

  • このテストクラスが使うコメントテーブルを、テスト開始前に delete_all で空にする。
  • これにより、
    • 他テストでロードされた fixture コメント行が残っている
    • @target.id == 1 になってしまう
      といった条件が重なっても、クエリの結果が汚染されない。

  1. 影響範囲・注意点
  • 影響範囲は Active Record のテストコード (chained alias associations のテストクラス) に限定され、本体のライブラリコードには変更なし。
  • CI 上でたまに落ちていた ChainedAliasAssociationsTest のフレークが解消される見込み。
  • delete_all を使っているため、
    • テストデータはテストメソッドごとに「クリーンな状態」から開始される
    • パフォーマンスへの影響はごく小さい(対象テーブルはテスト環境の小さなテーブル)
  • 開発者側で追加の対応が必要になる箇所は基本的にありませんが、
    • Rails のテストを書く際に「fixtures を宣言していないのに他テストのデータが残っている可能性」をあらためて意識する
    • テストが特定 ID(例: 1)に依存していないか確認する といった観点で、自身のテストコードにも活かせるパターンです。

  1. 参考情報 (あれば)

#59506 Extract effective_order_values to pick the orders a query is built with ​

マージ日: 2026/11/1 | 作成者: @kamipo

  1. 概要 (1-2文で)
    Relation がクエリの ORDER BY を決定する際に、「明示的な order がなければ default_order を使う」というルールを一箇所 (effective_order_values) に集約した PRです。build_order, distinct_relation_for_primary_key, in_batches などでバラバラに実装されていた同じ判定ロジックを共通化し、今後の実装漏れや不整合を防ぐ狙いがあります。

  1. 変更内容の詳細

2-1. 問題背景 ​

これまで以下のような処理で、クエリの並び順を決めるロジックが重複していました。

  • build_order
    • order_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)。

ざっくりしたイメージ:

ruby
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 の修正 ​

変更された主な箇所は次の通りです。

  1. activerecord/lib/active_record/relation/query_methods.rb

    • build_order が内部で見ていた「order_values が空なら default_order_values」という判定が effective_order_values に切り出され、そこから参照されるようになりました。
    • ほかのメソッドも、直接 order_values + default_order_values を見るのではなく、必要に応じて effective_order_values を利用する形に整理されています。
  2. distinct_relation_for_primary_key 関連(schema_statements.rb)

    • #59496 で導入された「primary key で distinct な relation を作るロジック」の中で、order_values/default_order_values フォールバックの判定がローカルに複製されていましたが、それを effective_order_values に置き換えています。
  3. activerecord/lib/active_record/relation/batches.rb (in_batches)

    • バッチ処理 (in_batches) でクエリのソート順を決める際の判定が、従来は独自に order_values 等を見ていましたが、ここも effective_order_values を用いる方向に揃えられています。
    • これにより、「バッチ処理だけ order の扱いがちょっと違う」といった細かい不整合が減り、default_scope 等によるデフォルト order を考慮する一貫した挙動になります。

サンプル的挙動イメージ ​

ruby
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 を決定する想定なので、「どういう順序でレコードが走査されるか」を推論しやすくなります。


  1. 影響範囲・注意点
  • 既存アプリの動作への影響は原則的に無し (後方互換)

    • もともと各所で同じ判定ロジックをしていたものを共通化しただけなので、仕様として変化させる 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 作者にとっては有益です。

  1. 参考情報 (あれば)
  • 該当 PR:
  • 関連 PR (ロジックが複製されていた元):
  • コード的なキーポイント
    • 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. 概要 (1-2文で)
    has_many default_order: オプションが、関連を「遅延ロードする場合だけ」効いて「preload した場合は効かない」という不整合がありましたが、このPRで preload 時にも default_order: が適用されるように修正されています。これにより、default_order: オプションと lambda 版 (-> { default_order(...) }) の挙動が、少なくとも lazy load と preload の経路では揃いました。

  1. 変更内容の詳細

これまでの問題点 ​

has_many で default_order: オプションを指定した場合:

ruby
class Author < ApplicationRecord
  has_many :posts, default_order: "posts.id ASC"
end
  • 通常の遅延ロード:

    ruby
    author.posts.map(&:id)
    # => [1, 2, 4, 5, 6]  # default_order が適用されて昇順
  • preload 経由のロード:

    ruby
    Author.preload(:posts).find(author.id).posts.map(&:id)
    # => [6, 5, 4, 2, 1]  # default_order が無視され、DBのデフォルト順

というように、preload 時には default_order: が効いていませんでした。

一方、lambda 版で書くと:

ruby
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::Association
    • Preloader::Branch の merge 処理の中で、他の relation の値メソッド同様に default_order がマージされるようになります。
  • オーバーライドを AbstractReflection ではなく AssociationReflection に限定しているのは、

    • PolymorphicReflection は AbstractReflection を継承しつつ options を持たないため、
    • そこを巻き込むと壊れる可能性があるためです。

実装上の変更は小規模で、preloader に渡す scope に「default_order を含めておく」という一点です。


  1. 影響範囲・注意点

挙動の変化 (互換性) ​

今まで:

ruby
has_many :posts, default_order: "posts.id ASC"

としていると:

  • author.posts
    → default_order が適用されて並び替えられる
  • Author.preload(:posts).find(id).posts
    → default_order 無視、DBのデフォルト順

今後 (このPR適用後):

  • author.posts
  • Author.preload(:posts).find(id).posts

のどちらも default_order が適用された順序になります。
そのため、preload を使っていて「今までは暗黙的に順序なしになっていた」ケースでは、post の並び順が変わる可能性があります。

もし API レスポンスなどで、preload 済みの関連をそのまま配列の順番を信じて返していた場合、返る順番が変わることになります(ただし、宣言上は「default_order を指定しているのに今まで効いていなかった」状態だったので、仕様としては今回の挙動の方が自然です)。

依然として変わらない点 / 制限事項 ​

PRの説明に明記されていますが、以下はこのPRでは変わりません:

  1. JOIN パスでは順序は適用されない

    • JOIN を使うクエリ (例: Author.joins(:posts).where(...)) では、
      default_order: も lambda 版の default_order も、これまで通り JOIN 側には影響しません。
    • 今回の修正はあくまで「preload 経由で関連レコードをロードするとき」に限定されます。
  2. has_many :through の source に default_order: を書いた場合

    • 例:

      ruby
      class 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) などを挟んでルールを固定化すべきです。

  1. 参考情報 (あれば)
  • 類似のギャップとして挙げられている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. 概要 (1-2文で)
    Response#media_type を毎回呼ぶことで発生していた余分なオブジェクト生成を避けるため、内部的に直接ヘッダを参照するようにし、CORS まわりの JS レスポンス判定も content_type ベースに変更して、1リクエストあたりの割り当てオブジェクト数と処理時間を少し削減するパフォーマンス改善のPRです。挙動は従来と同じになるよう維持されているため、外部APIとしての振る舞いは変わりません。

  1. 変更内容の詳細

背景 ​

  • 以前の変更 (#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! ​

これまでは(擬似コードです):

ruby
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 に対して、先頭一致の正規表現で判定するように変更されています。

イメージ:

ruby
# 変更前(イメージ)
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):

リクエスト種別beforeafter
GET /posts/1 (HTML)352350
GET /posts.json10201018
ActionController::API JSON312311

1〜2オブジェクト/リクエストの削減ですが、これが全リクエストに乗るため、大規模トラフィックでは無視できない差になります。

text/html; charset=utf-8 レスポンスに対しての2つのチェックを 2,000,000 回繰り返した場合 (Ruby 4.1.0dev):

モードbeforeafterafter が速かったラウンド数
interpreter857 ns820 ns8/10
YJIT331 ns260 ns10/10
ZJIT369 ns320 ns10/10

JIT 環境で特に改善が顕著で、メソッド呼び出しや文字列コピー削減が効いていることがわかります。


  1. 影響範囲・注意点
  • 公開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利用時は特に高速化が見込めます。

互換性上大きなリスクはほぼなく、「ごく小さなコストを毎リクエストから確実に削っている」タイプのマイクロオプティマイズです。


  1. 参考情報 (あれば)

#59496 Fix eager loading with limit and default_order ​

マージ日: 2026/11/1 | 作成者: @czj

  1. 概要 (1-2文で)
    eager_load に limit / offset を組み合わせたときに default_order が無視され、その結果 PostgreSQL / MySQL で SELECT DISTINCT + ORDER BY の整合性が取れずにエラーになる不具合を修正した PR です。distinct 用に主キーを取得する内部クエリでも、明示的な order と同様のロジックで default_order を考慮するようになりました。

  1. 変更内容の詳細

問題の背景 ​

以下のようなコードで、eager_load + limit + offset + default_order を使った場合:

ruby
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 と同様に

    1. 明示的な order (order_values) があればそれを優先
    2. なければ default_order_values を使う

というルールで order 情報を決定し、その結果を columns_for_distinct に渡すようにしています。

これにより、

ruby
Post.eager_load(:comments).default_order(:title).limit(2).offset(1).to_a

のようなケースでも、内部の

sql
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 対応)

と同じ「ファミリー」の修正であることが明示されています。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象となるのは以下のようなクエリです:

    • 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 組み合わせ部分で、挙動の変化がないか(または期待通りか)を意識しておくと安心です。

  1. 参考情報 (あれば)
  • 本 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 候補とされている)
  • バグ再現例:

    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. 概要 (1-2文で)
    Railsの数値バリデーション (numericality) が、:decimal 以外の型でも「スケール(小数桁数)」を考慮して丸めた上で検証するように戻されたPRです。Rails 8.1 での挙動(型が持つスケールで丸めてから判定)を、#59246 による変更で一部崩れていたのを修正し、PostgreSQLの money 型などでも従来どおりの判定になるようにしています。

  1. 変更内容の詳細

背景となる問題 ​

numericality バリデーションは、#59246 で「属性の型情報を使って値をキャストしてから検証する」ようになりましたが、「型の type が :decimal を返す場合に限る」という条件が付いていました。

その結果、以下のように ActiveRecord::Type::Decimal を継承していても、type メソッドで :decimal 以外を返すカスタム型(や、PostgreSQL money 型)では、型が持つ scale(小数点以下桁数)で丸めずに 検証されるようになってしまいました。

ruby
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 では false

Rails 8.1 では scale に従って丸めてから比較していたため true でしたが、type == :decimal 限定の変更により 10.001 <= 10 として比較されて false になるという差分が発生していました。

PostgreSQL の money 型も同様で、Rails 側では :money という type シンボルを返しつつ scale = 2 を持つため、同じ問題が起きます。


このPRの核心的な変更点 ​

  1. 「:decimal かどうか」ではなく「スケールを持っているか」で判定するように変更

    • これまで:
      • 型の type が :decimal のときだけ、属性の型でキャストしてから numericality バリデーションを行っていた。
    • これから:
      • 型が「scale を持っている(=小数桁数が定義されている)」場合には、そのスケールを使って丸めた値でバリデーションを行う。
    • つまり、type == :decimal チェックは、「スケールを持たない DecimalWithoutScale の扱いのため」にのみ残しつつ、スケールを持つ型一般(カスタム型や money を含む) を対象に挙動を揃えるようにしています。
  2. バリデーション側で「スケールに応じた丸め」を行うようにした

    • 型に scale がある場合、numericality バリデーションの中で
      • 型でキャストした数値(Float や Numeric)を
      • スケールの桁数に丸めてから、less_than_or_equal_to など各種比較を行います。
    • Decimal 型の場合は、もともと cast 時点でスケールが適用されているため、今回の追加の丸めロジックは事実上「ノーオペレーション(値は変わらない)」です。
    • 一方 PostgreSQL の OID::Money は、
      • 文字列入力に対しては文字列→数値への変換時に丸めるが、
      • Float や Numeric を入力された場合は、その値をそのまま返す仕様
        になっているため、バリデーション側で明示的に丸めないと、スケールが効かない ケースが出てきます。
    • PRでは OID::Money 自体のキャスト仕様はあえて変えず、「8.1 で属性として返っていた値の挙動(互換性)」を維持したまま、バリデーション層でのみ丸めを補正する形をとっています。
  3. normalizes でラップされた属性にも対応

    • normalizes によって属性型がラップされている場合、ラッパーは scale や type を委譲するが、クラスとして Type::Decimal のインスタンスではない、というケースがあります。
    • これを is_a?(Type::Decimal) で判定してしまうと見逃されますが、「scale を持っているか」で判定する方式に変えたことで、こうしたラッパー型も正しくカバー できるようになっています。
  4. テスト追加

    • activerecord/test/cases/adapters/postgresql/money_test.rb
      • PostgreSQL money 型を使ったときに、numericality バリデーションが scale = 2 に基づいて丸めて判定されることを確認するテストを追加。
    • activerecord/test/cases/validations/numericality_validation_test.rb
      • カスタム Decimal 型や、スケール付き型+normalizes などの組み合わせに対して、8.1 と同じ挙動(スケールで丸めてから検証)になることを確認。

  1. 影響範囲・注意点
  • 影響する主なケース

    • 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 → 次バージョン」での挙動変化は発生しない想定です。

  1. 参考情報 (あれば)
  • 関連Issue / PR:
    • #59246: numericality バリデータが属性の型でキャストするようにした変更(今回の挙動差の原因)。
  • 実例:
    • PostgreSQL money 型(type: :money, scale: 2)や、ActiveRecord::Type::Decimal を継承したカスタム型で type を独自のシンボルにしている場合に、このPRで8.1同等の挙動(属性のスケールで丸めた値で検証)が保証されます。

#59485 Add support for comma-separated values in ENV["RAILS_MASTER_KEY"] ​

マージ日: 2026/11/1 | 作成者: @JeffTsang

  1. 概要 (1-2文で)
    ENV["RAILS_MASTER_KEY"] にカンマ区切りで複数のマスターキーを指定できるようになり、Rails が順番に試して復号に成功したキーを採用することで、master key のローテーションをしやすくする変更です。credentials.yml.enc / encrypted 系ファイルの復号処理が、この複数キー対応になりました。

  1. 変更内容の詳細

挙動の変更点 ​

これまで:

  • 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 読み込み処理)で以下のようなロジックが追加されています(概念的なイメージ):

ruby
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 にも、この挙動変更(新機能)が明記されています。


  1. 影響範囲・注意点

主な影響範囲 ​

  • 影響を受けるのは「ENV["RAILS_MASTER_KEY"] を使って credentials/encrypted ファイルを復号している箇所」全般です。
    • 代表例: config/credentials.yml.enc の読み込み
    • 複数環境 credentials (config/credentials/production.yml.enc など) も同じ仕組みを共有しているため、同様の挙動になります。

利用シナリオ(ローテーション例) ​

  • 旧キー: OLD_KEY
  • 新キー: NEW_KEY
  1. デプロイ前:

    • アプリ側は RAILS_MASTER_KEY として "OLD_KEY" を利用中
  2. ローテーション移行期:

    • credentials.yml.enc などを 新しいキー NEW_KEY で再暗号化
    • 本番環境などの ENV を以下のように設定
      bash
      RAILS_MASTER_KEY="OLD_KEY,NEW_KEY"
    • Rails はまず OLD_KEY を試みて失敗し、NEW_KEY で復号に成功 → 新キーに透明に切り替え可能
  3. 完了後:

    • 一定期間安定稼働を確認した後、OLD_KEY を削除し、
      bash
      RAILS_MASTER_KEY="NEW_KEY"
      のみにすることで、完全に新しいキーへ移行

※逆順 ("NEW_KEY,OLD_KEY") にしておくと「新旧両方で復号できる状態のファイル」があった場合の挙動が変わるため、運用ポリシーに応じて順序を決める必要があります(通常は「今後メインにしたい方を先頭」にするのが安全)。

注意点 ​

  • 区切りは カンマ (,) 固定 であり、空白トリミングなどの扱いに注意(.split(",") ベースなので "old, new" のように空白を入れると " new" がそのままキー候補になり失敗要因になりうる)
  • すべてのキーで復号に失敗するとこれまでどおり起動時にエラーになるため、
    • ENV へのキー指定ミス
    • 順序や値のコピペミス には引き続き注意が必要
  • 復号を試すキーが増えるほど、起動時に復号を試行する回数が増えるため、極端に大量のキーを列挙するのは非推奨(現実的には 2〜3 個程度に留める想定)

  1. 参考情報 (あれば)
  • 対応元 PR/Issue: https://github.com/rails/rails/pull/59388
  • 変更ファイル:
    • activesupport/lib/active_support/encrypted_file.rb
      • ENV["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. 概要 (1-2文で)
    親クラスに後から追加した enum を、既に存在しているサブクラスの defined_enums にも自動的に反映するようにした修正です。これにより、enum 自体のメソッド・スコープは動いているのに、defined_enums を使う箇所(fixtures や uniqueness validation など)だけが不整合を起こす問題が解消されます。

  1. 変更内容の詳細

これまでの挙動と問題点 ​

ActiveRecord::Enum はクラスメソッド enum で定義され、内部的に defined_enums というハッシュ({ "status" => { "active" => 0, ... } } のような構造)を持ちます。

inherited フックにより、サブクラスごとに defined_enums のコピーが作られますが、

ruby
class Parent < ApplicationRecord
end

class Child < Parent
end

Parent.enum status: { active: 0, archived: 1 }

のように、

  1. 先に Child < Parent を定義
  2. その後に 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 を独自に定義している場合は上書きせずにスキップする。

擬似コード的には次のようなイメージです(実際の実装はもっと細かいですが、挙動のイメージ用):

ruby
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 レベルで反映されるようになった。

  1. 影響範囲・注意点

影響を受けるケース

  • 以下のように「サブクラスを定義した後で親に enum を追加している」コードベースで、かつ、
  • fixtures や validation などで defined_enums ベースの処理をしている箇所
ruby
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 を直接操作しているようなコードがあれば、この修正と競合しないか一応確認すると安心です(通常はそのようなコードはないはずです)。

  1. 参考情報 (あれば)

#59442 Fix Ractor proxy savepoint rollback after before_commit ​

マージ日: 2026/11/1 | 作成者: @yorzi

  1. 概要 (1-2文で)
    Ractor 用の ActiveRecord プロキシで、before_commit コールバックが例外を投げた場合に「セーブポイントがメイン側に開きっぱなしになり、トランザクションの深さがズレる」バグを修正する PRです。セーブポイントのロールバック時に、メイン側とプロキシ側でトランザクションの深さを正しく同期させるように変更されています。

  1. 変更内容の詳細

何が問題だったか ​

  • 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 以上である間だけロールバックを行う」ようにする。
  • これにより、次のような振る舞いが保証されます:
    • before_commit で例外が起きてセーブポイントのリリース(コミット)が失敗した場合、
      • メイン側がすでにセーブポイントをロールバックし、1 レベル浅くなっているなら、追加のロールバックは行われず、そのままにする。
        • テスト: test_failed_savepoint_release_is_rolled_back_on_the_main_side
    • セーブポイントが「まだ 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 つのテストが追加されています:

  1. ractor_connection_handling_test.rb

    • test_savepoint_rolled_back_by_before_commit_is_rolled_back_on_the_main_side
    • Ractor プロキシ越しに、before_commit がセーブポイントをロールバックするシナリオを直接テストし、
      「メイン側のセーブポイントも正しくロールバックされ、深さが同期していること」を検証。
  2. transaction_callbacks_test.rb

    • test_before_commit_rollback_rolls_back_the_savepoint
    • sqlite3_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。
  • pg gem 不足に起因する ConnectionHandlersMultiDbTest#test_switching_connections_with_database_url のエラーのみ発生(PostgreSQL/MySQL は未実行)。
  • Ractor connection handler はまだリリース前のため、CHANGELOG への追記はなし。

  1. 影響範囲・注意点
  • 対象:
    • ActiveRecord の Ractor 用 connection handler(ractor_connection_handler/abstract_proxy_adapter.rb)周辺の「トランザクション・セーブポイントの同期ロジック」に限定されます。
    • 通常の(非 Ractor)接続アダプタのトランザクション処理は変更されていません。
  • 挙動面:
    • before_commit コールバック内で例外が発生し、その際にセーブポイントやネストしたトランザクションを利用しているケースで、
      • これまで「テスト上は通ってしまっていたが、実際にはメイン側に書き込みが残る/トランザクション深度がずれる」ような微妙な不整合があった場合、今回の修正で正しくロールバックされるようになります。
    • トランザクション深度の扱いがより厳密になったため、もし独自に Ractor プロキシと同様のメカニズムを模倣しているコードがあれば、
      同期方法(名前ベースではなく depth ベース)に注意が必要です。
  • パフォーマンス:
    • 変更は深さ計算と条件付きロールバック程度であり、通常ケースのオーバーヘッドはごく小さいと考えられます。
  • 互換性:
    • 公開 API のシグネチャや通常のトランザクション API は変わっていないため、アプリケーションコードから見た破壊的変更はありません。
    • ただし、「バグに依存していた」ケース(メイン側のセーブポイントが残ることを前提にしていたような挙動)があれば、その部分は修正が必要になります。

  1. 参考情報 (あれば)
  • 関連 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 ロールバック挙動のテスト追加。
    • 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. 概要 (1-2文で)
    attr_readonly を親クラスに後から指定した場合でも、そのサブクラスで同じ属性に対する代入時に正しく ActiveRecord::ReadonlyAttributeError を発生させるようにした修正です。以前は、サブクラス側に既に生成済みの writer メソッドが残っており、例外が出ず「書き込みは無視されるがエラーにもならない」という潜在的バグがありました。

  1. 変更内容の詳細

問題の背景 ​

Rails の attr_readonly は、指定された属性に対する writer メソッドを「読み取り専用」として扱うために、内部で属性メソッドの再生成などを行っています。

しかし、次のようなケースで問題が発生していました。

ruby
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 するように変更。

これにより:

  1. 親クラスで attr_readonly を宣言すると、
  2. その時点で既に attribute methods が生成されていたサブクラスも含めて、
  3. 対象属性のメソッドが一旦消され、
  4. 再生成される際には「readonly を考慮した writer(もしくは例外を投げる動作)」になる。

結果として、親クラスで readonly にした属性に対して、サブクラスから代入しても正しく ActiveRecord::ReadonlyAttributeError が発生するようになります。

テストと CHANGELOG ​

  • activerecord/test/cases/base_test.rb にテストが追加され、
    • 親クラスに attr_readonly を後から設定した場合に、
    • サブクラスの生成済み writer 経由でも ActiveRecord::ReadonlyAttributeError が発生することを検証しています。
  • activerecord/CHANGELOG.md に、この挙動修正が明記されています。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象となるのは次の条件を満たすコードです:

    1. ActiveRecord モデルの継承階層がある(親クラスとサブクラス)。
    2. サブクラス側で既に attribute methods が生成されている(レコード取得やカラム参照などを行っている)。
    3. その後で、親クラスに attr_readonly を追加している。
    4. サブクラスから、その readonly にされた属性へ代入している。
  • これまでは「エラーは出ないが、値も保存されない」というサイレントな不整合だった箇所が、
    今後は ActiveRecord::ReadonlyAttributeError を明示的に投げるようになります。

注意点 / マイグレーション上の観点 ​

  • もし既存コードで「サブクラスから readonly 属性に代入しようとしている」箇所があれば、
    これまでは気付かないまま保存されていなかった可能性があり、今回の修正により例外が顕在化します。

  • 対応としては、

    • そもそもその属性を書き換えるべきでないなら、代入コードを削除・修正する。
    • readonly にするタイミング(attr_readonly 呼び出し)を見直す。
    • readonly にしない方針に戻す(仕様として書き換えを許容する)
      などを検討する必要があります。
  • テストコード上で「例外が出ないこと」を暗黙に前提にしていた場合、
    今後は ReadonlyAttributeError が出るためテストが失敗する可能性があります。


  1. 参考情報 (あれば)
  • 該当 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. 概要 (1-2文で)
    belongs_to に touch: true を付けた関連が「複合外部キー」を持つ場合に、存在しえない親レコードを毎回 WHERE ... IS NULL で探しに行ってしまう不正なクエリが発行されるバグを修正した PRです。複合キーのうちどれかが nil の場合は「以前の親」は存在しないとみなして、親の touch 用クエリをスキップするようにしました。

  1. 変更内容の詳細

問題の背景 ​

  • 前提: 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 な配列」として扱われてしまっていました:

    ruby
    old_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 自体をスキップ」するように変更されています。

変更の中核はこの部分です:

ruby
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 モデルを使い、次を検証しています:

  1. 初回関連付け時(旧親なし)
    • 初めて親をセットするケースで、WHERE ... IS NULL を含む旧親 lookup クエリが一切発行されないこと。
  2. 複合キーの一部だけセットされている場合
    • 複合キーのうち一部のカラムしか埋まっていない状態でも、旧親 lookup が走らないこと。
  3. 実際の「親の付け替え」時
    • 旧親が実在するケースでは、依然として旧親に対して touch が行われること(過剰にスキップしていないこと)の保証。

  1. 影響範囲・注意点
  • 対象:
    • belongs_to ... , touch: true を使っていて
    • その関連が「複合外部キー(Composite Foreign Key)」を通じて親を参照しているケース
  • 影響:
    • これまでは、初回作成やキーの一部のみがセットされた状態で、不要な IS NULL 付き SELECT が毎回走っていましたが、この PR によりそれらが発行されなくなります。
    • 正常系の動作としては、「実際に前の親がいる場合のみ」旧親の touch が行われるようになり、期待どおりの挙動です。
  • パフォーマンス:
    • 特に大量 insert・バルク insert の場面で、複合キー + touch: true を用いていた場合、無駄クエリが減ることで DB 負荷が軽減される可能性があります。
  • 互換性:
    • 主キー列が非 NULL であるという Active Record/DB 設計の一般的前提に基づいたガードであり、正当な旧親タッチが失われるケースは想定されていません。
    • もし複合主キーに NULL を許容するような異例のスキーマを組んでいる場合(通常推奨されません)は、振る舞いが変わる可能性がありますが、そのような設計自体が AR の想定外と言えます。

  1. 参考情報 (あれば)
  • 対象コード:
    • 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. 概要 (1-2文で)
    has_one :through で、through側とsource側が同じテーブルを使う STI サブクラスの場合に、事前読み込み(preload/eager_load)すると nil が返ってしまうバグを修正した PR です。STI の type 条件が競合していたクエリ生成を見直し、正しいサブクラスを3クエリで確実にプリロードできるようにしています。

  1. 変更内容の詳細

問題の背景 ​

構成イメージ:

ruby
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
end

BranchRoute と 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)。

概ね以下のような判定が入ったと考えられます(※実際のコードは多少異なる可能性がありますが、意図はこういうものです):

ruby
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
end

through_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)

テストでは次を検証:

  1. 対象の has_one :through 関連が 3クエリでプリロードされること
    • おそらく:
      1. parents (e.g. Branch)
      2. through (BranchRoute)
      3. source (CustomerRoute)
  2. プリロード後、その関連が loaded? とマークされていること
  3. 関連へのアクセスが追加クエリなしで行われ、期待通りのレコードが返ること

また、以下一式が実行されており、既存影響もチェック済みです:

  • 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)。


  1. 影響範囲・注意点
  • 影響対象:
    • 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 が返ること」を前提にしたワークアラウンドを持っていた場合は、そのコードが不要になる(あるいは重複する)可能性があります。

  1. 参考情報 (あれば)
  • 対応 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.rb
    • activerecord/test/cases/associations/has_one_through_associations_test.rb
    • activerecord/test/cases/associations/has_many_through_associations_test.rb
    • activerecord/CHANGELOG.md

#59491 Pass attribute aliases added to a parent down to existing subclasses ​

マージ日: 2026/11/1 | 作成者: @kehra

  1. 概要 (1-2文で)
    親クラスにあとから追加した alias_attribute が、既に独自の attribute_aliases を持っているサブクラスには反映されず、クエリでエイリアス名がそのままカラム名として使われてしまう不具合を修正した PR です。alias_attribute 実行時に子孫クラスへもエイリアス定義(lookup 用ハッシュ)を伝搬させることで、Active Record の問い合わせでも正しくエイリアスを解決できるようにしています。

  1. 変更内容の詳細

問題の具体例 ​

元の問題は「親に後付けした alias_attribute が、既に attribute_aliases を複製済みの子クラスには載らない」というものです。

ruby
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 に出してしまう

同じ現象が、抽象クラス経由でも起きます。

ruby
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 では元のカラム名が使われるようになる

実装ポイント ​

  1. 処理場所

    • 修正は ActiveModel::AttributeMethods 側
      (ActiveRecord::Base.alias_attribute は super でここを呼ぶだけなので、Active Model を直せば Active Record も直る)
  2. 処理内容の流れ(概念的)

    擬似コードイメージ:

    ruby
    def 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
    end
  3. descendants の利用

    • descendants メソッドを使うために
      active_support/core_ext/class/subclasses を require
    • Active Model 単体ではこれが自動ロードされないため、明示的に読み込む必要がある
  4. テスト変更

    • activemodel/test/cases/attribute_methods_test.rb
      • 親に後から alias を追加したときに、サブクラスの attribute_alias? も true になるケースを追加
    • activerecord/test/cases/attribute_methods_test.rb
      • Active Record のモデル/抽象クラスを絡めたシナリオで、クエリにおいて alias が解決されることを検証
  5. CHANGELOG

    • activemodel/CHANGELOG.md にバグ修正として追記

  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • ActiveModel::AttributeMethods を継承したクラス(Active Record のモデル含む)
    • 特に、親/抽象クラスに alias を後付けする設計(concern や再オープン)があるアプリ
  • 効果:
    • 「親にあとから追加した alias_attribute が、すでに生成済みのサブクラスにも正しく効く」
    • Active Record のクエリ (where, order, select 等) で attribute エイリアスが安定して使える

この挙動は既存コードに対して基本的には「バグ修正」として期待通りの方向の互換性です。

トレードオフ / 注意点 ​

PR の説明にも明記されている重要な仕様:

  • サブクラスで既に同名エイリアスが存在する場合は、親の変更で上書きしない

    理由:

    • サブクラスが持っている attribute_aliases["headline"] = ... が
      • もともと自分で定義したものか
      • 過去に親からコピーされたものか
        を区別できない
    • 安全側に倒して「サブクラス側の定義を優先する」というポリシーにしている

    そのため:

    ruby
    class 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 が残っていないかも確認したほうがよいです。

  1. 参考情報 (あれば)
  • 類似の修正:

    • 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. 概要 (1-2文で)
    親クラスにあとから attr_readonly を追加した場合でも、それ以前から存在していた子クラスにも正しく読み取り専用属性が引き継がれるようにした修正です。これにより、親と子で readonly_attributes の内容がずれて、一部のサブクラスだけ読み取り専用制約が効かない不整合が解消されます。

  1. 変更内容の詳細

これまでの問題 ​

以下のような状況で不具合がありました。

ruby
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 のコピー」を持っている場合にだけ新しい属性を追加
        (まだ親と配列を共有しているクラスは、親側での追加だけで内容が反映されるためスキップ)

というロジックになっています。

これにより、先ほどの例は次のように挙動が揃います。

ruby
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 の同じ配列インスタンスを共有していたケース」でもすでに起きていた挙動であり、今回の変更で新たに発生する副作用ではないと説明されています。

  1. 影響範囲・注意点
  • 影響を受けるのは以下のようなコードパターンです:

    • STI や継承を使っている ActiveRecord モデル
    • 子クラスで既に attr_readonly を宣言済み
    • その後に、親クラス側へ attr_readonly で新たな属性を追加している(concern ロード順や再オープンで起こりがち)
  • この変更によって:

    • これまでサイレントに更新できていたサブクラスの属性更新が、例外や更新拒否として表面化する可能性があります。
    • すでに本番運用中のアプリケーションでは、「子クラスから親の readonly 属性を更新していたコード」があれば、それが壊れる(正しくは修正される)ことになるため、移行時には動作確認が必要です。
  • 例外が出ないが保存されないケース:

    • 一部のケース(属性メソッドが先に生成されている)では、更新時に例外は発生しないものの値は永続化されません。
    • テストを書く際には、「例外が出ること」だけでなく「DB 上の値が変わらないこと」も合わせて確認しておくと安全です。

  1. 参考情報 (あれば)
  • この PR が解決したのと同種の問題(アソシエーションの伝搬):

    • #59462 – associations に対する add_reflection の伝搬修正
    • #20678 – 元となる関連の readonly 伝搬問題
  • 変更点は以下の 3 ファイル:

    • activerecord/lib/active_record/readonly_attributes.rb
      • attr_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. 概要 (1-2文で)
    has_many :through + counter cache + partial_inserts を無効にした場合に、関連を1件作成しただけで counter cache が2回インクリメントされてしまう不具合を修正するPRです。未変更の counter cache カラムを INSERT 文から除外することで、メモリ上の値と DB 上の値が二重に更新されないようにしています。

  1. 変更内容の詳細

問題の背景 ​

構成イメージ:

ruby
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 モデルレベルで:

ruby
ActiveRecord::Base.partial_inserts = false
# または Owner.partial_inserts = false

この状態で:

ruby
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 the INSERT.

つまり:

  • 未変更 (dirty ではない) な counter cache カラム は INSERT の対象から外す。
  • 開発者が明示的に owner.items_count = 5 のように 自分で値を設定した場合 は、その値は「変更あり」とみなされるので、従来通り INSERT に含める。

これにより:

  • Owner の新規作成時に、counter cache カラムを触っていなければ:
    • INSERT では items_count を書き込まない(DB のデフォルト値 / NULL)。
    • Ownership の belongs_to コールバックが +1 し、DB 上の値は正しく 1 になる。
  • 逆に、開発者が初期値を明示的に設定したい場合は、その値がそのまま 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)をカバーするようになっています。

サンプル的な期待動作:

ruby
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

明示的に設定した場合:

ruby
owner = Owner.new(items_count: 10)
owner.items << Item.create!
owner.save!
# -> INSERT では items_count = 10 が書かれる
# -> その後 counter_cache のコールバックで +1 される可能性があり、結果は 11 になる
#    (この挙動は従来どおりで、PR の対象外)

  1. 影響範囲・注意点
  • 対象:
    • 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デフォルトに任せる)箇所がないか確認した方が安全です。

  1. 参考情報 (あれば)
  • 対応 issue: #47555
  • 変更ファイル:
    • activerecord/CHANGELOG.md
    • activerecord/lib/active_record/attribute_methods/dirty.rb
    • activerecord/test/cases/associations/has_many_through_associations_test.rb
    • activerecord/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. 概要 (1-2文で)
    composed_of で定義した aggregation を、後から親クラスに追加した場合でも、既に存在しているサブクラスに正しく伝播させる修正です。これにより、サブクラスでも reflect_on_aggregation や where が期待通りに動作するようになります。

  1. 変更内容の詳細

何が問題だったか ​

次のような状況を考えます:

ruby
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)]

このとき:

ruby
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)
  • 動き:
    1. まず親クラス自身の aggregate_reflections に aggregation を登録
    2. その後、descendants を辿ってサブクラスを走査し、必要に応じて同じ aggregation をコピーして登録するようにした
      (これは associations 用の add_reflection と同じ戦略)

ポイント:

  • 「すでに独自の aggregate_reflections ハッシュを持っているサブクラス」で
    • かつ「同名の aggregation が 定義されていない」クラスには、親で追加された aggregation をコピーして追加する
  • もしサブクラス(またはその途中のクラス)が、同じ名前の aggregation を定義している場合は、「オーバーライド」とみなし、その定義を優先して上書きしない
  • 「まだ親と aggregate_reflections のハッシュを共有しているだけのサブクラス」は、親に追加された時点で見えているので、追加処理をスキップする

これにより、次が保証されます:

ruby
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.rb
    • add_aggregate_reflection 内の子クラス伝播ロジックを追加(+13/-1)

  1. 影響範囲・注意点

影響範囲

  • ActiveRecord の composed_of aggregation を使っていて、
  • 「親クラスに後から aggregation を追加する」パターン(concern やクラス再オープンなど)
  • かつ「すでに独自の aggregation を持ったサブクラス」が存在するケース

このパターンで:

  • 以前は reflect_on_aggregation が nil を返していたサブクラスが、今後は正しい reflection を返すようになります。
  • その結果、where(balance: Money.new(5)) のような「aggregation への値オブジェクトを伴うクエリ」が、サブクラスでも問題なく動作するようになります。

注意点

  • サブクラス側で同名の aggregation を定義している場合は「上書きされない」ため、既存のオーバーライド挙動は維持されます。
  • これはバグフィックスであり、期待されていたが動いていなかった継承挙動を正すものなので、通常のアプリケーションで後方互換性への影響は小さいはずです。
  • もしアプリ側で「サブクラスはあえて reflect_on_aggregation で見えないことを前提にしていた」ような非常に特殊なロジックを書いていた場合は、挙動が変わる可能性がありますが、想定されるユースケースとしてはかなりレアです。

  1. 参考情報 (あれば)
  • 関連 PR(同様の問題の association 版修正):
    • #59462: associations を親に後から追加したときの子クラスへの伝播修正
    • #20678: 上記に関連する過去の議論/修正
  • aggregation 機能 (composed_of) のリファレンス:

#58966 Fix the response Content-Type memo from #59096 ​

マージ日: 2026/11/1 | 作成者: @linyiru

  1. 概要 (1-2文で)
    Rails 8.1.4以降で導入予定だった「レスポンスの Content-Type をパースした結果のメモ化処理 (#59096)」に潜んでいた、オブジェクト共有・エンコーディング・不正バイト処理など5つの不具合をまとめて修正するPRです。response.media_type / response.charset の挙動を 8.1.4 相当の期待値に合わせつつ、安全で予測可能なメモ化ロジックにします(ただし、どれもまだリリース前の挙動なので既存アプリには影響なし)。

  1. 変更内容の詳細

背景: 何が問題だったか ​

#59096 で「レスポンスの Content-Type ヘッダをパースした結果(media_type, charset)を、文字列パースコスト削減のためにメモ化」する仕組みが入りましたが、実装上、以下のような問題がありました:

  1. ヘッダ文字列をキーにしていたため、String#replace に追随しない

    ruby
    response.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 しても「別のヘッダ」と認識できず、古い結果を使い続けていた。

  2. media_type / charset がメモ済みの“同一インスタンス”を返すため破壊的変更が波及する

    ruby
    mt = response.media_type
    mt << "x"     # 呼び出し側がやりがちな誤り
    
    response.media_type #=> 以降すべて "text/htmlx" になる

    → 呼び出し側が破壊的に変更すると、メモされているオブジェクト自体が壊れる。

  3. ASCII だけの文字列の場合にエンコーディングの違いを無視していた

    ruby
    response.headers["Content-Type"] = "text/plain".b  # ASCII-8BIT(BINARY)
    response.headers["Content-Type"] = "text/plain"    # UTF-8
    
    # 値「text/plain」は同じなので == でヒットしてしまい、
    # 一度目の BINARY な結果がそのまま使われ続ける

    → パース結果のキャッシュが、エンコーディング差異を考慮せずに再利用されてしまう。

  4. 不正バイトを含むヘッダで例外が2回目以降別種になる

    ruby
    response.headers["Content-Type"] = "text/html; charset=\xFF"
    
    response.media_type # 1回目: ArgumentError
    response.media_type # 2回目: NoMethodError (メモキーが壊れている)

    → パース失敗時のメモ状態が壊れてしまい、同じコードなのに実行回数によって別の例外になる。

  5. writer 経由でセットしたときにヘッダ文字列とは違うエンコーディングが残る

    ruby
    response.content_type = "text/plain".b  # BINARY
    response.charset      = :UTF8          # Symbol
    
    # 本来は、最終的なヘッダ文字列のエンコーディング(たとえば UTF-8)に揃った
    # パース結果が欲しいが、writer に渡した元のオブジェクトの encoding が残ってしまう

どれも #59096 の挙動であり、まだ gem としてはリリースされていない時点の問題です。


このPRでの修正ポイント ​

1. メモキーの持ち方の見直し ​

  • **ヘッダ文字列そのものではなく、「パース後に取得した frozen コピー(-raw)をキーとして保存」**するようになりました。
  • かつ、パースが成功した後にのみキーを保存します。
    → これにより:
    • headers["Content-Type"].replace(...) で中身が変わったら、新しいヘッダとして再パースされる。
    • パースに失敗した時点ではメモされないので、後の挙動が壊れにくい。

実装イメージ(擬似コード):

ruby
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
end

2. エンコーディングも含めて比較 ​

  • 「値だけでなくエンコーディングも一致しているか」を比較に含めるように変更。
  • これにより、"text/plain".b → "text/plain" のようなケースでも、別のヘッダとして扱われ、再パースされるようになります。

3. media_type / charset は必ずコピーを返す ​

  • メモにはオリジナルを保存するが、呼び出し側へ返すときは dup 等でコピーを返すように変更。
  • 呼び出し側が << や gsub! など破壊的メソッドを呼んでも、後続の response.media_type / response.charset が壊れない。

サンプル:

ruby
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 のエンコーディングがずれないようになります。

イメージ:

ruby
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 ケースすべてパス。


  1. 影響範囲・注意点
  • 影響範囲

    • ActionDispatch::Response の Content-Type 周り (media_type, charset, content_type= など) の内部実装。
    • 外部 API としては
      • 返ってくるオブジェクトが「毎回別インスタンス」になる
      • 不正ヘッダでの例外種類やタイミングが安定化
      • writer 経由のエンコーディングの扱いが、実際のヘッダ文字列と整合するようになる
        といった点で、より直感的・一貫した挙動になります。
  • 破壊的変更っぽく見える点

    • response.media_type / response.charset に対して破壊的操作をして、かつ「後続の呼び出しにもその変更が効いている」ことに依存しているコードは、今回の変更で動かなくなります。
      • ただし、これはそもそも「メモ化キャッシュを意図的に破壊している」挙動への依存であり、避けるべきパターンです。
    • == 比較時にエンコーディング差異を無視していた挙動にも依存しないほうがよいです(比較処理としてはより厳密になった)。
  • 実務への影響

    • この PR が修正している挙動は、いずれもまだリリースされていない(main ブランチ上のみの)挙動なので、現行安定版(8.1.4 など)を使っているアプリケーションには直接の影響はありません。
    • 将来のリリースで Content-Type 周りのメモ化が入る際には、すでに本 PR の修正が含まれており、より安全な実装が利用される想定です。

  1. 参考情報 (あれば)

今後、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. 概要 (1-2文で)
    ActiveRecord の Relation#or と Relation#and が、内部で行う互換性チェックを最適化することで最大 1.3〜1.6 倍程度高速化された PRです。互換性エラー時の挙動やエラーメッセージは変えずに、不要な比較処理だけを削って性能を上げています。

  1. 変更内容の詳細

背景: 遅かったのはどこか ​

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 である値は互換性問題になり得ない」という事実を利用し、実際にどちらか一方で値がセットされているキーだけを比較するように変更しています。

大まかな流れは次のようなイメージです(擬似コード):

ruby
# 旧: すべての構造的メソッドを毎回チェック
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.10Ruby 3.4.10 +YJITRuby 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_sql13.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 行と小さめの変更です。


  1. 影響範囲・注意点
  • 対象:
    • ActiveRecord の Relation#or, Relation#and を利用するすべてのコードパス
    • 内部的には structurally_incompatible_values_for の実装(Relation 同士の互換性チェック)
  • 仕様面の変更なし:
    • or / and で互換性がない Relation を組み合わせた場合のエラー発生条件
    • その際に出るエラーメッセージの内容・リスト順 は旧実装と完全に一致するように作られています。
  • パフォーマンス面:
    • or/and を多用するクエリ組み立て(特に複雑なスコープを組み合わせるケース)で、CPU 時間が削減される可能性が高いです。
    • 逆に、単純なクエリで or / and をほとんど使わないアプリでは、体感差はほぼ出ません。
  • リスク:
    • ロジックとしては互換性チェックの「比較対象の絞り込み」のみで、完全に仕様互換であることがランダムテストで検証済み。
    • 互換性判定に依存したメタプログラミング等をしていない限り、挙動変更のリスクは低いと考えられます。

  1. 参考情報 (あれば)
  • 該当 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. 概要 (1-2文で)
    has_many :through の関連をプリロードするとき、同じ関連先を使う「順序なし」と「順序あり」の関連を混在させた場合に、順序あり側の order が効かず「through 側の並び順」で返ってしまうバグを修正した PR です。条件付き (where) のケースに続き、order のケースでも「既にロード済みの中間・ソースレコードをそのまま再利用しない」ようにすることで正しい並び順を保証します。

  1. 変更内容の詳細

問題の挙動 ​

次のような関連があるとします:

ruby
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 の並び順で並んだ posts

categorized_posts(順序なし)を先にプリロードしたあとに、categorized_posts_desc(order(id: :desc) 付き)をプリロードすると、後者でも前者でロード済みの posts がそのまま使い回され、order が反映されませんでした。

内部的には、has_many :through のプリロードは

  1. through レコード (categorizations) をロード
  2. source レコード (posts) をロード
  3. 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 が守られます:

ruby
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 にバグ修正として追記。

  1. 影響範囲・注意点
  • 影響するケース:

    • 同じ through / source を共有する has_many :through が複数あり、
      • 片方はスコープなし(または order なし)
      • もう片方は order 付き
    • かつ、順序なしの方が先にプリロードされ、その後に順序ありの方をプリロードするケース
      (手動の preload だけでなく、goldiloader などの gem によるバッチロードでも起きうる)
  • 挙動の変化:

    • 上記ケースで、以前は「順序ありの関連でも through の順序で返ってしまう」ことがあったが、常にスコープに定義した ORDER BY が反映された結果になる。
    • その代わりに、「順序ありの through 関連をプリロードするタイミングで追加クエリが 1 本発行される」可能性がある。
      • 具体的には「順序なし → 順序あり」の順でプリロードしたときに、順序あり側でソースレコードを再取得する。
  • 既存最適化との関係:

    • 「through 側のスコープが条件付きでソースを eager load する経路」(例えば includes + where 等でソースがまとめてロードされるケース)は今回触っていないため、test_preload_scoped_has_many_through_reuses_eager_loaded_source_records のようなケースのクエリ数は増えていません。
    • つまり「フィルタ条件だけ」のケースでは、従来の「再利用によるクエリ削減」は維持され、order が絡むケースだけ再取得する形に限定されています。
  • パフォーマンス上の注意:

    • 同じ has_many :through を複数のバリエーション(order や where 付き)で heavy に使うコードでは、プリロード順序や gem 側のバッチ戦略により、クエリ数が増える可能性があります。
    • ただし増えるのは「順序あり側のソース再取得 1 クエリ」レベルに限定されており、明確なバグ修正の代償としては許容範囲と考えられます。

  1. 参考情報 (あれば)
  • 関連 issue:
    • #56110: has_many :through のプリロードで、where / order 付きスコープの挙動が壊れる問題
  • 関連 PR:
    • #59444: where 条件付きの has_many :through プリロード時に、条件なしでロードされたソースレコードを再利用しないようにした変更(本 PR はその order 版)
  • 外部報告:
    • salsify/goldiloader#166: goldiloader がバッチロードした結果としてこのバグが顕在化した事例

#59483 Build cookie jars' verifier and encryptor on first use ​

マージ日: 2026/11/1 | 作成者: @linyiru

  1. 概要 (1-2文で)
    SignedKeyRotatingCookieJar と EncryptedKeyRotatingCookieJar が、クッキーの署名・暗号化用オブジェクト(MessageVerifier / MessageEncryptor)を「生成時」ではなく「初めてクッキーを読む/書くとき」に遅延生成するように変更されました。これにより、対象クッキーが存在しないリクエストでの不要なキー導出・オブジェクト生成が減り、特にAPI等でセッションを毎回読むアプリケーションのパフォーマンス(オブジェクト数・レイテンシ)が改善します。

  1. 変更内容の詳細

背景と問題点 ​

従来:

  • 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 → 2513.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.signed
      • cookies.encrypted
      • cookies.signed_or_encrypted
  • 既存の Action Pack・Railties のテストはすべてパス:

    • cookie / session middleware
    • key rotation
    • AES-CBC → AES-GCM のセッションアップグレード

  1. 影響範囲・注意点

直接の影響 ​

  • 主に影響を受けるのは以下のようなアプリケーション:
    • CookieStore セッションを使用していて、before_action などで毎回セッションを読むが、実際には cookie が付いていない API リクエストが多い
    • Devise の rememberable を有効にしているが、クライアントが remember_user_token cookie を送ってこないパターンが多い

このようなケースでは、「リクエストごとに不要な 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 移行など、暗号周りの実動作は同一です(初回アクセスのタイミングが変わるだけ)。

  1. 参考情報 (あれば)
  • 関連するクラス/モジュール:

    • ActionDispatch::Cookies
    • ActionDispatch::Cookies::SignedKeyRotatingCookieJar
    • ActionDispatch::Cookies::EncryptedKeyRotatingCookieJar
    • ActionDispatch::Session::CookieStore
    • ActiveSupport::MessageVerifier
    • ActiveSupport::MessageEncryptor
    • ActiveSupport::KeyGenerator
  • パフォーマンス観点:

    • high-QPS な API サーバで、cookie ベースのセッションを「一応読む」けれど、実際には cookie が付かないケースが多いときに最も効く最適化です。
    • 1リクエストあたり数十オブジェクト削減・数 µs 改善ですが、トラフィックが多いほど GC 頻度などにポジティブな影響が見込めます。

#59476 Fix Active Support inline RBS signatures ​

マージ日: 2026/10/31 | 作成者: @amatsuda

  1. 概要 (1-2文で)
    Active Support に最近追加された「インライン RBS 型定義」と、実際の Ruby 実装とのズレをまとめて修正した PRです。主に「メソッドが本当は受け付けない値を受け付けることになっていた型」や、「戻り値の型が実装より粗かった箇所」が、実装に沿うように精密化されています。

  1. 変更内容の詳細

全体像 ​

  • 対象は Active Support のインライン RBS (# :sig コメント形式) で、実装と異なる部分を gem_rbs_collection にある手書きシグネチャと突き合わせて修正。
  • 修正は大きく分けて:
    • 「実装では拒否される値を受け付けることになっていたシグネチャの修正」
    • 「振る舞いは正しいが、型がラフすぎたシグネチャの精密化」
  • 実装コード自体はほぼ変えず、型側の修正が中心です。

実装が拒否する値を許容していたシグネチャの修正 ​

1) ActiveSupport::ErrorReporter#report / #unexpected ​

Before (誤った型イメージ)

rbs
def report(..., severity: Symbol?): void  # nil も OK という扱い

After (正しい型のイメージ)

rbs
def report(..., severity: Symbol): void   # nil は NG
  • 実装では severity が SEVERITIES に含まれるかをチェックしており、nil を渡すと ArgumentError。
  • よって Symbol?(nil 許容)ではなく、Symbol 固定に修正。

挙動例

ruby
reporter.report(error, severity: nil)  # 実装上 ArgumentError になる

→ 型もこれを反映して nil 不可。


2) String#pluralize ​

Before

rbs
def pluralize(count: Integer?, locale: Symbol?): String

After

rbs
def pluralize(count: Integer?, locale: Symbol): String
  • 実装では locale に nil を渡すと I18n::InvalidLocale が発生。
  • 従って「nil も受け取れる」という型は誤り。

挙動例

ruby
"post".pluralize(2, nil) # => I18n::InvalidLocale が raise される

→ locale は Symbol のみ受け取るシグネチャに修正。


3) Array#in_groups / Array#in_groups_of のグループサイズ ​

Before

rbs
def in_groups_of(number: Integer | Float, ...): Array[Array[...]]

After

rbs
def in_groups_of(number: Integer, ...): Array[Array[...]]
  • 実装は Integer を期待しているが、Float を渡しても Ruby 側で to_i 的な解釈で動いてしまうケースがある。
  • 型として「Float も正式サポート」とは言えないため、Integer のみに制限。
  • in_groups も同様に修正。

挙動例(実装側の実際の動き)

ruby
[1, 2, 3].in_groups_of(2.5)
# => [[1, 2], [3, nil], [nil]]

このように「たまたま動く」挙動はあるが、型として保証したい仕様ではないので Integer のみに。


4) ActiveSupport::Rescuable.rescue_from ​

Before (ざっくり 1 パターン)

rbs
def rescue_from(*klasses, with: ^(Exception) -> untyped ?): void

After (block 版 / with: 版の 2 パターンに分割)

  • with: キーワードを使うオーバーロード
  • ブロックを渡すオーバーロード

加えて:

  • 「with: もブロックもどちらも指定しない」呼び出しは実装上 ArgumentError になるため、型的にも「必ずどちらかが必要」であることを表現。
  • ブロックハンドラは実際には instance_exec でレシーバに対して実行されるため、型もそれに即した形に。

挙動イメージ

ruby
# 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

rbs
def assert_changes(expression, message: untyped?, &block): void
# expression: Proc | Kernel となっており、ほぼ何でも通ってしまう

After

rbs
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? に精密化

使用例

ruby
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 パターンがあるため、オーバーロードで表現:

    1. fill_with 省略 / nil … パディング要素は nil
    2. fill_with: false … パディングなし(最後の配列のサイズが小さくなり得る)
    3. fill_with に任意の値 … その値でパディング
  • グループの中に nil が入るのは「パディングに nil を使う場合のみ」であることを型に反映。

  • in_groups_of にブロックを渡した場合の戻り値も修正:

    • 実装上は「ブロックに各グループを渡して、元の配列自身を返す」ため、
    • 戻り値型を「グループの配列」ではなく「レシーバの Array」に合わせている。

挙動例

ruby
[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 を返し得るので、? 付き。

挙動例

ruby
"2024-01-01".in_time_zone      # => ActiveSupport::TimeWithZone
"2024-01-01".in_time_zone("UTC")  # => ActiveSupport::TimeWithZone
"".in_time_zone                # => nil

8) String#squish! / String#remove! ​

  • 戻り値は「標準の String」ではなく レシーバ自身 (self) であることを反映。
    • SafeBuffer のようなサブクラスに対しても、SafeBuffer のまま戻ってくる。

挙動例

ruby
buf = ActiveSupport::SafeBuffer.new("  foo  ")
res = buf.squish!
res.class # => ActiveSupport::SafeBuffer
res.equal?(buf) # => true

→ 型でも self を返すようにして、サブクラス情報が失われないようにした。


9) 真偽値フラグ引数を bool → boolish に ​

対象メソッド:

  • String#titleize

  • String#humanize

  • String#parameterize

  • String#foreign_key

  • String#indent

  • String#indent!

  • これらのオプション引数は実装上「真偽そのもの」ではなく「truthiness(真として評価されるか)」でチェックしている。

    • 例: if options[:uppercase] のような単純真偽値チェック
  • そのため、厳密な bool ではなく、RBS 用語での「boolish(true と評価されうる型)」として定義。

イメージ

ruby
"foo".titleize(keep_id_suffix: "yes")  # "yes" も truthy として扱われる

→ 実装と整合が取れるように、型も「Ruby 的な truthiness」を許容する。


  1. 影響範囲・注意点
  • 主な影響は「型チェック/型補完ツール」側

    • 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 というインタフェースで表現しているが、インラインでは定義できないため。

  1. 参考情報 (あれば)

#59308 Instrument connection pool checkout waits ​

マージ日: 2026/10/31 | 作成者: @andrewfader

  1. 概要 (1-2文で)
    Active Record のコネクションプールで、空きコネクションがなくて checkout がブロックしている待ち時間を、checkout_wait.active_record というイベントとして計測・通知できるようにした変更です。これにより、プール逼迫時の「よく分からない遅さ」が可観測になり、ConnectionTimeoutError が出る前から問題把握・チューニングがしやすくなります。

  1. 変更内容の詳細

何を計測するようになったか ​

  • 対象: Active Record のコネクションプール (ActiveRecord::ConnectionAdapters::ConnectionPool) における acquire_connection 内の「待ち時間」
  • 条件:
    1. アイドルなコネクション取得が失敗
    2. バックグラウンドコネクションの取得もできない
    3. 新規コネクションの作成もできない
      → つまりプールが一杯で、他のスレッドがコネクションを返すのを待つしかない状態
  • その「ブロックして待っている区間」を checkout_wait.active_record という Active Support Instrumentation イベントとして発火します。

イベント名とペイロード ​

  • イベント名:
    • checkout_wait.active_record
  • 主なペイロード:
    • :connection_pool
      • 対象となるコネクションプールオブジェクト(もしくは識別可能な情報)
    • :checkout_timeout
      • 設定されている checkout_timeout の値(秒)
    • タイムアウトした場合:
      • :exception
        • ConnectionTimeoutError などが入る(ActiveSupport::Notifications 標準の :exception 形式に準拠)

イベントの「duration(処理時間)」は「実際にブロックして待った時間」になります。
つまり、checkout に成功した場合も、タイムアウトした場合も、その前にどれくらい待たされたかが計測可能です。

実行タイミングとロックとの関係 ​

  • イベント発火はプールのロックの「外側」で行われます。
    • これにより、Instrumentation 自体がプールのロック競合を悪化させないように配慮されています。
  • 例外として、「ピン留めされていたコネクションが破棄され、プール側にフォールバックして取り直す」ような経路では、ロック内で発火される場合がありますが、これはレアケースです。

コード変更の概要 ​

変更ファイル:

  • activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb
    • acquire_connection 内に ActiveSupport::Notifications.instrument("checkout_wait.active_record", payload) { ... } のような計測ブロックが追加された形(+3/-1 行程度の小変更)。
  • activerecord/test/cases/connection_pool_test.rb
    • checkout_wait.active_record イベントが正しく発火し、ペイロードや例外情報が期待通りであることを確認するテストが追加(+33 行)。
  • guides/source/active_support_instrumentation.md
    • Active Support Instrumentation ガイドに checkout_wait.active_record の説明を追加(+12 行)。
  • activerecord/CHANGELOG.md
    • 新しい計測イベント追加のエントリを追加(+8 行)。

サンプルでの利用イメージ ​

アプリ側でこのイベントを購読すると、プール逼迫状況が可視化できます。例:

ruby
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 などにメトリクスとして送ることもできます:

ruby
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

  1. 影響範囲・注意点
  • パフォーマンス・動作への影響
    • 通常の checkout(すぐにアイドルコネクションが取れる場合)は、このイベントは発火しません。
    • イベントはロック外で発火するため、プールの性能劣化リスクは低いです。
    • ただし、プールが慢性的に逼迫している環境では、このイベントが頻発するため、購読側(ログ出力・メトリクス送信)のオーバーヘッドに注意する必要があります。
  • エラー(ConnectionTimeoutError)との関係
    • これまでは、ConnectionTimeoutError が起きるまで「何が遅いのか分からない」状態になりがちでしたが、
    • 今後はタイムアウト発生前から、待ち時間分のイベントが飛ぶため、「プールに空きがなくて詰まっている」ことを早期に検知できます。
  • 計測の粒度
    • 計測対象はあくまで「プールが一杯で他スレッドの返却を待っている時間」のみです。
    • コネクション取得前後のアプリ処理時間やクエリ実行時間は別途、既存の instrumentation(sql.active_record など)で見る必要があります。
  • バージョン依存
    • この PR は Rails 本体への変更なので、マージされたバージョン以降(Active Record の該当バージョン)でのみ利用可能です。
    • 自前で ConnectionPool#checkout に prepend して計測していた場合、この公式イベントに切り替えることで、パッチの維持コストを減らせます。

  1. 参考情報 (あれば)
  • 追加されたイベント:
    • checkout_wait.active_record
  • 主な関連ドキュメント:
    • guides/source/active_support_instrumentation.md
      • Active Record セクションに、新しいイベントの説明が追記されています。
  • 関連クラス:
    • ActiveRecord::ConnectionAdapters::ConnectionPool
    • ActiveSupport::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. 概要 (1-2文で)
    Arel の SQL 生成処理(ToSql ビジター)から each_with_index / inject を排除し、YJIT(および ZJIT)で最適化しやすい each ベースのループに書き換えることで、SQL コンパイルを最大約 1.23 倍高速化するパフォーマンス改善 PRです。生成される SQL 自体はビット単位で同一であり、挙動変更はありません。

  1. 変更内容の詳細

背景・問題点 ​

  • プロファイルの結果、リレーションを 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 を除去 ​

元のイメージ(擬似コード):

ruby
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 フラグに書き換え:

ruby
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 本文には載っていませんが、パターンとしては次のような変化です:

ruby
# 変更前のイメージ
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 は従来とバイトレベルで同一になるよう確認済みです。


  1. 影響範囲・注意点

性能面の影響 ​

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 も更新されていない(挙動変更ではなく微小なパフォーマンス改善のため)。

  1. 参考情報 (あれば)
  • 関連 Issue: #54943 「Slow query generation」

    • 本 PRはそのボトルネックの一部(Arel ToSql レベルの each_with_index 由来のもの)を解消するもの。
    • まだ残っている each_with_index(INSERT のカラム、VALUES の行、BoundSqlLiteral の配列バインド、CTE など)も同様に変換し得るが、この PR では未対応。
      → 著者は「同じPRでやるか、後続PRにするか、それとも放置するか」についてメンテナへの意見を求めている。
  • 技術的ポイントのまとめ:

    • YJIT / ZJIT 最適化を考えると、「C 実装の each_with_index / inject より、Ruby 実装の each とフラグ・インデックス変数を使う」方が有利。
    • SQL 生成のようなホットパスでは、標準 Enumerable メソッドの抽象化を少し崩してでも、JIT に寄せたループ構造にする価値があるケース。

#59479 Keep the optimized URL helpers with default URL options that don't shape the path ​

マージ日: 2026/10/31 | 作成者: @linyiru

  1. 概要 (1-2文で)
    routes.default_url_options や default_url_options に host などの「パスに影響しないオプション」だけが設定されている場合でも、パフォーマンスの良い「最適化された URL ヘルパー」を使い続けられるようにする変更です。これにより、特にシリアライザや ActivityPub などリクエスト外で URL を大量生成するケースで、オブジェクト数・処理時間の削減が見込まれます。

  1. 変更内容の詳細

これまでの挙動 ​

  • routes.default_url_options(ルートセット全体のデフォルト)や
    コントローラ/メイラーの default_url_options(レシーバ単位のデフォルト)に 何か1つでも オプションがあると、

    • post_path(1) や post_url(1) といった「最適化された URL ヘルパー」が無効になり、
    • 常に汎用ルート解決経路(RouteSet#url_for がフルに動く「general path」)が使われていました。
  • これは、host しか設定していなくても同様で、
    たとえばシリアライザ/メイラーでよくやる次のような設定だけでも最適化ヘルパが無効化されていました:

    ruby
    Rails.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 経由のオプション は依然として「最適化ヘルパーを無効にする要因」のままでした。

今回の変更のポイント ​

  1. 「パスに影響しない 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" }
  2. ルートセット側の default_url_options のマージ順を、RouteSet#url_for に合わせる

    • RouteSet#url_for は、最後に routes.default_url_options をマージする挙動になっています。
    • 最適化されたヘルパーで default_url_options を扱う際にも、
      この挙動と 同じマージ順 になるように変更されています。
    • これにより、同じ入力(パラメータ・default_url_options)に対して
      「最適化ヘルパー」と「general path(url_for 経由)」が 同じ URL あるいは同じエラー を返すことが保証されます。
  3. 「そもそも最適化できないルート」は、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 と挙動がずれないようにしています。
  4. パフォーマンス計測結果(Ruby 3.4)

    予約済みオプションだけを default_url_options に設定している場合の改善例:

    呼び出しオブジェクト数 (before → after)時間 (before → after)
    post_url(1) + routes.default_url_options[:host]27 → 117.41 → 2.88 µs
    post_path(1) + routes.default_url_options[:host]25 → 96.40 → 2.10 µs
    post_url(1) + def default_url_options = { host:, protocol: }27 → 127.89 → 3.20 µs

    いずれも 2〜3 倍近い高速化と、オブジェクト数の半減に近い効果が出ています。

  5. 実アプリ(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 回の交互実行で同一値):

    Endpointmainこのブランチ削減率
    Home timeline (20 statuses)120,721113,401−6.1%
    Public timeline74,64870,028−6.2%
    Account statuses93,30987,609−6.1%
    ActivityPub outbox page62,90057,731−8.2%
    Status11,26111,030−2.0%

    なお、これは #59474(requirements 付きルートを最適化可能にした変更)の効果も含みます。
    Mastodon の ActivityPub ルートは requirements を持ち、従来は default_url_options があると最適化を妨げていました。

  6. テスト

  • 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 のせいで誤って最適化されないこと。

  1. 影響範囲・注意点
  • パフォーマンス面

    • host や protocol など、パスに影響しない default_url_options を多用しているアプリ(API サーバー、マイクロブログ系、SNS など)で特に恩恵が大きいです。
    • URL ヘルパーを大量に呼ぶ箇所(シリアライザ、バックグラウンドジョブ、ActivityPub など)でオブジェクト数・CPU 時間が目に見えて減る可能性があります。
  • 互換性・挙動

    • 最適化ヘルパーと general path で URL/エラーの結果が一致するよう厳密にテストされており、
      正常系・異常系ともに挙動変更は原則ありません(内部実装の差し替えに近い)。
    • ただし、もしアプリ側が
      • 「わざと general path にフォールバックさせていた」
      • かつ「default_url_options に予約済みオプションだけを載せていた」 ような極端なケースでは、今回からフォールバックしなくなりパフォーマンスが向上しますが、 その挙動変化を前提にしていたコードがあると影響を受ける可能性はあります(通常は想定されないパターンです)。
  • default_url_options の設計方針への示唆

    • 「パスを変える要素(locale, format など)」「クエリを変える要素(params, page など)」と、 「ホストやプロトコルなどの URL メタ情報」を default_url_options で明確に分けて管理すると、 今回の最適化の恩恵を最大限に受けられます。
    • default_url_options に「クエリパラメータ相当」を足していくと、
      今回の最適化条件から外れてしまい general path に戻る点に注意してください。

  1. 参考情報 (あれば)

#59471 Reduce allocations of polymorphic URL helpers with arrays and actions ​

マージ日: 2026/10/31 | 作成者: @linyiru

  1. 概要 (1-2文で)
    polymorphic_path / polymorphic_url 系ヘルパーを「配列引数」や「アクション付き」で呼び出したときのオブジェクト生成数を減らし、パフォーマンスを改善する変更です。動作(生成されるURLやヘルパー名)は一切変えず、内部実装だけを最適化しています。

  1. 変更内容の詳細

対象となるヘルパー ​

  • polymorphic_path, polymorphic_url
  • edit_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])1714
polymorphic_path([user, Comment])1613
polymorphic_path([:edit, post])1713
polymorphic_path([:edit, user, comment])1814
edit_polymorphic_path(post)118
polymorphic_path(post) / user_comment_path(user, comment)変化なし変化なし

特に「ネストされたリソース + 配列」「アクション付き」のパターンで 3〜5 オブジェクト程度の削減が見られます。


  1. 影響範囲・注意点
  • 出力結果は完全に互換

    • 生成されるURL、named route helper の名前ともに、変更前と同一です。
    • テスト (polymorphic_routes_test.rb) もその前提で通っています。
  • 主な影響範囲

    • Action Pack の ActionDispatch::Routing::PolymorphicRoutes
    • それを利用する Action View の URL/フォームヘルパー (link_to, form_with, form_for 等) が内部的に高速化されます。
    • 既存のアプリケーションコードやルーティング DSL の書き方は一切変更不要です。
  • パフォーマンス的なインパクト

    • 単発呼び出しでは数オブジェクトの差ですが、ビュー内で多用される(特にネストされたリソースを大量に表示する)ケースでは GC 負荷の減少が期待できます。
    • resources を多用し、form_with(model: [parent, child]) を頻出させているようなアプリほど恩恵が大きいです。
  • 互換性上の懸念

    • public API の挙動は変えていないため、通常の利用では互換性問題は起きにくいです。
    • もし polymorphic_path の内部実装に依存した monkey patch を書いている場合は、内部の配列/ハッシュの扱いが一部変わっている点に注意が必要です。

  1. 参考情報 (あれば)
  • 変更ファイル:

    • 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. 概要 (1-2文で)
    コントローラの action_methods を計算する際に、全ての public インスタンスメソッドを一度 String 化してから Set に入れていた処理を見直し、Symbol のままルートヘルパー名を差し引くことで、大規模アプリ(例: GitLab)のブート時間とオブジェクトアロケーションを大幅に削減するパフォーマンス改善です。挙動は従来と同一になるように保ちつつ、内部実装とキャッシュ戦略を最適化しています。

  1. 変更内容の詳細

背景 / 問題点 ​

  • AbstractController::Base#action_methods は、各コントローラごとの「アクションとして扱うメソッド名の Set」を計算します。
  • 現状は:
    1. コントローラの「全ての public インスタンスメソッド」を列挙
    2. それらをすべて String に変換
    3. さらに UrlFor レイヤで「ルートヘルパーメソッド名の集合」を String として取り出し、それを差し引き
    4. 残ったものを Set として action_methods にする
  • 多くのコントローラで url_helpers を include しているため、public メソッドの大半が「ルートヘルパー」であり、それらを毎回 String 化しては Set 演算、という処理が非常に重くなっていました。
    • GitLab 例
      • コントローラ 684 個
      • 各コントローラの public インスタンスメソッド約 8800 個
        • うち約 8100 個がルートヘルパー (ルート数 3300 に由来)
      • action_methods 計算にだけ 5.4 秒 (全体ブート 33 秒のうち) 消費
      • 結果的な action_methods Set 自体はごく少数の「本当のアクションメソッド」しか含まないのに、そこに至るまでの計算コストが高かった。

変更のアイデア ​

  • 「ルートヘルパー名を差し引く」ところまでは、Symbol のまま処理する。
  • 最終的に「コントローラの action 名」として公開するタイミングでだけ String に変換して Set を作る。
  • 併せて、「ルートヘルパー名のリスト (helper_names)」も、Symbol 版をメモ化して再利用する。

実装上の変更点 ​

1. AbstractController::Base#action_methods の分割 ​

従来:

ruby
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

新しい構造 (概要):

ruby
# 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 相当) をオーバーライドするように変更。
    • すなわち:
      1. ベース実装で「コントローラの public インスタンスメソッド (Symbol 群)」を取得
      2. UrlFor で「Symbol のままルートヘルパー名の集合を差し引く」
      3. 残った 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 を返すなどの内部実装に留まる)。

疑似コードイメージ:

ruby
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
end

4. 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つたりとも差分がない」ことを検証済みです。


  1. 影響範囲・注意点
  • 公開 API / 挙動:
    • AbstractController::Base#action_methods の返り値 (内容・型) は従来と同一を維持する方針です。
    • UrlFor を include しているコントローラの action_methods も結果は変わらない想定です。
    • helper_names もインターフェイスは変更なし。
  • 影響がありうるケース:
    • Rails 内部の非公開フック (:nodoc: で追加された Symbol リスト返却フック) を、独自に monkey patch したり rely するようなコードがあると影響を受ける可能性があります。
    • UrlFor や AbstractController::Base に対して、action_methods 自体を再定義・上書きするようなメタプログラミングをしている場合は、内部構造の変更と競合しうるため、アップデート時に挙動を確認してください。
  • 性能的な期待:
    • ルート数 × コントローラ数が多い大規模モノリスほど恩恵が大きいです。
    • 開発環境のブート時間や Spring のプリロード時間、CI の起動時間短縮などにも寄与します。
  • 互換性:
    • CHANGELOG にもある通り、ユーザランドから見える範囲での互換性は保たれており、「パフォーマンス改善のみ」を意図した変更です。

  1. 参考情報 (あれば)
  • 該当 PR: https://github.com/rails/rails/pull/59464
  • 関連クラス:
    • AbstractController::Base#action_methods
    • AbstractController::UrlFor
    • ActionDispatch::Routing::RouteSet
  • 類似の最適化パターンとして、「Symbol のまま集合演算し、最終出力フェーズでだけ String 化する」「ルート定義が変わらない限りヘルパー名をメモ化する」という設計は、大規模 Rails アプリで頻出するパフォーマンスチューニングの基本パターンと言えます。

#59468 Autoload Arel ​

マージ日: 2026/10/31 | 作成者: @linyiru

  1. 概要 (1-2文で)
    Active Record が require "arel" していたのをやめて、Arel をオートロードに変更し、「実際に必要になったタイミングでのみ Arel を読み込む」ようにした PR です。これにより開発環境でのブート時間短縮と読み込まれるファイル・オブジェクト数の削減を狙っています。

  1. 変更内容の詳細

何をしているか ​

  • これまで:

    • 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 した直後にはまだ読み込まれておらず、必要になったときに読み込まれる」構造になる。

挙動の互換性 ​

  • 説明中の例:
    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 にテストが追加されています。内容は概ね次の検証です:

  1. 開発モードでアプリケーションをブートした直後は、Arel がまだオートロード対象として残っていること。
  2. 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 部分を担当する変更です。


  1. 影響範囲・注意点

影響範囲 ​

  • 主に影響するのは開発環境でのブートシーケンスです。
    • bin/rails 系コマンド(server, console, runner, test など)がアプリケーションをロードする際に、不要なプロセスでは Arel を読み込まなくなります。
  • 本番環境(config.eager_load = true)では、イager ロード時に結局 Arel 一式がロードされるため、本番のブート挙動・性能は実質的に変わりません。

既存コードへの影響 ​

通常の Rails/Active Record 利用パターンではほぼ影響はありませんが、以下のような特殊なケースでは意識しておくとよいです:

  1. Active Record を require しているが、ActiveRecord::Base をロードしない構成で、かつ Arel を直接使うコード:
    • 例: require "active_record" だけ行い、ActiveRecord::Base を一切触らずに Arel::Table などを直接使うカスタムコード。
    • この場合でも、この PR の説明によれば Arel::Table.new(name: "users") は従来通り動くよう考慮されていますが、「どのタイミングで Arel がロードされるか」を前提にした非常にトリッキーなコードを書いている場合は挙動が変わる可能性があります。
  2. Arel がオートロードかどうかに依存したメタプログラミングをしている場合:
    • たとえば ActiveSupport::Dependencies.autoload_paths や autoload?("Arel") の結果に依存するようなコードなど。
    • Rails 内部実装寄りのケースなので、通常のアプリ開発ではまず問題になりません。

デバッグ時の注意 ​

  • 開発環境で「起動直後に Arel 関連のコードがまだロードされていない」ことがあり得ます。
    何か定数解決問題が起きた場合は、ActiveRecord::Base のロードタイミングと Arel のオートロードタイミングを意識すると切り分けがしやすくなります。

  1. 参考情報 (あれば)
  • この 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. 概要 (1-2文で)
    このPRは、Railsアプリのブート時に常に読み込まれていたテストランナー関連(特に Prism と Rake::FileList)を、「実際にテストを実行する時だけ」読み込むように変更するものです。これにより、開発環境でのアプリ起動や各種 bin/rails コマンドの起動時間が数十ミリ秒改善されます。

  1. 変更内容の詳細

何が問題だったか ​

従来は、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 が拡張される瞬間まで遅延 させています。

イメージとしては:

ruby
# 以前: 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 などがロードされていないことを検証

これにより、「アプリブート時にテストランナーを読み込まない」という今回の変更が、将来の改修で誤って元に戻されないようにテストで担保しています。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • 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" する必要があります。

  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    Rails 8.1系において、ActionController::AllowBrowser が内部で使う useragent gem を「最初のリクエスト時」ではなく「コントローラ定義時(クラス評価時)」に読み込むようにしたバックポートです。挙動やAPIは変えずに、JITコンパイラへの悪影響を減らし、起動後の最初のリクエスト時のオーバーヘッドを避けるためのパフォーマンス改善です。

  1. 変更内容の詳細

何が変わったか ​

元の変更 (#57974) を 8-1-stable にそのまま cherry-pick したもので、実質 2 行差分の小さな修正です。

これまでの 8.1 では:

  • allow_browser を使ったコントローラで
  • リクエストが初めて来たタイミングの before_action の中で
  • require "useragent" が呼ばれていた

という構造になっていました。

このPRでは:

  • allow_browser のクラスマクロを呼んだタイミングで
  • require "useragent" を実行するように変更しています。

擬似コードでいうと、イメージは以下のような変化です(実際のコードは2行変更のみですが、概念的な違いを示します):

変更前(イメージ)

ruby
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

変更後(イメージ)

ruby
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 する」ことによる互換性問題は基本的に発生しません。

  1. 影響範囲・注意点

振る舞い面での影響 ​

  • 公開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" が発生するタイミングが変わる可能性があります。
    • ただし、現実的にはほぼ想定されないケースです。
  • テスト:
    • actionpack/test/controller/allow_browser_test.rb はこのブランチでも通過することが確認されています。
    • 実際に allow_browser versions: :modern を持つコントローラを定義すると、
      • 変更前: 最初のリクエスト時に $LOADED_FEATURES に useragent が追加
      • 変更後: コントローラ定義時点で $LOADED_FEATURES に追加 が確認されています。

  1. 参考情報 (あれば)

この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. 概要 (1-2文で)
    Rails の URL ヘルパー最適化で導入された挙動により、「必須フォーマット付きのルート + trailing_slash: true」のときに、拡張子の後ろに余計なスラッシュ (/projects/1.json/) が付いてしまう不具合を修正する PRです。一般ルート生成ロジックと同様に、フォーマットが必須セグメントの場合は末尾スラッシュを付けないよう、最適化ヘルパー側の条件分岐が調整されています。

  1. 変更内容の詳細

問題の背景 ​

以下のようなルーティングを考えます:

ruby
# config/routes.rb
get "/projects/:id.:format", as: :project

このとき、trailing_slash: true を指定して URL ヘルパーを呼ぶと:

ruby
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 を無視し、末尾スラッシュを追加しないように条件を変更しています。

擬似コード的には、以下のようなイメージです(実際のコードはもう少し低レベルですが、意味としてはこうなります):

ruby
# 変更前 (イメージ)
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 によって一致するように修正しています。


  1. 影響範囲・注意点
  • 対象:
    • :format をルートの必須パーツに含めているルート(例: get "/projects/:id.:format")
    • かつ、*_path / *_url ヘルパー呼び出しで trailing_slash: true を使っているケース
  • 影響内容:
    • これまで (まだ未リリースの main のみ) "/projects/1.json/" のように「拡張子の後に / が付いていた」最適化ルートの挙動が、"/projects/1.json" に戻ります。
    • 一般的な URL 仕様としても、拡張子の直後に意味のないスラッシュが入るのは不自然であり、既存の安定版 Rails の挙動とも整合します。
  • 後方互換性:
    • バグ修正であり、リリース済みバージョンにはまだ入っていない挙動を正すものなので、実運用中アプリへの互換性影響は基本的にありません。
    • もし main ベースで "/projects/1.json/" に依存するようなコードや外部サービス連携を組んでいた場合は、URL が "/projects/1.json" に変わる点に注意が必要ですが、そのような使い方は通常想定されません。

  1. 参考情報 (あれば)
  • 関連 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. 概要 (1-2文で)
    constraints などの「セグメントに対する正規表現制約」を持つルートでも、URLヘルパーの高速な最適化パス (OptimizedUrlHelper) を使えるようにしたパフォーマンス改善のPRです。制約違反時のエラー挙動や、オプション付き URL 生成の挙動は既存の一般パスと揃えたまま、オブジェクト生成数と実行時間を大きく削減しています。

  1. 変更内容の詳細

何が問題だったか ​

Rails のルーティングでは、以下のように constraints でセグメントを正規表現制約するのがよくあります:

ruby
resources :posts, constraints: { id: /\d+/ }
get "/@:username/:id",
    to: "statuses#show",
    as: :short_account_status,
    constraints: { username: %r{[^@/.]+}, id: /\d+/ }

ところが、従来の UrlHelper.optimize_helper? は

ruby
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)なので、挙動が従来と変わらないように注意されています。

疑似コード的にはイメージとして:

ruby
def optimized_helper(...)
  if all_required_segments_match_requirements?(args)
    # 早いパス(OptimizedUrlHelper)
    build_url_fast(...)
  else
    # 従来の一般パスにフォールバック
    build_url_general(...)
  end
end

2. オプションセグメントの扱い ​

  • 一般パス側は、「フォーマット以外のオプションセグメント」は 現在のリクエストから値を埋める 仕様があります。
  • 一方、OptimizedUrlHelper は「リクエスト由来でオプションセグメントを埋める」ことはしません。

そのため、この PR では次のポリシーが取られています:

  • フォーマット (.:format) 以外のオプションセグメントを持つルート は
    引き続き 最適化しない(一般パスのまま)

  • 例:

    ruby
    scope "(:locale)", locale: /en|es/ do
      resources :posts
    end

    ここでは (:locale) がオプションセグメント & 制約付きなので、このルートは最適化対象外。
    テスト TestOptionalScopesWithOrWithoutParams でそのケースをカバーしています。

逆に言うと、

  • 制約付きだけど
  • 「フォーマット以外のオプションセグメントを持たないルート」

に限って OptimizedUrlHelper が適用されます。

3. パフォーマンス改善の具体的な数字 ​

Ruby 3.4 での 1 コールあたりのオブジェクト数と時間:

呼び出し例オブジェクト数 (before → after)時間 (before → after)
post_path(1)25 → 105.97 → 2.18 µs
post_url(1, host: "example.com")28 → 137.15 → 3.40 µs
short_account_status_url("alice", 1, host: "example.com")28 → 138.36 → 4.02 µs

1 回あたり数 µs・十数オブジェクトの差ですが、タイムライン表示など URL ヘルパーの多用箇所ではトータルで効く改善になります。

4. テスト追加内容 ​

新しいテストでは、「最適化パス」と「一般パス」が

  • 同じ URL を生成するか
  • 同じエラーメッセージを投げるか

を多様な引数パターンで比較しています。カバーしている引数:

  • 制約に マッチする値
  • マッチしない値
  • nil
  • 空文字
  • 末尾に改行を含む値
  • to_param を実装したオブジェクト

さらに、それぞれについて:

  • オプション無し
  • format オプション
  • anchor(フラグメント)オプション
  • クエリパラメータ付き
  • host オプション付き

を組み合わせてテストし、挙動が完全に一致することを検証しています。
また、制約付きかつオプションセグメントを持つルート((:locale) など)で、「リクエストからオプションが埋まる」従来挙動も維持されていることを確認しています。

著者は

  • 「制約チェックを外す」とテストが落ちる
  • 「オプションセグメントを持つルートも最適化する」とテストが落ちる

ことも確認済みと明記しています。


  1. 影響範囲・注意点
  • 対象:
    • resources ... constraints: {...} のような 制約付きルートの URL ヘルパー 全般
    • とくに、ID や username に対する正規表現制約を多用するアプリ(Mastodon など)では効果が大きい
  • 互換性:
    • 正規表現の適用方法は従来と同じ(アンカー付き)で、
      「引数が制約に合わないときに発生するエラー」は、メッセージ含めて以前と同じになるよう調整されています。
    • (:locale) など フォーマット以外のオプションセグメントを持つルートは最適化しない ので、
      その種のルートの挙動は完全に従来通りです。
  • アプリ側の対応:
    • 通常は何も変更不要で、そのままパフォーマンス向上の恩恵を受けられます。
    • もし以前から URL 生成時の例外メッセージに依存したコードがあっても、
      テストで「一般パスと同じメッセージ」であることが保証されています。
    • 自作のルーティング拡張(エッジなメタプロ / monkey patch)をしている場合は、
      UrlHelper.optimize_helper? 周りの挙動変化の影響がないかは一応確認した方が安全です。

  1. 参考情報 (あれば)
  • 変更ファイル:

    • actionpack/lib/action_dispatch/routing/route_set.rb
      • UrlHelper / OptimizedUrlHelper と、どのルートを最適化対象にするかのロジックが変更
    • actionpack/test/dispatch/routing_test.rb
      • 制約付きルート + 最適化ヘルパーの比較テストが多数追加
  • ベンチマークコード(PR 記載):

    ruby
    Routes = 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. 概要 (1-2文で)
    allow_browser が内部で利用する BrowserBlocker において、UserAgent から「ブラウザ名」と「バージョン」を何度も再計算していた処理を一度だけにまとめることで、各リクエストあたりのオブジェクト生成数と処理時間を削減するパフォーマンス改善です。Rails が新規生成したアプリのデフォルト設定(allow_browser versions: :modern)で、UA パースのオーバーヘッドを軽減します。

  1. 変更内容の詳細

背景となる問題 ​

  • Rails の新規アプリケーションでは、生成される ApplicationController にデフォルトで以下のようなコードが入る想定です:

    ruby
    class ApplicationController < ActionController::Base
      allow_browser versions: :modern
    end
  • allow_browser はリクエストごとに UserAgent をパースしてブラウザやバージョンを判定しますが、その内部で使われる UserAgent オブジェクトが、

    • version を読むたびにバージョン解析をやり直す
    • browser を読むたびにブラウザ判定をやり直す
      という実装になっており、BrowserBlocker 側でも
    • version を 2 回
    • browser を 3 回
      読み出していました。
  • その結果、1 回の allow_browser チェックで Webkit#application が 27 回呼ばれるなど、不要なスキャンが大量に走っていました。

この PR の変更点(ロジック面) ​

BrowserBlocker 内で、UserAgent からブラウザ名とバージョンを「一度だけ」取得し、それをローカル変数にキャッシュしてから後続の判定に使うように変更しています。

概念的には、以下のようなイメージです(擬似コード):

変更前(イメージ)

ruby
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

変更後(イメージ)

ruby
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 ブランチの時点では

  • version 2 回
  • browser 3 回
    呼ばれてしまうため失敗し、この PR の変更により通るようになります。

また、allow_browser 関連のテストは Rails CI と同じく warnings as errors 設定で実行されており、Action Pack 全体のテストスイートも問題なくパスすることが確認されています。


  1. 影響範囲・注意点

パフォーマンスへの影響 ​

計測環境: Ruby 3.4.12, Debian 13, Ryzen 7 8745HS

allow_browser による 1 回のブラウザチェックあたりのオブジェクト数と時間:

ブラウザmainこのブランチ
Safari 18 (macOS)300 objects, 36.7 µs240 objects, 28.2 µs
Chrome 130 (Windows)273 objects, 31.4 µs221 objects, 27.3 µs
Firefox 132 (Linux)281 objects, 30.4 µs206 objects, 24.6 µs
Safari 18 (iPhone)315 objects, 43.0 µs253 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 をアプリ側で直接利用しているコードには影響しません。

  1. 参考情報 (あれば)
  • 変更ファイル:

    • actionpack/lib/action_controller/metal/allow_browser.rb
      • BrowserBlocker 内で user_agent.browser / user_agent.version をローカル変数にキャッシュする変更。
    • actionpack/test/controller/allow_browser_test.rb
      • browser / version の参照回数を検証するテストの追加。
  • 実運用上のインパクト:

    • 新規アプリのデフォルト設定で、全リクエストが allow_browser を通過するため、アプリ規模が小さいほどこの最適化のメリットが相対的に大きくなります(他処理が重くなってくると相対的な比率は下がるが、それでも無駄な UA パースを減らせる利点は残る)。
    • レイテンシや GC 負荷を少しでも下げたい API サーバなどでも地味に効くタイプの最適化です。

#59478 ActiveSupport::Duration: precompute float versions of constants ​

マージ日: 2026/10/31 | 作成者: @byroot

  1. 概要 (1-2文で)
    ActiveSupport::Duration が内部で使っている時間定数(1分=60秒、1時間=3600秒、1日=86400秒など)について、「float 版」をあらかじめ計算しておくことで、to_f 呼び出しを減らし、Duration 周りの浮動小数演算を高速化するパフォーマンス改善の PR です。元 PR #59266 の修正として入り、同等の計算結果を保ったまま割り算や fdiv のコストを下げています。

  1. 変更内容の詳細

何をしたか ​

  • 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_f
    • n.fdiv(SOME_INT)

    のようなパターンが、

    • n / SOME_FLOAT
    • n.fdiv(SOME_FLOAT)

    に置き換わる(あるいは Ruby がより最適化しやすい形になる)ように調整されています。

なぜそれで速くなるのか ​

PR 内のベンチマークは「整数定数を都度 to_f する場合」と「最初から float 定数を使う場合」を比較しています。

ベンチマークコードの本質は次の通りです:

ruby
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 の狙いです。


  1. 影響範囲・注意点

影響範囲 ​

  • 影響を受けるのは、ActiveSupport::Duration が内部で秒換算などを行う箇所です。
    • 例:
      • 5.minutes.to_f, 1.hour.to_f, 1.day.to_f
      • 2.days / 3 のような計算で内部的に行われる秒換算
      • duration.in_hours, duration.in_days などの内部実装
  • API の挙動(戻り値の型や見た目)は変わらない想定です。
    • 元々 float を返していたところは引き続き float を返し、計算結果の「人間が見て分かるレベル」の値も変わらないように設計されています。

注意点(浮動小数の誤差について) ​

  • この PR は「整数 → float への変換タイミング」を変えているだけで、整数演算を float 演算に置き換えるような大胆な変更ではありませんが、
    • 既存でも float を使っていた箇所について、「to_f の場所」が変わるため、strict equality (== でのビットレベル同一期待) をしているような 極端に厳しいテスト がある場合は、誤差によりごく小さな差が出る可能性があります。
  • 通常の Rails アプリで、Duration#to_f や in_hours などの結果をそのまま UI 表示やおおまかな計算に使っている範囲では問題にならないはずです。

パフォーマンス的なインパクト ​

  • 変更は activesupport の 1 ファイル・十数行レベルの軽微なもので、互換性を崩さず局所的に高速化しています。
  • Duration の計算が非常に頻繁に走るようなアプリケーション(大量の時間計算・スケジューリング・ログ集計など)では、微小ですが GC や CPU 使用率の観点でプラスに働く可能性があります。

  1. 参考情報 (あれば)
  • 元 issue / PR
  • Ruby の数値演算最適化の文脈
    • Ruby 3.2 以降、fdiv や除算の最適化、YJIT による数値演算高速化が進んでおり、「整数 to float 変換の回数を減らしておく」ことはベンチマークでも度々言及されています。
  • PR で使われたベンチマークパターンは、自身のアプリで「to_f の位置を変えたらどの程度影響が出るか」を見る際の参考にもなります。

#59294 Forward arguments from HashWithIndifferentAccess#merge to update ​

マージ日: 2026/10/31 | 作成者: @sampokuokkanen

  1. 概要 (1-2文で)
    ActiveSupport::HashWithIndifferentAccess#merge が、可変長引数の受け渡し方法を従来の「配列にまとめてから splat」でなく、Ruby 3.4 以降で効率的に動作する「... による引数転送」に書き換えられました。これにより一部 Ruby バージョンで割り当て(allocation)削減とわずかな性能改善が見込まれます。

  1. 変更内容の詳細

何を変えたか ​

対象:activesupport/lib/active_support/hash_with_indifferent_access.rb

HashWithIndifferentAccess#merge メソッド内で update を呼び出す部分の引数の渡し方のみが変更されています。

もともとの典型的なイメージは次のような形です(擬似コード):

ruby
def merge(*args, &block)
  dup.update(*args, &block)
end

これを「引数転送演算子 ...」を用いた形に変更しています:

ruby
def merge(...)
  dup.update(...)
end

※ 実際のコードは多少文脈が異なるかもしれませんが、変更の本質はこの「*args + *args での再スプラット」から「... でそのまま転送」にする点です。

なぜ変えたか ​

従来の実装では:

  1. merge が *args で引数を一度配列にまとめる
  2. update(*args) で再度それをスプラットし
  3. 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 では僅かながら高速化が見られます。


  1. 影響範囲・注意点

挙動の互換性 ​

  • 公開 API(HashWithIndifferentAccess#merge の振る舞い)は変更されていません。
    • 受け取れる引数の種類・個数
    • ブロックの扱い
    • 戻り値 いずれもこれまでと同じです。
  • そのため、通常のアプリケーションコードから見たときの互換性リスクは極めて低いです。

パフォーマンス面 ​

  • Ruby 3.4 以降:
    • HashWithIndifferentAccess#merge に対するアロケーションが 1 つ減る想定。
    • 大量に merge を呼び出す処理(例:パラメータのマージを繰り返す API 層など)では、微小ながら GC プレッシャーの削減・処理時間の改善が期待できます。
  • Ruby 3.3 系:
    • アロケーション数は変わらず、性能差もごく小さい(計測上は誤差レベルの改善)。
  • YJIT 利用時:
    • 現状の YJIT は ... のフォワーディングコールをコンパイルしていないため、HashWithIndifferentAccess#merge に対して統計上は send_forwarding のフォールバックカウントが増えます。
    • 説明では 3% 程度の遅さが観測されていますが、これは非常にマイクロなベンチマーク環境での数字であり、実アプリケーションでは他の要因に埋もれる可能性が高いです。
    • 将来の Ruby / YJIT 側の最適化で改善される見込みがあります。

その他の注意点 ​

  • 変更ファイルは 1 ファイル・4 行(+2/-2)のみであり、テストスイート(Active Support)は通過済みです。
  • バグフィックスや表向きの挙動変更ではないため、CHANGELOG の更新は行われていません。

  1. 参考情報 (あれば)
  • PR 本体: https://github.com/rails/rails/pull/59294
  • 関連トピック:
    • Ruby の引数転送 (def method(...); other(...); end) に関する最適化は Ruby 3.4 以降で導入・強化中。
    • 同作者による類似の最適化 PR(特に #59293)も、HashWithIndifferentAccess 周辺や Active Support の内部での引数転送パターンの最適化を目的としていると考えられます。

#59320 Ignore blank forwarded hosts during request host extraction ​

マージ日: 2026/10/31 | 作成者: @svyatov

  1. 概要 (1-2文で)
    Rails の ActionDispatch::Request#host が X-Forwarded-Host: , のような「空のフォワードホスト」ヘッダで NoMethodError を起こしていた不具合を修正し、空の値は無視して HTTP_HOST などの従来ロジックにフォールバックするようにした PR です。これにより、HostAuthorization を含むリクエスト処理が壊れにくくなり、非標準的・壊れたプロキシヘッダにもより強くなります。

  1. 変更内容の詳細

問題の背景 ​

現在の(PR 適用前の)ActionDispatch::Request#raw_host_with_port は概ね以下のような動きをしていました(PR 説明の「OriginalRequest」と同等):

ruby
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 などにフォールバックする」というチェックを追加しています。

おおまかな挙動は以下のようになります(擬似コードレベルの説明):

ruby
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
end

PR 説明中の具体例:

text
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 でも許可ホストなら問題なく通過する
      • 禁止ホストなら従来どおりブロックされる といったケースを確認

いずれも「元々の host 認可ロジックは変えないが、空の Forwarded Host のせいで例外だけ出る」という状況を防ぐための回帰テストです。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • ActionDispatch::Request#host / #raw_host_with_port
    • X-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 系ヘッダを扱うコードはこの挙動に合わせて整理しておくとよいです。

セキュリティ的な観点 ​

  • この PR は「許可/禁止判定を変える」のではなく
    • 「Forwarded Host が壊れていても例外で落ちないようにする」ための堅牢性向上が主眼です。
  • HostAuthorization のロジック自体(どのホストを許可とみなすか)は変更されておらず、
    • 修正後も、Forwarded Host に有効な値が入っていれば従来どおりそれが優先されます。
  • 逆に言うと、「空または空白だけの X-Forwarded-Host でホスト偽装を狙う」といった攻撃ベクトルは
    • この修正により「エラーにはならないが、偽装にもならずオリジナルホストになる」という挙動になります。

  1. 参考情報 (あれば)
  • 関連 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. 概要 (1-2文で)
    ActiveSupport::Duration#iso8601 が、常に ISO 8601 準拠かつ ActiveSupport::Duration.parse で再パース可能なフォーマットになるように修正した PR です。浮動小数点数や Rational を含む duration でも、指数表記や分数表記ではなく「通常の 10 進小数」で出力されるようになります。

  1. 変更内容の詳細

問題点 ​

従来の ActiveSupport::Duration#iso8601 は内部パーツをそのまま to_s していたため、以下のような問題がありました。

  • Float は Float#to_s により指数表記になることがある
    • 0.0001 未満の値、非常に大きな値など
  • Rational は Rational#to_s により「分数」として出力される

結果として、ISO 8601 として不正な文字列が生成され、ActiveSupport::Duration.parse で読めないケースがありました。

ruby
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 に限定

修正後の挙動例 ​

ruby
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 秒 (混在ケース)
  • 各 iso8601 出力が ActiveSupport::Duration.parse でパースでき、元の duration と 等価であること を確認するようにテストを拡張

これらのテストは本 PR の変更なしでは失敗し、変更後は通ることが確認済みです。

また、テストスイート全体をある程度走らせた結果、main と同じ既知の失敗のみで、新規の失敗は増えていないことが報告されています (主にタイムゾーン関係のテスト)。

CHANGELOG ​

activesupport/CHANGELOG.md に今回の仕様変更が追記されています。
(バグ修正だが挙動が変わるため、利用者向けに通知されるべき変更として扱われている)


  1. 影響範囲・注意点
  • 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 相当の精度なので、以前と比較して「値そのものの精度」が落ちる変更ではなく、「表記の仕方」だけを変えています。

  1. 参考情報 (あれば)
  • 関連 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. 概要 (1-2文で)
    ActionController::Base の CSRF 対策用 after_action が Vary ヘッダに Sec-Fetch-Site を追加する処理について、Vary が空のケースでは文字列操作を行わず、直接 "Sec-Fetch-Site" をセットするようにしてオブジェクト生成と処理時間を削減するパフォーマンス改善のPRです。既に Vary が存在する場合の挙動は変えず、空の場合のみ最適化しています。

  1. 変更内容の詳細

背景 ​

  • 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 を重複しないように追加
    • 再びカンマ区切り文字列として結合 という既存ロジックを使い続けます。

コードイメージ(あくまで説明用の擬似コード):

ruby
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
end

ActionController::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 がある場合は、ほぼ同等で僅差。新旧どちらが速いかはケースにより分かれるが誤差レベル。

  1. 影響範囲・注意点
  • 機能面の変更は実質ゼロ

    • Vary が空: 以前も結果は "Sec-Fetch-Site" だけだったため、最終的なレスポンスヘッダは同じ。
    • Vary が存在する: 既存コードパスを通るため動作は変わらない。
    • そのため、アプリ側から見た挙動変更・互換性問題は基本的にありません。
  • パフォーマンスへの影響

    • ActionController::Base を継承する全コントローラに対し、CSRF 保護が有効なアクションでは毎回呼ばれるため、トラフィックの多いアプリ/API ほど恩恵があります。
    • 特に Vary を明示的に使っていないアプリでは、ほぼすべてのレスポンスでこの最適化が効きます。
  • テスト状況

    • Action Pack のテストスイート(システムテストを除く)がパスしているとのこと。
    • PR時点ではこの変更専用の追加テストは書かれておらず、「外部挙動は変えていない」という前提で既存テストに頼っている状態です。
  • 将来の拡張時の注意

    • 今後、Vary を操作する別のミドルウェア/フィルタを追加する場合も、このロジックは「Vary が空ならセット、そうでなければマージ」という形なので、挙動として自然です。
    • ただし、もしアプリ側で Vary を nil や空文字に明示的に書き換えるようなコードがある場合、その後にこのフィルタが走ると Sec-Fetch-Site だけが再セットされる、という点は頭に入れておくとよいです(これは以前から同様の結果になっていたはずです)。

  1. 参考情報 (あれば)
  • 類似実装: 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. 概要 (1-2文で)
    Active Record の非同期クエリ用セッション(AsynchronousQueriesTracker)が、リクエストやジョブごとに必ずフル構築されていたのを「実際に非同期クエリが使われたときにだけ本体を構築する」ように遅延初期化した PR です。これにより、非同期クエリを使わない大多数のケースでオブジェクト生成コストと実行時間が削減されます。

  1. 変更内容の詳細

背景 ​

  • 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. 影響範囲・注意点

パフォーマンスへの影響 ​

作者提供のベンチマークから:

  • 1ファイルの production アプリ (Ruby 4.0.7) での 1 リクエストあたりのオブジェクトアロケーション数:

    • ActionController::API JSON 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 + 非同期クエリの基本的な挙動は担保されています。

  1. 参考情報 (あれば)
  • 対象ファイル: 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. 概要 (1-2文で)
    Request#GET がクエリ文字列を持たないリクエストでも毎回クエリ解析処理を走らせていたのをやめ、クエリ文字列が nil または空文字のときは即座に空の params ハッシュを返すようにして、無駄なオブジェクト生成と処理時間を削減した PR です。機能的な挙動はほぼ変えず、パフォーマンスとメモリ効率を改善する目的の変更です。

  1. 変更内容の詳細

何が問題だったか ​

  • 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行のみの変更)。
  • 処理イメージとしては次のような形です(実際のコードとは若干擬似コードですが、意図としてはこんな感じです):
ruby
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リクエストあたりアロケーション:

    requestbeforeafter
    ActionController::API JSON show357354
    GET /posts.json1073.11070.1

    数値差は小さいですが、「クエリなしの大量アクセス」がある場合、積み上がりで効いてくるタイプの最適化です。

  • ParamBuilder.from_query_string 単体のベンチ(Ruby 4.1.0dev):

    • クエリが空文字 "" の場合は、Interpreter/YJIT/ZJIT いずれも「常に after が速い」(10/10 ラウンド)。
    • 通常のクエリ (page=2&q=rails) については性能はほぼ同等(ごくわずかな差で前後するレベル)で、空クエリ向け最適化によるデメリットはほぼなし。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象:
    • ActionDispatch::Request#GET(= Rack 環境からクエリパラメータを取得する処理)に依存するすべてのコントローラ・ミドルウェア。
    • ただし、クエリがないケースの挙動は「空の params ハッシュを返す」という点で以前と同じです。
  • 実質的な挙動変更:
    • ありません。返るオブジェクトの種類と性質(HashWithIndifferentAccess / 新規インスタンス / ミュータブル)は変わりません。
    • 違いは「内部的な無駄なパース処理が走らなくなった」ことだけです。

注意点 ​

  • 空クエリの場合に「パラメータパーサが必ず呼ばれる」ことに依存しているようなコードがあれば理論上は影響しますが、
    • そういった依存は本来内部実装に対する過度な依存であり、
    • 通常のアプリケーション・ライブラリではまず想定されません。
  • テスト:
    • Action Pack のテストスイートは(ブラウザドライバが必要な system test を除き)通過。
    • この PR 自身で新規テスト追加はしていませんが、「仕様変更」というより「最適化」であるため、既存テストの通過をもって安全性を確認しています。
  • CHANGELOG:
    • 性能改善・内部実装の最適化にとどまり、外部 API の仕様変更はないため、CHANGELOG は未更新です。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/59376
  • 関連クラス:
    • ActionDispatch::Request
    • ActionDispatch::Http::ParamBuilder
    • ActionDispatch::Request::QueryParser(クエリパースの内部実装)
  • 主なメリット:
    • クエリなしのリクエストが多数ある API サーバや SPA バックエンドなどで、1リクエストあたりのオブジェクトアロケーション数と CPU 時間をわずかに削減。
    • 内部実装が「入力に対して自然な挙動」(空入力には即座に空結果を返す)に近づき、理解しやすくなる。

#59381 Backport to 8-1 exif orientation bugfix ​

マージ日: 2026/10/31 | 作成者: @g-pavlik

  1. 概要 (1-2文で)
    Rails 8.1 ブランチに対して、Active Storage の画像解析処理における EXIF Orientation 関連のバグ修正(#57290)がバックポートされた PR です。ImageMagick・Vips 双方のアナライザで EXIF の回転情報を正しく扱えるようにし、その挙動をテストとフィクスチャで保証しています。

  1. 変更内容の詳細

主なポイント ​

  • 元々 main(または 8-2 以降)に入っていた EXIF Orientation のバグ修正 (#57290) を 8-1 ブランチに「チェリーピック」した PR。
  • 対象は Active Storage の画像アナライザ:
    • ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick
    • ActiveStorage::Analyzer::ImageAnalyzer::Vips
  • 回転・反転(mirror/flip)を含む EXIF Orientation を正しく解釈し、画像のメタ情報(幅・高さなど)が正しく取得されるようにする修正。

コードレベルの変更(推測を含む解説) ​

実際の差分は 1 行置き換えレベルですが、典型的には次のような修正が入ります(イメージ):

ruby
# 例: 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.jpg
  • racecar_mirrored_rotated.jpg

これらの画像に EXIF Orientation がセットされており、

  • mirrored: 左右反転のみ
  • mirrored_rotated: 左右反転+回転(たとえば 90° 回転)

のようなパターンをカバーすることで、以前バグが出ていたケースを再現・検証しています。

CHANGELOG ​

  • activestorage/CHANGELOG.md に、8.1 系でも EXIF Orientation バグが修正されたことを明示するエントリが追加されています。
    • ライブラリの振る舞い変更として扱われており、バージョンアップ時にこの修正が入ることが分かるようになっています。

  1. 影響範囲・注意点

影響範囲 ​

  • 対象: 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(未回転)と見なされる可能性があります。その場合はこの修正の効果は限定的です。

  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. 概要 (1-2文で)
    Active Storage の Vips 画像アナライザが画像の回転情報を誤認識する不具合(特に TIFF など EXIF 以外で orientation が保存される場合)を修正し、libvips の orientation フィールドを一貫して参照するようにした PR です。これにより、アナライザと変換(variant)パイプラインの挙動が揃い、回転メタデータの解釈が正しくなります。

  1. 変更内容の詳細

問題点 ​

従来の 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 を見に行くように統一された形です。

変更ファイルのポイント ​

  1. activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb

    • metadata の取得時に orientation を読むキーを exif-ifd0-Orientation から orientation に変更。
    • 実装イメージとしては、例えば以下のようなイメージの差分と考えられます(実際のコードは若干異なる可能性あり):
    ruby
    # 旧実装(イメージ)
    def rotation
      metadata["exif-ifd0-Orientation"]
    end
    
    # 新実装(イメージ)
    def rotation
      metadata["orientation"]
    end

    これにより、EXIF に限らず libvips が認識している orientation 情報を一貫して利用します。

  2. activestorage/test/analyzer/image_analyzer/vips_test.rb

    • 新しい挙動(libvips の orientation を読む)のためのテストケースを追加。
    • 特に TIFF ファイルなど EXIF ベースでない orientation を持つ画像について、正しく回転情報が検出されることを確認するテストが含まれています。
  3. activestorage/CHANGELOG.md

    • Active Storage の変更点として「Vips image analyzer が libvips の orientation フィールドを読むようになった」旨を追記。
    • バグ修正としてリリースノートに載る形の変更です。

  1. 影響範囲・注意点
  • 主な影響対象:

    • Active Storage で image_processing: vips を利用し、ActiveStorage::Analyzer::ImageAnalyzer::Vips を使っているアプリケーション
    • 特に、TIFF や EXIF 以外のメタデータ領域に orientation を持つ画像を扱うケース
  • 挙動の変化:

    • 以前は exif-ifd0-Orientation のみを見ていたため「回転なし」と判定されていた画像が、今回の変更後は「回転あり」と正しく検出されるようになります。
    • これに伴い、Active Storage の解析結果として得られるメタ情報(例: image.metadata[:rotation] 相当の値)や、その結果を前提とした UI / 処理ロジックが、過去と異なる結果を返す可能性があります。
    • 特に、これまで「バグを前提に」回転補正やトリミング位置を調整していたワークアラウンドがある場合は、挙動確認が必要です。
  • 安全性・後方互換性:

    • バグ修正としての変更であり、画像ファイルそのものを書き換えるような処理はありません。
    • 「正しい orientation 情報を得られるようになる」方向の変更なので、仕様としては自然であり、多くのアプリにとっては改善になる想定です。
    • ただし「過去に保存された解析済みメタデータ」をどこかにキャッシュしており、新しい解析結果と食い違う場合は、再解析 or キャッシュクリアの検討が必要になるケースがあります。

  1. 参考情報 (あれば)
  • libvips の orientation フィールドは、EXIF だけでなく TIFF 等のネイティブタグを含む、ライブラリ全体として解釈した回転情報を指します。
  • Active Storage では、variant パイプライン側がすでに orientation を基準にしていたため、今回の変更で「解析結果と実際の変換結果の不一致」が解消されます。
  • TIFF 画像などで「サーバ側では正しい向きなのに、アプリ側のメタ情報上は回転なしと見えていた」ような症状がある場合、この PR を含むバージョンで改善が期待できます。

#59383 ActiveStorage: Fix JSON::ParserError from ffprobe duplicate keys ​

マージ日: 2026/10/31 | 作成者: @jdelStrother

  1. 概要 (1-2文で)
    ActiveStorage が ffprobe の JSON 出力に重複キーが含まれる場合に JSON::ParserError を起こしていた問題を、JSON パース時に重複キーを許可することで修正した PRです。あわせて、JSON gem の最低バージョン引き上げと、ffprobe に依存するテストまわりの調整が行われています。

  1. 変更内容の詳細

2-1. ffprobe 出力の重複キーによる JSON::ParserError の回避 ​

問題の発端は、ffprobe -show_format -show_streams -print_format json などの出力に、以下のように同じキー(ここでは date)が複数回出てくるケースがあることです:

json
"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 を明示的に渡すようにして、重複キーを許容するように変更しています。

変更イメージ(実際のコードに準じた擬似コード):

ruby
# 変更前(概念的に)
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.rb
  • activestorage/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 する実装になっていた。
  • AudioAnalyzerTest:
    • ffprobe 非存在でも skip されず、テストが失敗する可能性があった。

この 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.wav
  • activestorage/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.rb
  • activestorage/test/previewer/video_previewer_test.rb

これらのテストファイルにも 1行ずつ変更が入っています(+1/-1)。
PR 説明から推測される範囲では、ffprobe や外部コマンド有無に応じた skip 条件や、出力抑制のための細かな調整がされたと思われます。


  1. 影響範囲・注意点

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 をこのコミット以降に更新すると、json gem の最低バージョンが 2.3 になります。
    • もしアプリ側で Gemfile に gem "json", "< 2.3" のようなピン留めをしている場合は、バージョン競合が発生します。
    • レガシーな理由で古い JSON を使っている環境では、アップグレードの検討が必要です(JSON 2.3 自体は 2019年リリースで比較的古いので、通常は問題になりにくいはずです)。

3-3. CI / 開発環境のテスト挙動 ​

  • ffprobe がインストールされていない環境では、Audio/Video Analyzer 関連テストが自動的に skip されるようになります。
    • これ自体はテストが不必要に壊れないというメリットがありますが、ffprobe 依存の機能を確実に検証したい場合は、CI イメージに ffprobe をインストールしておく必要があります。
  • command -v ffprobe の標準出力がテストログに出なくなります。ログを見て ffprobe の有無を目視していたようなケースがあるなら、挙動の変化に注意してください(通常は問題にならないはずです)。

  1. 参考情報 (あれば)

#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. 概要 (1-2文で)
    PostgreSQL の Arel visitor がバインドパラメータ用の文字列("$1", "$2" …)を毎回新規生成していたのをやめ、最大 256 個分をあらかじめ凍結したテーブルから再利用するようにした PR です。これによりオブジェクト生成数が大きく減り、特に IN 句で多数の ID を渡すクエリのコンパイル性能が最大 1.24 倍ほど向上します。

  1. 変更内容の詳細

何をしているか ​

  • 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_DIGITS in ActiveSupport::TimeZone で使っている「2 桁の文字列キャッシュ」と同じパターンの最適化。
    • arel/visitors がどの visitor を使う場合でもロードされるため、PostgreSQL を使わないアプリでも起動時にこのテーブルは一度作られる。

実際のコード変更範囲は小さく、postgresql.rb 内の BIND_BLOCK 実装が以下のようなイメージに変わっています(擬似コード):

ruby
# 変更前 (イメージ)
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 JITYJIT
    main12143.8k i/s411.0k i/s
    PR後8147.0k i/s (1.02x)473.7k i/s (1.15x)
  • IN 句で 50 ids:

    ケースオブジェクト数no JITYJIT
    main16438.1k i/s71.7k i/s
    PR後6441.6k i/s (1.09x)88.8k i/s (1.24x)
  • IN 句で 500 ids:

    ケースオブジェクト数no JITYJIT
    main1,5144,742 i/s8,702 i/s
    PR後1,0045,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 のどちらが良いか?」をレビューに問いかけています。


  1. 影響範囲・注意点

影響範囲:

  • 対象:
    • ActiveRecord + PostgreSQL を使う全アプリケーションの SQL 生成 (Arel visitor) に影響。
    • 特に where(id: array) のように 大量の bind を使うクエリを頻発するアプリ(多くの ID をまとめて検索するパターン)で効果が出やすい。
  • 直接的な仕様変更はなし:
    • 生成される SQL のプレースホルダ形式 ($1, $2, ...) は従来通りで、見た目も順序も同じ。
    • bind 数が 256 を超えるケースでも、257 以降は従来通りの生成方法にフォールバックするだけで、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)は発生します。

  1. 参考情報 (あれば)
  • 関連 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 など極端に大きい配列クエリでさらに効果大。
    • 大半のアプリでは IN 256 を超えるケースはそこまで多くないため、現時点では 256 が妥当なバランスという前提で PR が書かれています。

#59418 Accept Symbol values for the routing format: option ​

マージ日: 2026/10/31 | 作成者: @cestercian

  1. 概要 (1-2文で)
    このPRは、ルーティングの format: オプションに Symbol(例: format: :json)を渡した場合でも、String("json")と同様に正しく扱われるようにするバグ修正です。Rails 3.2 では動いていた挙動が Rails 4.0 で静かに壊れていた回帰を解消しています。

  1. 変更内容の詳細

何が問題だったか ​

以下のようなルーティング定義をしても:

ruby
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"(文字列)と書いた場合は、期待通りに:

ruby
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" を設定
  • これにより、params[:format] は他のデフォルト同様 String のまま(:json にはならない)

コードイメージとしては:

ruby
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 ルート ​

ruby
# 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 ​

ruby
scope format: :json do
  resources :users
end

# もしくは
resources :users, format: :json
  • GET /users
    • params[: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 にこの挙動修正が記載され、外部向けに明示されています

  1. 影響範囲・注意点
  • 影響範囲:
    • ルーティングで format: :json のように Symbol を使っている(または今後使う)場合にのみ影響します
    • これまで format: :json が「効いていなかった」アプリでは、PR適用後に挙動が変わります
      • 今まで /users.xml が通っていたルートが、通らなくなる可能性があります
  • 既存アプリでの注意点:
    • 回避のために既に format: "json" と書いている場合は、挙動は変わりません(現状維持)
    • 逆に、「format: :json が効いていない前提で、わざと /users.xml も通していた」ようなコードがある場合は、修正後にルーティングが変わるので確認が必要です
  • 型について:
    • params[:format] は常に String です(:json ではなく "json")。これまで format: "json" で得られていた挙動を壊さないように配慮されています。

  1. 参考情報 (あれば)
  • 対応 Issue: #59412(format: :json が効かないことに関する報告)
  • 回帰元コミット: Rails 4.0 頃に入った format 判定の String === format 変更
  • 類似オプションとの一貫性:
    • constraints: { format: :json }
    • controller: :users
    • action: :index
    • via: :get
      これらが元々 Symbol を受け付けていたのに対し、format: だけが受け付けない状態だったのを解消し、API の一貫性を取り戻す変更です。

#59477 Check if ::UserAgent is defined before requiring ​

マージ日: 2026/10/31 | 作成者: @byroot

  1. 概要 (1-2文で)
    このPRは、ActionController::Metal::AllowBrowser が ::UserAgent クラスを require する前に、その定義有無をチェックするように変更し、不要な require 呼び出しを避けてパフォーマンスを改善するものです。require が $LOADED_FEATURES を都度走査するコストを、既に定義済みのケースでは回避できるようにしています。

  1. 変更内容の詳細

変更ファイル:

  • actionpack/lib/action_controller/metal/allow_browser.rb (+1 / -1)

実質的には1行のロジック修正で、イメージとしては次のような変更です(※実際のコードを概ね再構成した例):

ruby
# 変更前(例)
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 するよりも高速に判定が可能です。

  1. 影響範囲・注意点

影響範囲:

  • actionpack の ActionController::Metal にある AllowBrowser 機能(おそらくブラウザ判定や User-Agent ベースの許可制御に関するモジュール)が UserAgent クラスに依存する部分のみ。
  • ::UserAgent が
    • すでにアプリ側や別のライブラリ側で定義済み → 今後は Rails 側では require "user_agent" を呼ばなくなる。
    • 未定義 → 従来どおり require "user_agent" が行われるため挙動は変わらない。

挙動面の注意:

  • 名前空間を変更しているわけではなく トップレベル ::UserAgent の有無で判断するため、以下のようなケースでも従来どおり require されます:
    ruby
    module MyApp
      class UserAgent
        # ...
      end
    end
    → この場合、defined?(::UserAgent) は nil なので require "user_agent" が実行されます。
  • 逆に、アプリ内で独自の ::UserAgent クラスを定義している場合:
    ruby
    class UserAgent
      # アプリ独自の実装
    end
    → Rails 側では require "user_agent" を行わず、その独自実装が利用され得ます。
    • すでにそういう構成で動かしているアプリでは、今回の変更により「意図通りに独自 ::UserAgent が使われる」ことがより確定的になる反面、「本来は gem の UserAgent を使う前提だったが、たまたま同名クラスを定義していた」という場合には潜在的に挙動が変わる可能性があります。
    • ただし、これは Ruby の定数解決ルールに沿った自然な結果であり、PRの目的である「無駄な require の回避」とトレードオフになる部分です。

パフォーマンス面:

  • アプリケーションの起動時やリクエスト処理中に何度も AllowBrowser がロード・参照されるような環境では、特に $LOADED_FEATURES が肥大化している場合に軽微ながらパフォーマンス改善が見込めます。
  • 変更はきわめて小さく、API 互換性への影響は限定的です。

  1. 参考情報 (あれば)

#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. 概要 (1-2文で)
    このPRは、ActiveRecord 8.1系 (8-1-stable) に対して、「joinable なトランザクション内では非同期クエリを同期的に実行する」よう挙動を修正するバックポートです。async_find_by_sql / async_count_by_sql や、非同期計算 (async_*) 系メソッドがトランザクション内で例外を起こさず安全に動くようにします。

  1. 変更内容の詳細

背景 ​

  • 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) を利用する形でバックポートされています。

イメージとしては次のような処理です(概念的な疑似コード):

ruby
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 トランザクション中かどうか」を判定して同期 / 非同期を切り替えるようにしています。

概念的には以下のような分岐です:

ruby
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_sql
    • async_count_by_sql
    • async_ids
    • 他の async 計算関連メソッド
  • 典型的には次のようなケースがカバーされます(イメージ):
ruby
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

これにより、今回の挙動変更が実際に動作していることが検証されています。


  1. 影響範囲・注意点
  • 対象バージョンは Rails 8.1 系 (8-1-stable) のみです(main 側の変更 #59223 のバックポート)。
  • アプリケーションコードから見える主な変化:
    • joinable なトランザクション内で async_* メソッド(async_find_by_sql, async_count_by_sql, async_ids など)を呼んだ場合、
      • 以前: 環境や組み合わせによっては例外や不整合が起こりうる
      • 今回以降: 例外を避けるため同期的に即時実行にフォールバックする
    • そのため「トランザクション内なのにバックグラウンドでクエリが走り続けている」といった危うい状態を避けられます。
  • パフォーマンス上の注意:
    • joinable トランザクション内で async_* を多用しても、実際には同期クエリとして実行されるため、期待した非同期並列実行による高速化は得られません。
    • 非同期化によるスループット向上を狙う場合は、トランザクション境界の外側で async_* を呼ぶ設計にする必要があります。
  • 互換性:
    • 公開APIのメソッドシグネチャは変わらず、挙動も「例外が減り、安全側に倒れる」方向の変更のため、後方互換性は高いと考えられます。
    • ただし「トランザクション内で非同期になること」を前提にした特殊な実装があれば、その前提は成り立たなくなる点には注意が必要です。

  1. 参考情報 (あれば)
  • 元PR: #59223 – 本PRのバックポート元
  • 差し戻し・やり直し PR: #59310 – 本PRはその「Redo」
  • 関連トピック:
    • Rails の非同期クエリ (load_async, Relation#async_*)
    • ActiveRecord のトランザクション管理と joinable トランザクション
    • コネクションプールとマルチスレッド実行時の制約

#58728 Read structure dump exclusions from the database being dumped ​

マージ日: 2026/10/31 | 作成者: @ngan

  1. 概要 (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 が反映されます。

  1. 変更内容の詳細

問題の背景 ​

従来の 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.rb
    • schema_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 への接続で行う。

疑似コードイメージ(概念):

ruby
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" が出てしまう場合にはテストが落ちるようになっている。

  1. 影響範囲・注意点
  • 影響を受ける主なケース
    • 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 を基準にする」方が正しいため、多くの場合はバグ修正としてのプラス影響のみです。
  • 接続/到達性に関する注意
    • MySQL / SQLite:
      • schema_ignored_tables が空(デフォルト)なら従来通り DB への新規接続は不要です。
      • schema_ignored_tables を設定している場合は、ダンプ対象 DB への接続が実際に張れる必要があります(テーブル一覧を取得するため)。
    • PostgreSQL:
      • もともと search_path の取得のために接続していたので「新しく到達性の要件が増える」ことはありませんが、 今回から「必ずダンプ対象 DB に対して接続を開く」ことになったため、
        テストや CI で「ダンプ対象 DB だけ意図的に到達不能にしている」ような状況があれば考慮が必要です。
  • リソースと副作用
    • with_temporary_pool / with_temporary_connection を使わずに スタンドアロンのコネクション を開いて即クローズする方式なので、
      • 呼び出し元の ActiveRecord::Base のコネクションプールには影響しません。
      • 特に SQLite では、「with_temporary_connection が DB ファイルを再作成してしまう」といった副作用を避けています。

  1. 参考情報 (あれば)
  • PR タイトル: Read structure dump exclusions from the database being dumped
  • 対応範囲:
    • SQL 形式の schema dump (schema_format = :sql) をサポートする 3 つのアダプタ(MySQL / PostgreSQL / SQLite)+ Trilogy における structure_dump。
  • 関連設定:
    • ActiveRecord::Base.schema_ignored_tables
      (ActiveRecord::SchemaDumper.ignore_tables と同義に扱われることが多い)
      正規表現/文字列/配列などで、ダンプ時に無視したいテーブルを指定するための設定。
  • 類似パターン:
    • Action Cable の PostgreSQL アダプタがサブスクリプション用に pool.new_connection + disconnect! を使っているのと同じパターンで、
      「グローバルな接続状態を汚さない一時的な接続」の作り方として Rails 内で既存実績のある手法に揃えています。

#59463 Bump trilogy to 2.13.2 and use ujis in test_character_set_connection_is_configured ​

マージ日: 2026/10/31 | 作成者: @yahonda

  1. 概要 (1-2文で)
    trilogy を 2.13.2 にバージョンアップし、その影響で落ちていた ConnectionTest#test_character_set_connection_is_configured を cp932 ではなく ujis を使うように修正した PRです。trilogy 2.13.2 で非サポートとなった文字コードをテストから排除し、脆弱性修正後の挙動に沿うようにしています。

  1. 変更内容の詳細

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.rb
ConnectionTest#test_character_set_connection_is_configured

以前は「接続の character_set が正しく反映されるか」を cp932 で検証していましたが、trilogy 2.13.2 では cp932 が非サポートになったため、テストが以下のようなエラーで失敗していました:

text
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):

ruby
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 が接続エンコーディングに変換して送信
  • 現在: アプリ側/テスト側で接続エンコーディングの文字列を渡す前提

にそろえる変更です。


  1. 影響範囲・注意点
  • ActiveRecord の MySQL 系アダプタ + trilogy 利用環境

    • trilogy 2.13.2 以降を使う場合、big5, sjis, gbk, cp932, gb18030 を接続エンコーディングとして前提にしたコードやテストは動作しません。
    • Rails 本体のテストは ujis に切り替えたため、nightly CI の MySQL(trilogy) テストは通るようになります。
  • アプリケーションコードへのインパクト

    • アプリ側で trilogy の encoding オプションに上記非サポートエンコーディングを指定している場合、Unsupported encoding エラーが発生するようになります。
    • trilogy 2.13.2 では「クエリを接続エンコーディングに自動トランスコードしない」ため、アプリケーションがマルチバイト文字を多用し、かつ接続エンコーディングを UTF-8 以外にしている場合は、手元の文字列エンコーディングとの整合性を要確認です。
      • 通常の Rails + MySQL では UTF-8(utf8mb4)利用が一般的なため、多くのアプリでは影響は限定的と考えられます。
  • セキュリティ面

    • この PR 自体はライブラリ(trilogy)の更新とテスト修正のみですが、取り込むことで GHSA-744p-w4gc-m3gh への対応版 trilogy を確実に使うことになります。
    • 「ASCII 領域を含みうるマルチバイトエンコーディング」を避ける設計方針にテストも追従した形です。

  1. 参考情報 (あれば)

#59458 Freeze tag strings before marking them HTML safe ​

マージ日: 2026/10/31 | 作成者: @linyiru

  1. 概要 (1-2文で)
    content_tag や tag.xxx などのタグヘルパーが内部で生成する文字列を、HTML セーフ化する前に freeze することで、不要な文字列オブジェクトの割り当てを 1 つ削減し、リンクやフォーム要素が多いページでのメモリ・パフォーマンスを改善する変更です。挙動や API は変わらず、SafeBuffer 自体は引き続き変更可能なままです。

  1. 変更内容の詳細

背景となる問題 ​

content_tag_string / self_closing_tag_string は、以下のようなおおよその処理をしています:

ruby
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 する」ように変更しています。

イメージとしては:

ruby
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_tag
  • tag.div
  • tag.input
  • tag(任意タグ)

いずれも、

ruby
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")54
content_tag(:div, "Hello", class: "card")54
tag.input(type: "hidden", name: "authenticity_token", value: "abc")54
tag.br(23byte以下で埋め込み可能)33
パーシャル内で 100 レコード分に link_to21482048

主に 23byte を超えるタグ(属性付きの多くのタグ)で 1 オブジェクト削減されており、
link_to, button_to, form helpers など tag builder ベースのあらゆるヘルパーが恩恵を受ける形です。


  1. 影響範囲・注意点
  • 影響範囲:
    • Action View のタグ関連ヘルパー全般 (content_tag, tag.*, link_to, button_to, 各種 form helper など)
    • しかし、外部に見える挙動や API は変更されていません。
  • 注意点:
    • 返される SafeBuffer はこれまで同様にミュータブルであり、<< などで追記できます。
    • CHANGELOG 更新は不要とされていますが、動作仕様の変更ではなくパフォーマンス最適化扱いです。
    • タグ文字列が 23byte 以下(オブジェクト内に埋め込める長さ)の場合、もともと追加アロケーションはほぼなかったため、改善効果はありません(tag.br のようなケース)。

アプリケーションコード側で、content_tag や tag が返す SafeBuffer をこれまで通り扱っている限り、
この変更による互換性問題が発生する可能性はかなり低いと考えられます。


  1. 参考情報 (あれば)
  • 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. 概要 (1-2文で)
    Rails の ActionDispatch::RemoteIp ミドルウェアで、リモート IP 判定時に同じ IP 文字列を何度も IPAddr にパースしていた処理を見直し、1回のパース結果を再利用するようにしてオブジェクト生成数を削減した PR です。挙動(どの IP がクライアントとして選ばれるか)は変えずに、特にログ出力など頻繁に呼ばれるケースのパフォーマンスを改善します。

  1. 変更内容の詳細

背景となる既存実装の問題 ​

ActionDispatch::RemoteIp の内部では、以下のような流れでクライアント IP を決定しています(簡略化):

  • GetIp#sanitize_ips
    • Client-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 を呼んでおり、不要なオブジェクト(IPAddr 2つ + 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.76545
2段プロキシ: XFF=203.0.113.7, 10.1.2.310165
IPv6 LB: REMOTE_ADDR=fc00::5, XFF=2001:db8::714296
直アクセス: REMOTE_ADDR=127.0.0.13722
  • Rack 経由のシンプルな render plain: リクエストで、401 → 386 object allocations に削減
  • マシン負荷の関係で時間計測は載せていないが、GC 負荷低下につながる変更

  1. 影響範囲・注意点
  • 対象範囲:
    • 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 している」ような場合に、内部の挙動に依存していると微妙な影響が出る可能性はありますが、通常のアプリケーション利用ではそのようなケースは稀で、基本的に安全な内部最適化です。


  1. 参考情報 (あれば)
  • 対象ファイル:
    • actionpack/lib/action_dispatch/middleware/remote_ip.rb
    • actionpack/test/dispatch/request_test.rb
  • 関連 PR:
    • #57627: first_non_proxy 内で ip.native を使う変更(この PR と意味的に競合しない)
  • Rack の挙動:
    • Rack 3.2.7 では X-Forwarded-For 内のスラッシュ付き IP を捨てる挙動があり、Rails 側より前段でフィルタされる
    • そのため、ネットマスク付き IP のテストは Client-Ip と REMOTE_ADDR に限定している

#59453 Reuse the period of time zones without transitions ​

マージ日: 2026/10/31 | 作成者: @linyiru

  1. 概要 (1–2文で)
    UTC など「一切の遷移(サマータイム等)がないタイムゾーン」について、TimeZone が TZInfo::TimezonePeriod を毎回新規生成せずに再利用するようにして、ActiveSupport/ActiveRecord におけるタイムゾーン関連のオブジェクト生成数と処理時間を削減する変更です。機能的な挙動は変えずに、UTC をデフォルトタイムゾーンとする一般的な Rails アプリのパフォーマンスを改善します。

  1. 変更内容の詳細

問題背景 ​

  • 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 オブジェクトを返すように変更。

イメージとしては:

ruby
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 を通したローカル時刻計算も期待通りであること。
  • 遷移を持つゾーン(例: 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

  1. 影響範囲・注意点
  • 挙動の変更は原則なし

    • 仕様上は「遷移のないタイムゾーンの period オブジェクトを毎回新規生成していたものを、同一オブジェクトに変えただけ」なので、 TimeWithZone の返す値(ローカル時間・offset・abbr)は変わりません。
    • サマータイムを持つゾーンは完全にこれまで通りです。
  • 恩恵が大きいケース

    • config.time_zone = 'UTC' や Etc/UTC, 固定 offset のタイムゾーン (Etc/GMT+5 など) を使っているアプリケーション。
    • 特に大量の Active Record レコードの timestamp をテンプレート側でレンダリングしている場合に、オブジェクト生成数削減の恩恵を受けます。
  • 互換性リスク

    • TimezonePeriod オブジェクトの「同一インスタンス性」に依存したコード(例えば object_id の比較など)を書いていると、UTC ゾーンでは今後「常に同じオブジェクトが返ってくる」ようになるため、挙動が変化する可能性はあります。ただし、そのようなコードは通常の利用では想定されていません。
    • Ractor 互換性はテストで担保されています。
  • CHANGELOG なし

    • 挙動の変更ではなく最適化のみのため、CHANGELOG には記載していません。

  1. 参考情報 (あれば)
  • 関連 PR / コミット:
    • #58785 およびコミット bd2f4d1cb9
      • TimeZone#today における「UTC が一定なゾーンでの period lookup をスキップする」最適化。今回の変更はその流れをさらに進め、period オブジェクトそのものの再利用に踏み込んだもの。
  • 計測方法:
    • 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. 概要 (1-2文で)
    Rails の ActionController::Parameters における to_h / to_unsafe_h で、ネストしたハッシュを二重コピーしていた箇所を一度のコピーに抑えることで、最大約 1.6 倍の高速化を行った PR です。挙動(返却値の構造や HashWithIndifferentAccess の性質)は維持しつつ、内部実装をより効率的なメソッドに差し替えています。

  1. 変更内容の詳細

何をしているか ​

対象は Strong Parameters のこのあたりの処理(擬似コードイメージ)です:

ruby
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 ケースでは変換が必要なため、動作は維持される:
    1. to_h にブロックを渡したとき(ブロックが plain Hash を返しうる)
    2. Parameters#transform_values! で plain Hash が保存されたとき

結果として:

  • ネストしたハッシュは原則「1回だけ」コピーされるようになり、to_h / to_unsafe_h が高速化

テストの追加・変更 ​

2 つの重要なテストが追加されています:

  1. to_h / to_unsafe_h の「完全コピー性」の保証テスト

    • 返り値のハッシュ/配列が、元の Parameters オブジェクトと ネストしたハッシュや配列を共有していない ことを確認
    • これにより、「高速化のために破壊的・共有的になっていないか」を担保
  2. transform_values! 後のハッシュが「indifferent」で返ってくることのテスト

    • Parameters#transform_values! で単なる Hash を内部にセットした場合でも
    • to_h / to_unsafe_h の結果ではそのハッシュが HashWithIndifferentAccess として振る舞うことを確認
    • このテストは nested_under_indifferent_access による変換をやめると失敗するため、変換が必要であることの裏付けにもなっている

擬似コードイメージ ​

元:

ruby
# HashWithIndifferentAccess を transform したあとで、
# with_indifferent_access でもう一度包み直していたイメージ
hash = some_hash_with_indifferent_access.transform_values { ... }
hash = hash.with_indifferent_access  # ← ここで不要な二重コピーが発生

変更後:

ruby
# nested_under_indifferent_access により、
# 既に indifferent なものはそのまま利用し、
# plain Hash だけを変換する
hash = some_hash_with_indifferent_access.transform_values { ... }
hash = hash.nested_under_indifferent_access

  1. 影響範囲・注意点

影響範囲 ​

  • 対象: ActionController::Parameters#to_h / #to_unsafe_h の内部実装
  • 主に次のようなコードパスで恩恵がある:
    • Strong Parameters を使ったフォーム入力のマスアサイン:
      ruby
      Post.new(params.expect(post: [:title, :body]))
      # 内部で to_h が走る
    • 深くネストしたパラメータを to_h / to_unsafe_h で普通のハッシュに変換する処理

パフォーマンス計測結果(抜粋・概略):

  • to_h(フラット 5 キー): 最大 ~1.5–1.6x
  • to_h(ネストした order): 最大 ~1.5–1.6x
  • to_unsafe_h(ネスト): 最大 ~1.4–1.6x
  • expect + to_h 全体では 1.08–1.11x 程度の向上(expect 部分のコストも含むため)

互換性・注意点 ​

  • 48,000 パターンのランダムパラメータを使った検証で、以下が「main ブランチと完全に同じ」であることが確認済み:
    • to_h(許可された場合 / 許可されていない場合のエラー含む)
    • ブロック付き to_h
    • to_unsafe_h
    • to_hash
  • 比較内容:
    • 返り値のクラス
    • frozen 状態
    • default / default_proc
    • compare_by_identity
    • ネスト構造
    • 返り値を変更しても元の Parameters が変わらないこと

つまり、外部から見える挙動は変わらず、パフォーマンスだけが向上している変更と見なせます。

注意が必要なケース:

  • ActionController::Parameters の戻り値の 内部実装の細部に依存したメタプログラミング をしている場合(HashWithIndifferentAccess の変換タイミングなど)には、挙動が微妙に変わる可能性がありますが、通常のアプリケーションコードでは問題にならない想定です。

  1. 参考情報 (あれば)
  • この 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. 概要 (1-2文で)
    Hash#deep_symbolize_keys / deep_stringify_keys / deep_transform_keys と ActionController::Parameters#deep_transform_keys の内部実装を変更し、キーごとに不要な [key, value] 配列を作らないようにして、最大約 1.5 倍の高速化とオブジェクト生成数削減を行う最適化 PR です。外部から見た振る舞い(結果のクラス・キー順序・衝突時の優先ルール)は一切変わりません。

  1. 変更内容の詳細

もともとの問題点 ​

deep_symbolize_keys / deep_stringify_keys / deep_transform_keys は、内部で _deep_transform_keys_in_object というヘルパを使っていますが、その中で次のようなイテレーションをしていました。

ruby
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
end

each_with_object によってブロックには |(key, value), result| という形で渡されるため、Rails 側が (key, value) という 2 要素配列を毎回展開することになり、「キー1つごとに [key, value] の一時配列を1個生成する」というコストがかかっていました。
深くネストした大きめのハッシュ(JSON:API 形式のペイロードなど)では、この一時配列の生成が多くなり、計測では deep_symbolize_keys の 92 個のオブジェクト割り当てのうち 66 個がこの「ペア配列」という状況でした。

今回の変更内容 ​

この PR は、そのイテレーションを次のように変更しています。

ruby
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 個のハッシュ(配列混在・キー衝突も含む)に同じ処理を走らせ、以下を比較して「完全一致」であることを確認済みです。

  • 結果オブジェクトのクラス
  • キー順序
  • 値

  1. 影響範囲・注意点

影響範囲

  • ActiveSupport:
    • Hash#deep_symbolize_keys
    • Hash#deep_stringify_keys
    • Hash#deep_transform_keys とその内部実装 _deep_transform_keys_in_object
    • HashWithIndifferentAccess に対しても、同じ実装経由で効果あり
  • ActionPack / ActionController:
    • ActionController::Parameters#deep_transform_keys
      → Strong Parameters でパラメータキーを書き換えるケース(underscore したり、camelCase 変換したり)に影響

動作上の挙動

  • 変更前後で:
    • 返ってくるオブジェクトの型
    • キーの順序
    • 同じキーに変換されたときの「最後の値が勝つ(last-wins)」ルール
  • は明示的に変えないことが保証されています(広範な比較検証済み)。

そのため、アプリケーションレベルでは「速度とメモリが良くなっただけ」で、既存のコードを変更する必要はありません。

注意点

  • 公開 API のシグネチャや例外挙動は変わっていませんが、もしアプリ内で deep_transform_keys の挙動に依存した繊細な処理(キー衝突順序や HashWithIndifferentAccess のクラスなど)をしているなら、念のため既存テストを一通り流しておくと安心です。
  • ActionController::Parameters では効果は限定的ですが、大量パラメータを扱う API・大きな JSON ボディを受け取るエンドポイントでは、レスポンスタイムや GC 圧が多少軽くなる可能性があります。

  1. 参考情報 (あれば)
  • 類似変更: 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. 概要 (1-2文で)
    dependent: :restrict_with_error のエラーメッセージに、従来の %{record} に加えてオリジナルの大文字・小文字を保持した %{association_name} を渡すようにし、多言語対応(特に名詞を大文字で書く言語)で正しい表記ができるようにした変更です。既存の英語訳などの挙動はそのまま維持されます。

  1. 変更内容の詳細

これまでの問題点 ​

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} を使うように変更することで正しい表記が可能になる。

具体例 ​

従来 (英語想定のまま):

yml
# 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 済みの名前が入ります。

ドイツ語などで今回の変更を活かす例:

yml
# 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"
  • モデル:
ruby
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: を追加した、というシンプルな変更です。

イメージとしては:

ruby
# (擬似コード・実際とは多少異なります)
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 にも、この新しい補間キー追加についてのエントリが追記されています。


  1. 影響範囲・注意点
  • 互換性:
    • %{record} の意味・値は変わっていないため、既存アプリのメッセージはそのまま動作します。
    • 新規に %{association_name} が追加されただけの後方互換な変更です。
  • ロケールファイルの修正方針:
    • 英語ロケール:
      • ほとんどのケースで今までどおり %{record} を使い続けて問題ありません。
      • ラベル的に「Client companies」のように大文字混じりで翻訳している場合、%{record} に依存しているときは従来通り小文字化された形で出力されます(挙動は変えないため)。
    • ドイツ語など大文字小文字が意味を持つ言語:
      • restrict_dependent_destroy のメッセージを %{record} から %{association_name} に変更することで、正しい大文字表記を維持できます。
  • もし自前で restrict_dependent_destroy メッセージをオーバーライドしていて、かつ %{association_name} というプレースホルダをすでに別用途で使っている場合は、今回から Active Record が同名のキーを渡すようになる点に注意が必要です(通常はそのような使い方はしていないはずですが、名前衝突の可能性としてはあり得ます)。

  1. 参考情報 (あれば)

#59462 Pass associations added to a parent down to existing subclasses ​

マージ日: 2026/10/31 | 作成者: @jasonmaxlee

  1. 概要 (1-2文で)
    Active Record の関連定義(has_many など)を親クラスに「後から」追加した場合でも、その親クラスを継承したサブクラスに正しく伝播するようにしたバグ修正です。エンジン/Concern/再オープンしたクラスで親に関連を追加したとき、既存サブクラスで関連メソッドはあるのに AssociationNotFoundError が出る問題を解消します。

  1. 変更内容の詳細

問題の背景 ​

従来の挙動:

ruby
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 ハッシュを書き込む
  • FancyPost が has_many :tags を定義した時点で、Post の _reflections をコピーした 自前の _reflections ハッシュ を持つようになる
  • その後で Post.has_many :comments を呼ぶと:
    • Post の _reflections は更新される
    • しかし FancyPost は自前で持っているコピーを更新しないため、comments の Reflection を知らない
  • さらに、normalized_reflections(Reflection のキャッシュ)もサブクラス側で一度読まれると、親に変更があってもキャッシュが更新されないケースがある

結果として、「親クラスに後から追加した関連」がサブクラスに見えず、例外になるというバグが起きていました。

PR で行ったこと ​

ActiveRecord::Reflection.add_reflection の挙動を変更して、親クラスに関連を追加したときに、既存のサブクラスにもその情報を適切に反映するようにしました。

具体的には:

  1. 親クラスに Reflection(関連)が追加されたあと、

    • そのクラスの descendants(すべての子孫クラス)を辿る
  2. 各子孫クラスに対して:

    • まず、そのクラスの Reflection キャッシュ(normalized_reflections など)をクリアする
      → 親クラスの変更を正しく反映させるため
    • その子孫クラスが「自分専用の _reflections ハッシュ」を持っているかを確認
      • 持っていない(= 親から継承のみしている)場合:
        • キャッシュだけクリアして終了。後で参照するときに、親から正しく継承される
      • 自分専用の _reflections を持っている場合:
        • 「同じ名前の関連を自分(または間のクラス)がオーバーライドしていないか」を確認
        • オーバーライドがなければ、親に追加された Reflection を、その子孫クラスの _reflections にも追加
        • もし同名の関連がすでに子孫側で定義されていれば、そちらを優先して上書きしない

これにより、

  • 後から親に追加された関連が、
    • 独自の関連を持つサブクラス
    • その孫クラス にも正しく見えるようになる
  • ただし、サブクラス側で明示的に同名の関連を定義している場合は、これまで通りサブクラスの定義が優先される

テストで確認していること ​

activerecord/test/cases/reflection_test.rb に追加されたテストで以下を保証:

  1. サブクラスが自前の関連を一つ持っている場合

    • 親に後から追加した関連もサブクラスで利用できる
    • サブクラスから親の関連を使ったとき、正しくサブクラス用の行(例: fancy_posts の comments)だけをロードする
  2. 孫クラス(子の子)でも同様に動く

  3. サブクラスが親の Reflection を一度読んでキャッシュしている場合でも

    • 親に後から追加した関連が見えるようになる(キャッシュが正しくクリアされる)
  4. サブクラスが同じ名前の関連をオーバーライドしている場合

    • これまで通りサブクラス側の関連定義が保持される(親の新しい関連で上書きしない)

1〜3 は本 PR 前は失敗していたが、変更後はパスするようになったことが示されています。4 は前後で挙動に変化なし。

さらに、この変更を入れた状態で Active Record SQLite のフルテストスイートがパスしていることも記載されています。


  1. 影響範囲・注意点

影響範囲 ​

  • 対象:

    • Active Record を使った STI/継承モデル全般
    • 特に、親クラスより先にサブクラスがロード・定義され、その後に親クラスへ関連が追加されるようなケース
      • Rails Engine が親モデルに関連を追加する
      • Concern を include することで親クラスに has_many 等が後から乗る
      • クラス再オープン(class Post < ApplicationRecord; end を複数ファイルで定義)で関連を追加する
  • これまで:

    • 「メソッドは生えているのに、呼ぶと AssociationNotFoundError」のような微妙な不整合が起き得た
  • これから:

    • 親に後付けした関連が、既に存在するサブクラスにも一貫して伝播する

パフォーマンス面 ​

  • descendants の走査は:
    • サブクラスが既に存在する状態で関連を追加したときに限り実行される
    • 通常の「親クラス → その後でサブクラスを定義 → 親に関連を追加しない」という多くのケースでは descendants は空で、余計な仕事は発生しない
  • 関連定義 (has_many など) 自体はそこまで頻繁に実行されるものではないため、実運用への影響は限定的と考えられます

互換性・注意点 ​

  • サブクラスが同名の関連を明示的に定義している場合は、今まで通りサブクラスの定義が優先される
    → 挙動が変わるのは「親にだけ定義された関連」がサブクラスに見える/見えないの差分のみ
  • もしアプリ側で _reflections や normalized_reflections を直接触っているような高度なメタプログラミングをしている場合は、この変更で挙動がより一貫する方向に揃えられますが、念のため挙動確認をした方が安全です

  1. 参考情報 (あれば)
  • 対応 Issue: #20678
  • 過去の関連 PR: #24719
    • 2016 年に議論されつつもマージされなかったアプローチ(sgrif, matthewd 提案)を、今回の PR で実現した形
  • 変更ファイル:
    • activerecord/lib/active_record/reflection.rb
      • add_reflection のロジック拡張(子孫クラスへの伝播 + キャッシュクリア)
    • activerecord/test/cases/reflection_test.rb
      • 上記シナリオをカバーするテストを追加
    • activerecord/CHANGELOG.md
      • 振る舞い変更として記載