Skip to content

Ruby on Rails PR Digest - 2026年 6月

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

#57738 Return an empty string from word_wrap when the text is nil

マージ日: 2026/6/15 | 作成者: @55728

  1. 概要 (1-2文で)
    word_wrap ヘルパーに nil を渡したときに NoMethodError が発生していた問題を修正し、nil の場合でも空文字列 ("") を返すようにしたPRです。これにより他のテキスト系ヘルパーと挙動が揃い、ビューからそのまま呼び出しても安全になりました。

  1. 変更内容の詳細

もともとの問題点

既存コード(抜粋):

ruby
def word_wrap(text, line_width: 80, break_sequence: "\n")
  return +"" if text.empty?
  ...
end

ここで textnil の場合、text.empty? の呼び出しで NoMethodError: undefined method 'empty?' for nil が発生していました。

ruby
word_wrap(nil) # => NoMethodError
word_wrap("")  # => ""(こちらは既にハンドリング済み)

他のテキストヘルパーは nil を素通し、もしくは安全な値に変換しており、word_wrap だけが例外を投げる状態でした。

  • truncate(nil)nil
  • highlight(nil, ...)""
  • simple_format(nil)"<p></p>"
  • excerpt(nil, ...)nil

修正内容

ガード条件を nil にも対応させました:

ruby
def word_wrap(text, line_width: 80, break_sequence: "\n")
  return +"" if text.nil? || text.empty?
  ...
end

ポイント:

  • text.nil? || text.empty? の明示的チェックにしており、blank? などには変更していません。
    • これにより「空文字列」はこれまで通り空文字列で返す。
    • 「空白のみの文字列(例: " ")」はこれまで通り word_wrap のロジックに渡される(挙動を変えない)。

テスト追加

actionview/test/template/text_helper_test.rb にテストを1件追加:

ruby
def test_word_wrap_with_nil
  assert_equal "", word_wrap(nil)
end

このテストは修正前は NoMethodError で落ち、修正後はパスすることが確認されています。


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

    • View / helper で word_wrap(some_maybe_nil_value) のように、nil になる可能性のある値をそのまま渡していた箇所で、例外が出なくなり、代わりに空文字列が返るようになります。
    • nil を渡して例外が出ることを前提にしているコードがもしあれば(あまり考えにくいですが)、その挙動は変わります。
  • 挙動の一貫性

    • word_wrap(nil)"" という仕様は、他ヘルパーの「nil を許容し、ビューとしてレンダリング可能な文字列にする」方針と整合的です。
    • ただし truncate(nil)excerpt(nil, ...)nil をそのまま返すため、「すべてのテキストヘルパーが nil"" というわけではない」点は従来通りです。word_wrap は既に「空文字列を返す」仕様だったので、それに nil を揃えた形です。
  • 意図的に変えていない点

    • blank? を使っていないため、空白だけの文字列 " " はこれまで通り word_wrap の処理を通ります。
    • そのため、「空白のみの文字列を渡したときの改行位置など」は一切変わっていません。

  1. 参考情報 (あれば)
  • 対象メソッド: ActionView::Helpers::TextHelper#word_wrap
  • 追加されたテストファイル: actionview/test/template/text_helper_test.rb
  • 変更行数:
    • actionview/lib/action_view/helpers/text_helper.rb: 1行差し替え
    • actionview/test/template/text_helper_test.rb: テスト 1ケース追加 (約5行)

#57737 Stop documenting the removed args: Hash form of assert_enqueued_email_with

マージ日: 2026/6/15 | 作成者: @55728

  1. 概要 (1-2文で)
    assert_enqueued_email_with のドキュメントが、既に削除済みの args: { ... } 形式でのパラメータ付きメールの検証方法を案内していたため、実装に即した params: 形式の説明に修正した PR です。コード変更はなく、RDoc の不整合解消のみです。

  1. 変更内容の詳細

何が問題だったか

ActionMailer::TestHelper#assert_enqueued_email_with の RDoc に、以下のような説明とサンプルが残っていました:

ruby
# If +args+ is provided as a Hash, a parameterized email is matched.
#
#   def test_parameterized_email
#     assert_enqueued_email_with ContactMailer, :welcome,
#       args: { email: 'user@example.com' } do
#       ContactMailer.with(email: 'user@example.com').welcome.deliver_later
#     end
#   end

および同様の args: { greeting: "Hello" } を使った例。

しかし、現在の実装は以下のようになっており:

ruby
args = Array(args) unless args.is_a?(Proc)
...
params === job_kwargs[:params] && args === job_kwargs[:args]
  • args: に Hash を渡すと、Array(args) によって [{ email: 'user@example.com' }] ではなく、[[:email, "user@example.com"]](Hash の to_a に準じた形)になる
  • 一方、パラメータ付きメール (with(email: ... )) の情報は job_kwargs[:params] に格納される
  • よって、paramsnil のまま、nil === { email: ... } という比較が行われ、必ずマッチに失敗する

すなわち、ドキュメント通りに書くと「メールはちゃんと enqueue されているのに、テストは失敗する」という状態になっていました。

何を修正したか

  1. args に Hash を渡すと parameterized email がマッチする」という説明文を削除
  2. それに依存したサンプルコード(args: { ... } を使った例)も削除
  3. 既に同じドキュメント内で紹介されている、正しい params: を使う例に統一

つまり、parameterized mail を検証したい場合は、次のような形だけを推奨するようにしました:

ruby
def test_parameterized_email
  assert_enqueued_email_with ContactMailer, :welcome,
    params: { email: 'user@example.com' } do
    ContactMailer.with(email: 'user@example.com').welcome.deliver_later
  end
end

ファイルレベルでは:

  • actionmailer/lib/action_mailer/test_helper.rb の RDoc コメントから 10 行削除
  • params: を指し示す文を 1 行追加(or 既存の流れに合わせて修正)

コード本体 (assert_enqueued_email_with の挙動) には一切変更はありません。


  1. 影響範囲・注意点
  • テストコード側の影響
    • これまでドキュメントに従って args: { ... } で parameterized mail を検証していた場合、そのテストは既に失敗している(あるいは、挙動が期待と違う)はずです。
    • 今後は一貫して params: { ... } を使う必要があります。
  • 本体コードへの影響
    • 実装は変わっていないため、ランタイム挙動には一切変更なし。
    • CI も [ci skip] 付きで、実質ノーリスクなドキュメント修正。
  • バージョン間の認識差
    • 古い Rails バージョンでは args: Hash 形式が動いていた時期があるため、その記憶のまま最新の Rails に移行すると「昔の書き方がドキュメントに残っているが、実際には動かない」という落とし穴になっていました。
    • この PR により、公式ドキュメントからその古い書き方が消えるため、新規利用者は正しい params: 形式だけを学ぶことになります。

  1. 参考情報 (あれば)
  • 対象メソッド: ActionMailer::TestHelper#assert_enqueued_email_with

  • 内部的なマッチ条件(簡略化):

    ruby
    params === job_kwargs[:params] && args === job_kwargs[:args]

    parameterized email (with(...)) の情報は job_kwargs[:params] に乗るため、メールのパラメータを検証したい場合は params: だけを見るべきという設計になっています。


#57739 Apply the deprecated scope hash :except option to except, not only

マージ日: 2026/6/15 | 作成者: @55728

  1. 概要 (1-2文で)
    Rails のルーティングで scope に「非推奨のハッシュ形式」を渡したとき、:except オプションが誤って :only として扱われ、指定と逆のルートが生成されていたバグを修正する PR です。あわせて、この退行を検出するテストが追加されています。

  1. 変更内容の詳細

問題のあった箇所

ActionDispatch::Routing::Mapper#scope に対して、古い書き方である「位置引数+ハッシュ形式」を使った場合の処理にバグがありました。

問題のコード(修正前)は以下のようになっていました。

ruby
# actionpack/lib/action_dispatch/routing/mapper.rb

only ||= assign_deprecated_option(deprecated_options, :only, :scope)
only ||= assign_deprecated_option(deprecated_options, :except, :scope) # <- 本来は `except` に代入すべき

ここで本来は deprecated_options[:except]except ローカル変数に割り当てるべきところを、誤って only に代入していました。

その結果、例えば以下のような「非推奨の positional-hash 形式」の書き方:

ruby
scope({ except: :destroy }) do
  resources :posts
end

が、内部的には

ruby
scope(only: :destroy) do
  resources :posts
end

と同等に扱われてしまい、意図と真逆のルーティング になる問題が発生していました。

  • 本来の期待: destroy アクションのみ除外し、それ以外(index, show, new, create, edit, update)は生きている。
  • 実際の挙動(バグあり時): destroy のみ生成され、それ以外のルートがすべて消える。

一方で、キーワード引数形式の:

ruby
scope(except: :destroy) do
  resources :posts
end

は正しく動作しており、同じ意味のはずの2つの書き方が食い違う 状態になっていました。

このバグは、ルーティング関連のメソッドを位置ハッシュからキーワード引数へ移行したコミット(3b4255e180)の際に混入した退行です。namespace 周りの別の退行(:path / :shallow_path / :shallow_prefix が無視される問題)は別 PR (#57740) で対応中とされています。

修正内容

修正後は次のように、except ローカル変数に正しく代入するよう変更されています。

ruby
only   ||= assign_deprecated_option(deprecated_options, :only,   :scope)
except ||= assign_deprecated_option(deprecated_options, :except, :scope)

これにより、

  • scope({ except: :destroy })(非推奨の hash 位置引数形式)
  • scope(except: :destroy)(キーワード引数形式)

の両方が同じ意味で解釈され、destroy だけを除いたルーティングが生成されるようになります。

テスト追加

actionpack/test/dispatch/routing_test.rb に以下のテストが追加されています。

  • test_scope_with_deprecated_except_hash_option

このテストでは、あえて非推奨の hash 形式を使って:

ruby
scope({ except: :destroy }) do
  resources :posts
end

のようなルーティングを定義し、

  • /posts (index) が 存在すること
  • destroy ルートが 除外されていること

をアサートしています。
修正前は /posts が "Not Found" になりテストが落ちるため、except が誤って only として扱われていたことが確認できます。修正後はテストが通ることが確認されています(2 runs, 13 assertions, 0 failures, 0 errors, 0 skips)。


  1. 影響範囲・注意点
  • 影響を受けるのは、scope に対して非推奨の「位置引数+ハッシュ形式」を使い、かつ except: を指定しているケース のみです。
    • 例: scope({ except: :destroy }) { resources :posts }
  • 通常推奨されるキーワード引数形式:
    • scope(except: :destroy) { resources :posts } は元々正しく動いており、今回の変更で挙動は変わりません。
  • つまり、ルーティング定義を徐々にキーワード引数形式へ移行している途中のアプリで、
    • 一部に scope({ except: ... }) 形式が残っている
    • そのルートに関するテストが不十分 な場合、本番環境で気づかないまま逆転したルート定義が動いていた可能性 があります。
  • この PR により、非推奨形式もキーワード形式と同じ意味にそろえられるため、将来的に形式を移行する際にも挙動が一致します。
  • 非推奨であること自体は変わらないので、長期的には scope(except: ...) などキーワード引数形式への置き換えが推奨されます。

  1. 参考情報 (あれば)

#57740 Honor the deprecated namespace hash path options

マージ日: 2026/6/15 | 作成者: @55728

  1. 概要 (1-2文で)
    Rails のルーティング DSL において、namespace が受け取る 非推奨のハッシュ形式オプション{ path: ... } など)が正しく扱われず、パスの二重付与や shallow_path / shallow_prefix 無視といったバグが出ていたのを修正する PR です。キーワード引数形式との挙動の不一致を解消し、非推奨形式でも期待どおりルーティングが生成されるようにしています。

  1. 変更内容の詳細(あればサンプルコードも含めて)

問題のあったコードパス

namespace メソッドの定義(ActionDispatch::Routing::Mapper)では、キーワード引数にデフォルトで「真偽値として真のダミーオブジェクト」DEFAULT を入れています。

ruby
DEFAULT = Object.new.freeze

def namespace(name, deprecated_options = nil, as: DEFAULT, path: DEFAULT,
              shallow_path: DEFAULT, shallow_prefix: DEFAULT, **options, &block)
  if deprecated_options.is_a?(Hash)
    as = assign_deprecated_option(deprecated_options, :as, :namespace) if deprecated_options.key?(:as)
    path ||= assign_deprecated_option(deprecated_options, :path, :namespace) if deprecated_options.key?(:path)
    shallow_path ||= ...
    shallow_prefix ||= ...
    ...
  end
  ...
end
  • path / shallow_path / shallow_prefix は初期値として DEFAULT(truthy)を持っている
  • そこに対して path ||= ... としているため、常に左辺が truthy と評価されて右辺が実行されない
  • 結果として、「非推奨のハッシュ形式で渡された値」がまったく読まれず無視される

さらに悪いことに、その非推奨ハッシュから読み取られなかったキーが **options 側に残り、後続処理で「もとの name を使ったデフォルト path」と「ハッシュに残った :path」が二重に扱われるケースが発生します。

バグの具体例

ruby
namespace :admin, { path: "adm" } do  # 非推奨のハッシュ形式
  resources :posts
end

期待されるルーティング:

text
/adm/posts

バグ発生時の実際のルーティング:

text
/admin/adm/posts   # `admin` と `adm` が二重に付く

同様に、

ruby
namespace :foo, { shallow_path: "bar" } do
  resources :posts, shallow: true
end

namespace :foo, { shallow_prefix: "bar" } do
  resources :posts, shallow: true
end

のようなケースで、shallow_path / shallow_prefix完全に無視される という問題がありました。

なお、キーワード引数形式:

ruby
namespace :admin, path: "adm" do
  resources :posts
end

は正しく動作しており、「ハッシュ形式だけが壊れている」状態でした。

修正内容

非推奨ハッシュ形式からの値の取り出しに使用している代入演算子を ||= から = に変更し、すでに正しく動作している :as オプションと同じパターンに揃えました。

修正後:

ruby
if deprecated_options.is_a?(Hash)
  as = assign_deprecated_option(deprecated_options, :as, :namespace) if deprecated_options.key?(:as)
  path = assign_deprecated_option(deprecated_options, :path, :namespace) if deprecated_options.key?(:path)
  shallow_path = assign_deprecated_option(deprecated_options, :shallow_path, :namespace) if deprecated_options.key?(:shallow_path)
  shallow_prefix = assign_deprecated_option(deprecated_options, :shallow_prefix, :namespace) if deprecated_options.key?(:shallow_prefix)
  ...
end

ポイント:

  • すべて if deprecated_options.key?(:xxx) でキーの存在を確認したうえで、= で上書き
  • DEFAULT という sentinel 値を使う設計と矛盾しないように、
    • 「ユーザがキーワードで与えた値」 > 「非推奨ハッシュで与えた値」 > 「name から導かれるデフォルト」という優先度を実現
  • ||= だと「すでに DEFAULT(truthy)が入っているため二度と書き換わらない」ので、ここを素直な = に変えることで、期待どおり deprecated ハッシュから値を採用できるようになった

テストの追加

actionpack/test/dispatch/routing_test.rb に以下のテストが追加されています。

  • test_namespace_with_deprecated_path_hash_option
  • test_namespace_with_deprecated_shallow_path_hash_option
  • test_namespace_with_deprecated_shallow_prefix_hash_option

それぞれ、すでにある「キーワード引数形式のテスト」をほぼそのまま「ハッシュ形式」で書き直したものになっており、以下を確認しています。

  • path ハッシュオプションを使っても、パスが二重にならず期待どおりの URL になる
  • shallow_path / shallow_prefix のハッシュオプションが正しく shallow ルーティングに反映され、ヘルパーも期待どおり定義される

修正前はこれら 3 テストが失敗し、修正後は既存のテストも含めてすべて成功することが確認されています。


  1. 影響範囲・注意点

影響を受けるアプリケーション

  • namespace を以下のような 非推奨ハッシュ形式で呼び出しているアプリが対象です:

    ruby
    namespace :admin, { path: "adm" } do
      # ...
    end
    
    namespace :users, { shallow_path: "u" } do
      # ...
    end
    
    namespace :api, { shallow_prefix: "api_v1" } do
      # ...
    end
  • これまで:

    • path が二重になっていたり、
    • shallow_path / shallow_prefix が無視されていたり、
    • それに起因してヘルパーメソッドが期待どおり定義されていない、 といった「おかしな挙動」が出ていた場合、それが修正されます。

互換性について

  • 非推奨とはいえ、このハッシュ形式は「まだサポート対象」であるため、今回の修正は 本来期待される動作に近づけるためのバグ修正 です。
  • ただし、もし既存のアプリが「不具合を前提にしたワークアラウンド」(例: パスの二重付与を見越して path を調整している等)を行っていた場合、今回の修正でルーティングのパスが変わる可能性があります。
  • 実運用では、以下の確認を推奨します。
    • rake routes で namespace 周りのルーティングが変化していないか
    • 特に shallow ルーティング (shallow: true) と組み合わせている namespace の URL とヘルパー名を確認

推奨される対応

  • 新規コード・既存コードともに、非推奨のハッシュ形式はできるだけ早くキーワード引数形式に書き換えることが推奨されます。

    ruby
    # 旧 (非推奨)
    namespace :admin, { path: "adm", shallow_path: "a" } do
      # ...
    end
    
    # 新 (推奨)
    namespace :admin, path: "adm", shallow_path: "a" do
      # ...
    end
  • 今回の修正により、両方の書き方が同じ結果になることが保証されるため、移行作業はやりやすくなっています。


  1. 参考情報 (あれば)
  • このバグは namespace / scope をキーワード引数に移行したコミット 3b4255e180 ("Use keywords in routing mapper") で混入した回帰です。
  • 兄弟の問題として、scope における :except:only の取り扱いバグ(:except が誤って only に割り当てられフィルタリングが反転する)があり、これは別 PR #57739 で修正されています。
  • ルーティング DSL では、オプションの取り扱い順序・デフォルト値(sentinel を使うかどうか)・キーワード/ハッシュの双方を同じ意味に保つ必要があり、今回のような「||= の機械的な置き換え」が回帰を生みやすいことが示唆されています。

#57721 Return an Enumerator from Parameters#select / #reject when no block is given

マージ日: 2026/6/15 | 作成者: @55728

  1. 概要 (1-2文で)
    ActionController::Parameters#select / #reject をブロックなしで呼び出したとき、これまで例外が発生していた挙動を修正し、Hash と同様に Enumerator を返すようにした PR です。Rails の Strong Parameters が Ruby 標準の Hash のインターフェースとより一貫するようになります。

  1. 変更内容の詳細

何が問題だったか

ActionController::Parameters には Hash と似たインターフェースを提供するためのラッパーメソッドが多数あります:

  • each_pair
  • each_value
  • transform_values, transform_values!
  • transform_keys, transform_keys!
  • select
  • reject

このうち、多くのメソッドは「ブロックが渡されなかった場合には Enumerator を返す」という Ruby 標準の Hash と同じ挙動を実装するために、以下のようなガードを持っていました。

ruby
return to_enum(:transform_values) unless block_given?

しかし select / reject にはそのガードが存在せず、代わりに次のような実装でした。

ruby
def select(&block)
  new_instance_with_inherited_permitted_status(@parameters.select(&block))
end

そのため、ブロックなしで呼び出した場合:

ruby
params = ActionController::Parameters.new(a: 1, b: 2)

# 期待される (Hash 互換な) 挙動
# params.select #=> Enumerator (e.g. params.select.with_index {...})

params.select

は内部的に @parameters.select(&nil) が実行され、@parameters.select 自体は Enumerator を返すものの、その戻り値を new_instance_with_inherited_permitted_status に渡してしまいます。

new_instance_with_inherited_permitted_status は内部で each_key など、Hash 前提のメソッドを呼ぶため、Enumerator に対して each_key を呼び出そうとして以下のような例外になります。

text
NoMethodError: undefined method `each_key' for #&lt;Enumerator: ...>

つまり:

  • Hash#select / Hash#reject → ブロックなしだと Enumerator を返す (OK)
  • Parameters#select / #reject → ブロックなしだと Enumerator を内部でさらに処理してしまい NoMethodError (NG)

という不整合が起きていました。

修正内容

select / reject に、既存の transform_values と同じ形式のガードを追加しました。

ruby
def select(&block)
  return to_enum(:select) unless block_given?
  new_instance_with_inherited_permitted_status(@parameters.select(&block))
end

def reject(&block)
  return to_enum(:reject) unless block_given?
  new_instance_with_inherited_permitted_status(@parameters.reject(&block))
end

これにより:

ruby
params = ActionController::Parameters.new(a: 1, b: 2)

enum = params.select
# enum は Enumerator
enum.with_index { |(k, v), i| ... } # などが可能

enum2 = params.reject
# こちらも Enumerator として利用可能

という、Hash と同様の利用パターンがサポートされます。

テスト

actionpack/test/controller/parameters/accessors_test.rb に以下に相当するテストが追加されています:

  • #select にブロックを渡さない場合、Enumerator を返すこと
  • #reject にブロックを渡さない場合、Enumerator を返すこと

既存の「transform_values はブロックなしで Enumerator を返す」テストと同パターンのテストが select / reject に対しても追加されており、修正前は NoMethodError で落ちるケースだったことが確認されています。


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

    • ActionController::Parameters#select / #reject を「ブロックなしで」呼び出すコードに影響があります。
    • 修正前は「内部で例外が発生していた」ケースなので、基本的には「これまでクラッシュしていたコードが正しく動くようになる」方向の変更です。
    • 通常の、ブロック付きの params.select { ... } / params.reject { ... } の挙動は変わりません。
  • 互換性上の注意点

    • もしアプリ側で「params.select を呼ぶと例外になること」を前提にしたワークアラウンドをしていた場合、その挙動は変わりますが、実用上そのような前提を持つコードはまずないと思われます。
    • Ruby の Hash と同じく Enumerator が返るため、select.with_indexreject.with_index のようなチェインがそのまま利用可能になります。
  • セキュリティ・Strong Parameters 的な注意点

    • new_instance_with_inherited_permitted_status の呼び出しは、ブロックが渡された場合のみ行われます (return to_enum で早期リターンするため)。
    • したがって、実際に Parameters の新しいインスタンスを生成して permitted 状態を引き継ぐ処理の流れ自体はこれまで通りで、Strong Parameters のセキュリティ特性に変化はありません。
    • Enumerator 経由で要素を列挙するだけでは permitted / unpermitted 状態そのものが変わるわけではありません。

  1. 参考情報 (あれば)
  • Ruby 本体の仕様:
    • Hash#select / Hash#reject はブロックなしで呼ばれると Enumerator を返す:
      ruby
      h = { a: 1, b: 2 }
      h.select #=> #&lt;Enumerator: {...}:select>
      h.select.with_index { |(k, v), i| ... }
  • Rails 内での類似実装:
    • ActionController::Parameters#transform_values / #transform_keys などは既に return to_enum(:method_name) unless block_given? で同様の取り扱いをしており、今回の変更により select / reject もそれに揃えられました。

#57735 [RF Docs] Rails Internationalization (#57381)

マージ日: 2026/6/15 | 作成者: @p8

  1. 概要 (1-2文で)
    Rails ガイドの「国際化 (I18n)」ドキュメントを大幅にリライトし、初心者にも読み進めやすい構成と、より具体的なコード例・出力例を備えた内容に刷新した PR です。あわせて、古い情報の削除や他ガイドとの表記・スタイルの整合も図られています。

  1. 変更内容の詳細

2-1. ガイド全体構成の大幅な再編成

  • 旧構成を見直し、以下の流れに整理し直したとの記述があります:

    1. 初心者向けの導入(「どういうときに I18n を使うか」「このガイドを読むと何ができるようになるか」)
    2. 「リクエスト間でロケールを管理する」セクションを独立させて前半に配置
      • URL パラメータやセッションなどから locale を決める話が分かりやすくまとまるように
    3. I18n API の機能紹介(I18n.t, I18n.l, pluralization, interpolation など)
    4. より高度なトピック(バックエンド差し替え、例外ハンドリングなど)
  • 導入部分は、他のガイドと同様に「After reading this guide, you will know...」形式に書き換えられ、ステップ一覧的な説明から「学べることの一覧」に変更。

2-2. 古い / 不正確な情報の整理・削除

  • 削除・変更点の例:
    • 「Contributing」セクション: 非アクティブなフォーラムへの言及を削除。
    • 「Translating Model Content」セクション: サードパーティ gem へのリンクを削る方針になり、I18n ガイドの範囲を Rails 標準機能の説明に絞る方向へ。
    • 「将来の Rails バージョンで public/assets の自動ローカライズが入るかも」といった将来予測的な文言を削除(実現されておらず計画もないため)。
    • 「Using Different Exception Handlers」セクションを「Handling Missing Translations / Handling I18n Exceptions」相当の内容に整理し直し、Rails における MissingTranslationData の扱い説明にフォーカス。

2-3. コード例と出力例の大幅追加

ガイド全体で「説明だけ」で済ませていた箇所に、コードとその結果(コメントなど)を多数追加しています。代表的なもの:

基本的な翻訳例の明確化

yml
# config/locales/en.yml
en:
  hello: "Hello world"
ruby
I18n.t("hello") # => "Hello world"

といった形で、キーと実際の I18n.t の呼び出し・戻り値を並べて説明するスタイルに統一。

ActiveModel / ActiveRecord メッセージの例

  • バリデーションメッセージの設定と結果をコードで明示:
yml
en:
  activerecord:
    errors:
      models:
        user:
          attributes:
            name:
              blank: "must be present"
ruby
user = User.new(name: "")
user.valid?
user.errors.full_messages
# => ["Name must be present"]
  • User.model_name.human などのメソッドもコードブロックで例示:
yml
en:
  activerecord:
    models:
      user: "Account"
ruby
User.model_name.human
# => "Account"

スコープ・ネストされたキーの例

「Basic Lookup, Scopes, and Nested Keys」の各例について、I18n.t の戻り値もコメントで併記:

yml
en:
  date:
    formats:
      short: "%b %d"
ruby
I18n.t("date.formats.short")
# => "%b %d"

HTML セーフ / エスケープの例と出力 HTML

Safe HTML 翻訳の章で、app/views/home/index.html.erb のレンダリング結果 HTML も追加:

erb
<%= t("welcome_html") %>
yml
en:
  welcome_html: "Welcome <strong>World</strong>"

結果:

html
Welcome &lt;strong&gt;World&lt;/strong&gt;

など、「どこでエスケープされるか」が目で追いやすいように。

変数の挿入と通貨表現の例の改善

  • Passing Variables to Translations セクションでは、通貨記号 $ が混在する、不自然な例を修正。
    • 例: 「オランダ語 (dutch) は € 100、スペイン語 (spanish) は 100 €」のように、言語によるフォーマット差を説明する例に置き換え。
  • number_to_currency に関する注意は、一般的な NOTE スタイルの注意書きに統一。
ruby
number_to_currency(100, locale: :nl)
# => "€ 100"

number_to_currency(100, locale: :es)
# => "100 €"

例外ハンドリング・Missing Translation の例

I18n::MissingTranslationData について、実際の例を追加:

ruby
I18n.t("missing")
# => raises I18n::MissingTranslationData (Translation missing: en.missing)

および Rails 側でのハンドリング・カスタムハンドラ導入方法を説明する流れに整理。

2-4. 「リクエスト間でロケールを管理する」章の独立化・強化

  • Managing the Locale across Requests をトップレベルセクションとして独立。
  • その中で、以下のようなパターンごとに小見出し化:
    • 「Setting the Locale from a request parameter」(リクエストパラメータ params[:locale] で決定)
    • セッション / ユーザ設定 / Accept-Language など(詳細は PR コメント上では「要対応」となっているが、少なくともパラメータでの設定例はヘッダ付きで整理)。
  • 例(代表的な Rails コントローラのパターン):
ruby
class ApplicationController < ActionController::Base
  before_action :set_locale

  def set_locale
    I18n.locale = params[:locale] || I18n.default_locale
  end
end

2-5. バックエンド差し替え・他ビルトインメソッドの説明明確化

  • 「Using Different Backends」セクション:
    • 以前は「追加のバックエンドをチェインするコード例」までしかなかったが、「どう使うのか」まで踏み込む説明を追加。
    • 例: SimpleBackend と KeyValue backend を組み合わせる、など。
  • 「Overview of Other Built-In Methods that Provide I18n Support」:
    • number_to_currency, distance_of_time_in_words, time_ago_in_words など、I18n 対応のヘルパは名前だけでなく、短いコード例付きで紹介されるように。

  1. 影響範囲・注意点
  • 影響対象は Rails ガイド(ドキュメント)のみで、アプリケーションコードやフレームワーク本体の挙動には変更なし。
  • ただし:
    • 既存のガイドへのリンク(特定セクションへのアンカー)を社内ドキュメントやブログなどで参照している場合、セクション名・見出し構造が変わっている可能性があるため、リンク切れに注意。
    • I18n ガイド内からサードパーティ gem への誘導が削られているため、「モデルの翻訳」などを学ぶ際は別途 gem ドキュメントを参照する必要がある。
  • ガイドが初心者向けに再構成されているため、新しく I18n を導入する開発者は、最新ガイドに合わせてチュートリアル的に読み進めるのが推奨されます。

  1. 参考情報 (あれば)
  • 元の改善提案・TODO リスト: https://github.com/rails/rails/pull/51840#discussion_r1603318416
  • この PR: [RF Docs] Rails Internationalization (#57381) / #57735
  • 関連ファイル(主に内容が変わったガイド):
    • guides/source/i18n.md(メインの I18n ガイド、+1144/-603 と大幅更新)
    • guides/source/getting_started.md(I18n への導入リンクや記述の調整)
    • guides/source/ruby_on_rails_guides_guidelines.md(ガイド執筆ガイドラインの微調整)

#57734 [ci skip] Remove orphaned backtick from routing docs

マージ日: 2026/6/15 | 作成者: @tylerlwsmith

  1. 概要 (1-2文で)
    Rails のルーティングガイド(routing.md)の「Path and URL Helpers」セクションに紛れ込んでいた不要なバッククォート(`)1文字を削除した、ドキュメントのみの修正です。機能や挙動の変更は一切なく、表記・レイアウトの体裁を整えるためのものです。

  2. 変更内容の詳細 (あればサンプルコードも含めて)

  • 対象ファイル: guides/source/routing.md
  • 変更内容:
    • 該当セクション内の文章中に、Markdown のインラインコードとして閉じていない「余計なバッククォート」が 1 文字存在していたのを削除しています。
    • 実際の差分は「1 行においてバッククォートを 1 文字削除」という最小限の変更で、文章やコード例の意味自体は変わっていません。

イメージとしては、例えば以下のような状態になっていたものを:

md
You can use `user_path(@user)`` to get the path...

これを:

md
You can use `user_path(@user)` to get the path...

のように修正した、というタイプの変更です(実際の文面はこれと多少異なる可能性がありますが、性質としては同じです)。

  1. 影響範囲・注意点
  • 影響範囲:
    • Rails 本体のコード・API には一切影響ありません。
    • 影響するのは Rails Guides の表示のみで、Markdown → HTML 変換後の見た目(インラインコードの崩れ、余計な記号の表示)が解消されます。
  • 注意点:
    • ドキュメント修正のみのため、アプリケーション側で対応すべきことはありません。
    • CI を回さないための [ci skip] がタイトルに付いていますが、これはコード変更がないドキュメント PR でよく行われる運用上の指定であり、特別な意味はありません。
  1. 参考情報 (あれば)
  • 該当セクション: Rails Guides > Routing > Path and URL Helpers
  • 目的: Markdown 記法上のタイポ修正によるドキュメント品質の向上であり、API 仕様やルーティングの挙動に関する変更・補足は行っていません。

#57381 [RF Docs] Rails Internationalization

マージ日: 2026/6/15 | 作成者: @Ridhwana

  1. 概要 (1-2文で)
    Railsガイドの「Rails Internationalization (I18n)」ドキュメントを大幅に書き換え、構成を初心者フレンドリーに再編しつつ、API・ベストプラクティス・例外処理などを具体的なコード例付きで整理したPRです。既存のガイドラインとの整合性を取りつつ、古い情報や不要なセクションも整理しています。

  1. 変更内容の詳細

2-1. ガイド全体構成の再編成

  • I18nガイド guides/source/i18n.md が大きく書き換えられています(+1144 / -603 行)。
  • 構成の方針:
    1. 初心者向けの導入
      • 導入部を「I18nを実装するための手順の要約」から、他のガイドと同様の
        「このガイドを読むと次のことが分かります(After reading this guide, you will know…)」形式に変更。
      • 最初に、用語の定義・YAMLの基本・I18n.t の基本的な使い方などをまとめることで、Rails初心者でも読み進めやすい構成にしています。
    2. リクエスト単位のロケール管理を独立セクション化
      • 「Managing the Locale across Requests」をトップレベルの大きなセクションに昇格。
      • 実際のアプリ開発で一番迷いがちな「どこで locale を決めるか?」にすぐ到達できるようナビゲーションを改善。
    3. API機能の説明 → 高度なトピックという流れ
      • 下位セクションに、バックエンドの切り替え・例外処理・カスタムフォーマットなど、より高度な内容をまとめています。

2-2. 導入・基本セクションの改善

  • 導入文を全面的に書き換え:

    • 旧: 実装手順の箇条書きなど、他ガイドとフォーマットがズレていた。
    • 新: 「このガイドを読んだあとに理解できること」の列挙(標準形式)。
  • キーと翻訳の関係を、文章説明だけでなくコード例で示すように変更:

    ruby
    # config/locales/en.yml
    en:
      hello: "Hello world"
    ruby
    I18n.t('hello')
    # => "Hello world"
  • ActiveModel / ActiveRecord のバリデーションメッセージも、実際の出力例をコードで示すように追記。

2-3. 「リクエスト間でのロケール管理」セクションの強化

  • 「Managing the Locale across Requests」関連の内容を整理・昇格:
    • 例: パラメータからロケールを設定する例に、明示的な見出しを追加:
      • 「Setting the Locale from a request parameter」(params[:locale] を使うパターン)
    • before_action などを使って I18n.locale を決める標準的なやり方を、より読みやすく整理。
  • ガイド全体の流れとして、
    1. ロケールとは何か
    2. 翻訳を書いて I18n.t する
    3. 実際のリクエストでどのロケールを使うか決める という順序になるよう再構成されています。

2-4. 翻訳の詳細なAPI解説の充実

基本的な lookup / スコープ / ネスト

  • 「Basic Lookup, Scopes, and Nested Keys」部分で、単に I18n.t の呼び出しだけではなく、戻り値の例も追加:

    yaml
    # config/locales/en.yml
    en:
      dashboard:
        title: "Dashboard"
        welcome: "Welcome, %{name}"
    ruby
    I18n.t('dashboard.title')
    # => "Dashboard"
    
    I18n.t('dashboard.welcome', name: "Alice")
    # => "Welcome, Alice"

変数埋め込み(%{})と通貨の例

  • 「Passing Variables to Translations」の例を修正:
    • 通貨例が $ の混在だったものを、ロケールに応じた通貨表記の違いをより現実的に示すように変更(例: オランダ語とスペイン語で € 100 / 100 € の違い)。
  • number_to_currency についての説明を通常テキストから明示的な NOTE に変更し、「フォーマットはヘルパ側に任せられる」ことを明確化。

Safe HTML とエスケープ

  • 「Using Safe HTML Translations」セクションで、html_safe の扱いを説明する際に生成されるHTMLの例を追加:
    • どこまでがエスケープされ、どこからはされないのかが明示されるように。

2-5. Active Record / モデル関連の翻訳

  • 「Translations for Active Record Models」などで、これまでは文章中に埋め込まれていたメソッド名を、コードブロックで明示:
    ruby
    User.model_name.human
    # => "Account"  # など、ロケールに応じたモデル名
    
    User.human_attribute_name(:email)
    # => "Email address"
  • モデル内容の翻訳に関する「Translating Model Content」セクションから、サードパーティgemへのリンクを削除:
    • 公式ガイドとしては外部gemの宣伝をせず、必要ならイントロの注意書きレベルの記述に留める方針に変更。

2-6. カスタムフォーマット・バックエンド・例外処理など高度な機能

カスタム翻訳・フォーマット

  • 「Custom Translations」セクションの例に対し、出力例を追加:

    yaml
    # config/locales/en.yml
    en:
      date:
        formats:
          short: "%b %d"
    ruby
    I18n.t("date.formats.short")
    # => "%b %d"
  • 日付・時刻・数値フォーマッタとの連携など、フォーマットの役割をより明示的に説明。

複数バックエンドの利用

  • 「Using Different Backends」セクションを拡張し、
    • バックエンドをチェーンする例に加えて、
    • 「バックエンドを追加したあとどう使うか」を、より具体的なコード例で説明。
    • 例: メモリ内バックエンド + データベースバックエンドなど。
    • ※実装詳細までは踏み込み過ぎず、「RailsのI18nは pluggable backend である」ことを分かりやすく。

例外処理・Missing translations

  • 「Using Different Exception Handlers」セクションを、より役割を明確にするために
    • 「Handling Missing Translations」または「Handling I18n Exceptions」的な名前に変更。
  • I18n::MissingTranslationData の実際の例を追加:
    ruby
    I18n.t('missing')
    # Translation missing: en.missing
    # (I18n::MissingTranslationData)
  • Rails側でカスタムハンドラを設定してメッセージを変えたりログを取る、といった標準的なパターンについても説明を補強。
  • 他の例外についても、少なくとも言及や簡単な例を追加(完全な網羅は今後の課題として残タスク扱い)。

2-7. 古い情報・ガイドライン不整合の整理

  • Contributing セクションの削除
    • 非アクティブなフォーラムを参照していたため削除。
  • Authors セクションの削除予定
    • 他のガイドで著者を明示していない方針と揃えるため、このPRでは未完了タスクとしてマークされているが、方向性としては削除。
  • 将来計画として書かれていた以下の一文を削除:
    • 「Future Rails versions may well bring this automagic localization to assets in public, etc.」
    • 実際に実装予定・ロードマップがないため、読者に誤解を与えないようにする目的。

2-8. 関連する他ガイドの微修正

  • guides/source/action_view_overview.md

    • I18n関連の説明やリンクを、更新されたI18nガイド構成に合わせて微調整(1行追加 / 2行削除レベル)。
  • guides/source/getting_started.md

    • Getting Started ガイドからI18nへの誘導文を現行のI18nガイドの構成に合わせて調整(28行ずつ増減の範囲で文章の書き換え)。
  • guides/source/ruby_on_rails_guides_guidelines.md

    • ドキュメント執筆ガイドラインの中で、I18nガイドに関係するポリシー(例: 認証情報の表記、サンプルコードスタイルなど)に関する小変更(+2 / -2)。
  • guides/source/documents.yaml

    • ガイド一覧でのI18nガイドのタイトルや説明の更新。ナビゲーションに出る説明文も、新しい方針に合うように調整。
  • guides/assets/images/i18n/demo_html_safe.png

    • 画像ファイルは差分上は変更なし(+0/-0)だが、参照箇所の文脈が変わっている可能性あり。

  1. 影響範囲・注意点
  • コードへの影響:

    • 変更はすべてガイド(ドキュメント)のみであり、Rails本体の挙動・APIには影響なし。
    • 既存のI18nコードが壊れる / 警告が出るといった影響はありません。
  • 開発者への実務的影響:

    • これまでグレーだったベストプラクティス(特に「どこでロケールを決定するか」「例外をどう扱うか」)が明確になり、新しくI18n対応を始めるチームにとっての参照元が改善されます。
    • 既存プロジェクトで独自にやっていたパターンが、ガイドの推奨とズレている場合は、
      • どこを標準寄りに寄せるか
      • 逆にどこをあえてカスタムとして残すか
        の検討がしやすくなります。
  • ドキュメント参照先の変更:

    • Getting Started / Action View Overview からI18nガイドへのリンク・説明が変更されているため、
      チーム内で「◯◯セクション参照」と共有していた場合は、新しい節タイトルに合わせて更新が必要なことがあります。
  • 未完のTODO:

    • PR説明文上でチェックが外れているタスク(Authors セクション完全削除、他例外の例追加など)がいくつか残っているため、今後のPRでさらに細部が変更される可能性があります。

  1. 参考情報 (あれば)

#57726 Fix max_resumptions attribute name in ResumeLimitError docs

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveJob::Continuation::ResumeLimitError の RDoc に誤って記載されていたクラス属性名 max_resumes を、正しい max_resumptions に修正したドキュメント専用のPRです。コード挙動自体は一切変わらず、ドキュメントの記述だけが1行修正されています。

  1. 変更内容の詳細(あればサンプルコードも含めて)
  • 対象: activejob/lib/active_job/continuation.rb 内の RDoc コメント

  • 問題点:

    • エラークラス ActiveJob::Continuation::ResumeLimitError のコメントで、次のように書かれていました:

      ruby
      # Raised when a job has reached its limit of the number of resumes.
      # The limit is defined by the +max_resumes+ class attribute.
      class ResumeLimitError < Error; end
    • しかし実際のクラス属性は max_resumptions であり、max_resumes というクラス属性は存在しません。

    • ActiveJob::Continuable モジュール内では、正しく max_resumptions が定義・説明されていますが、この RDoc の1箇所だけ表記揺れ/誤りがありました。

  • 修正内容:

    • 上記コメントの +max_resumes+ を、正しい +max_resumptions+ に変更:

      ruby
      # Raised when a job has reached its limit of the number of resumes.
      # The limit is defined by the +max_resumptions+ class attribute.
      class ResumeLimitError < Error; end
  • 想定される誤利用例:

    • 誤ったドキュメントを信じて、ジョブクラス側で以下のように書いてしまうと:

      ruby
      class MyJob < ApplicationJob
        self.max_resumes = 3
      end
    • 実際には max_resumes= が定義されていないため、NoMethodError: undefined method 'max_resumes=' が発生していました。

  • 正しい使用例:

    ruby
    class MyJob < ApplicationJob
      # ジョブが再開(resume)できる回数の上限を設定
      self.max_resumptions = 3
    end

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

    • ランタイムコードには一切変更がなく、挙動に影響はありません。
    • 既存の max_resumptions の利用コードはそのまま問題なく動作します。
    • これまで RDoc やコメントだけを見て max_resumes を使っていた場合、既にエラーになっていたはずで、このPRによって新たに壊れるコードはありません。
  • 注意点:

    • ActiveJob の Continuation/Continuation::ResumeLimitError を使っているプロジェクトで、もし max_resumes を使おうとしていた・コメントだけを頼りにしていた場合は、クラス属性名が max_resumptions であることを改めて確認してください。
    • CIスキップ ([ci skip]) のタグが付いているため、この変更は完全にドキュメント更新扱いです。

  1. 参考情報 (あれば)
  • 対象クラス/モジュール:
    • ActiveJob::Continuable
    • ActiveJob::Continuation::ResumeLimitError
  • 概念メモ:
    • max_resumptions は、継続可能ジョブ(continuable job)が resume される回数の上限を指定するクラス属性です。
    • 上限に達すると ResumeLimitError が発生し、その説明コメントが今回の修正対象でした。

#57730 Don't mask errors raised inside a cursor's succ in Step#advance!

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveJob::Continuation::Step#advance! がカーソルの succ 呼び出し時に発生する本来の NoMethodError を飲み込んで別エラーにすり替えていた問題を修正し、succ を実装していないカーソルだけを明示的に弾くように変更した PR です。これにより、カスタムカーソルの succ 内部のバグが正しく例外として表に出るようになります。

  1. 変更内容の詳細

変更前の挙動

Step#advance! は、succ を持たないカーソルに対して分かりやすいエラーを出すために、NoMethodError を rescue して UnadvanceableCursorError に変換していました:

ruby
def advance!(from: nil)
  from = cursor if from.nil?

  begin
    to = from.succ
  rescue NoMethodError
    raise UnadvanceableCursorError, "Cursor class '#{from.class}' does not implement 'succ'"
  end

  set! to
end

しかしこの実装だと、

  • カーソルオブジェクトが succ を定義している
  • その succ の「内部」で NoMethodError が発生した

という場合でも、元の NoMethodError が rescue されてしまい、常に

text
UnadvanceableCursorError: Cursor class 'MyCursor' does not implement 'succ'

という誤ったエラーになり、実際の原因やバックトレースが隠蔽されてしまっていました。

変更後の実装

succ を事前に respond_to? でチェックし、未実装の場合のみ UnadvanceableCursorError を投げるように変更されました:

ruby
def advance!(from: nil)
  from = cursor if from.nil?

  unless from.respond_to?(:succ)
    raise UnadvanceableCursorError, "Cursor class '#{from.class}' does not implement 'succ'"
  end

  set! from.succ
end

ポイント:

  • respond_to?(:succ) で「メソッドが存在するかどうか」だけを確認
  • その後の from.succ 実行時に発生した例外 (NoMethodError を含む) はそのまま外に伝播する
    • つまり、succ 実装のバグは本来の例外としてデバッグ可能な形で見える

テスト追加

  • succ を実装しているが、その中で「別の理由」で NoMethodError を投げるカーソルクラスを用意し、
    • その NoMethodErrorUnadvanceableCursorError にラップされず、そのまま伝播することをテスト
  • 既存のテスト (nil / Float / Integer などのカーソルに対する挙動) は変更なしで通ることを確認

  1. 影響範囲・注意点
  • 影響を受けるのは:
    • ActiveJob::Continuation を使っていて、独自のカーソルオブジェクトを Step に渡し、そのオブジェクトに succ を実装しているケース
  • これまで:
    • カーソルの succ 内部で NoMethodError が起きると、UnadvanceableCursorError として報告され、原因特定が難しかった
  • 今後:
    • succ が存在するカーソルでは、succ 内の例外はそのまま表に出る
      → バグが正しいエラー種別・スタックトレースで見えるようになる
    • succ を実装していないカーソル (nil, Float, Array など) には、従来通り UnadvanceableCursorError が発生
    • succ が private な場合も respond_to?(:succ)false を返すため、以前と同様に「実装されていないもの」として扱われる

互換性の観点では、以下のような「エラーの見え方」が変わる可能性があります:

  • 以前: succ 内部の NoMethodError も「カーソルが succ を実装していない」ように見えていた
  • 以後: succ 内部の NoMethodError は、そのままのエラー (例: undefined method など) として上がる

アプリケーションコードで UnadvanceableCursorError に依存したハンドリングをしている場合、
これまで「succ 内部バグも全部 UnadvanceableCursorError だった」前提のコードがあると、挙動が変わる可能性があります。
ただし、この変更の方が「メソッド契約どおり」であり、バグ調査もしやすくなるため、正しい方向の修正といえます。


  1. 参考情報 (あれば)
  • 対象コード: activejob/lib/active_job/continuation/step.rb
  • 想定している契約:
    • Step#advance! は、カーソルが succ を実装していない場合に UnadvanceableCursorError を投げる」
    • succ を実装している場合は、その実行中の例外はそのまま伝播させる
  • コアな設計意図:
    • 「API 契約違反 (succ 未実装)」と「実装バグ (succ 内部の例外)」を区別し、後者を隠さないようにすること

#57729 Pass the exception positionally when resuming a continuation after an error

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    Active Job の「継続可能(continuable)ジョブ」をエラー後に再開する際、例外オブジェクトの渡し方に不整合があり、exception_executions のキーが意図しない文字列になる不具合を修正した PR です。例外は常に位置引数で resume_job に渡すように統一し、それを検証するテストが追加されています。

  1. 変更内容の詳細

問題の背景

ActiveJob::Continuable#continue は、継続可能ジョブが途中まで進んだ後にエラーを投げた場合に、そのジョブを再開する責務を持ちます。内部ではおおよそ以下のような例外ハンドリングをしていました(PR 説明からの抜粋・整形):

ruby
rescue Continuation::Interrupt => e
  resume_job(e)                 # 位置引数
rescue Continuation::Error
  raise
rescue StandardError => e
  if resume_errors_after_advancing? && continuation.advanced?
    resume_job(exception: e)    # キーワード風(実際にはハッシュ)
  else
    raise
  end
end

一方、resume_job の定義は次のようになっており、位置引数1つのみを受け取るメソッドです:

ruby
def resume_job(exception)
  executions_for(exception)
  ...
end

ここで resume_job(exception: e) と呼んだ場合、Ruby では exception: eキーワード引数ではなくハッシュリテラル { exception: e } と解釈され、それがそのまま位置引数 exception に渡されます。

結果として:

  • 正常系(Continuation::Interrupt 経由)
    • resume_job(e)exceptionStandardError などの例外オブジェクト
  • 標準エラー系(StandardError 経由)
    • resume_job(exception: e)exception{ exception: e } というハッシュ

となり、resume_job 内部で使われる executions_for(exception) が異なる値を元にキーを生成してしまいます。

Active Job は exception_executions というハッシュに「特定のエラーが何回起きたか」を記録しますが、今回の挙動により、キーが以下のように不自然な文字列になっていました:

ruby
# before
{ "{exception: #&lt;StandardError: Cursor error>}" => 1 }

# 本来期待される形 (Interrupt 経由のパスと同じキー)
{ "Cursor error" => 1 }

つまり、

  • Interrupt で再開した場合: 例外メッセージ(例: "Cursor error")がキーになる
  • 標準エラーで再開した場合: ハッシュを to_s した文字列("{exception: #&lt;StandardError: Cursor error>}")がキーになる

という不整合が生じていました。

修正内容

PR の修正は非常にピンポイントで、resume_job 呼び出しを以下のように変更しています。

diff
- resume_job(exception: e)
+ resume_job(e)

これにより:

  • すべての再開パス(Continuation::Interrupt と通常の StandardError)で
  • 例外オブジェクトが 同じく位置引数として resume_job に渡され
  • executions_for(exception) が一貫して「例外オブジェクト」を受け取れる

ようになり、exception_executions のキーも、すべて exception.message (例外メッセージ)ベースで統一されます。

テストの追加

activejob/test/cases/continuation_test.rb に 13 行のテストが追加され、次のことを検証しています:

  • 継続可能ジョブが途中まで進んだ後にエラーを発生させ
  • resume_errors_after_advancing? が有効で再開されるケースにおいて
  • exception_executions のキーが、例外メッセージ(Interrupt 経由のパスと同じ形式)で記録されること

これにより、修正が仕様として固定され、回 regress しにくくなっています。


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

    • ActiveJob::Continuable を利用しているジョブで、途中で進捗を記録した後、StandardError 系の例外で再開されるパスに影響します。
    • 特に、exception_executions の値(キーが何か)を見て独自にモニタリングしているコード、メトリクス集計、再試行制御をしているコードがある場合、キーの形式が変わるため注意が必要です。
  • 挙動の変化:

    • 以前: StandardError 由来の再開では、キーが "{exception: #&lt;StandardError: Some message>}" のような文字列になっていた。
    • 以後: Interrupt 由来と同様に、例外メッセージ(例: "Some message")をキーとして扱うようになる。
    • これにより、「同じエラーなのにパスによって別キーになる」という問題が解消され、再試行カウントなどが正しく集計されやすくなります。
  • 後方互換性:

    • メソッドシグネチャは元々位置引数 1 つのみだったため、API 的な破壊的変更はありません。
    • すでにキースキーマを前提にしたストレージやダッシュボードがある場合は、キーの変化を考慮した移行(過去データとの付き合わせなど)が必要になる可能性があります。

  1. 参考情報 (あれば)
  • 対象クラス: ActiveJob::Continuableactivejob/lib/active_job/continuable.rb
  • 関連概念:
    • exception_executions: 例外ごとの実行回数を管理しているハッシュ
    • resume_errors_after_advancing?: 進捗済みでもエラー後にジョブを再開するかどうかのフラグ
    • Continuation::Interrupt / Continuation::Error: 継続処理に関連する内部的な例外クラス

この PR は、実装の意図(resume_job の引数名が exception であること)と実際の呼び出しを一致させる、小さいながらも意味のあるバグ修正です。


#57728 Clear enqueue_error when re-enqueuing a job

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    Active Job の同一ジョブインスタンスを再キューイングした際、前回の失敗時の enqueue_error が残り続けてしまう問題を修正し、再キューイング時に enqueue_error をクリアするようにした PR です。これにより、ジョブが実際には正常にキューイングされたにもかかわらず、監視やログに例外が発生したかのように記録される不整合が解消されます。

  1. 変更内容の詳細

問題の背景

ActiveJob::Enqueuing#enqueue は毎回 successfully_enqueued をリセットしていますが、enqueue_error はリセットしていませんでした。

疑似コードイメージ:

ruby
def enqueue(options = {})
  set(options)
  self.successfully_enqueued = false
  # self.enqueue_error は触っていない

  raw_enqueue
  ...
end

そのため、以下のようなケースで状態が矛盾していました。

ruby
job.enqueue                 # adapter が EnqueueError を raise -> false を返す
job.enqueue                 # 2回目は成功 -> job を返す
job.successfully_enqueued?  # => true
job.enqueue_error           # => #&lt;ActiveJob::EnqueueError ...> (本来は nil であるべき)

さらにこの矛盾は、ActiveJob::StructuredEventSubscriber#enqueue にも影響します。このクラスではイベントの exception を次のように決めています:

ruby
exception = event.payload[:exception_object] || job.enqueue_error

2回目の enqueue が成功したケースでは event.payload[:exception_object]nil になりますが、job.enqueue_error に前回の例外が残っているため、

  • 実際には成功した enqueue に対して
  • exception_class / exception_message がイベントとして発火されてしまう

という誤ったログ・メトリクスが出る状況になっていました。

修正内容

この PR では、enqueue 前に enqueue_error もクリアするようにしています。

ruby
def enqueue(options = {})
  set(options)
  self.successfully_enqueued = false
  self.enqueue_error = nil      # ← 新しく追加

  raw_enqueue
  ...
end

これにより、

  • 2回目の enqueue が成功した場合
    • successfully_enqueued?true
    • enqueue_errornil
      となり、状態が一貫します。

テスト

新規テストでは、同一インスタンスに対して

  1. 1回目: adapter が EnqueueError を返すようにして enqueue を失敗させる
  2. 2回目: 成功するようにして enqueue する

という流れを再現し、

ruby
assert job.successfully_enqueued?
assert_nil job.enqueue_error

を検証しています。


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

    • ActiveJob::Enqueuing#enqueue を使っている全てのジョブクラスが対象ですが、
      • 変更は「enqueue 開始時に enqueue_errornil にリセットする」だけです。
    • 特に、同じジョブインスタンスを使い回して再キューイングするケース や、 キューイング失敗時のエラー情報を enqueue_error 経由で参照しているコード に影響します。
    • ActiveJob::StructuredEventSubscriber を使ったログ出力やメトリクス収集では、
      • これまで「成功した enqueue なのに例外付きでイベントが飛んでいた」ような誤検知が解消されます。
  • 注意点

    • 「直前の enqueue 失敗のエラーを、後続の enqueue 成否に関わらずジョブインスタンス内に残したい」という特殊な用途がもしあれば、そのユースケースには合わなくなります(しかし一般的には、ジョブの状態は最新の enqueue の結果を反映すべきなので、この変更が正しい挙動と言えます)。
    • もしアプリ側で enqueue_error を参照しているコードがある場合は、
      • 「どの enqueue の結果を見たいのか」を明確にし、必要であれば独自にログや別フィールドに保存する実装が望まれます。

  1. 参考情報 (あれば)
  • 関連クラス/メソッド
    • ActiveJob::Enqueuing#enqueue
    • ActiveJob::EnqueueError
    • ActiveJob::StructuredEventSubscriber#enqueue
  • 想定ユースケース
    • flaky な adapter(外部キューサービス一時障害など)に対して、同じジョブインスタンスを再利用してリトライするケース
    • enqueue の成功/失敗を監視基盤(構造化ログ、APM など)に送っている環境でのノイズ削減

#57725 Include adapter in Active Job perform_start event payload

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    Active Job の perform_start 構造化イベントのペイロードに adapter 情報が含まれていなかった不整合な挙動を修正し、他のイベント(enqueue, enqueue_at, enqueue_all, perform)と同様にアダプタ名を含めるようにした PR です。これによりログや構造化イベントの利用時に、ジョブ実行開始時点でも正しいアダプタ名が参照できるようになります。

  1. 変更内容の詳細

問題の背景

Active Job には、構造化イベントを発行する ActiveJob::StructuredEventSubscriber があり、以下のようなイベントでペイロードに adapter キーを含めています。

  • enqueue
  • enqueue_at
  • enqueue_all
  • perform(= 完了)

ところが perform_start だけが adapter キーをペイロードに含めておらず、結果として:

  • active_job.started イベントには adapter が無い
  • active_job.completed イベントには adapter がある

という不整合な状態になっていました。

ActiveJob::LogSubscriber#started は、イベントペイロードから :adapter:queue を読み取り、adapter(queue) という形の文字列に整形してログを出力しますが、perform_start 側のペイロードに adapter が無いせいで、開始ログだけアダプタ名が欠落していました。

挙動の例

Async アダプタでジョブを実行したときのログ:

text
Performing TestJob (Job ID: ...) from (default)
Performed  TestJob (Job ID: ...) from Async(default) in 0.3ms

期待されるのは以下のように両方にアダプタ名が含まれることです:

text
Performing TestJob (Job ID: ...) from Async(default)
Performed  TestJob (Job ID: ...) from Async(default) in 0.3ms

根本原因

内部的には、Active Job のインストルメンテーション層 (Instrumentation#instrument) がすでに payload[:adapter] = queue_adapter を設定しており、通知イベント自体には adapter が入っている状態です。

問題は ActiveJob::StructuredEventSubscriber#perform_start が、その event.payload[:adapter] を構造化イベント用のペイロードに転記していなかった点にあります。

具体的な修正内容

perform_start において、他のイベントと同様に adapter をペイロードに含めるよう修正されています。

変更後のイメージ:

ruby
def perform_start(event)
  job = event.payload[:job]
  adapter = event.payload[:adapter]

  payload = {
    job_class: job.class.name,
    job_id: job.job_id,
    queue: job.queue_name,
    adapter: ActiveJob.adapter_name(adapter),
    enqueued_at: job.enqueued_at&.utc&.iso8601(9),
  }

  # この payload を使って structured event を発行
end

ポイント:

  • event.payload[:adapter] をローカル変数 adapter に取り出し、
  • 他イベント同様 ActiveJob.adapter_name(adapter) でアダプタ名へ正規化し、
  • payload[:adapter] に含めています。

テストの変更

activejob/test/cases/structured_event_subscriber_test.rbtest_perform_start_job を更新:

  • 以前は job_classqueue しか検証しておらず、adapter 欠落を検知できなかった。
  • 本 PR により perform_start イベントのペイロードに adapter が含まれていることを明示的に期待・検証するように変更。
  • これで enqueue / enqueue_at のテストと同等レベルのアサーションになり、再発防止になります。

テスト結果(ローカル実行例):

text
activejob/test/cases/structured_event_subscriber_test.rb  -> 11 runs, 24 assertions, 0 failures
activejob/test/cases/logging_test.rb                      -> 39 runs, 186 assertions, 0 failures

  1. 影響範囲・注意点
  • ログ出力の変化

    • active_job.started(=ジョブ実行開始)に対応するログ行が、これまでよりも情報リッチになります。
    • 具体的には "from (default)" のようにアダプタ名なしだった部分が "from Async(default)" のようにアダプタ名付きになります。
    • ログ文言をパースしているツールがある場合、from <adapter>(<queue>) の形式を前提としていれば、今回の変更でより一貫性のある入力が得られます。
  • 構造化イベント利用者への影響

    • active_job.started イベントのペイロードに adapter キーが新たに追加されます。
    • すでに active_job.enqueue などの sibling イベントで adapter を利用している監視・メトリクス基盤であれば、started に対しても同様の集計(アダプタ別のメトリクスなど)が可能になります。
    • adapter が増えるのは additive な変更であり、既存コードが adapter を前提としていない限り壊れることはありません(nil でなくなったことで問題が出るケースは基本的に考えづらい)。
  • リリース状態

    • この構造化イベント / EventReporter 経由のログサブスクライバは、まだ正式リリースタグには入っていない部分とのことで、公開済みバージョン上の「回 regress」ではなく、そもそも初期実装時の欠落を修正した位置づけです。
    • そのため CHANGELOG には記載されていません。

  1. 参考情報 (あれば)
  • 対象クラス:
    • ActiveJob::StructuredEventSubscriber
    • ActiveJob::LogSubscriber(特に #startedqueue_name ヘルパ)
  • 関連イベント:
    • active_job.enqueue
    • active_job.enqueue_at
    • active_job.enqueue_all
    • active_job.started(今回修正対象)
    • active_job.completed
  • アダプタ名の解決:
    • ActiveJob.adapter_name(adapter) により、Async, Inline, Sidekiq などの文字列表現に正規化されます。

#57688 Run Action Cable JS tests with Web Test Runner

マージ日: 2026/6/14 | 作成者: @matthewd

  1. 概要 (1-2文で)
    Action Cable の JavaScript テスト実行環境を、非推奨となった Karma から Web Test Runner + Sauce Labs を使う構成に移行した PRです。既存の QUnit テストスイートを壊さずに、新しいテストランナー経由でブラウザテストを実行できるようにしています。

  1. 変更内容の詳細

2-1. Karma からの移行

  • actioncable/karma.conf.js を削除 (中身 63 行すべて削除)

    • Karma ベースの設定一式を廃止
    • これにより、Action Cable の JS テストは Karma 経由では動かなくなり、今後は Web Test Runner 経由のみを想定
  • actioncable/package.json の変更 (+6/-7)

    • 主な内容(推測を含む)
      • devDependencies から karma, karma-* 関連パッケージを削除
      • 代わりに @web/test-runner, @web/test-runner-sauce 等を追加
      • テストスクリプトを Karma から Web Test Runner に切り替え
        例(イメージ):
        jsonc
        {
          "scripts": {
            // 旧:
            // "test": "karma start karma.conf.js"
            // 新:
            "test": "node test/javascript/run_web_test_runner.mjs"
          }
        }
      • QUnit 自体は現行のテストスイートを使い回すため、そこは温存。Web Test Runner 経由で QUnit を動かすアダプタ構成に。

2-2. Web Test Runner 実行スクリプトの追加

  • actioncable/test/javascript/run_web_test_runner.mjs (+26)
    • Web Test Runner を起動するためのエントリポイントスクリプト
    • 概ね以下のようなことを行う構成が想定されます:
      • web-test-runner.config.mjs を読み込んで設定を適用
      • CI かローカルかなどの条件に応じて、起動オプションを調整
      • Node からプログラム的に @web/test-runner を実行し、終了コードをそのままプロセスの exit code に反映
    • これにより、yarn test や CI の job から統一的に Web Test Runner を叩けるようになる

簡易イメージ(あくまで参考):

js
// test/javascript/run_web_test_runner.mjs
import { startTestRunner } from '@web/test-runner';
import config from '../../web-test-runner.config.mjs';

const result = await startTestRunner({ config });
process.exit(result.failed ? 1 : 0);

2-3. Sauce Labs 連携の設定

  • actioncable/test/javascript/sauce_labs.mjs (+278)
    • Sauce Labs 上でブラウザテストを実行するための設定・ヘルパーロジック一式
    • 主な内容(推測を含む):
      • Sauce Labs のブラウザ環境定義(Chrome / Firefox / Safari / Edge 等のバージョン行列)
      • 環境変数 (SAUCE_USERNAME, SAUCE_ACCESS_KEY など) から認証情報を取得
      • CI ブランチ・PR かどうかに応じて、テスト対象ブラウザや並列度を切り替え
      • Web Test Runner に渡す Launcher 設定 (SauceLauncher 的なもの) の生成
    • Karma 時代に使っていた Sauce 連携ロジックがあれば、それの Web Test Runner 版に相当

イメージコード(抜粋イメージ):

js
// test/javascript/sauce_labs.mjs
import { sauceLauncher } from '@web/test-runner-sauce';

export function createSauceLaunchers() {
  return {
    chromeLatest: sauceLauncher({
      browserName: 'chrome',
      platformName: 'Windows 11',
      browserVersion: 'latest',
      // ...
    }),
    // 他ブラウザ...
  };
}

2-4. Web Test Runner の設定

  • actioncable/web-test-runner.config.mjs (+65)
    • Web Test Runner のメイン設定ファイル
    • 主な要素(推測を含む):
      • テスト対象ファイルパターン(例:test/javascript/**/*_test.js
      • QUnit を使うためのブラウザ側インポート・セットアップ
      • ローカル実行時は puppeteer or Playwright ベースの headless Chrome/Firefox を起動
      • CI で Sauce Labs を使う場合は sauce_labs.mjs から Launcher を読み込み、browsers に指定
      • タイムアウト、並列実行数、カバレッジ設定など

例イメージ:

js
// web-test-runner.config.mjs
import { playwrightLauncher } from '@web/test-runner-playwright';
import { createSauceLaunchers } from './test/javascript/sauce_labs.mjs';

const isCI = process.env.CI === 'true';

export default {
  files: 'test/javascript/**/*_test.js',
  nodeResolve: true,
  browsers: isCI
    ? Object.values(createSauceLaunchers())
    : [playwrightLauncher({ product: 'chromium' })],
  testFramework: {
    config: {
      ui: 'qunit',
    },
  },
};

2-5. 依存関係 (yarn.lock) の大幅更新

  • yarn.lock (+3109/-840)
    • Karma 関連ライブラリの削除、新規に導入した Web Test Runner + Sauce 関連ライブラリのロックが大量に追加
    • これに伴い transitive dependencies も大きく変動
    • ランナーやブラウザドライバ関連のパッケージが追加されている想定

  1. 影響範囲・注意点
  • テスト実行コマンドの変更

    • これまで yarn test 等で Karma を前提としていた場合、今後は Web Test Runner が使われます。
    • 開発者ローカルで Action Cable の JS テストを回すときは、PR の変更後の package.json に書かれている新しいコマンドを確認する必要があります。
  • CI 設定の更新必須

    • CI で karma start karma.conf.js を呼んでいたジョブは失敗するため、
      • node actioncable/test/javascript/run_web_test_runner.mjs
      • または yarn test (PR で定義された新しい script)
        に切り替える必要があります。
    • また、Sauce Labs 用の環境変数 (ユーザ名・アクセストークンなど) が CI 上で適切に設定されていることが前提です。
  • テストの見た目と挙動が変わる可能性

    • テストランナーの UI / ログ形式が Karma から Web Test Runner に変わるので、
      • ログの読み方
      • テスト失敗時のスタックトレースのフォーマット
      • ブラウザコンソール出力の扱い
        などが変わる可能性があります。
    • ただしテスト本体は QUnit のままのため、テストコードの API レベルの変更はほぼありません。
  • フレークテスト調査の前段階

    • PR 説明にもある通り、「既存 CI ジョブで発生しているフレーク (flaky) な失敗調査をする前に、まずはサポートされているツールチェーンに乗せ換える」ことが目的。
    • そのため、この PR マージ後もしばらくはテストが不安定な可能性は残っており、今後の PR で flakiness 改善が図られる想定です。
  • ローカル環境差異

    • ローカルでブラウザテストを実行する際に、Node / npm / yarn のバージョンや OS の違いで動作が変わる可能性があります。
    • 新しく導入された Web Test Runner / Playwright / Sauce 関連依存が Node のサポートバージョンを制限する場合があるため、開発環境の Node バージョンは(Rails リポジトリの推奨に)合わせておく方が安全です。

  1. 参考情報 (あれば)

この PR は「テストランナーの世代交代」が主目的で、テスト内容そのものを大きく変えないまま、Action Cable の JS テストを将来もメンテ可能な基盤に載せ替える変更と位置付けられます。


#57687 Assortment of CI/test fixes

マージ日: 2026/6/14 | 作成者: @matthewd

  1. 概要 (1-2文で)
    CI とテスト周りの安定性を向上させるための修正がまとめて行われ、特に Active Record のコネクションプール切断時の例外ハンドリングが「例外を報告して処理は継続する」挙動に変更されました。その他は主にテストコード側の調整・リファクタリングで、本体 API の互換性を壊す変更は原則ありません。

  1. 変更内容の詳細

2-1. connection_pool 切断時の例外ハンドリング (lib 側唯一の本体変更)

対象:
activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb

これまで、プールの disconnect 中に発生した例外は、そのままテストや呼び出し元に伝播して CI を落とす原因になりうる状態でした。
この PR では、**「例外をログ等に report した上で、プールの切断処理自体は継続する」**方針に変更されています。

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

ruby
def disconnect!
  with_connection do |conn|
    # 以前はここで raise されると全体が中断していた
    conn.disconnect!
  rescue => e
    # 今回の変更: フレームワークとして例外を報告しつつ、処理は続行
    ActiveSupport::Notifications.instrument("connection_pool_disconnect.error", error: e)
    # あるいは logger.error などで報告し、プールのクリーンアップは続ける
  end
end

PR の説明文にある通り、「プールを明示的に切断する」というのはサポートされているフレームワークの挙動/ API だが、実際には Rails 自身のテストスイートが最大の利用者の一つという前提があり、テストの安定性を重視した変更といえます。

期待される効果:

  • 切断処理中の一時的な例外(接続先 DB が既に落ちている等)が、テスト全体を不安定にしない
  • 呼び出し側は「切断要求はベストエフォートで実行され、失敗はログまたは通知経由で観測する」スタイルになる

2-2. 各アダプタのテスト修正

対象:

  • activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb
  • activerecord/test/cases/adapters/trilogy/trilogy_adapter_test.rb
  • activerecord/test/support/adapter_helper.rb

主に CI での不安定要因を減らすための修正が含まれていると考えられます。
具体的には:

  • 切断・再接続・トランザクション周りで、DB 状態に依存してフレークしやすいテストの条件や期待値の調整
  • Trilogy / PostgreSQL 固有の挙動に合わせて、アサーションや前提条件を整理
  • テストヘルパの設定値(接続情報やスキップ条件)の微修正

これらにより、異なる環境/DB バージョンでも一貫した成功を得やすくなります。

2-3. connection_pool_test の拡充・修正

対象:
activerecord/test/cases/connection_pool_test.rb (+94/-30)

今回の本体変更に直結する部分で、テストがかなり増えています。

想定される内容:

  • 切断時に例外を発生させるダミー接続オブジェクトを用意して、
    • 例外が「報告される」こと
    • それでもプールのクリーンアップ処理が継続すること
      を検証するテスト
  • 複数の接続を持つプールに対して、一部の切断のみが失敗しても、他の接続やプールオブジェクトの状態が破綻しないことの確認
  • マルチスレッド/並列環境での切断時挙動に関する安定性テスト

このファイルが最も大きく増えており、「report-and-continue」ポリシーの仕様をテストで明示した位置づけになっています。

2-4. fixtures / relation / strict_loading / transactions 関連テストの調整

対象:

  • activerecord/test/cases/fixtures_test.rb
  • activerecord/test/cases/relation/or_test.rb
  • activerecord/test/cases/strict_loading_test.rb
  • activerecord/test/cases/transactions_test.rb
  • activerecord/test/fixtures/pirates.yml

主に以下のような細かいチューニングが想定されます:

  • テストデータ (pirates.yml) の調整:
    • テストケースに必要なフィールド値の修正
    • DB 制約や strict loading 仕様の変更/明確化にあわせて意図した状態に揃える
  • transactions_test の大幅な整理 (+21/-53):
    • 不安定なアサーションの削除・簡略化
    • DB アダプタ差異を吸収した期待値の見直し
    • 「トランザクション中に例外が出た場合」の挙動テストを、現在の実装と一致するように修正
  • strict_loading_test / relation/or_test:
    • strict loading や or クエリの仕様が変わったわけではなく、
    • テスト側の前提(事前ロード・関連の有無など)を現行挙動に沿うように修正

これらは基本的に テストが実際の挙動とズレていた部分を修正するものであり、API 側の破壊的変更を伴うものではないと見てよいです。

2-5. Active Support / Railties / Guides まわりのテスト修正

対象:

  • activesupport/test/evented_file_update_checker_test.rb (+9/-2)
  • guides/test/epub_test.rb (+0/-3)
  • railties/test/isolation/abstract_unit.rb (+7/-2)
  • railties/test/isolation/test_helpers_test.rb (+22/-0)

想定される変更点:

  • evented_file_update_checker_test.rb:
    • OS ファイルシステム差異やタイミング依存で落ちやすいテストに対し、待ち時間やイベント検知条件を調整
    • CI 環境での flakiness を減らす目的
  • Guides の epub_test.rb:
    • CI でサードパーティツールや外部依存が原因で失敗しないように、テストケースやアサーションを簡略化
  • Railties の isolation テスト:
    • abstract_unit.rbtest_helpers_test.rb で、テストアプリケーションの起動・終了や環境変数/ロードパス操作をより安全に行うためのヘルパ強化
    • テストプロセス間の汚染(環境変数、グローバル状態など)が他のテストに波及しないようにする

これらはすべて「CI が安定して通るようにするためのテスト基盤側の改善」です。


  1. 影響範囲・注意点
  • 本体 API への主な影響は connection_pool の切断時挙動のみ
    • disconnect 系メソッド呼び出しで例外が起きた場合、今後は「例外がログ/通知されつつ、処理は続行」されることが前提になります。
    • 以前、disconnect での例外を rescue して独自処理していたコードは、「そもそも例外が上がってこない」可能性があるため、挙動を確認してください。
  • とはいえ、この API の強い利用者は Rails 自身のテストスイートであり、アプリケーションコードで直接 connection_pool を操作しているケースは多くありません。
    通常のアプリケーションでは、実質的な挙動差はほぼ感じないはずです。
  • テストコードが多く変更されていますが、いずれも 既存の実装に合わせてテストを直したり、CI の不安定さを解消したりするもので、アプリケーションコード側の対応は原則不要です。

  1. 参考情報 (あれば)
  • PR 本文の要点:
    • lib の変更は 1 点のみで、AR プール切断中の例外は report-and-continue にした」
    • 「この API 自体は公式な挙動だが、最大の利用者はおそらく Rails のテストスイート自身」
  • もし自前で connection pool を直接扱っている場合は、
    ActiveRecord::Base.connection_pool.disconnect! / clear_active_connections! / clear_all_connections! 周りの挙動がどう変わるかを、一度ログ出力などで確認すると安心です。

#57723 Fix retry_on wait: proc with an optional argument

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveJob.retry_onwait: オプションに渡す Proc が「省略可能な引数1つ」を取る場合(->(executions = 0) { ... } など)に ArgumentError が発生していた不具合を修正する PR です。Proc#arity の扱いを見直し、1引数・可変長引数・2引数の各パターンで後方互換性を保ちながら適切な呼び出しが行われるようになりました。

  1. 変更内容の詳細

背景となる仕様

ActiveJob の retry_on は、再実行時の待機時間を wait: オプションで指定できます:

ruby
retry_on SomeError, wait: ->(executions) { executions * 2 }

この wait: には以下の2系統のプロックがサポートされています:

  1. 1引数: ->(executions) { ... }

    • executions: 何回目の実行か(1回目=1, 2回目=2...)
  2. 2引数: ->(executions, error) { ... }

    • executions: 上と同じ
    • error: 発生した例外オブジェクト

もともとの実装では、Proc#arity を使って「1引数なのか、2引数(以上)なのか」を判定していました。

既存の問題点

元コード(説明文中の旧実装)はおおよそ次のようなロジックでした:

ruby
if algorithm.arity == 1
  algorithm.call(executions)
else
  algorithm.call(executions, error)
end

Ruby の Proc#arity は以下のような振る舞いをします:

  • ->(x) {} # arity == 1
  • ->(x, y) {} # arity == 2
  • ->(x = 0) {} # arity == -1 (「必須引数1個、オプションあり」)
  • ->(x, *rest) {} # arity == -2 (「必須引数1個+可変長」)

そのため、->(executions = 0) { ... } のような「オプション引数1つ」の待機時間プロックを渡すと arity == -1 になり、else 側で

ruby
algorithm.call(executions, error)

2引数で呼ばれてしまい ArgumentError になる というバグがありました。

修正内容

新しいロジックでは、「その callable が 2つ目の引数を安全に受け取れるか」を基準に判断するように変更されています:

ruby
if algorithm.arity == 2 || algorithm.arity < -1
  algorithm.call(executions, error)
else
  algorithm.call(executions)
end

この判定が意味するところは以下です:

  • algorithm.arity == 2
    → 「引数2つを必須で取る」: ->(executions, error) { ... } のようなケース
    executions, error を渡して呼ぶ

  • algorithm.arity < -1
    → 「可変長引数を持ち、2つ以上の引数を受けられる」: 例

    • ->(executions, *rest) { ... } # arity == -2
    • ->(*args) { ... } # arity == -1 ではなく -n のパターン
      executions, error で呼んでも許容される
  • 上記以外(arity == 1 または arity == -1

    • ->(executions) { ... } # 1
    • ->(executions = 0) { ... } # -1(今回問題になっていた形) → 1つ目の引数のみを渡す (executions のみ)

結果として、説明にあった通り次のような対応になります:

  • ->(executions) (1) と ->(executions = 0) (-1) → 1引数で呼び出す
  • ->(executions, error) (2) と ->(executions, *rest) (-2) → 2引数で呼び出す

これにより、「もともと1引数のつもりで書いたが、デフォルト値を付けたくなった(->(executions = 0))というコード」が、従来通り 1引数の扱い をされるようになります。

テストの追加

以下のテストが追加されています:

  1. activejob/test/jobs/retry_job.rb

    • ジョブに次のような宣言を追加:

      ruby
      retry_on SomeError, wait: ->(executions = 0) { executions * 2 }
  2. activejob/test/cases/exceptions_test.rb

    • 上記のジョブについて、AJ_ADAPTER=test 環境で
      「期待したスケジュールでリトライされるか」を検証するテストを追加。

このテストは、修正前はリトライ中に ArgumentError が発生してレッドになり、修正後はグリーンになることが確認されています。既存の 1 引数・2 引数 wait: プロックのテストも引き続きグリーンで、後方互換性も保たれています。


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

    • ActiveJob の retry_on / discard_onexceptions.rb で使われる同系のロジック)における wait: プロックの呼び出しに限定されます。
    • wait:
      • オプション引数1つ (->(executions = 0) { ... })
      • 可変長引数 (->(executions, *rest) { ... } など) を使っている場合に挙動が変わる(あるいは初めて期待通りに動く)可能性があります。
  • 後方互換性

    • 既存の以下のパターンはそのまま動作します:
      • ->(executions) { ... } … 1引数として呼ばれる
      • ->(executions, error) { ... } … 2引数として呼ばれる
    • 「今までたまたま動いていた変なケース」があるとすれば、例えば
      • ->(*args) { ... } など、引数を何でも受け入れるような形で、arity が -1 未満になるケースでも 2 引数で呼ばれます。
        通常の使い方では問題にならないはずですが、もし wait:*args を前提とした特殊な処理をしている場合は、一応確認すると安心です。
  • 注意点

    • wait: プロックの設計としては:
      • 「エラーに依存しない」待機時間 → 1引数 or オプション1引数で ->(executions = 0) { ... }
      • 「発生した例外に応じて変えたい」待機時間 → 2引数以上で ->(executions, error) { ... } と明示的に書き分けるのがよいです。
    • Proc#arity の仕様に依存した分岐であるため、「より複雑なシグネチャ(キーワード引数など)」を wait: に与えるのは避ける方が無難です(現状サポート範囲外と考えた方がよい)。

  1. 参考情報 (あれば)
  • 該当コード: activejob/lib/active_job/exceptions.rb
    retry_on/discard_on の内部で wait: オプションを解釈している部分の 1 行のみが変更されています。
  • Ruby の Proc#arity 仕様(ざっくり):
    • 正の値: 必須引数の数
    • 0: 引数なし
    • 負の値: -(必須引数 + 1)
      例:
      • ->(x = 0) {} # -1 (必須1 + 可変/オプション)
      • ->(x, *rest) {} # -2 (必須1 + 可変)
    • この PR はこの仕様を踏まえて「2つ目の引数を安全に渡せるかどうか」を判定しています。

#57715 Exclude all composite primary key components from content_columns

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    content_columns が複合主キー(composite primary key, CPK)のカラムを正しく除外できていなかった問題を修正し、CPK の全ての主キー構成カラムを確実に除外するようにしました。単一主キーの場合の挙動・ドキュメントと一貫した動作になります。

  1. 変更内容の詳細

何が問題だったか

元の実装:

ruby
def content_columns
  @content_columns ||= columns.reject do |c|
    c.name == primary_key ||
    c.name == inheritance_column ||
    c.name.end_with?("_id", "_count")
  end.freeze
end
  • 単一主キーの場合
    primary_key # => "id" なので、c.name == primary_key"id" カラムは除外される。

  • 複合主キーの場合
    primary_key # => ["author_id", "id"] のように Array を返すため、

    ruby
    c.name == primary_key # String == Array → 常に false

    となり、主キーのどのカラムもこの条件では除外されない。

  • たまたま動いていたケース

    • author_id は「_id で終わる」ので c.name.end_with?("_id", "_count") で除外される。
    • しかし "id"_id で終わらないため、どの条件にもマッチせず残ってしまう。

その結果:

ruby
Cpk::Book.primary_key            # => ["author_id", "id"]
Cpk::Book.content_columns.map(&:name)
# => ["id", "title", "revision"]   # 本来は PK を除いた ["title", "revision"] が期待

ドキュメント上も「primary id は除外される」とされているため、仕様に反する挙動になっていました。

どのように直したか

primary_key を常に Array として扱う Rails 既存のイディオムに合わせて修正:

ruby
# 修正後の条件部分イメージ
Array(primary_key).include?(c.name) ||
c.name == inheritance_column ||
c.name.end_with?("_id", "_count")

ポイント:

  • Array(primary_key) は以下のようにふるまう:
    • 単一主キー: primary_key == "id"Array("id") # => ["id"]
    • 複合主キー: primary_key == ["author_id", "id"] → そのまま ["author_id", "id"]
    • 主キーなし: primary_key == nilArray(nil) # => []
  • これにより:
    • CPK の全ての構成カラムが include? によって確実に除外される
    • 単一主キーでも "id" が正しく除外され続ける
    • 主キーなしモデルでは PK による除外条件は空配列になり、これまで通り「常に false」で影響なし

テスト

  • Cpk::Book (primary key [:author_id, :id]) に対して:
    ruby
    Cpk::Book.content_columns.map(&:name)
    # 修正前: ["id", "title", "revision"]
    # 修正後: ["title", "revision"]
    となることを test/cases/base_test.rb で検証(PR 説明中)。
  • 既存の単一主キー (Topic) に対する content_columns のテストはそのままグリーン。

コード変更自体は:

  • activerecord/lib/active_record/model_schema.rb の条件式を
    c.name == primary_keyArray(primary_key).include?(c.name) に 1行差し替え
  • テスト (activerecord/test/cases/reflection_test.rb) に CPK ケースを追加 (+5 行)

  1. 影響範囲・注意点
  • 影響する箇所:

    • ActiveRecord::Base#content_columns を利用しているコード全般
      • 管理画面ジェネレータや scaffold 系コード
      • content_columns ベースで「入力フォーム用カラム」や「表示カラム」を自動列挙しているような実装
  • 具体的な挙動変化:

    • 複合主キーのモデルのみ 影響を受ける。
      • 以前: CPK のうち、_id_count で終わらない主キー(例: "id", "uuid" など)が content_columns に含まれてしまうことがあった。
      • 変更後: それらも 全て content_columns から除外される
    • 単一主キー / 主キーなしモデルの挙動は、論理的には従来仕様と同等。
  • 注意点:

    • もし既存アプリケーションで「content_columns に主キーの一部が含まれていたこと」を前提にしていた場合(特に CPK モデル)、この PR 適用後にそのカラムが取れなくなります。
      • 例: content_columns を使って「リソースの ID も含めた表示カラム一覧」を作っていたようなケース。
    • CPK を使っていて、content_columns を利用している箇所がないか確認すると安全です。

  1. 参考情報 (あれば)
  • この PR で使われている Array(primary_key) は、以下のような CPK 対応箇所でも使われている既存パターン:
    • activerecord/lib/active_record/model_schema.rb
    • activerecord/lib/active_record/persistence.rb
    • activerecord/lib/active_record/relation/batches.rb
    • activerecord/lib/active_record/relation/calculations.rb
  • 複合主キーを使う際に、primary_key を直接文字列前提で比較せず、Array(primary_key) 経由で扱うのが Rails 内部実装の標準的な流儀になっています。

#57714 Treat remove_column with options but no type as irreversible

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    remove_column に「型なし+オプションあり」で書かれたマイグレーションが、これまで「可逆」と誤判定されて壊れた逆操作 (add_column) を生成していた問題を修正し、正しく IrreversibleMigration を投げるようにした PR です。これによりロールバック時の謎の例外ではなく、明示的な「型がないので不可逆」というエラーメッセージが得られるようになります。

  1. 変更内容の詳細

何が問題だったか

remove_column の逆操作は CommandRecorder で定義されていますが、その可逆性チェックが不完全でした。

元の実装:

ruby
def invert_remove_column(args)
  raise ActiveRecord::IrreversibleMigration, "remove_column is only reversible if given a type." if args.size <= 2
  super
end

args は記録された remove_column の引数配列です。

  • 正しく可逆なケース:
    ruby
    remove_column :users, :name, :string, null: false
    # 記録される args: [:users, :name, :string, { null: false }]
    # args.size == 4 → ガードを通過(OK)
  • 問題になるケース:
    ruby
    remove_column :users, :name, null: false
    # 記録される args: [:users, :name, { null: false }]
    # args.size == 3 → 「型がある」と誤解してガードを通過してしまう

この場合 super(親実装)が逆操作を次のように組み立てます:

ruby
[:add_column, [:users, :name, { null: false }], block]
#               table    name   "type"  options  と解釈されてしまう

つまり「本来は :string のような型が来るべき位置に options ハッシュが入り込む」ため、ロールバック時の add_column 呼び出しが壊れます。

結果として、ロールバック時には本来出したかった

remove_column is only reversible if given a type.

ではなく、以下のような意味不明な例外が発生していました。

  • 位置引数とキーワード引数の扱いによる ArgumentError: wrong number of arguments (given 2, expected 3)
  • あるいは NoMethodError: undefined method 'to_sym' for an instance of Hash

修正内容

兄弟メソッドである invert_remove_columns は、既に「型の有無」をより厳密に判定しており、

ruby
args[-1].is_a?(Hash) && args[-1].has_key?(:type)

という形で「最後の引数が Hash で、その中の :type キーで型が指定されている」ケースのみを「型あり」とみなしていました。

今回の修正では、invert_remove_column も同様の方針に寄せ、

ruby
def invert_remove_column(args)
  if args.size <= 2 || args[2].is_a?(Hash)
    raise ActiveRecord::IrreversibleMigration, "remove_column is only reversible if given a type."
  end
  super
end

と変更しています。

ポイント:

  • args.size <= 2
    → 旧来どおり、そもそも 3 つ目の引数(型に相当)が無い場合は不可逆。
  • args[2].is_a?(Hash)
    → 3 つ目が Hash(= options)であり、実際の型が渡されていない場合も不可逆として扱う。
    つまり remove_column :users, :name, null: false も不可逆。

これにより:

  • 可逆な例:
    ruby
    remove_column :users, :name, :string, null: false
    # args: [:users, :name, :string, { null: false }]
    # args[2] == :string(Hash ではない)→ super へ → add_column :users, :name, :string, null: false
  • 不可逆な例:
    ruby
    remove_column :users, :name
    # args: [:users, :name] → size <= 2 → IrreversibleMigration
    
    remove_column :users, :name, null: false
    # args: [:users, :name, { null: false }] → args[2].is_a?(Hash) → IrreversibleMigration

テスト

activerecord/test/cases/migration/command_recorder_test.rb にテストを追加:

ruby
inverse_of(:remove_column, [:table, :column, { null: false }])
# が IrreversibleMigration を raise することを確認
  • 修正前: 例外が出ず、壊れた add_column が生成される → 実行時にクラッシュ
  • 修正後: 期待どおり IrreversibleMigration が発生

既存のテスト(型あり・完全に引数不足のケース)はそのままグリーン。


  1. 影響範囲・注意点
  • 影響を受けるのは「change マイグレーションで remove_column を使い、型を省略したがオプションは渡していた」ケースです:

    ruby
    def change
      remove_column :users, :name, null: false  # これまで「可逆」と誤判定されていた
    end
  • このようなマイグレーションは、これまではロールバック時に

    • 「よく分からない ArgumentErrorNoMethodError」でコケていたものが、
    • 今後はマイグレーション起動直後に ActiveRecord::IrreversibleMigration で明示的に止まる

    ようになります。

  • 対応策としては、明示的に型を指定するか、up / down に分けて書く必要があります:

    ruby
    # 可逆にしたい場合
    def change
      remove_column :users, :name, :string, null: false
    end
    
    # 不可逆前提なら
    def up
      remove_column :users, :name, null: false
    end
    
    def down
      add_column :users, :name, :string, null: false
    end
  • 既に本番に適用済みのマイグレーションでこのパターンがある場合:

    • 以前から実質ロールバック不能だったものが、「はっきりそう表示される」ようになっただけとも言えるため、挙動としては安全側への変更です。
    • ただし「今まではたまたまロールバックしなかった環境で、今回初めてロールバックを試みる」といった場合に、IrreversibleMigration のエラーに気付くことになります。

  1. 参考情報 (あれば)
  • 修正対象クラス: activerecord/lib/active_record/migration/command_recorder.rb
  • コントラクトのドキュメント: command_recorder.rbremove_column (must supply a type) と明記されており、この PR はその仕様に実装を揃えるものです。
  • 類似ロジック: 複数カラム削除用の invert_remove_columns は既に :type キーの有無で判定しており、今回の変更で invert_remove_column も同じ思想に揃いました。

#57712 Fix counter_cache_column crash when no column is given on the has_many side

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    Rails 7.2系で、has_many / has_one 側に counter_cache: truecounter_cache: { active: false } を指定した際に NoMethodError: undefined method '-@' for nil で落ちるリグレッションが修正されました。カウンターキャッシュ列が省略された場合でも、従来どおり自動で xxx_count というカラム名が使われるようになります。

  1. 変更内容の詳細

問題のあったコード

has_many / has_one のリフレクションでカウンターキャッシュ列名を決定するメソッド counter_cache_column が、次のようになっていました:

ruby
-((counter_cache && -counter_cache[:column]) || "#{name}_count")

一方で、関連定義のオプションは normalize_options で以下のように正規化されます:

ruby
# reflection.rb#normalize_options
when Hash
  active = counter_cache.fetch(:active, true)
  column = counter_cache[:column]&.to_s   # カラム未指定時は nil
end
options[:counter_cache] = { active: active, column: column }

この結果:

  • counter_cache: true
  • counter_cache: { active: false }

のようにカラム名を明示しない場合、options[:counter_cache]{ active: true/false, column: nil } になります。

Ruby では Hash は常に truthy なので、

ruby
counter_cache && -counter_cache[:column]

は必ず -counter_cache[:column] を評価しようとし、counter_cache[:column]nil のときに -nil が実行されて NoMethodError になります。この例外は || "#{name}_count" に到達する前に発生するため、"books_count" などのデフォルト名にフォールバックできませんでした。

belongs_to 側では同様の処理が正しく書かれており、単項マイナスをカラム名そのものにはかけていません:

ruby
counter_cache[:column] || -"#{active_record.name.demodulize.underscore.pluralize}_count"

ここでの単項マイナス -@ は「文字列を freeze する」ための Active Support の仕様です(-"foo""foo".freeze)。

修正内容

has_many / has_one 側の実装から「余計な内側の -」を取り除きました:

ruby
# 修正前
-((counter_cache && -counter_cache[:column]) || "#{name}_count")

# 修正後
-((counter_cache && counter_cache[:column]) || "#{name}_count")

これにより:

  • counter_cache[:column] が nil なら || "#{name}_count" にフォールバックし、
  • 最終結果に対してだけ単項マイナス(freeze)をかける、

という belongs_to 側と整合した動作になります。

テスト追加

activerecord/test/cases/reflection_test.rb に以下のパターンを検証するテストが追加されています:

  • has_manycounter_cache: true を指定した場合 → "books_count" を返すこと
  • has_manycounter_cache: { active: false } を指定した場合 → "books_count" を返すこと

修正前はどちらも NoMethodError で落ち、修正後にパスすることが確認されています。既存の reflection / belongs_to のカウンターキャッシュ関連テストはすべてグリーンのままです。


  1. 影響範囲・注意点
  • 影響を受けるバージョン
    • リグレッションはコミット e79455f3d4(「バックフィル中に counter cache カラムを無視する機能の追加」)以降、Rails 7.2.0 から発生しています。
  • 影響を受けるコードパターン
    • has_many / has_one 側で次のように宣言しているケース:
      ruby
      has_many :books, counter_cache: true
      has_many :books, counter_cache: { active: false }  # 推奨されている安全なバックフィル形
    • これらの関連に対して:
      • author.books.create!
      • author.books.size
      • その他、HasManyAssociation#update_counter_in_memory を経由する操作 が NoMethodError: undefined method '-@' for nil で落ちる可能性がありました。
  • 影響を受けないパターン
    • counter_cache: :custom_name のように明示的にカラム名を指定している場合は、counter_cache[:column]nil ではないため、問題は発生しません。
    • belongs_to 側での counter_cache 設定も、本件のバグの影響は受けません(実装が別で、もともと正しくフォールバックする)。
  • 実務上の注意点
    • Rails 7.2.0 〜 7.2.x を使っていて、has_many / has_onecounter_cache: truecounter_cache: { active: false } を書いている場合は、この修正が入ったバージョンへのアップデートで例外が解消されます。
    • 一時的なワークアラウンドとしては、counter_cache: true の代わりに明示的なカラム名を指定することでも回避できます:
      ruby
      has_many :books, counter_cache: :books_count
      has_many :books, counter_cache: { active: false, column: :books_count }
      ただし、PRの修正が取り込まれたバージョンにアップデートできるなら、それが一番シンプルです。

  1. 参考情報 (あれば)
  • 該当コミット(リグレッション導入元): e79455f3d4 – 「Add the ability to ignore counter cache columns while they are backfilling」
  • 関連クラス / メソッド:
    • ActiveRecord::Reflection::AssociationReflection#counter_cache_column
    • ActiveRecord::Reflection::AssociationReflection#normalize_options
    • ActiveRecord::Associations::HasManyAssociation#update_counter_in_memory
  • -"string" という書き方は、Active Support による String#-@ の拡張で「文字列を freeze する」省略記法であり、本PRではその freeze の振る舞い自体は維持したまま、nil ハンドリングのみを修正しています。

#57708 Clear the connections registry on server restart

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    このPRは、Action Cable のサーバ再起動 (Server::Base#restart) 時に接続レジストリ(connections_map)がクリアされず、クローズ済み接続オブジェクトが蓄積してしまう不具合を修正します。再起動処理の中で connections_map.clear を実行することで、開発モードなどでの繰り返しリロード時に「死んだ接続」が溜まらないようにしています。

  1. 変更内容の詳細

問題の構造

現状の Server::Base#restart は以下の流れです(要点のみ):

ruby
def restart
  each_connection do |connection|
    connection.close(reason: ...)
  end

  @mutex.synchronize do
    @heartbeat_timer.shutdown if @heartbeat_timer
    @heartbeat_timer = nil
    @worker_pool.halt if @worker_pool
    @worker_pool = nil
    # executor, pubsub なども tear down
  end
end
  • each_connection で全接続に対して connection.close を呼ぶ。
  • 各接続は Socket#handle_close 内の server.remove_connection によって connections_map から削除される設計。
  • しかし handle_close はワーカープール上で非同期に send_async :handle_close される。
  • restart はそのワーカープールを @worker_pool.halt で停止してしまうため、キューに溜まっていた remove_connection コールバックが実行されない。
  • 結果として、既に close 済みの接続オブジェクトが connections_map に残り続ける。

このため:

  • #connections
  • open_connections_statistics

といったメソッドが、実際にはクローズ済みの接続を数え続け、かつオブジェクトを保持し続ける(リーク)状態になっていました。

特に問題になるのが開発モードのリロード:

  • engine.rbbefore_class_unload で、クラスアンロード前に毎回 ActionCable.server.restart が呼ばれる。
  • そのたびにクローズ済み接続が connections_map に残り、開発セッションが長くなるほど不要な接続オブジェクトが蓄積する。

先行する #57700 でハートビートタイマーの tear down ギャップが修正されましたが、connections_map のクリアが抜けていた、という位置づけです。

修正内容

restart の tear down セクション(@mutex.synchronize ブロック内)で、connections_map を明示的にクリアするようにしました。

イメージとしては以下のような変更です:

ruby
@mutex.synchronize do
  # Drop the closed connections. remove_connection normally runs on the
  # worker pool, which we halt below, so the entries would otherwise leak.
  connections_map.clear

  @heartbeat_timer.shutdown if @heartbeat_timer
  @heartbeat_timer = nil

  @worker_pool.halt if @worker_pool
  @worker_pool = nil

  # ...executor, pubsub など既存のリセット処理...
end

ポイント:

  • connections_map.clear@mutex 下で呼ばれており、他のリソースの tear down と同じクリティカルセクションに収まっている。
  • connections_map.delete / clear は冪等であるため、たまたま一部の handle_close がワーカープール停止前に走って remove_connection していても、clear と競合しない(実害がない)。

テスト

actioncable/test/server/base_test.rb にテストが追加されています:

  • 手順イメージ:
    1. サーバに接続を 1 件追加。
    2. server.restart を呼び出す。
    3. server.connections が空であることをアサート。
  • 修正前は、クローズ済み接続が connections_map に残っているためテストが失敗(red)。
  • 修正後は、connections_map.clear により connections が空になりテスト成功(green)。
  • 既存の base_test.rb のテスト群にも影響はなく、すべて green。

  1. 影響範囲・注意点
  • 対象コンポーネント: ActionCable::Server::Base の再起動ロジック。
  • 主な影響:
    • 開発モードでのコードリロード(before_class_unloadActionCable.server.restart)を繰り返しても、クローズ済み接続が内部に溜まらなくなる。
    • #connectionsopen_connections_statistics が、より正確に「現在オープンしている接続」を反映するようになる。
  • メモリ使用:
    • 既存挙動では、dev 環境で長時間開発するとクローズ済み接続オブジェクトが Action Cable サーバに保持され続け、メモリ使用量が徐々に増加しうる。
    • この PR により、restart 実行ごとにレジストリをクリアするため、そうしたリーク傾向が抑制される。
  • 互換性:
    • restart 後に connections を参照するコードが、以前も論理的には「接続がクローズされているべき」タイミングであるため、connections が空になるのは自然な挙動の修正と考えられます。
    • restart を「ソフト再起動」と捉えて手動で何らかの状態を引き継いでいたコードがある場合は、connections に依存していると挙動が変わる可能性がありますが、設計的には restart 後はクリーンな状態が期待されるため、むしろ仕様に沿った形になります。
  • スレッド・ロック:
    • connections_map.clear は既存の @mutex 内で実行されるため、新たな競合やデッドロック要因は導入していません。
    • handle_close / remove_connection との整合性も冪等性により担保されています。

  1. 参考情報 (あれば)
  • 関連 PR: #57700
    • 同様に Action Cable サーバの tear down ギャップ(ハートビートタイマーの後処理漏れ)を修正した PR。
  • 修正ファイル:
    • actioncable/lib/action_cable/server/base.rb
      • restart 内に connections_map.clear 追加。
    • actioncable/test/server/base_test.rb
      • restart 後に connections が空であることを確認するテスト追加。
  • CHANGELOG:
    • バグフィックスのため、CHANGELOG への追記は行われていません。

#57717 Coerce seeds and use_metadata_table booleans in UrlConfig

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    DATABASE_URL のクエリパラメータで指定された seedsuse_metadata_table が、文字列 "false" のまま扱われて常に truthy になっていた問題を修正し、既存の replica / database_tasks と同様に真偽値へ強制変換するようにした PR です。これにより、?seeds=false?use_metadata_table=false が正しく false として扱われます。

  1. 変更内容の詳細

どのような問題だったか

UrlConfig では、DATABASE_URL のクエリ文字列から読み込んだ設定を @configuration_hash に格納しています。このとき、クエリ文字列の値はすべて文字列になるため、そのままだと "false" も Ruby では truthy です。

過去の変更ですでに以下の2つは boolean 変換されていました:

ruby
to_boolean!(@configuration_hash, :replica)
to_boolean!(@configuration_hash, :database_tasks)

しかし :seeds:use_metadata_table は変換されておらず、例えば:

ruby
postgres://host/db?seeds=false

と書いても、

ruby
configuration_hash[:seeds] #=> "false"(Stringで truthy)

となり、seeds? が truthy を返してしまうため、db:prepare のシーディングを無効化できませんでした。use_metadata_table についても同様で、メタデータテーブルの使用を DATABASE_URL 経由で無効化できない状態でした。

seeds? / use_metadata_table? の定義は以下のようになっており、保存されている値をそのまま返すため、変換漏れがそのまま挙動不良になっていました:

  • seeds? : configuration_hash.fetch(:seeds, primary?)
  • use_metadata_table? : configuration_hash.fetch(:use_metadata_table, true)

今回の修正内容

UrlConfig の初期化処理に、seedsuse_metadata_table も boolean 変換対象として追加しました:

ruby
to_boolean!(@configuration_hash, :replica)
to_boolean!(@configuration_hash, :database_tasks)
to_boolean!(@configuration_hash, :seeds)               # ← 追加
to_boolean!(@configuration_hash, :use_metadata_table)  # ← 追加

これにより、以下のような URL が:

ruby
postgres://host/db?seeds=false&use_metadata_table=false

内部的には:

ruby
configuration_hash[:seeds]            #=> false
configuration_hash[:use_metadata_table] #=> false

config.seeds?             #=> false
config.use_metadata_table? #=> false

と解釈されるようになります。

テストの追加

activerecord/test/cases/database_configurations/url_config_test.rb にテストが追加されています。内容は既存の database_tasks に対するテストを踏襲しており、以下を検証しています:

  • ?seeds=falseseeds?false を返す
  • ?use_metadata_table=falseuse_metadata_table?false を返す

修正前は "false"(String)が入るためテストが失敗し、修正後に通ることを確認しています。


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

    • DATABASE_URL 経由で seeds / use_metadata_table を指定している環境に影響します。
    • これまで "false" が truthy として扱われていたため、以下のように「うっかり動いていた」可能性があります:
      • ?seeds=false を付けていたが、実際には seeds が実行されていた
      • ?use_metadata_table=false を付けていたが、実際にはメタデータテーブルが使われていた
  • 破壊的な変化の可能性

    • もし現状、「seeds / use_metadata_table を本当に true にしたいが、DATABASE_URL 上の都合で ?seeds=false のような値を付けていた」という特殊な誤用をしていると、今回の修正で false として扱われるようになり挙動が変わります。
    • 通常は「明示的に false を指定したいのに効いていなかった」という状況が正しくなる方向なので、意図せぬ挙動変更は限定的なはずです。
  • マイグレーション・タスクへの影響

    • Rails の DB 関連タスク(db:prepare など)で seeds? / use_metadata_table? の値を見て挙動を変える部分に影響します。
    • CI や本番で DATABASE_URL?seeds=false を指定して seed 実行を抑制したい、といったユースケースが正しく動くようになります。

  1. 参考情報 (あれば)
  • 本 PR が修正している過去コミットの意図:
    • 63631e2d5b: 「schema_dump, query_cache, replica, database_tasksDATABASE_URL で設定可能にする」
      • コメント上は「すべての boolean 設定を扱う」とされていたが、seeds / use_metadata_table が変換漏れしていたものを今回補完。
  • 関連する boolean 設定(全て DATABASE_URL のクエリ文字列から boolean として解釈されるべきもの):
    • schema_dump
    • query_cache
    • replica
    • database_tasks
    • seeds(今回追加)
    • use_metadata_table(今回追加)

#57719 Raise RecordNotFound for find(nil) on a composite primary key model

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    複合主キーを持つモデルで Model.find(nil) や引数なしの Model.find を呼び出した際に、想定外の NoMethodError が発生していた問題を修正し、単一主キーと同様に ActiveRecord::RecordNotFound を投げるようにしたPRです。複合主キー用の内部判定ロジックに nil ガードを追加し、それに対応するテストを追加しています。

  1. 変更内容の詳細

問題の背景

単一主キーのモデルでは、以下のような呼び出しは:

ruby
Post.find           # => ActiveRecord::RecordNotFound: Couldn't find Post without an ID
Post.find(nil)      # => ActiveRecord::RecordNotFound: Couldn't find Post without an ID

のように ActiveRecord::RecordNotFound が投げられるのが仕様です。

しかし、複合主キーを持つモデル (例: Cpk::Book) では:

ruby
Cpk::Book.find
Cpk::Book.find(nil)

を呼ぶと、ActiveRecord::RecordNotFound ではなく:

text
NoMethodError: undefined method `first` for nil:NilClass

が発生していました。

原因は ActiveRecord::Key::Composite#expects_multiple_ids? の実装で、引数が nil の場合の考慮がなく、nil.first を呼び出してしまっていたことです。

既存コード(問題箇所)

ruby
# Key::Composite
def expects_multiple_ids?(value)
  value.first.is_a?(Array)      # nil.first => NoMethodError
end

これに対し、単一主キーのパスでは nil セーフになっていました:

ruby
# Key::Single
def expects_multiple_ids?(value)
  value.is_a?(Array)            # nil-safe
end

修正内容

複合主キー側の expects_multiple_ids? にも同様の nil ガードを追加し、配列であることと、その先頭要素も配列である場合のみ「複数IDが渡されている」とみなすように変更しています。

修正後コード

ruby
def expects_multiple_ids?(value)
  value.is_a?(Array) && value.first.is_a?(Array)
end

これにより:

  • value == nil の場合:
    • value.is_a?(Array)false → 全体として false
    • 以降の処理で正しく「単一ID扱い」になり、結果として RecordNotFound が発生するパスに流れる
  • value == [ [1, 2], [3, 4] ] のような「複数レコード用複合キー配列」の場合:
    • value.is_a?(Array)true
    • value.first.is_a?(Array)true
    • 複数IDとして処理される (期待どおりの挙動)

テスト追加

activerecord/test/cases/finder_test.rb に、すでに単一主キー用に存在しているテスト (test_find_with_ids_with_no_id_passed) と並べて、複合主キー用のテストを追加しています。

追加されたテストの意図は以下の2点:

ruby
assert_raises(ActiveRecord::RecordNotFound) { Cpk::Book.find }
assert_raises(ActiveRecord::RecordNotFound) { Cpk::Book.find(nil) }

以前はこれらが NoMethodError になっていたのが、修正後は期待どおり RecordNotFound になることを検証しています。


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

    • 複合主キーを使用している ActiveRecord モデル (self.primary_keys = ... を設定しているモデルなど) の find 振る舞いに影響します。
    • 対象は以下パターン:
      • Model.find (引数なし)
      • Model.find(nil)
    • これらの呼び出しで、以前は NoMethodError が出ていたケースが ActiveRecord::RecordNotFound に変わります。
  • 互換性

    • 正しい例外クラスに揃えるバグフィックスであり、ドキュメント上も RecordNotFound が想定されているため、仕様としては後方互換的な修正です。
    • ただし、既存コードが誤って NoMethodError を捕まえていた場合は、例外クラスの変更により挙動が変わる可能性があります。そのようなコードは ActiveRecord::RecordNotFound を捕まえるべきです。
  • 複合主キー用の複数ID指定

    • Model.find([[id1a, id1b], [id2a, id2b]]) のような複数ID指定は、value が配列であり、先頭要素も配列であるため、引き続き問題なく「複数ID」として扱われます。
    • Model.find([id1a, id1b]) のような「単一レコード用の複合キー配列」も、従来通り単一ID扱いです (expects_multiple_ids?false)。

  1. 参考情報 (あれば)
  • 修正ファイル:

    • activerecord/lib/active_record/key.rb
      • Key::Composite#expects_multiple_ids? のロジック修正 (+1/-1)
    • activerecord/test/cases/finder_test.rb
      • 複合主キー向けの find に関するテストを追加 (+5)
  • 関連仕様:

    • Rails ガイド/ドキュメント上で Model.find(nil)ActiveRecord::RecordNotFound を投げることになっており、本PRは複合主キーでもこの仕様に揃えるためのバグ修正です。

#57722 Don't log a dangling "with arguments:" for jobs with no arguments

マージ日: 2026/6/14 | 作成者: @55728

  1. 概要 (1-2文で)
    Active Job のログ出力で、引数なしジョブにも with arguments: という文言だけがぶら下がって出力されてしまう問題を修正する PR です。構造化イベント対応時に削除されていた「引数が空かどうかのチェック」を復元し、従来通りのログフォーマットに戻しています。

  1. 変更内容の詳細

問題の背景

ActiveJob::LogSubscriber#args_info は、ジョブの引数をログに出すためのヘルパーメソッドです。
構造化イベント対応(event[:payload] ベースの実装)に書き換えられた際に、もともとあった「引数が空でないかのチェック (any?)」が抜け落ちました。

以前は概ね以下のようなロジックでした(イメージ):

ruby
# 以前(構造化イベントリライト前)のイメージ
def args_info(job)
  if job.class.log_arguments? && job.arguments.any?
    " with arguments: " + job.arguments.map { ... }.join(", ")
  else
    ""
  end
end

これが構造化イベント対応後は以下のようになっていました:

ruby
def args_info(event)
  if (arguments = event[:payload][:arguments])
    " with arguments: " + arguments.map { ... }.join(", ")
  else
    ""
  end
end

ここで:

  • 引数なしジョブの場合でも event[:payload][:arguments] には空配列 [] が入る
  • Ruby では [] は truthy なので if (arguments = ...) が常に真になる
  • その結果、"with arguments: " という接頭辞だけ付き、肝心の中身が空の状態でログに出てしまう

例:

Enqueued MyJob (Job ID: ...) with arguments:
Performing MyJob (Job ID: ...) with arguments:

(「with arguments: 」の後ろが空)

今回の修正

この PR では、以前の挙動と同じく「引数が空配列でないこと」をチェックするように修正しています:

ruby
# activejob/lib/active_job/log_subscriber.rb

def args_info(event)
  if (arguments = event[:payload][:arguments]) && arguments.any?
    " with arguments: " + arguments.map { ... }.join(", ")
  else
    ""
  end
end

これにより:

  • 引数ありのジョブ: これまで通り with arguments: foo, bar のように出力
  • 引数なしのジョブ: with arguments: 自体を出力しない(完全に空文字を返す)

テストの追加

activejob/test/cases/logging_test.rb にテストが追加されています。

  • 引数なしジョブ (ConfigurationJob など) を enqueue して実行
  • ログ出力をキャプチャし、with arguments: という文字列が含まれないことをアサート

テストの意図:

  • Enqueued ログ行
  • Performing ログ行

のどちらにも余計な with arguments: が付いていないことを保証しています。


  1. 影響範囲・注意点
  • 影響範囲は Active Job のログ出力のみです。ジョブの実行ロジックやキューイング処理そのものには影響しません。
  • 引数ありジョブのログフォーマットは変わりません。変わるのは「引数なしジョブの時に、with arguments: という文言が出るかどうか」のみです。
  • 構造化イベント導入後(このバグが入り込んでいた期間)に、ログパースや監視ツール側で「with arguments: が常に出る前提」で実装していた場合は、今回の修正でその前提が崩れます。
    • ただし、元々の(構造化イベント前の)Rails の挙動に戻っただけなので、正しい仕様は「引数がある場合のみ with arguments: を出す」と理解しておくのがよいです。
  • パフォーマンス面の影響はほぼゼロです。arguments.any? のチェック追加のみで、ログ出力時にしか呼ばれません。

  1. 参考情報 (あれば)
  • 対象クラス: ActiveJob::LogSubscriber
    • ジョブの Enqueued, Performing, Performed などのログフォーマットを司るクラス
  • バグの原因: 構造化イベント対応のリファクタリング中に .any? チェックが削除されたことによる仕様逸脱
  • テストファイル: activejob/test/cases/logging_test.rb
    • ログメッセージの仕様がテストで明示されているので、カスタムロガーやログパーサを書く際の参考になります。

#57709 Restart the Redis listener thread when it dies

マージ日: 2026/6/14 | 作成者: @chaadow

  1. 概要 (1-2文で)
    Action Cable の Redis サブスクリプションアダプタにおいて、リスナースレッドが死んだまま二度と復帰せず、そのプロセスでのブロードキャストが黙って失われる問題を修正する PR です。リスナースレッドが死亡していた場合に再起動できるようにし、その振る舞いをテストで保証しています。

  1. 変更内容の詳細

問題の背景

Action Cable の Redis アダプタでは、Redis の pub/sub 用に「リスナースレッド」が常駐し、購読中のチャンネルに届いたメッセージを受け取っています。

ensure_listener_running

ruby
@thread ||= Thread.new { ... }

のように「一度作ったスレッドをメモ化」して再利用する仕組みになっています。

ところが、以下のようなケースでスレッドが死ぬと問題が起こります。

  • 再接続処理の試行回数 (reconnect_attempts) を使い切ってしまった場合
    • ensure_listener_running の内部で Redis 再接続をリトライするが、retry_connecting?false を返した時点で rescue ブロックを抜けてスレッド終了
  • スレッドオブジェクト自体は @thread に残るが、既に dead になっている (non-nil かつ @thread.alive? == false)
  • その後の subscribe 呼び出しは、@thread ||= ...||= によって「既に @thread があるから新しいスレッドは作らない」という挙動になり、新しいリスナーが起動しない
  • 新しい購読要求は @when_connected キューに積まれるが、@subscribed_clientnil のままなので一向に処理されず、そのプロセスでは以後のブロードキャストが全部「無視される」状態になる

これにより、プロセス再起動まで該当プロセス配下の WebSocket 接続に対するブロードキャストが失われてしまう、という致命的な不具合が生じていました。

修正内容 (リスナースレッドの再起動)

actioncable/lib/action_cable/subscription_adapter/redis.rb において、ensure_listener_running の実装が変更されています。

ポイントは以下です。

  • @thread が存在していても「死んでいるスレッド」なら一度 @threadnil にリセットする
  • その上で @thread ||= Thread.new によって新しいリスナースレッドを起動する
  • 併せて、再接続回数カウンタも「スレッド再生成時」にリセットされるようにする (=新しいリスナーとしてまっさらな状態で復帰)

疑似コードイメージ:

ruby
def ensure_listener_running
  # 1. すでにあるスレッドが死んでいたら破棄
  if defined?(@thread) && @thread && !@thread.alive?
    @thread = nil
    @reconnect_attempts = 0 # 実際にはそれに準じたカウンタのリセット
  end

  # 2. 生きているスレッドがなければ新しく起動
  @thread ||= Thread.new do
    begin
      # Redis 接続&購読ループ
    rescue => e
      # retry_connecting? が false でなければ再接続を試みる
      # ここで試行し尽くしてスレッドが死ぬとき、
      # 次回 ensure_listener_running が呼ばれた際に上記の判定で再起動される
    end
  end
end

これにより、

  • 再接続試行を使い切って一度はスレッドが死んでも
  • 次の subscribe 呼び出し時に ensure_listener_running が「死んだスレッド」を検知して破棄
  • 新しいリスナースレッドを起動し直す

という復旧パスが追加されます。

テストの追加

actioncable/test/subscription_adapter/redis_test.rb に回帰テストが追加されています。

内容の要点:

  • reconnect_attempts: 0 の設定で Redis アダプタを初期化
    • これにより「再接続を1度も行わず即座に諦める」挙動を強制
  • pub/sub 接続を意図的に切ることでリスナースレッドを殺す
  • 新しいチャンネルに subscribe し、その後のブロードキャストが再び届くことを確認
    • = 死んだリスナースレッドが再起動されたことの検証

また、このテストで「スレッドの死をポーリングで待つ」ために、ActionCable::TestCase に小さな wait_for ヘルパーが追加されています (actioncable/test/test_helper.rb)。

  • Active Record のテストスイートにある wait_for と同様のユーティリティ
  • 一定時間、条件が満たされるまでポーリングするために利用
  • 固定 sleep に頼らず、安定かつ高速なテストにする意図

  1. 影響範囲・注意点
  • 対象: Action Cable で Redis アダプタ (ActionCable::SubscriptionAdapter::Redis) を使用している環境
  • 直接の影響:
    • これまで「Redis との pub/sub 接続が落ちて再接続試行を使い切った後、リスナースレッドが死にっぱなしになる」ケースで、ブロードキャストが黙って失われていた
    • この PR により、その後の新規 subscribe のタイミングでリスナースレッドが自動的に再起動し、ブロードキャストが復旧するようになる
  • Sentinel サブスクリプションとの関係 (#57690):
    • #57690 では「unsubscribe による購読数 0 → スレッド終了」という典型的な死因を、内部の sentinel 購読によって防いだ
    • 本 PR は「それ以外の死因 (例: 再接続試行の枯渇)」に対して、死んだあとに再起動する経路を追加する
    • 両方を合わせることで、通常運用時のスレッド死亡も、障害時の死亡もカバーし、より堅牢になる

運用上の注意:

  • reconnect_attempts を 0 やかなり小さい値にしていると、障害発生時に「すぐにリスナーが死んでは subscribe のたびに再起動される」ような挙動も起こり得ます
    • ただし、その場合でも「黙って配信ロストし続ける」よりは安全側の挙動です
    • 本 PR 自体は設定値を変えていないので、必要なら各アプリ側で reconnect_attemptsreconnect_delay などのチューニングを検討するとよいです

  1. 参考情報 (あれば)
  • 関連 PR: #57690 (Redis リスナースレッドが unsubscribe で死ぬ問題に対する sentinel サブスクリプションの導入)
  • 実装の考え方:
    • 長寿命スレッドを @thread ||= ... でメモ化する場合、「スレッドが正常終了 or 例外死したときにそれを検知して再生成するパスを用意する」ことが重要
    • 今回のように @thread&.alive? をチェックし、dead なら @thread = nil してから再生成するパターンは、スレッドプール/バックグラウンドワーカーなどでも使える定石です

#57697 Don't fire remove_channel for an unknown channel

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Action Cable の SubscriberMap#remove_subscriber が、存在しないチャンネルに対しても remove_channel(UNLISTEN / unsubscribe)を発火してしまうバグを、ハッシュのキー存在チェックで防ぐ修正です。これにより、まだ購読登録されていない・すでに削除済みのチャンネルに対して、不要な UNLISTEN / unsubscribe が飛ばなくなります。

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

問題の背景

SubscriberMap の内部では、購読者リストを次のようなハッシュで管理しています:

ruby
@subscribers = Hash.new { |h, k| h[k] = [] }

この「デフォルト proc 付きハッシュ」の特徴は、@subscribers[channel] とアクセスした時に、そのキーが存在しないと自動的に channel => [] が追加される(オートバイビフィケーション)ことです。

  • broadcastadd_subscriber は、事前に @subscribers.key?(channel) を見て、未知のチャンネルでは何もしないようになっていました。
  • 一方で、remove_subscriber だけが 無条件で @subscribers[channel] にアクセス しており、存在しないチャンネルに対しても空配列を自動生成してしまっていました。

その結果:

  1. 存在しないチャンネルに対して remove_subscriber が呼ばれる
  2. @subscribers[channel] が空配列として勝手に作られる
  3. 当然空なので @subscribers[channel].empty?true
  4. remove_channel channel が呼ばれ、実際に UNLISTEN / Redis unsubscribe が発行される

つまり「サーバー自身は購読していないチャンネル」に対しても、UNLISTEN / unsubscribe を投げてしまう不整合が発生していました。

特に SubscriberMap::Async 経由の非同期処理では、

  • add_subscriberremove_subscriber別タスク として executor に投げている
  • そのため「subscribe → すぐ unsubscribe」のようなケースで、「remove の方が先に走る」ことがある

このレース条件により、「まだチャンネル登録が完了していないのに remove_subscriber が走り、未知のチャンネルとしてオートバイビファイ → 不要な UNLISTEN が発行」という状況が現実に起こり得ます。

修正内容

remove_subscriber にも broadcast と同様のキー存在チェックを追加し、「知らないチャンネルなら何もしない」ように合わせました。

修正差分:

diff
 def remove_subscriber(channel, subscriber)
   @sync.synchronize do
+    return if !@subscribers.key?(channel)
+
     @subscribers[channel].delete(subscriber)

     if @subscribers[channel].empty?
       @subscribers.delete channel
       remove_channel channel
     end
   end
 end

ポイント:

  • @sync.synchronize ブロックの中で @subscribers.key?(channel) をチェック
  • キーがなければその場で return(呼び出し元は戻り値を使っていないため互換性問題なし)
  • これにより @subscribers[channel] アクセスによるオートバイビフィケーションを避ける

テストの追加

SubscriberMapTest に新しいテストが追加されています:

  • 「unknown な channel に対して remove_subscriber を呼んでも remove_channel が呼ばれない」ことを検証
  • 既存の「broadcast should not change subscribers」に並ぶ形でテストが追加

テストの結果:

  • 新規テスト: 修正前は remove_channel が 1 回呼ばれるため RED、修正後は 0 回で GREEN
  • actioncable/test/subscription_adapter/subscriber_map_test.rb 全体: 2 run / 0 failure

  1. 影響範囲・注意点
  • 対象箇所:
    • actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb
    • actioncable/test/subscription_adapter/subscriber_map_test.rb
  • 主な影響:
    • Action Cable の Redis / PostgreSQL / Inline いずれの pubsub アダプタでも、未知のチャンネルに対する remove_subscriber 呼び出しは完全に無視されるようになります。
    • その代わりに「不要な UNLISTEN / unsubscribe」が送られなくなるため、バックエンド側のログやメトリクスのノイズが減り、状態整合性も改善されます。
  • 後方互換性:
    • 呼び出し元は remove_subscriber の戻り値を利用しておらず、副作用(チャンネルを本当に unsubscribe するかどうか)のみを期待している実装なので、仕様上は「より正しい挙動」への修正です。
    • 「存在しないチャンネルに対して unsubscribe を投げる」ことに依存しているアプリケーションがある可能性は極めて低く、実質的に互換性問題はないと考えられます。
  • レース条件への影響:
    • Async アダプタでの「subscribe → 直後に unsubscribe」ケースなど、タスクの順序が逆転しても、「まだ登録されていないチャンネルへの remove では何も起きない」という安全な挙動になります。
    • 逆に言えば、「remove の方が先に走った場合、そのチャンネルは一度も UNLISTEN されない」ということになりますが、サーバー側ではそもそも購読が開始されていないため、状態としては一貫しています。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57697
  • 修正対象クラス: ActionCable::SubscriptionAdapter::SubscriberMap
  • 関連する既存の挙動:
    • broadcast は既に return if !@subscribers.key?(channel) を行っていた
    • add_subscriberkey? を用いて「新規チャンネルかどうか」を判定しており、今回の修正で 3 メソッドのポリシーが統一された形になります。

#57677 Fix Array#to_query to skip empty Array elements, mirroring the empty-Hash case

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Array#to_query が、ネストされた空配列の要素をクエリ文字列から除外するように修正されました。これにより、既に実装されていた「空ハッシュ要素は落とす」という挙動と整合がとれ、余計な「値なしパラメータ」が出力されなくなります。

  1. 変更内容の詳細

問題となっていた挙動

Array#to_query は以前の修正により、「ネストされた要素をシリアライズした結果が空文字列なら、その要素はクエリから除外する」というフィルタリングを行うようになっていました。

  • 空ハッシュ {} の場合は {}.to_query(prefix) が空文字列になり、結果的にその要素はクエリから削除される挙動になっていた。

一方で空配列 [] については内部実装上、次のような挙動になっていました。

  • [].to_query(prefix)nil.to_query(prefix) にフォールスルーする
  • nil.to_query(prefix) は「キーだけ」をエスケープした文字列 (例: a%5B%5D%5B%5D) を返す
  • その結果、値がないパラメータ断片(a[][] に相当)がクエリ文字列に紛れ込む

具体例:

ruby
require "active_support/core_ext/object/to_query"

[1, [], 2].to_query("a")
# 修正前: "a%5B%5D=1&a%5B%5D%5B%5D&a%5B%5D=2"
#                         ↑ これが余計なセグメント

{ a: [1, {}, 2] }.to_query
# => "a%5B%5D=1&a%5B%5D=2"  # 空ハッシュ要素は既に除外されていた

{ a: [1, [], 2] }.to_query
# 修正前は "a%5B%5D=1&a%5B%5D%5B%5D&a%5B%5D=2"
# 本来は空ハッシュ同様、空配列要素も除外したい

修正内容

Array#to_query 内で、要素ごとに to_query へ委譲する前に、以下のチェックを追加しました。

  • 要素が「空ハッシュ」または「空配列」であれば、その要素はスキップする(クエリに出さない)

ポイント:

  • これは既に Hash#to_query では行われていたロジックであり、Array#to_query でも同じガードを入れて挙動を揃えた形です。
  • トップレベルが空配列のケースは従来どおりで、変更していません。

例:

ruby
[].to_query("a")
# 修正前後とも: "a%5B%5D"
# (トップレベル空配列は「キーだけの配列パラメータ」として扱われる仕様のまま)

{ a: [1, [], 2] }.to_query
# 修正前: "a%5B%5D=1&a%5B%5D%5B%5D&a%5B%5D=2"
# 修正後: "a%5B%5D=1&a%5B%5D=2"

テストの追加

activesupport/test/core_ext/object/to_query_test.rb に以下のテストが追加されました。

  • test_array_with_empty_array
    • test_array_with_empty_hash と対になるテスト
    • { a: [1, [], 2] }.to_query"a%5B%5D=1&a%5B%5D=2" になることを検証

このテストは修正前の実装では失敗し、修正後にパスすることが確認されています。


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

    • ActiveSupportObject#to_queryArray#to_query を使っているコードのうち、
      「配列の中に空配列を含むパラメータ」をクエリにシリアライズしている箇所が影響します。
    • 典型的には、フォームヘルパーやURLヘルパーを通して生成されるパラメータで、
      ネストした配列を扱っているケース(例: params[:a] = [1, [], 2])です。
  • 互換性の観点

    • これまでは「意図しない余分なキーだけのパラメータ」が出ていた状態であり、
      仕様というよりは不整合/バグに近い挙動です。
    • ただし、もし既存コードで「この余分なキー (例: a[][]) をサーバ側で何らかのトリガーとして利用していた」ような特殊な処理があれば、そのキーが今後送られてこなくなります。
    • 一般的なRailsアプリでは、むしろ意図通りのクリーンなクエリ文字列になる改善と考えてよいです。
  • 注意点

    • トップレベル空配列 ([].to_query("a") #=> "a%5B%5D") の挙動は変わっていません。
      「配列の中にある空配列」をスキップするだけで、「配列そのものが空」のケースは従来のままです。
    • 「空の配列要素を明示的に送りたい」ようなユースケースがある場合は、別の表現(nil や特別な値)で表す必要があります。

  1. 参考情報 (あれば)
  • 該当PR: https://github.com/rails/rails/pull/57677
  • 関連コミット: 68160c4417 (Array#to_query のフィルタリングロジックが導入されたコミット)
  • 関連メソッド:
    • Object#to_query
    • Hash#to_query
    • Array#to_query (ActiveSupport コア拡張)

#57655 Support a single composite primary key id in update/update!

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Rails の ActiveRecord::Base.update / update! が、複合主キー(composite primary key)モデルに対して「単一レコードの id」として配列を渡した場合に RecordNotFound を出してしまう問題を修正した PR です。複合主キーでも単一主キーと同様に Model.update(record.id, attrs) が正しく単一更新として動作するようになります。

  1. 変更内容の詳細(あればサンプルコードも含めて)

これまでの挙動と問題点

単一主キーのモデルでは、クラスメソッド update / update! は以下の2パターンを取りうる挙動をします:

ruby
# 単一レコード更新
Topic.update(1, title: "updated")

# 複数レコード更新
Topic.update(
  [1, 2],
  [{ title: "updated 1" }, { title: "updated 2" }]
)

ここで、「複数 id かどうか」の判定に id.is_a?(Array) が使われていました。

複合主キーのモデルでは、1レコードの id 自体が配列になります(例: [author_id, id])。そのため:

ruby
book = Cpk::Book.first

# 期待: book 1件だけを更新
Cpk::Book.update(book.id, title: "updated")

という呼び出しにおいて、book.id が配列であるため 「複数 id」と誤判定 されてしまい、内部的に要素ごと(author_idid 各々)で find を呼び出そうとして ActiveRecord::RecordNotFound になる、というバグが発生していました。

修正内容

この PR では、「単一 id か複数 id か」の判定ロジック を以下のように変更しています。

  • まず :all(id 省略)パターンを優先的に判定
    Model.update(:all, attrs) の既存挙動を壊さないため。
  • それ以外の場合に、「複数 id かどうか」を以下のルールで判断する private メソッド update_multiple_ids? を新設:
    • 単一主キー:
      • id が配列なら「複数 id」(例: [1, 2]
    • 複合主キー:
      • 1レコードの id は配列(例: [author_id, id]
      • 複数レコードなら「配列の配列」(例: [[author1, id1], [author2, id2]]
      • したがって、「複合主キーで複数 id」の場合は id が「配列の配列」であることをチェックする

Relation#destroy が既に同様のロジック(配列の配列かどうか)で判定しており、その考え方を update / update! に合わせた形です。

この helper メソッドは updateupdate! の両方から使われ、単一主キーのコードパスは(挙動として)変わらないように実装されています。

修正後のサンプルコード

ruby
# 単一主キー: これまで通り
Topic.update(1, title: "updated")                      # 単一更新
Topic.update([1, 2], [{ title: "a" }, { title: "b" }]) # 複数更新

# 複合主キー: この PR により、以下が正しく動作する

book = Cpk::Book.first
# book.id は [author_id, id] のような配列

# 1. 単一レコード更新 (以前は RecordNotFound になっていた)
Cpk::Book.update(book.id, title: "updated")
# => 1件の book が更新される

# 2. 複数レコード更新 (配列の配列を渡す)
books = Cpk::Book.limit(2)
Cpk::Book.update(
  books.map(&:id),               # [[author1,id1], [author2,id2]]
  [{ title: "a" }, { title: "b" }]
)
# => 2件の book がそれぞれ更新される

テストとしては activerecord/test/cases/persistence_test.rb に、複合主キーの単一 id 更新が成功することなどを確認するケースが追加されています。


  1. 影響範囲・注意点
  • 複合主キーを使っているアプリ:
    • これまで Model.update(record.id, attrs)RecordNotFound になっていたケースが、正しく単一更新として動作するようになります。
    • もしバグ回避のために独自実装(find + update など)を入れていた場合は、PR 適用後にそれを簡略化できる可能性があります。
  • 単一主キーのモデル:
    • 仕様としての挙動は変わらず、update(1, attrs) / update([1, 2], [attrs1, attrs2]) は従来通りに動作する想定です。
  • 複数 id の引数形式:
    • 複合主キーで複数レコードを update する場合は、「配列の配列」形式で id を渡す必要がある点に注意してください。
      • OK: [[author1, id1], [author2, id2]]
      • NG(意図した複数更新にはならない / 単一扱いになる): [author1, id1](これは単一レコードの id)
  • :all の扱い:
    • Model.update(:all, attrs) の挙動は変わりません。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57655
  • 類似の判定ロジック: Relation#destroy が複合主キー対応のために行っている「配列の配列」判定と同等の考え方を、update / update! にも適用した修正です。

#57690 Keep the Redis listener alive when the last channel is unsubscribed

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Redis クライアントの実装変更により、最後のチャネルが UNSUBSCRIBE された瞬間に Action Cable の Redis リスナー用スレッドが終了してしまう不具合があり、それを修正する PRです。内部用の「番兵」チャネル(_action_cable_internal)への購読を復活させることで、通常の購読解除ではリスナーが死なず、明示的なシャットダウン時のみ終了するようにしました。

  1. 変更内容の詳細

問題の背景

  • 以前の redis gem ベースの実装では、Action Cable は常に _action_cable_internal という内部チャネルに購読していました。

  • このため Redis の「購読数」は、アプリ側の全てのチャンネルを UNSUBSCRIBE しても 1 は維持され、Redis の subscribe ループがゼロ購読で終了することは「明示的な shutdown 時」以外には起こりませんでした。

  • 新しい redis-client ベースの実装 (ef812c2652) では、この内部チャネルへの購読がなくなり、

    • 最後のユーザーチャネルが UNSUBSCRIBE されると購読数が 0 になり
    • listen ループが break し、@subscribed_clientnil にされる
      という挙動になっていました。
  • ensure_listener_running は以下のようなガードになっており:

    ruby
    @thread ||= Thread.new { listen }

    一度死んだスレッドは再生成されません。その結果:

    • 以降の subscribe 呼び出しは全て @when_connected キューに積まれるだけで実行されない
    • @subscribed_client はずっと nil
    • Redis からのメッセージも届かないので 全ブロードキャストが黙って捨てられる
    • クライアントは WebSocket 接続自体は張れるが、Redis と繋がらないためメッセージが飛ばない

    という「サイレントな本番障害」が発生し得る状態でした。
    特に以下のような環境で起きやすいです:

    • 低トラフィック環境
    • チャンネルが1つしか無いアプリ
    • デプロイ中にノードを drain しているとき など

修正内容

1) 番兵チャネル _action_cable_internal への購読を復活

actioncable/lib/action_cable/subscription_adapter/redis.rb にて、listen メソッド中で @subscribed_client をセットした直後に、内部チャネルへの購読を行うように変更されています。

おおよそのイメージは以下です(※説明用の擬似コード):

ruby
def listen
  @subscribed_client = build_subscribed_client

  # ここで番兵を追加
  @subscribed_client.call("SUBSCRIBE", "_action_cable_internal")

  @subscribed_client.listen do |message|
    # ...
  end
ensure
  @subscribed_client = nil
end

重要なポイント:

  • _action_cable_internalSubscriberMap(実際の Action Cable チャンネルと Redis チャンネルを紐づけるマップ)にエントリを持たないため、
    • ここに publish されても受信処理は「何もしない」=完全な no-op
  • しかし Redis 的には「常に購読数が 1 以上」の状態が保たれるので、
    • 通常の UNSUBSCRIBE(ユーザーチャネルが0になる)では listen ループは終了しない
    • 全てのチャネル(この内部チャネルを含む)が明示的に UNSUBSCRIBE された場合のみ購読数が 0 になり、ループ終了 → スレッド終了となる
  • これにより、以前の redis gem ベースの挙動と同じ「番兵つきの常駐リスナー」が復活します。

2) 回帰テストの追加

actioncable/test/subscription_adapter/redis_test.rb にテストが追加されています(+17 行)。

テストの内容(概念的な流れ):

  1. 同じ Redis アダプタインスタンスに対して、順番に2回 subscribe を行うテストケースを用意
  2. 1回目の subscribe でチャンネルを購読 → unsubscribe まで行い、この時点で Redis 側の購読数が 0 になる状況を再現
  3. その後、2回目の subscribe を行い、
    • リスナーのスレッドがまだ生きていて
    • 実際に publish → subscribe が正常動作すること を検証

これにより、「一度全チャンネルが unsubscribe されても、以降の subscribe が正常に働く(リスナーが死んでいない)」ことを回帰テストで保証しています。


  1. 影響範囲・注意点
  • 影響範囲
    • Action Cable の Redis サブスクリプションアダプタ(actioncable/lib/action_cable/subscription_adapter/redis.rb)を利用しているアプリ全般。
    • 特に、
      • チャンネル数が少ない
      • 接続が一時的に全て切れることがある
        といった環境で、Redis リスナーが知らないうちに停止してしまう問題を防ぎます。
  • 互換性
    • _action_cable_internal チャンネルは以前の実装でも使われており、再導入なので互換性上の問題はほぼありません。
    • このチャネルに対してアプリが明示的に subscribe/publish している前提はない(内部用)ため、通常のアプリコードが影響を受けることはありません。
  • パフォーマンス/リソース
    • 常に1つのチャネルに購読し続けることになるため、「購読数ゼロで完全に Redis から切り離される」ことはなくなります。
    • ただし listen 用の接続はもともと張りっぱなしにする設計であり、以前の redis gem 実装でも同様だったため、特段の追加コストはありません。
  • 運用上の注意
    • もし「意図的に listener を止めたい」場合(例: シャットダウンフックなど)には、
      • 内部チャネルも含めて UNSUBSCRIBE するか、
      • またはアダプタの明示的な shutdown API を使う
        などの手段が必要ですが、これは以前と同じ考え方です。
    • 逆に、今回の修正により「一時的に全クライアントがいなくなっただけ」でリスナーが落ちるケースは解消されます。

  1. 参考情報 (あれば)
  • 該当 PR: https://github.com/rails/rails/pull/57690
  • 問題の導入元と思われるコミット: ef812c2652(redis-client ベースへの書き換え)
  • 変更概要:
    • 変更ファイル数: 2
    • 追加行数: 26
    • 削除行数: 0

この修正は、Redis ベースの Action Cable を使っている本番環境における「ごく一部のタイミングでのみ発生する静かな障害」を潰す目的のものなので、redis-client に移行している Rails バージョンを利用中であればアップデート推奨です。


#57700 Shut down the heartbeat timer on server restart

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Action Cable サーバ再起動時に、ハートビート用の @heartbeat_timer が停止されずに残り続けていた不具合を修正する PR です。
    開発環境のコードリロードごとにスレッドプールとタイマーがリーク・再生成される問題を解消します。

  1. 変更内容の詳細

問題の内容

ActionCable::Server::Base#restart は、サーバ再起動時に以下のリソースを破棄していました:

  • @worker_pool
  • @executor
  • @pubsub

しかし、定期的にハートビートを送るための @heartbeat_timer (Concurrent::TimerTask) だけはそのまま動き続けていました。

このタイマーのブロック内では executor が呼ばれますが、executor は「遅延生成」されるため、次のような状況が発生していました:

  1. restart が呼ばれる
    → 既存の executor スレッドプールは停止・破棄される。
  2. しかし、古い @heartbeat_timer は生きていて、BEAT_INTERVAL ごとに実行される。
  3. タイマー内の処理で executor を呼ぶ
    → 「必要に応じて再生成」の仕組みにより、新しい executor スレッドプールが勝手に作られてしまう。
  4. 結果として:
    • teardown したはずの executor が即座に復活する
    • 古い @heartbeat_timer も生き続ける
    • コードリロードのたびにスレッドプール・タイマーが増え、リソースリーク/スレッド churn を起こす

開発環境では restartbefore_class_unload を通じてクラスリロードごとに呼ばれるため、この問題は特に dev で顕著になります。

修正内容

ActionCable::Server::Base#restart 内で、他のリソースと同様に @heartbeat_timer も停止・クリアするようにしました。

該当部分のイメージ:

ruby
@mutex.synchronize do
  # Shutdown the heartbeat timer
  @heartbeat_timer.shutdown if @heartbeat_timer
  @heartbeat_timer = nil

  # Shutdown the worker pool
  @worker_pool.halt if @worker_pool
  @worker_pool = nil

  # ... 既存の teardown ロジック ...
end

あわせて、initialize でも他の遅延生成リソースと同様に @heartbeat_timernil で初期化するリストに追加し、一貫性を取っています。こうすることで:

  • setup_heartbeat_timer が次のリクエスト時に呼ばれたときに、
  • 必要に応じて新しい @heartbeat_timer を遅延生成する、

という既存パターンにきれいに乗るようになっています。

テスト

ActionCable::Server::Base のテストに以下を追加:

  • "restart shuts down the heartbeat timer"

テスト内容の要点:

  • restart 実行後に:
    • @heartbeat_timerrunning? でないこと
    • @heartbeat_timernil になっていること

既存の "restart shuts down ..." 系テストと並ぶ形で追加されており、4回実行して red/green が確認されています。

CHANGELOG には記載なし(バグ修正扱い)。


  1. 影響範囲・注意点
  • 対象: ActionCable::Server::Base の再起動処理 (#restart)
  • 主な影響:
    • 開発環境でのコードリロード時に、不要なスレッドプールやタイマーが増殖しなくなる。
    • 長時間開発サーバを動かしていても、Action Cable 周りのスレッド・タイマーがリークしにくくなる。
  • 本番環境への影響:
    • 通常、本番で restart が頻繁に呼ばれるケースは少ないため、主なメリットは安定性・リソース解放の明確化。
    • Restart 後は、次のリクエストが来たタイミングでハートビートタイマーが再生成されるため、ハートビート動作は従来どおり維持される。

互換性を壊す変更ではなく、「本来そうあるべきだった teardown を正しく行う」性質の修正です。


  1. 参考情報 (あれば)
  • 対象ファイル:
    • actioncable/lib/action_cable/server/base.rb (+5/-1)
    • actioncable/test/server/base_test.rb (+11/-0)
  • 関連キーワード:
    • Concurrent::TimerTask
    • BEAT_INTERVAL
    • 遅延生成された executor / worker_pool / pubsub
    • before_class_unload による開発時の server restart

#57699 Raise ChannelNotFound for a subscribe command without a channel

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Action Cable の subscribe コマンドで channel キーが省略された場合に、これまで素の NoMethodError が発生していたのを、他の経路と同様に ChannelNotFound 例外が発生するように統一したバグフィックスです。サーバ側は異常クライアントからの不正な subscribe メッセージに対して、より一貫したエラー処理ができるようになります。

  1. 変更内容の詳細

問題の背景

Action Cable ではクライアントからの subscribe コマンドは、概ね以下のような JSON を想定しています。

json
{
  "command": "subscribe",
  "identifier": "{\"channel\":\"ChatChannel\",\"room_id\":1}"
}

このうち identifier は JSON 文字列で、サーバ側ではパースして id_options(Hash)として扱います。その際、id_options[:channel] を元にチャンネルクラスを定数解決します。

問題になっていたのは、クライアントが誤って channel キーを含まない identifier を送ったケースです。

例:

json
{
  "command": "subscribe",
  "identifier": "{\"id\":1}"
}

この場合、Rails 側では以下のような処理が行われていました。

ruby
# 変更前のコード(簡略)
subscription_klass = id_options[:channel].safe_constantize
if subscription_klass && subscription_klass < Base
  # ...
else
  raise ChannelNotFound
end

id_options[:channel]nil になるため、nil.safe_constantize が呼ばれ、NoMethodError(undefined method safe_constantize' for nil:NilClass)が発生していました。この例外は Action Cable が想定している「チャンネルが解決できない」場合の ChannelNotFound` とは異なり、スタックトレース的にも分かりづらく、ハンドリングもしにくい状況でした。

修正内容

nil の場合を考慮して safe navigation 演算子 &. を使用し、channel が存在しないケースでも通常の「チャンネル解決失敗」フローに乗るようにしています。

diff
- subscription_klass = id_options[:channel].safe_constantize
+ subscription_klass = id_options[:channel]&.safe_constantize

これにより、

  • id_options[:channel] が存在し、有効なクラス名 → そのクラスを subscription_klass に設定
  • id_options[:channel] が存在するが、クラス定数として解決できない → safe_constantizenil を返し、else ブランチに入り ChannelNotFound を raise
  • id_options[:channel] 自体が存在しない(nil)→ &. により safe_constantize が呼ばれず、subscription_klassnil となり、同じく else ブランチで ChannelNotFound を raise

という挙動になります。

テスト追加

actioncable/test/connection/subscriptions_test.rb に以下のようなテストが追加されています(概要):

  • テスト名: "subscribe command without a channel"
  • 内容:
    • channel キーを含まない subscribe コマンドを送る
    • 以前は undefined method 'safe_constantize' for nil で落ちていたところ、
    • 修正後は ChannelNotFound 例外が発生することを確認

既存の「Base を継承していないチャンネル(Base channel)」のテストの横に追加されており、一連の「チャンネル解決に失敗した場合の動作」のカバレッジを補強しています。


  1. 影響範囲・注意点
  • 主な影響範囲は Action Cable の subscribe 処理です。
    • 想定外の identifier(channel キーの欠如)に対して、これまで NoMethodError が飛んでいた環境では、今後は ChannelNotFound が飛ぶようになります。
  • これにより:
    • ログやエラーハンドリングの観点で、subscribe 失敗時の例外種別が一貫します。
    • ChannelNotFound を前提にした救済処理(rescue, instrumentation 等)が正しく働くようになります。
  • 互換性:
    • 正常なクライアント(正しい channel を送る)は挙動に変化はありません。
    • 不正なクライアント/バグのあるクライアントに対して、これまで「500 エラー+NoMethodError」となっていたものが、「想定された例外型(ChannelNotFound)」に変わる可能性があります。
    • もしアプリケーション側で NoMethodError を元に特別な処理をしていた場合(あまりない想定ですが)、動作が変わる可能性はあります。

  1. 参考情報 (あれば)
  • 対象コード:
    actioncable/lib/action_cable/connection/subscriptions.rb
  • 例外クラス:
    ActionCable::Connection::Subscriptions::ChannelNotFound(Action Cable 内部で「存在しない/解決できないチャンネル」を表現するために使われる例外)
  • この PR はバグ修正であり、CHANGELOG には追加されていません。
    ただし、Action Cable を直接利用しているアプリケーションや、カスタムのエラーハンドリングを行っているミドルウェアでは、例外種別の変化を念のため確認するとよいです。

#57695 Keep pending subscribe confirmations across a Redis reconnect

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Redis の pub/sub 接続が切れた直後に発生していた「サブスクライブ確認(on_success)が二度と返ってこない」問題を修正し、再接続後も未完了の subscribe 確認を正しく引き継ぐようにした PR です。Redis クライアント刷新時のリグレッションで、Action Cable の Redis アダプタの挙動のみをピンポイントに修正しています。

  1. 変更内容の詳細

問題の内容

  • 対象クラス: ActionCable::SubscriptionAdapter::Redis::Listener
  • 現象:
    • Redis の pub/sub 接続が切断されると Listener#reset → 再接続 → resubscribe という流れで復旧が行われる。
    • reset は内部状態のリセットとして以下を行っていた:
      • @subscribed_client = nil
      • @subscribe_callbacks.clear
      • @when_connected.clear
    • ところが @subscribe_callbacksclear してしまうため、
      • ちょうどそのタイミングで SUBSCRIBE を送信済みだが ack(["subscribe", "channel", 1])が戻ってきていない「in-flight な subscribe」がある場合、
      • 再接続後に resubscribe によって再度 SUBSCRIBE は出されるものの、それに紐づく on_success コールバックが消えている。
      • そのため ack が返ってきても何も実行されず、クライアント側では confirm_subscription / subscribed が決して呼ばれない「永遠に pending のチャネル」になってしまう。
  • これは redis-client への書き換え (ef812c2652) によって入り込んだリグレッションであり、別 PR #57690 で修正されている「最後のチャネルが unsubscribe されたときに Listener を生かし続ける問題」とは独立した不具合です。

修正内容

reset から @subscribe_callbacks.clear を削除し、pending な subscribe 確認情報を再接続後も維持するようにしました。

diff
 def reset
   @subscription_lock.synchronize do
     @subscribed_client = nil
-    @subscribe_callbacks.clear
     @when_connected.clear
   end
 end

なぜこれで問題が解決するか

  • @subscribe_callbacks は「チャンネルごとの subscribe 確認用コールバック配列」を持つハッシュと考えられます。
  • subscribe が成功して ack を受け取ると、そのチャンネルに紐づくコールバックが実行され、その中で自分自身のエントリを削除するような実装になっている(PR 説明より)。
  • つまり:
    • すでに subscribe 済み(ack 済み)のチャネルは、コールバック実行時に自分のキーを削除しているため、reset 時点で @subscribe_callbacks に残っているのは「まだ ack が返ってきていない subscribe だけ」。
    • reset でこれを消さずに残しておけば、再接続後の resubscribe によって再度発行される SUBSCRIBE と、元のコールバックを正しく結びつけられる。
    • コールバック配列が空なチャネルは ack 時に何も実行されないだけで、古い状態が残るわけではない。
  • そのため、clear をやめても「古い/不要なコールバックが大量に残る」といった問題は基本的に起きません。

テストの追加

実バグは「subscribe が in-flight の瞬間に接続が落ちる」というレース条件でしか起きないため、実 Redis を使うと再現が非常に難しい(ack がサブミリ秒で返ってくる)。

この PR では、実際の Listener#listenresetresubscribe → retry の経路を通しつつ、pub/sub 接続部分だけを疑似実装に差し替えるテストを追加しています。

ポイント:

  • 疑似 pub/sub 接続の next_event は Ruby の Queue を使ってブロックする実装。
    • テスト側は Queue にイベントを順番に push することで、Listener のイベント処理順を完全に制御できる。
    • シナリオ:
      1. :drop を push → next_eventRedisClient::ConnectionError を投げる → reset が呼ばれ、再接続 & resubscribe が走る。
      2. その後、再接続した際の ack イベント ["subscribe", "channel", 1] を push。
  • Concurrent::Event + インライン executor を使用し、Listener がポストした仕事(コールバック)が同期的に処理されるようにすることで、subscribe 確認が実際に届いたかどうかをテストから確実に観測可能にしている。
  • 新規テストクラス: RedisAdapterTest::ListenerReconnection
    • 修正前: 2 秒タイムアウトで subscribe 確認が発火せず red
    • 修正後: ~30ms 程度で確認を受信し green
  • 既存の Redis 再接続系の統合テストを含む actioncable/test/subscription_adapter/redis_test.rb 全体も 26 テストすべてパス。

  1. 影響範囲・注意点

影響範囲

  • 対象:
    • Action Cable + Redis アダプタを利用しているアプリケーション。
    • 特に、ネットワークが不安定な環境や Redis 接続が一時的に切断されうる状況で、
      • クライアントが新しいチャネルに subscribe した直後に接続が落ちるケース。
  • 期待される改善:
    • 接続が落ちた直後に発行された subscribe が、再接続後に正しく confirm される。
    • クライアント側で confirmed? / subscribed コールバックが永久に呼ばれない「ハングした subscription」が事実上解消される。

トレードオフ / 残る可能性のある問題

PR 本文で言及されている既知のトレードオフ:

  • シナリオ:
    1. チャンネル A に subscribe(コールバック登録) → ack 待ちの in-flight 状態。
    2. その後、接続が落ちる前にチャンネル A を完全に unsubscribe。
    3. その後で Redis 接続が落ちる → reset → 再接続 & resubscribe
  • この場合:
    • A はすでに @subscribers から削除されているので resubscribe 対象にならない。
    • 一方で @subscribe_callbacks 内には A 用のコールバックが残り続ける可能性がある(実行も削除もされない)。
    • 結果として、そのチャネル向けの小さな Proc オブジェクトがリークする。
  • PR 作成者はこれを「極めてレアなエッジケース」「リークするのは小さなコールバックだけ」として許容されるトレードオフだと位置付けており、
    • もし気になる場合は、reset 時に「@subscribers に残っているチャネルの分だけコールバックを保持する」ような後続の改善が可能と述べています。

運用上の注意:

  • この PR 自体は バグフィックスのみ であり、外部 API や設定の互換性を壊すような変更はありません。
  • 接続切断と再接続が頻繁に起こる環境では、今回の修正により subscribe 確認周りの安定性が向上するはずです。
  • もし独自フォークで Listener 周りをカスタマイズしている場合は、@subscribe_callbacks をリセットしていない前提でコードを見直すとよいです。

  1. 参考情報 (あれば)
  • 関連 PR:
    • #57690: 「最後のチャネルが unsubscribe されたときにも Redis Listener を維持する」問題の修正。今回とは別のバグだが、同じ redis-client 書き換え由来。
  • 関連コミット:
    • ef812c2652: Redis クライアント周りを redis-client ベースに書き換えたコミット。今回のリグレッションの起点。
  • 実装の観点でのポイント:
    • subscribe の ack を「チャネルごとの callback array で管理」し、ack 受信時にコールバック側で自分を削除する設計になっているため、「callback テーブルの一括初期化」は非常にデリケートな操作である、という教訓が得られる変更でもあります。

#57696 Respect an explicit id: nil in the Redis cable config

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Action Cable の Redis アダプタにおいて、Redis 接続設定で明示的に id: nil を指定した場合にそれが上書きされず、そのまま尊重されるように修正した PR です。これにより、ドキュメント通り「id: nil を指定して CLIENT SETNAME を発行しない」挙動が正しく動作するようになります。

  1. 変更内容の詳細

問題点

元の実装(ActionCable::SubscriptionAdapter::Redis.redis_connector)では、Redis 接続設定 config に対して ID を以下のように設定していました。

ruby
config[:id] ||= "ActionCable-PID-#{$$}"

||= は「値が nil または false の場合に右辺で上書きする」演算子のため、

ruby
config[:id] = nil

と明示的に nil を設定しても、「値がない」と判断されて "ActionCable-PID-#{PID}" が再度代入されてしまっていました。

しかし、Action Cable のドキュメント上は「id: nil を指定すると Redis の CLIENT SETNAME をスキップする(=クライアント名を付けない、もしくはプロキシ側に命名を任せる)」という仕様になっており、これが実際には効いていなかった、というバグです。

同じ ID を扱う別の箇所 SubscriptionAdapter::Base#identifier では、すでに「キーの存在有無」を使って nil を尊重する挙動になっていたため、実装の一貫性も欠けていました。

ruby
# イメージ: Base 側はこういう guard をしていた
config[:id] = ... unless config.key?(:id)

修正内容

上記の不整合とバグを解消するため、redis_connector 側の ID 設定ロジックを以下のように変更しています。

diff
- config[:id] ||= "ActionCable-PID-#{$$}"
+ config[:id] = "ActionCable-PID-#{$$}" unless config.key?(:id)

ポイント:

  • config.key?(:id) で「キーが存在するか」を見るようにした
    • id キー自体が存在しない場合のみ、"ActionCable-PID-#{$$}" をセットする
    • id: nil と明示されている場合は、そのまま nil を保持する(=CLIENT SETNAME を行わない)

これにより、Base 側と Redis コネクタ側で同じポリシー(「キーが存在しないときだけデフォルトを入れる」)になり、一貫性のある挙動になります。

テスト修正

既存のテスト RedisAdapterTest::ConnectorCustomIDNil は、「id: nil の時の挙動」をカバーしているつもりでしたが、実際には不十分でした。

  • もともとのテストでは「テストヘルパーの connection_idnil であること」だけを確認しており、Redis クライアントの実際の ID 名 (client.id / CLIENT GETNAME 相当) を検証していなかった
  • そのため、「id: nil を指定したのに PID ベースの名前が設定されてしまう」バグを検出できていませんでした(テストは「たまたま」通っていた)

PR では、このテストを以下のように修正しています。

  • 実際の Redis クライアントに対して client.id(=CLIENT GETNAME の結果)を取得し、それが nil であることをアサートする

これにより、id: nil が本当に Redis のクライアント名未設定(CLIENT SETNAME 未実行)として扱われることをテストで保証できます。


  1. 影響範囲・注意点

影響範囲

  • 対象:
    • Action Cable の Redis サブスクリプションアダプタを利用しているアプリケーション
    • 特に config.action_cable.cable = { adapter: "redis", id: nil, ... } のように明示的に id: nil を指定している構成
  • 影響内容:
    • これまで:
      • id: nil を指定していても、内部的には "ActionCable-PID-#{PID}" が設定され、CLIENT SETNAME が実行されていた
    • 修正後:
      • id: nil を指定した場合、CLIENT SETNAME が行われず、クライアント名は未設定のままとなる
      • id をまったく指定していない場合は、従来通り "ActionCable-PID-#{PID}" が自動的に設定される

実運用上の注意点

  1. id: nil を前提にプロキシやミドルウェア側で命名している場合

    • これまで意図通り動いていなかった(Action Cable 側の PID ベース名前が付いていた)ケースでは、今回の修正でようやくプロキシ側の命名が使われるようになります。
    • そのため、Redis モニタリング・接続監視ツールの表示やフィルタ条件が変わる可能性があります。
  2. id を明示的に指定していないアプリケーション

    • 挙動は従来から変わりません。
    • デフォルトの "ActionCable-PID-#{$$}" 付与は継続されるので、特別な対応は不要です。
  3. idnil 以外で明示的に指定している場合

    • 例: id: "my-custom-name"
    • こちらも挙動は変わりません(キーが存在するため、デフォルトは上書きされない)。

  1. 参考情報 (あれば)
  • 対象クラス・メソッド:
    • ActionCable::SubscriptionAdapter::Redis.redis_connector
    • ActionCable::SubscriptionAdapter::Base#identifier
  • 関連仕様:
    • Redis CLIENT SETNAME / CLIENT GETNAME
    • Action Cable の Redis adapter 設定での id オプション:
      • id 省略時: "ActionCable-PID-#{PID}" が自動付与される
      • id: nil: CLIENT SETNAME を呼ばない(クライアント名を設定しない)
      • id: "string": 指定した名前で CLIENT SETNAME が呼ばれる
  • PR 番号: #57696
  • 変更ファイル:
    • actioncable/lib/action_cable/subscription_adapter/redis.rb
    • actioncable/test/subscription_adapter/redis_test.rb

#57698 Coerce stream name to String in the test adapter accessors

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Action Cable のテスト用アダプタ (SubscriptionAdapter::Test) において、シンボルのストリーム名を使ったときにブロードキャスト検証が正しく動かない不具合を修正する PR です。内部で文字列キーとして保存しているのに対し、読み出し側が生の引数(シンボルなど)でアクセスしていたため起きていた不整合を解消しています。

  1. 変更内容の詳細

問題の背景

Action Cable のブロードキャスト処理(Server::Broadcasting)では、メッセージを String(broadcasting) をキーにして保存しています。
一方、テスト用アダプタ (ActionCable::SubscriptionAdapter::Test) では、以下のようなアクセサを持っています:

ruby
def broadcasts(channel)
  channels_data[channel] ||= []
end

def clear_messages(channel)
  channels_data[channel] = []
end

ここで channel にシンボル :foo を渡した場合:

  • 書き込み側: String(:foo)"foo" をキーに保存
  • 読み出し側: channels_data[:foo] を参照

となり、:foo"foo" が別キーとして扱われるため、テストからは「メッセージがない」ように見えていました。

その結果:

  • assert_broadcasts(:foo, 1)
    実際には "foo" に 1件保存されていても、:foo では見つからず「0件」と判定される。
  • assert_no_broadcasts(:foo)
    実際には "foo" にメッセージがあっても、:foo 側は空なので「ブロードキャストなし」と誤判定される(危険)。

修正内容

この PR では、テストアダプタ側のアクセサで、必ず文字列に変換してから内部ハッシュを参照するように変更しています。

diff
def broadcasts(channel)
-  channels_data[channel] ||= []
+  channels_data[String(channel)] ||= []
end

def clear_messages(channel)
-  channels_data[channel] = []
+  channels_data[String(channel)] = []
end

これにより、以下が常に一致します:

  • 書き込み: String(broadcasting)(例: :foo"foo"
  • 読み出し: String(channel)(例: :foo"foo"

そのため、テストコード側がシンボル・文字列どちらで指定しても正しくメッセージを取得・クリアできます。

テスト追加

TransmissionsTest(実際には actioncable/test/test_helper_test.rb)に以下の性質を確認するテストが追加されています:

  • test_assert_broadcasts_with_symbol_stream
    • 修正前: assert_broadcasts(:test, 1) { ... } が「1 expected, but 0 were sent」で失敗(red)
    • 修正後: ブロードキャストを正しくカウントできて成功(green)

テストは 14 回実行されており、安定して再現・解消できていることが確認されています。


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

    • Action Cable のテストで assert_broadcasts / assert_no_broadcasts などを利用しているコードが対象です。
    • 特に、ストリーム名を シンボルで指定しているテスト に直接影響します。
    • 実動作(本番環境での Action Cable の挙動)には影響せず、テスト専用アダプタの挙動のみ変わります。
  • 何が変わるか

    • これまで「テストが通っていたが、実際にはブロードキャストされていた」ケースが、正しく失敗するようになります。
      • 具体例:
        ruby
        assert_no_broadcasts(:chat) do
          ActionCable.server.broadcast("chat", { msg: "hi" })
        end
        以前: :chat"chat" の不一致で「ブロードキャストなし」と誤判定 → テストが通る
        以後: "chat" にメッセージが載るのでテストが失敗 → 本来の期待どおり
  • 注意点

    • 既存テストで「たまたま」通っていたものが落ちる可能性がありますが、それは本来検知すべきバグ・回 regressions を正しく検出できるようになった結果です。
    • テスト側の API 仕様としては、「ストリーム名はシンボルでも文字列でもよい」「どちらも同じ扱いを受ける」という意図に沿う変更なので、利用者視点からはむしろ直感的になります。
    • channels_data を直接触っているようなメタなテスト・ツールがあれば、キーが文字列で統一される点を前提にしておく必要があります(とはいえ元々書き込み側は文字列だったため、実質的には仕様どおりになったとも言えます)。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57698
  • 関連コード:
    • actioncable/lib/action_cable/subscription_adapter/test.rb
    • Server::Broadcasting(ブロードキャスト時に String(broadcasting) を使っている箇所)
  • CHANGELOG には記載なし(バグフィックス扱い)。

#57692 Coerce broadcasting to String in Channel#stop_stream_from

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Action Cable の Channel#stop_stream_from が、stream_from と同様に引数を String に変換して扱うよう修正されました。これにより、シンボルを使って購読開始・停止を行った場合でも、正しく購読解除されるようになります。

  1. 変更内容の詳細

問題の背景

ActionCable::Channel::Streams モジュール内で:

  • stream_from は内部的に 常に String 化 してから streams ハッシュに登録しています:

    ruby
    def stream_from(broadcasting, ...)
      broadcasting = String(broadcasting)
      streams[broadcasting] = ...
    end

    そのため、stream_from :chat と書くと、内部的には "chat" というキーで管理されます。

  • 一方、stop_stream_from引数をそのまま キーとして streams.delete に渡していました:

    ruby
    def stop_stream_from(broadcasting)
      streams.delete(broadcasting)
    end

その結果:

ruby
stream_from :chat
stop_stream_from :chat

というペアで呼び出した場合、

  • 登録時のキー: "chat" (String)
  • 削除時のキー: :chat (Symbol)

となり、ハッシュのキーが一致せず、streams.delete(:chat) は何も削除しません。
そのため pubsub.unsubscribe が呼ばれず、接続が生きている間ずっと購読が残り続けるバグが発生していました。

これは実際にテスト (SymbolChannel in stream_test.rb) でも stream_from :channel としてシンボルを使っているため、現実的なバグです。

修正内容

stop_stream_from の先頭で broadcastingString に変換するようにし、stream_from と挙動を揃えました。

疑似コードとしては、今回の差分は以下のようなイメージです:

ruby
def stop_stream_from(broadcasting)
  broadcasting = String(broadcasting) # ← ここが追加
  streams.delete(broadcasting)
end

これにより、以下のようなコードが期待どおり動きます:

ruby
# 購読
stream_from :room_one

# 購読解除(同じシンボルを渡してもOK)
stop_stream_from :room_one

テスト追加

actioncable/test/channel/stream_test.rb にテストが追加されました。内容としては:

  1. stream_from :room_one で購読を開始
  2. stop_stream_from :room_one で購読を停止
  3. 最終的に購読者数(subscriber 数)が 0 になっていることを検証

という流れで、シンボルを使っても購読が正しく解除されることを確認しています。


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

    • Action Cable の Channel#stop_stream_from を利用している全てのコードに影響します。
    • とくに、stream_from / stop_stream_fromシンボルと文字列が混在して渡されていたケース で挙動が変わる可能性があります。
      • 例: これまでは「たまたま購読が解除されていなかった」コードが、今回の修正で正しく解除されるようになります。
  • 期待される挙動の変化

    • これまで:
      ruby
      stream_from :chat
      stop_stream_from :chat
      のようなコードでは、購読が解除されず、コネクションが生きている限り配信を受け続けていた。
    • 修正後:
      • 同じコードで、正しく購読解除 (pubsub.unsubscribe) が行われる。
      • メモリリークや不要なブロードキャスト受信が減る可能性があります。
  • 注意点

    • stream_from/stop_stream_from 両方で同じ値を渡しているつもりでも、「片方 Symbol / 片方 String」のようなコードがもしあった場合、そのコードは今後 正しく購読が解除される ようになります。
      • もし既存の挙動(バグ)に依存していた場合は影響がありますが、通常は正しい方向の修正です。
    • カスタム実装で streams ハッシュを直接触っている場合、キーの型が String に統一される前提でコードを書いたほうが安全です。

  1. 参考情報 (あれば)
  • 対象コード:
    • actioncable/lib/action_cable/channel/streams.rb
    • ActionCable::Channel::Streams モジュール内の stream_from / stop_stream_from
  • 関連するテスト:
    • actioncable/test/channel/stream_test.rb
      • 特にシンボルを引数に使う SymbolChannel のテストケース付近が参考になります。
  • バグの性質:
    • シンボル/文字列のキー不一致による購読解除漏れ (unsubscribe 漏れ)
    • 典型的な「ハッシュキーの型不一致バグ」を、API の挙動を揃えることで解消した形です。

#57691 Iterate a snapshot of the connections in Server#each_connection

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    Action Cable の Server#each_connection が、内部のハッシュを直接イテレートするのではなく「スナップショット(配列)」をイテレートするように変更され、接続の追加・削除が同時に起きても RuntimeError: can't add a new key into hash during iteration が発生しないようになりました。これにより、ハートビートやサーバ再起動時の接続処理が安定して動作します。

  1. 変更内容の詳細

背景: どんな問題だったか

ActionCable::Server::Baseeach_connection は、これまで connections_map.each_value を使って「生のハッシュ」を直接イテレートしていました。

一方で、以下の 3 つの処理がイベントループ / executor スレッド上で each_connection を呼んでいる一方、ワーカー側で add_connection / remove_connection が並行して走る構造になっています。

  • 3 秒ごとのハートビート
    ruby
    executor.post { each_connection(&:beat) }
  • サーバ再起動時のクローズ処理
    ruby
    each_connection { |c| c.close(...) }
  • 統計取得
    ruby
    open_connections_statistics # 内部で each_connection.map(&:statistics)

Ruby の Hash はイテレーション中に新しいキーを追加すると RuntimeError: can't add a new key into hash during iteration を投げるため、
connections_map.each_value 実行中に add_connection が走ると例外が発生し、以下のような不具合につながっていました。

  • ハートビートが例外で中断し、古い/stale な接続がクリーンアップされない
  • コードリロード時の再起動スイープが途中で止まり、接続が閉じられない

これは、接続ストアを Array から Hash に変更したコミット(8ed78693c5)以降のリグレッションです。
Array はイテレーション中に << しても例外にならない一方、Hash はキー追加に厳密なので挙動が変わってしまった形です。

修正内容: スナップショットをイテレートする

each_connection の実装を変更し、connections_map を直接イテレートせず、「スナップショット配列」を経由するようにしました。

変更ポイントは以下の通りです(概念的なイメージ):

ruby
# 変更前(イメージ)
def each_connection(&block)
  if block_given?
    connections_map.each_value(&block)
  else
    connections_map.each_value
  end
end

# 変更後(イメージ)
def each_connection(&block)
  if block_given?
    connections.each(&block)        # connections は connections_map.values を返す
  else
    connections.each                # Array#each なので Enumerator もスナップショットに対するもの
  end
end

connections メソッドは既に実装済みで、内部的には connections_map.values を返すだけのメソッドです。
これにより、each_connection は常に「現在の connections_map の値をコピーした配列」に対して each するようになります。

これで、イテレーション中に add_connectionconnections_map に新しいエントリを追加しても、
each の対象は既に取得済みの配列であり、Hash の「イテレーション中にキー追加」には該当しないため、例外が発生しなくなります。

ブロックなし呼び出しも維持

open_connections_statistics のように、each_connection をブロックなしで呼び出して Enumerator を受け取るケースもあります:

ruby
each_connection.map(&:statistics)

Array#each は Enumerator を返すため、each_connection の戻り値もそのまま Enumerator となり、
既存コードはインターフェースを変えずに動作します。
この Enumerator も、connections_map のスナップショット配列に対するものなので安全です。

テストの追加

回帰を防ぐためにテストが追加されています(actioncable/test/server/base_test.rb)。

テストの内容(要約):

  • ある接続の #beat メソッド内で add_connection を呼び出すようにセットアップ
  • each_connection(&:beat) を実行
  • その際に RuntimeError が発生しないことをアサートする

これにより、each_connection のイテレーション中に add_connection が走る典型的な競合パターンがカバーされます。


  1. 影響範囲・注意点
  • 対象コンポーネント

    • Action Cable サーバ (ActionCable::Server::Base) の接続管理まわり
    • 具体的には、ハートビート、接続クローズ処理、接続数/統計取得など each_connection に依存する箇所
  • 挙動面の変化

    • each_connection は「呼び出し時点の接続一覧」をスナップショットとして処理するようになります。
    • イテレーション中に新たに追加された接続は、その回の each_connection では処理されません(次回以降の呼び出しで処理される)。
    • イテレーション中に削除された接続がスナップショット側に含まれている場合、すでにクローズ済みかどうかを呼び出し側で考慮する必要がありますが、従来コードも実質的には同種の競合を抱えていたため、実務上の差分は小さいと考えられます。
  • メリット

    • 高負荷時でも、ハートビートやコードリロード時の再起動処理が Hash まわりの RuntimeError によって中断されなくなります。
    • マルチスレッド環境で Action Cable を使っているアプリケーションにおいて、
      「たまにハートビートで例外が出て stale connection が残り続ける」といった問題が解消される可能性があります。
  • パフォーマンス面の影響

    • each_connection 呼び出し毎に connections_map.values で配列コピーが発生します。
    • 接続数が非常に多い(数万オーダー)環境では、このコピーコストがわずかに増える可能性がありますが、
      • もともとのハートビートは 3 秒毎
      • 再起動・統計取得も高頻度の処理ではない
        ため、安定性向上とのトレードオフとしては妥当と考えられます。
    • 既に connections メソッド自体が同じコピー処理をしており、それを使うようにしただけなので、新しく増えたコストは実質ありません。

  1. 参考情報 (あれば)
  • 回帰の原因となったコミット: 8ed78693c5
    • 接続ストアを Array から Hash に変更したことで、Hash への追加時の制約に引っかかるようになった。
  • Ruby の仕様:
    • Hash#each 等でイテレート中に新しいキーを追加すると RuntimeError: can't add a new key into hash during iteration が発生する。
    • Array#each はイテレーション中の << に対して例外を出さない(ただし、同じループで新規要素が処理されるかどうかは別)。

#57675 Don't mutate the caller's string in Inflector#transliterate

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::Inflector.transliterate が、非 frozen かつ非 UTF-8 の文字列引数を「こっそり破壊的変更」してしまう不具合を修正し、常に引数を複製してから処理するようにした PR です。これにより parameterize も含め、呼び出し元の文字列オブジェクトのエンコーディングやバイト列が書き換わらないことが保証されます。

  1. 変更内容の詳細

問題の内容

ActiveSupport::Inflector.transliterate は内部で以下のような処理を行います(概略):

  • 引数が ASCII のみの場合は早期 return string.dup(このパスは以前から安全)。
  • それ以外の場合は、force_encodingencode! を呼んで UTF-8 に揃えたうえで、近似のローマ字表現などに変換。

問題は、「frozen でない非 UTF-8 文字列」を渡した場合に起きていました。

ruby
s = "中文".encode(Encoding::GB18030)
s.frozen?  # => false

ActiveSupport::Inflector.transliterate(s)

# ここで本来は s は変化しない想定だが…
s.encoding  # expected: GB18030, actual: UTF-8  # ← encoding が書き換わっている

原因:

  • 以前の実装では「frozen なら dup してから処理する」が、「非 frozen ならそのまま force_encoding / encode!」というパスが存在。
  • そのため、非 frozen の引数については、呼び出し側が渡した同じオブジェクトに対して エンコーディング変更・バイト列の再エンコードが走っていた。

この挙動は Ruby/ActiveSupport でよくある「非破壊メソッド(見た目)」の期待に反し、parameterizetransliterate を内部で使うため、同様に「引数を破壊的に変更する」副作用を持っていました。

修正内容

transliterate の中で、「frozen のときだけ dup する」ロジックを「常に dup する」ように変更しました。

以前(イメージ):

ruby
def transliterate(string, ...)
  return string.dup if string.ascii_only?

  # ここで frozen の場合のみ dup
  string = string.dup if string.frozen?

  string.force_encoding(Encoding::UTF_8)
  string.encode!(...)
  ...
end

修正後(イメージ):

ruby
def transliterate(string, ...)
  return string.dup if string.ascii_only?

  # frozen かどうかに関係なく必ず dup
  string = string.dup

  string.force_encoding(Encoding::UTF_8)
  string.encode!(...)
  ...
end

ポイント:

  • 早期リターンの return string.dup if string.ascii_only? はそのまま。
  • それ以外のパスに入った時点で「必ず dup 済みオブジェクト」に対して force_encoding / encode! を行うようになったため、呼び出し側が持っている元の文字列は一切触られない

テストの追加

activesupport/test/transliterate_test.rb に 3 つの回帰テストが追加されています。

  1. 非 frozen な GB18030 文字列を transliterate に渡しても、元の文字列の encoding / bytes が変わらないこと。
  2. 非 frozen な US-ASCII(中身は非 ASCII バイト)を渡しても、同様に encoding / bytes が変わらないこと。
  3. parameterize に GB18030 文字列を渡しても、元の encoding が変わらないこと。

これらは修正前は「encoding が UTF-8 に書き換わる」ために失敗し、修正後は全て成功します。


  1. 影響範囲・注意点
  • 挙動の本来の期待値に揃える後方互換修正
    表面的には「バグ修正」であり、transliterate / parameterize が非破壊であるという一般的な期待に沿うものです。ほとんどのコードにとっては望ましい・安全な変更です。

  • パフォーマンス面の影響(軽微だが存在)
    これまで「非 frozen かつ非 UTF-8」の場合はそのままのオブジェクトを使っていたところを、必ず dup するようになりました。

    • transliterate / parameterize を非常に大量・高頻度に呼んでいる場合は、オブジェクト生成コスト・GC にごく僅かな影響が出る可能性があります。
    • ただし、既に「frozen 文字列」で呼ばれるケースでは元々 dup しており、また ASCII のみ文字列はもともと string.dup で早期 return していたため、新規に増えたのは「非 frozen かつ非 UTF-8」のパスのみです。
  • 「暗黙の破壊的挙動」に依存していたコードは影響を受ける
    正常な設計ではありませんが、万が一「transliterate に渡すと encoding が UTF-8 に書き換わる」という副作用に意図的・無意識に依存していたコードがある場合、

    • 今後は「呼び出し元の文字列は元の encoding のまま」になります。
    • 必要なら string.encode!(Encoding::UTF_8) のように「自分で明示的に」変換すべきです。
  • マルチバイト・非 UTF-8 ロケール環境での安定性向上
    GB18030 や Shift_JIS などの非 UTF-8 文字列を扱うコードベースでも、transliterate / parameterize 呼び出しが元のデータを壊さないことが保証されるため、バグの温床が一つ減ります。


  1. 参考情報 (あれば)
  • 該当 PR: https://github.com/rails/rails/pull/57675
  • 関連コミット(過去の部分修正): 5623f7f1a3
    • frozen + ascii_only のケースに対する guard を追加していたが、今回の「非 frozen 非 UTF-8」パスはカバーしていなかった。
  • 関連メソッド:
    • ActiveSupport::Inflector.transliterate
    • String#parameterizeActiveSupport 拡張)

#57676 Fix StructuredEventSubscriber.debug_only leaking across subscriber subclasses

マージ日: 2026/6/13 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::StructuredEventSubscriberdebug_only で設定したメソッド名が、サブクラス間や基底クラスに「漏れ」て共有されてしまうバグを修正する PR です。class_attribute のデフォルト配列を破壊的に変更していたために起きていた問題を、非破壊的な再代入に変えることで解消しています。

  1. 変更内容の詳細

問題のポイント

  • ActiveSupport::StructuredEventSubscriber では debug_methods が次のように class_attribute で定義されている(実際の定義は省略):
    ruby
    class_attribute :debug_methods, default: []
  • debug_only :method_name を呼ぶと、この debug_methods にメソッド名を追加して「デバッグ環境のみでサイレンスするイベント」を指定する仕組み。
  • しかし、実装が次のように破壊的追加になっていた:
    ruby
    self.debug_methods << method
  • class_attributedefault: [] は 1 個の同じ配列オブジェクトを基底クラスとサブクラスで共有するため、「破壊的に push する」と全クラスで同じ配列が書き換えられてしまう。

その結果:

ruby
sx = Class.new(ActiveSupport::StructuredEventSubscriber) { def foo(e); end; debug_only :foo }
sy = Class.new(ActiveSupport::StructuredEventSubscriber) { def bar(e); end; debug_only :bar }

sx.debug_methods  # => [:foo, :bar]   # 本当は [:foo] が期待
sy.debug_methods  # => [:foo, :bar]   # 本当は [:bar] が期待
ActiveSupport::StructuredEventSubscriber.debug_methods # => [:foo, :bar] # 本当は [] が期待
  • さらに深刻なのは、ユーザーが自分の StructuredEventSubscriber にたまたま :sql:render_template などフレームワーク側と同名のメソッドを持っていると、そのイベントが「debug_only として扱われてしまい本番環境で黙ってしまう」可能性がある点。

修正内容

debug_only 内の 1 行だけを修正:

diff
- self.debug_methods << method
+ self.debug_methods += [method]

意味:

  • << は既存配列オブジェクトを破壊的に変更する。
  • += [method] は「新しい配列を作って代入」するので、class_attribute の writer 経由でクラスごとに独立した配列がセットされる。
  • class_attribute はサブクラスで書き込みが行われたタイミングで、そのクラス専用の値を持つようになるため、この書き方で「サブクラスごとに別々の debug_methods」を持てる。

この方針は、同じく class_attribute を使っている ActiveSupport::LogSubscriber.subscribe_log_level の実装(self.log_levels = log_levels.merge(...))と同じノンミュータブルな書き方にそろえたものです。

テストの追加・修正

  1. 新規テスト: test_debug_only_does_not_leak_across_subclasses

    目的:

    • 2 つの異なるサブクラスそれぞれが debug_only したメソッド名のみを、自分の debug_methods に持つこと。
    • 基底クラス ActiveSupport::StructuredEventSubscriberdebug_methods は空のままで汚染されないこと。

    検証内容(概念的にはこういうことをチェック):

    ruby
    sx = Class.new(ActiveSupport::StructuredEventSubscriber) do
      def foo(e); end
      debug_only :foo
    end
    
    sy = Class.new(ActiveSupport::StructuredEventSubscriber) do
      def bar(e); end
      debug_only :bar
    end
    
    assert_equal [:foo], sx.debug_methods
    assert_equal [:bar], sy.debug_methods
    assert_equal [], ActiveSupport::StructuredEventSubscriber.debug_methods
    • 修正前は [:debug_only_event, :foo, :bar] のようにすべて混ざってしまい、テストが落ちる。
    • 修正後は期待通りクラスごとに分離されてテストが通る。
  2. 既存テスト test_debug_only_methods の修正:

    • もともとは「基底クラスにサブクラスの debug_only が漏れている」ことに依存してテストが通っていた。
    • 今回の修正によりその前提が成り立たなくなるため、テスト対象を「実際に debug_only を宣言している TestSubscriber サブクラス」側に付け替えた。
    • さらにテストの teardown に detach_from :test を追加し、ActiveSupport::Notifications への subscription を後始末するようにしている。これによりテスト順序に依存しない(サブスクが他のテストに影響しない)ようになる。

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

    • ActiveSupport::StructuredEventSubscriber を直接 / 間接的に継承し、debug_only を使っている全てのコード。
  • 仕様面の挙動変化:

    • これまで「意図せず他サブスクライバの debug_only 設定が効いてしまっていた」ケースが、本来の期待通り効かなくなる(= バグ修正)。
    • 特に、Rails 本体の StructuredEventSubscriber(例: SQL, rendering まわり)が設定している debug_only が、ユーザー定義サブクラスに影響することはなくなる
  • 実運用で起こりうる変化:

    • もし既存コードが「たまたまバグに依存していた」場合、たとえば:
      • A サブスクライバが debug_only :foo
      • B サブスクライバも def foo(e); end を持っているが debug_only は呼んでいない
      • それでも B の foo イベントが本番で出力されない(A の debug_only が漏れていた)
    • 今後は B 側には debug_only が効かなくなり、本番環境でも foo が通知されるようになる。
    • これは意図された挙動への修正だが、「出力が増えた」と感じる場面があるかもしれないので、ログ周りで挙動が変わったと感じた場合はこの修正を疑うとよいです。
  • パフォーマンス:

    • += [method] によって小さい配列のコピーが行われる程度で、通常の利用では誤差レベルと考えてよいです。
    • class_attribute の「書き込み時にクラス単位でコピーを作る」という設計に沿った使い方になったため、むしろ一貫性は向上しています。

  1. 参考情報 (あれば)
  • 関連する Rails の仕組み:

    • ActiveSupport::StructuredEventSubscriber
      • 構造化されたイベント(通知)を購読し、特定のメソッド名をハンドラとして扱う仕組み。
      • debug_only で指定したメソッドは、開発/テスト環境では実行するが、本番ではサイレンスするなどの制御に利用される。
    • class_attribute
      • クラスレベルのアクセサを定義し、サブクラスごとに上書き可能な「継承されるクラス変数」のような仕組み。
      • デフォルト値がミュータブル([]{})な場合は、破壊的操作を避け、代入で上書きするのが定石。
  • 類似コード:

    • ActiveSupport::LogSubscriber.subscribe_log_level でも同様のパターンで:
      ruby
      self.log_levels = log_levels.merge(...)
      のように破壊的操作ではなく再代入している。

この PR により、debug_only の振る舞いがクラスごとにきちんと独立し、意図しないイベントサイレンスが起きづらくなります。


#57693 Make ActiveRecord::Base.with call Object#with when passed a block

マージ日: 2026/6/13 | 作成者: @janko

  1. 概要 (1-2文で)
    ActiveRecord::Base の .with がブロック付きで呼ばれた場合に、CTE 用の ActiveRecord::Relation#with ではなく、Ruby コア拡張の Object#with を呼ぶようにした変更です。これにより、Active Record モデルで with を使って一時的に設定を上書きするパターンが自然に利用できるようになります。

  1. 変更内容の詳細

これまでの挙動

ActiveRecord::Base.with は ActiveRecord のクエリ用 DSL における CTE (Common Table Expression, WITH 句) を構築するためのメソッドとして、ActiveRecord::Relation#with に委譲していました。

rb
# 例: CTE を使ったクエリ
User.with(active_users: User.where(active: true))
    .from("active_users")
    .where(...)

Relation#with はクエリ構築専用のため、ブロックは受け付けませんでした。
そのため、以下のように「オブジェクトを特定のオプションと一緒にブロックに渡す」という Object#with 的な使い方はできませんでした。

rb
# 想定していたが動かなかった使い方 (ブロックを渡すとエラー/無視される)
ActiveRecord::Base.with(logger: Logger.new($stdout)) do
  # ...
end

今回の変更点

ActiveRecord::Base.with を呼び出した際に ブロックが渡されているかどうか を判定し、挙動を切り替えるようになりました。

  • ブロックなし: 今まで通り、クエリ用の Relation#with を呼び出して CTE を構築する
  • ブロックあり: CTE ではなく、Object#with を呼び出す(core extension がロードされている場合)

Rails には ActiveSupport のコア拡張として Object#with が存在し、典型的には以下のような振る舞いをします(概念的な例):

rb
object.with(foo: 1, bar: 2) do
  # object に foo/bar が一時的に適用された状態でブロック実行
end

今回の変更により、ActiveRecord::Base もこのパターンに従うようになります。

サンプル: 一時的に logger を差し替える

PR の説明にある典型例:

rb
ActiveRecord::Base.with(logger: Logger.new($stdout)) do
  # このブロック内だけ SQL ログを標準出力に出す
  User.create!(...)
  User.first
end

# ブロックを抜けると、元の logger に戻る

これはテスト時に一時的に SQL ログを有効化したい/別の出力先に切り替えたいときなどに便利です。

実装上のポイント

  • activerecord/lib/active_record/querying.rb 内の Base.with の実装が修正され、block_given? を見て処理を分岐するようになっています。
  • ブロック付き呼び出しで Object#with を呼ぶのは、Enumerable#findEnumerable#select と ActiveRecord の findwhere の関係と同様のポリシーに沿っています。
    • すなわち、「ブロックがあるときは Ruby/Enumerable 側の意味を優先する」という一貫した挙動です。
  • テスト (relation/with_test.rb 等) が追加・更新され、ブロック付き .withObject#with を呼んでいることが検証されています。
  • activerecord/CHANGELOG.md にこの挙動変更が明記されています。

  1. 影響範囲・注意点

影響範囲

  • 影響を受けるのは ActiveRecord::Base.with を「ブロック付き」で呼んでいるコード だけです。
  • 既に大半の CTE 用コードはブロックなしで .with を使っているはずなので、その場合は挙動は一切変わりません。

注意点

  1. ブロック付き .with で CTE は作れない
    今回の仕様により、ブロック付き .with は「常に Object#with を意図したもの」と解釈されます。
    「ブロック付きで CTE を何かしら操作したい」ような使い方はできません。

  2. Object#with コア拡張のロードが前提

    • ActiveSupport の core extensions(特に Object#with)がロードされていない環境では、ブロック付き .withNoMethodError になる可能性があります。
    • Rails 標準のアプリケーション構成であれば通常はロードされているため問題になりにくいですが、ミニマル構成・一部のエンジン等では注意が必要です。
  3. メソッド解決の優先度の理解が必要

    • .with というメソッド名は、Active Record (CTE) 用と Object コア拡張の両方で使われているため、「ブロックの有無で意味が変わる」点をチームで共有しておいた方がよいです。
    • wherefind がブロック有無で Enumerable とクエリメソッドを切り替えるのと同じノリですが、with は使用頻度が低く気付きづらい可能性があります。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57693
  • 同系統の挙動:
    • Model.where { ... } → Enumerable の select 的意味 (ブロック) と Relation の where (ハッシュ・引数) を使い分け
    • Model.find { ... } → Enumerable の find にフォールバック
  • 用途イメージ:
    • 一時的なログ出力設定 (logger)
    • 一時的に ignored_columnsdefault_scopes 相当のカスタムアクセサを変更
    • 特定のテストケースでのみ設定を上書きして実行、など

#57689 Add test coverage for HashWithIndifferentAccess key access and except

マージ日: 2026/6/13 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActiveSupport::HashWithIndifferentAccess について、これまでテストされていなかった分岐や挙動(fetch のブロック・デフォルト値、values_at の欠損キー、dig の miss パス、except/without の基本仕様)に対してテストが追加されました。アプリケーションコードへの変更はなく、テスト追加のみのPRです。

  1. 変更内容の詳細

このPRでは activesupport/test/hash_with_indifferent_access_test.rb に 33 行のテストコードが追加されています。主な対象は以下の4点です。

2.1 fetch の未テスト分岐(デフォルト値・ブロック・*extras)

追加でカバーされた挙動:

  • fetchデフォルト値 を渡した場合の挙動
  • fetchブロック を渡した場合の挙動
  • HashWithIndifferentAccess 独自の「キーを String に変換したうえでブロックへ渡す」挙動
  • fetch*extras 引数(ブロックに追加引数を渡すパススルー)の挙動

イメージとしては以下のようなテストが追加されていると考えられます:

ruby
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1)

# デフォルト値
assert_equal 1, h.fetch(:a, 10)      # 既存キー => 値
assert_equal 10, h.fetch(:b, 10)     # 欠損キー => デフォルト値

# ブロック(キーは String で渡される)
value = h.fetch(:b) { |key, extra| [key, extra] }
assert_equal ["b", :foo], value      # key は "b"(String)、extra は *extras 経由

※ 実際のテストコードの細部は異なる可能性がありますが、

  • ブロックに渡るキーが String 化されること
  • *extras をきちんと透過していること
  • ドキュメントどおりの挙動が保証されること
    を確認するテストが追加されています。

2.2 values_at の欠損キー時の挙動

追加でカバーされた挙動:

  • values_at に存在しないキーを渡したとき、その位置に nil が入ること
  • fetch_values は同じ状況で例外を投げる(という仕様との対比)

例:

ruby
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1)

assert_equal [1, nil], h.values_at(:a, :b)   # :b は存在しない => nil
# fetch_values(:a, :b) は KeyError を raise する想定

これにより、values_at の「欠損は nil 埋め」という挙動が仕様どおりであることを回帰テストとして担保しています。

2.3 dig の欠損キー時の挙動(miss パス)

追加でカバーされた挙動:

  • dig が存在しないキーを辿ろうとした際に nil を返す、というドキュメント記載の miss パス

既存テストでは、dig が成功して値を返すパスのみカバーされていましたが、今回:

ruby
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1)

assert_nil h.dig(:zoo)  # ドキュメント例: counters.dig(:zoo) # => nil

のような「キーが存在しない場合」の挙動をテストしています。
HashWithIndifferentAccess かつ dig という組み合わせで、シンボル/文字列のどちらでも miss 時は nil を返すことが保証されます。

2.4 except / without の基本仕様(これまでテストなし)

今回もっとも大きい追加カバレッジ:

  • except / withoutまったくテストされていなかった ため、以下を確認するテストが追加されました。

カバーされる挙動:

  1. 新しい HashWithIndifferentAccess を返す

    • レシーバとは別オブジェクトであること
    • 戻り値のクラスが HashWithIndifferentAccess であること
  2. 文字列キー・シンボルキーを混在して受け取れる

    • :a"a" を指定しても、同じキーとして扱われる
  3. 元のハッシュは変更されない (non-destructive)

    • except / without の呼び出し後もレシーバはそのまま

例:

ruby
h = ActiveSupport::HashWithIndifferentAccess.new(a: 1, b: 2, "c" => 3)

h2 = h.except(:a, "b")

assert_instance_of ActiveSupport::HashWithIndifferentAccess, h2
assert_equal({ "c" => 3 }, h2)          # :a, "b" が除外された新しいハッシュ
assert_equal 3, h2[:c]                  # indifferent access 維持
assert_equal 1, h[:a]                   # 元の h は変わらない
assert_equal 2, h[:b]
assert_equal 3, h[:c]

withoutexcept のエイリアスであるため、同等の挙動を確認するテストも含まれていると考えられます。


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

    • 変更はテストファイル(hash_with_indifferent_access_test.rb)のみで、本体コードには一切変更がありません。
    • 既存アプリケーションの挙動には影響しませんが、今後これらの挙動を変えるとテストが落ちるようになりました。
    • 特に except / without はこれまでテストがなかったため、将来のリファクタリング時に仕様変更を誤って導入しにくくなります。
  • 注意点

    • このPRは「ドキュメントされている/既にそうであると仮定されていた」挙動を、テストとして明示的に固定化したものです。
    • 既に HashWithIndifferentAccess#fetch のブロック引数として「文字列キーが来る」前提でコードを書いている場合、その前提が今後も破られにくくなった一方、「実はシンボルになるべき」といった解釈変更はしづらくなります。
    • except / without が常に非破壊で HashWithIndifferentAccess を返すことが仕様として固まるため、これと異なる期待(たとえば破壊的に動いてほしい等)は今後も満たされません。

  1. 参考情報 (あれば)

#57680 Ensure assert_no_changes isn't given a static value

マージ日: 2026/6/12 | 作成者: @amomchilov

  1. 概要 (1-2文で)
    assert_no_changes(および関連メソッド)が「静的な値」を引数に取った場合に、対象が実際には変化していてもテストが常に成功してしまう問題を防ぐため、引数に許可される型を制限し、そうでない場合は ArgumentError を発生させるようにした変更です。これにより、書き間違い・勘違いによるテストの空振りを早期に検知できるようになります。

  1. 変更内容の詳細

何が問題だったか

これまでの assert_no_changes は、第一引数に任意のオブジェクトを受け取り、それをそのまま評価対象として扱っていました。

ruby
a = []
assert_no_changes(a.size) do
  a << "something"
end

上記のように誤って「値そのもの」(この例だと 0)を渡すと、

  • ブロック実行前: 評価対象 = 0
  • ブロック実行後: 評価対象 = 0(元のオブジェクトはすでに固定値)

となるため、「変化なし」と判定されてテストが成功してしまいます。
本来は下記のように「値を返す処理」か「評価可能な Ruby コード」を渡す必要があります。

ruby
assert_no_changes(-> { a.size }) do
  a << "something"
end

# または
assert_no_changes("a.size") do
  a << "something"
end

今回の変更内容

この PR では、assert_no_changes が受け取る「監視対象 expression」に対して、以下の制約を導入しました。

  • 許可される型:
    • Callable(通常は Proc / lambda / ->
    • String
    • Symbol
  • 上記以外のオブジェクトを渡すと ArgumentError を投げる

擬似コードで表すと、イメージは以下のようなチェックです(実装そのものではなく概念的なもの):

ruby
def assert_no_changes(expression, &block)
  unless expression.respond_to?(:call) || expression.is_a?(String) || expression.is_a?(Symbol)
    raise ArgumentError, "assert_no_changes requires a callable, String, or Symbol expression"
  end

  # 従来通りの比較ロジック
end

つまり、次は OK:

ruby
assert_no_changes(-> { user.reload.name }) { ... }
assert_no_changes("user.reload.name") { ... }
assert_no_changes(:some_method_name) { ... } # 実装依存だが概ね許容

次は NG (ArgumentError):

ruby
assert_no_changes(0) { ... }
assert_no_changes([]) { ... }
assert_no_changes(Object.new) { ... }

追加テスト

activesupport/test/test_case_test.rb にテストが追加され、主に次の点が確認されています。

  • Proc / String / Symbol を渡した場合に従来通り動作すること
  • Integer など静的値を渡した場合に ArgumentError が発生すること
  • これまで「たまたま to_s で Ruby コードになる」ような特殊なオブジェクトは、今後は ArgumentError になること

また CHANGELOG.md に、この動作変更が明記されています。


  1. 影響範囲・注意点

既存コードへの影響

  • 影響を受けるのは、assert_no_changes(および同様の実装がある「友達メソッド」)に対して、
    • 第一引数に「callable でも String でも Symbol でもないオブジェクト」を渡しているテストだけです。
  • 特によくある誤用パターンとしては:
    • 「メソッド呼び出し結果」をそのまま渡してしまっているケース
ruby
# NG: 呼び出し結果を渡している
assert_no_changes(user.reload.name) do
  user.update!(name: "Bob")
end

# OK: 呼び出しを遅延させる
assert_no_changes(-> { user.reload.name }) do
  user.update!(name: "Bob")
end

# あるいは
assert_no_changes("user.reload.name") do
  user.update!(name: "Bob")
end

「静的な String / Symbol」の限界

PR でも明記されていますが、この変更はあくまで「明らかな誤用」を防ぐものであり、完全に誤用を排除できるわけではありません。

  • String / Symbol は「Ruby コードとして eval されるもの」とみなされるため、
    • 静的な文字列でも eval が成功する場合は、そのまま通ってしまう可能性があります。
  • ただし、ほとんどの「ただの文字列」は Ruby として評価すると NameError などになるので、サイレントに通り抜けるケースは少ないだろう、という判断です。

例:

ruby
oncall = "Alice"
assert_no_changes(oncall) do # => NameError: uninitialized constant Alice
  oncall = "Bob"
end

以前「たまたま動いていた」変なコードの破壊的変更

PR の例:

ruby
class Hmm
  def to_s = "x" # 下の文脈では eval 可能
end

def test_hmm
  x = 1
  assert_no_changes(Hmm.new) do
    # no change
  end
end
  • 以前: Hmm.new.to_s #=> "x"eval されて(たまたま)動いていた
  • 今後: Hmm.new は callable/String/Symbol ではないので ArgumentError

もし本当にこのような(かなりレアな)コードを書いていた場合は、素直に to_s を呼ぶようにすることで回避できます。

ruby
assert_no_changes(Hmm.new.to_s) do
  # ...
end

  1. 参考情報 (あれば)
  • 対象メソッド: ActiveSupport::Testing::Assertions#assert_no_changes(およびフレンドメソッド)
  • 典型的な正しい使い方(改めて):
ruby
# ブロック実行で user.name が変わらないことを検証したい場合

# 推奨(lambda / Proc)
assert_no_changes(-> { user.reload.name }) do
  do_something
end

# もしくは String(eval ベース)
assert_no_changes("user.reload.name") do
  do_something
end
  • 今回の変更は「テストが静かに間違った成功をする」パターンを減らすための、安全性を高めるための軽い破壊的変更と言えます。

#57057 Report Postgres FATAL exceptions more clearly

マージ日: 2026/6/12 | 作成者: @matthewd

  1. 概要 (1-2文で)
    PostgreSQL から返される FATAL エラーを、一般的な StatementInvalid ではなく「接続が壊れた」ことを示す ConnectionFailed として明示的に扱うように変更し、壊れた接続を後からまで引きずらないようにした PR です。
    そのために libpq の使い方を exec_params 依存から、send_query_params + 明示的な get_result ループや notice receiver の活用に変更し、より確実に「本当の例外」を捕捉できるようにしています。

  1. 変更内容の詳細

2-1. 何を解決しようとしているか

PostgreSQL の FATAL エラーは「サーバー側で接続を終了する」ことを意味するため、その後のコネクションは使い物になりません。
しかし従来の Active Record では、FATAL も他の SQL エラーと同じように StatementInvalid として扱われるケースがあり、その結果:

  • 壊れたコネクションがプールに残る
  • 次のクエリ実行時にはじめて接続切断に気付く

という遅延的かつ分かりにくい障害につながる可能性がありました。

この PR は:

  • FATAL エラーを検知したら、その場で ConnectionFailed 相当として扱い直し
  • コネクションプール側からも壊れたコネクションとして扱えるようにする

ことで、より早く・明示的に障害を表面化させる狙いがあります。


2-2. PostgreSQL アダプタ側の主な変更点

2-2-1. exec_params から send_query_params + 明示的 get_result

これまで多くのパスで使われていた raw_connection.exec_params(...) をやめ、以下の形に近い実装に変えています:

ruby
raw_connection.send_query_params(sql, param_types, param_values, result_format)
raw_connection.set_single_row_mode if need_single_row # 例: streaming 的なケース

results = []
loop do
  result = raw_connection.get_result
  break unless result

  # ここで result.status やエラー情報を精査する
  results << result
end

ポイント:

  • exec_params は内部で send_query_params + get_last_result 相当を実行する「まとめメソッド」ですが、その内部実装に依存していると、「最後の結果」だけからでは FATAL など一部の重要なエラーを取りこぼす可能性があります。
  • 明示的に get_result をポーリングすることで:
    • 通常のエラー
    • FATAL エラー
    • NOTICE / WARNING など をより正確かつタイムリーに検出できます。

これに合わせて、PostgreSQL アダプタ内のクエリ実行系 (execute, prepared statement 実行など) が、この新しいパターンに書き換えられています。

2-2-2. FATAL エラーを ConnectionFailed にマッピング

PostgreSQL のエラー情報から severity (もしくは error_fieldPG_DIAG_SEVERITY / PG_DIAG_SEVERITY_NONLOCALIZED) を取り出し、それが FATAL / PANIC の場合には、通常の StatementInvalid ではなく「接続失敗系」の例外に変換します。

擬似的には以下のようなイメージです:

ruby
case severity
when "FATAL", "PANIC"
  raise ActiveRecord::ConnectionFailed, original_error.message
else
  raise ActiveRecord::StatementInvalid, original_error.message
end

実際には、ActiveRecord の例外ラッパ (translate_exception / translate_exception_class 的なメソッド) の中で振り分けが行われます。

2-2-3. Notice receiver からの FATAL も検知

PostgreSQL / libpq には、エラーや notice を「結果オブジェクトとして返す」以外に、「notice receiver 経由で非同期的に通知する」経路があります。

libpq の内部状態によっては:

  • クエリ実行結果としては成功 / 何も返さない
  • しかし notice receiver 経由で FATAL が届く

というパターンも起こり得ます。この PR では:

  • PostgreSQL アダプタで notice receiver を設定
  • そこに届いたメッセージから FATAL / PANIC を検知
  • 「本当は接続が死んでいる」ことを把握し、例外変換時に考慮

するようにしています。

これにより、libpq が「直接のクエリ結果として返さなかった FATAL」も漏れなく拾えるようになります。


2-3. 抽象アダプタ (abstract_adapter.rb) の変更

abstract_adapter.rb には例外変換と接続状態管理の共通処理が含まれています。

主な変更点:

  • 例外クラスを決めるロジックに「接続が壊れていると判断できる場合は ConnectionFailed にする」分岐を強化。
  • PostgreSQL アダプタが「これは FATAL だった」とわかるような情報を渡し、それを尊重して適切な ActiveRecord 例外にマッピングする。

これにより、DB ごとに異なる「致命的エラー」の検出方法をアダプタ側が実装し、抽象アダプタはそれを統一的な ActiveRecord 例外クラスに落とし込む、という役割分担が明確化されています。


2-4. テストの追加・変更

変更されたテストファイル:

  • activerecord/test/cases/adapter_test.rb
  • activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb
  • activerecord/test/cases/adapters/postgresql/statement_pool_test.rb

テストで主に担保していること:

  • FATAL 相当のエラーが発生したとき、ActiveRecord::ConnectionFailed が発生すること
  • その後の接続が再利用されず、適切に再接続 / プールからの切り離しが行われること
  • statement pool など、prepared statement やキャッシュ周りの挙動が今回の変更で壊れていないこと

postgresql_adapter_test.rb の変更行数が多いことから、PostgreSQL 向けのパスについてかなり細かくケースを足していると推測できます。


  1. 影響範囲・注意点

3-1. アプリケーションから見える挙動の変化

例外クラスが変わる可能性があります。

これまで:

  • FATAL に相当する PostgreSQL エラーが起きても:
    • ActiveRecord::StatementInvalid やそのサブクラスとして扱われていた
    • アプリケーション側が StatementInvalid を rescue してリトライ等していた

今後:

  • 同じ状況で ActiveRecord::ConnectionFailed (もしくは関連する接続エラー系) が発生するようになります。
  • rescue / エラーハンドリングで「SQL 文法エラー等」と「接続切断」を区別しやすくなりますが、既存コードが StatementInvalid のみを想定している場合には挙動が変わる可能性があります。

対策例:

ruby
begin
  Model.connection.execute("...")
rescue ActiveRecord::ConnectionFailed => e
  # 接続切断: コネクション再確立やリトライ戦略を検討
rescue ActiveRecord::StatementInvalid => e
  # SQL 文法ミスや constraint violation など
end

ConnectionFailed を上位に rescue することで、より正しい分類ができます。

3-2. コネクションプール / 再接続の挙動

FATAL で落ちたコネクションが:

  • プール内に「生きているように見える」状態で残る
  • 次のクエリで初めて「connection is closed」的なエラーになる

といったパターンが減り、その場で接続エラーとして扱われます。
その結果:

  • トラフィックが高い環境では、「たまたま次のリクエストで初めて気付く」タイムラグが減る
  • 一方で、エラーが発生したリクエストの時点で即座に ConnectionFailed が飛ぶため、「前は通っていたのに今回は接続エラーになった」という違いが観測されるかもしれません。

3-3. PostgreSQL / libpq バージョン依存の可能性

  • FATAL / PANIC の severity の扱い、notice receiver への振り分けなどは、PostgreSQL サーバ / libpq のバージョンや設定によって微妙に違う場合があります。
  • この PR はそのばらつきをある程度吸収しようとしているものの、古い組み合わせなどではテストでカバーされていない挙動があり得ます。

大規模システムや独自のコネクション設定をしている場合は、ステージング環境で:

  • 意図的に DB を落とす
  • pg_terminate_backend でセッションを kill する
  • ネットワーク切断をシミュレートする

などして、「どういう例外が上がるか」「再接続戦略に問題がないか」を確認しておくと安全です。


  1. 参考情報 (あれば)

この PR により、「クエリ失敗」と「接続自体が死んだ」の区別が明確になったので、アプリケーション側でも例外ハンドリングを整理しておくと、運用時のトラブルシュートがかなり楽になります。


#57656 Support composite primary keys in excluding

マージ日: 2026/6/12 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveRecord::Relation#excluding(およびエイリアスの without)が複合主キーを持つモデルでエラーになっていた問題を修正し、単一主キーと同様に正常動作するようにした PR です。単一主キー向けの挙動は変えず、複合主キーのときだけ where 句の組み立て方を切り替えています。

  1. 変更内容の詳細

何が問題だったか

excluding は以下のように「指定したレコード(や relation)を結果から除外する」ためのメソッドです。

ruby
Post.excluding(post)
Post.excluding(posts_relation)

単一主キーのモデルでは問題なく動きますが、複合主キーの場合に ActiveRecord::StatementInvalid が発生していました。

ruby
# 単一主キー: OK
Post.excluding(post)

# 複合主キー: NG
Cpk::Book.excluding(book)
# => ActiveRecord::StatementInvalid: no such column: cpk_books.["author_id", "id"]

原因は、excluding! が内部で predicate_builder[primary_key, records] を使って除外条件を組み立てている点にあります。

  • 単一主キー: primary_key"id" などの文字列 → 正常
  • 複合主キー: primary_key["author_id", "id"] のような配列
    → これがそのまま 1 つのカラム名として扱われてしまい、
    → SQL 上では "cpk_books"."[""author_id"", ""id""]" という存在しないカラム参照になりエラー

どう直したか

修正方針は「複合主キーのときだけ、finder と同じ書き方で where 句を組み立てて、それを反転する」というものです。

Rails の finder はすでに複合主キーに対応しており、例えば:

ruby
Cpk::Book.where(primary_key => ids)
# primary_key: ["author_id", "id"]
# ids: [[author_id1, id1], [author_id2, id2], ...]

という形で複合キーを扱えています。これと同じアプローチで排除条件を作り、その NOT を取るようにしています。

疑似コード的には以下のような分岐になります(※実際のコードは byte-to-byte では異なりますがニュアンスとして):

ruby
if Array(primary_key).size == 1
  # 既存ロジック (単一主キー) は一切変更なし
  predicate = predicate_builder[primary_key, records]
else
  # 新ロジック (複合主キー)
  # ids は excluding(record) の場合は record の複合キーの値
  # excluding(relation) の場合は relation.ids から得た複合キーの配列
  ids = relation.ids
  clause = build_where_clause(primary_key => ids)   # where(primary_key => ids) と同様の処理
  predicate = clause.ast.not                        # それを否定して「除外」条件に
end

@records = where(predicate)

ポイント:

  • 単一主キーの処理ルートは一切変更していない(バイトレベルで同一)ことが PR で明示されています。
  • excluding(record)excluding(relation) の両方のパターンをテストでカバーしています。
  • テストは activerecord/test/cases/excluding_test.rb に複合主キー用のケースを追加 (+21/-1)。

  1. 影響範囲・注意点
  • 影響範囲:
    • ActiveRecord::Relation#excluding#without を、複合主キーを持つ ActiveRecord モデルで利用しているコード。
    • 単一主キーのモデルは挙動が変わらないため影響なし。
  • 実務的な影響:
    • これまで複合主キーのモデルに対して excluding / without を呼ぶと SQL エラーになっていた箇所が、正しく「そのレコード(群)を除外する」動作をするようになります。
    • excluding(relation) もサポートされているため、サブクエリで取得した複合主キーの集合をまとめて除外するパターンも安全に利用できます。
  • 注意点:
    • 既にエラー回避のためにアプリ側で where.not や生 SQL で独自に除外処理を書いていた場合、今後は excluding / without を利用する方が一貫したインターフェイスになります。
    • ただしこの PR はあくまで「正しい挙動に修正」するものなので、既存のワークアラウンドを削るかどうかは各プロジェクトで判断が必要です。

  1. 参考情報 (あれば)
  • 対象メソッド:
    • ActiveRecord::Relation#excluding
    • ActiveRecord::Relation#withoutexcluding のエイリアス)
  • 関連する内部 API:
    • Relation#build_where_clause
    • Relation#ids(複合主キー時には複合キー値の配列を返す)
  • 複合主キーでの finder の使い方(参考イディオム):
ruby
# 複合主キー: [:author_id, :id]
# 特定の複合キーを持つレコードを検索
Cpk::Book.find([author_id, id])

# 複数の複合キーの集合で where
Cpk::Book.where([:author_id, :id] => [[1, 10], [1, 11]])

この PR は excluding も上記のような finder パターンに揃えていると言えます。


#57670 Fix Hash.from_xml raising Date::Error on type="date" values surrounded by whitespace

マージ日: 2026/6/12 | 作成者: @55728

  1. 概要 (1-2文で)
    Hash.from_xmltype="date" の要素に前後の空白文字(改行やスペース)が含まれていると Date::Error で落ちていた問題を修正した PR です。日付文字列を Date.strptime に渡す前に to_s.strip することで、インデント付き・整形済み XML でも問題なくパースできるようになりました。

  1. 変更内容の詳細

問題の挙動

Rails の ActiveSupport::XMLMiniHash.from_xml で XML を Hash に変換するとき、type="date" が付いたノードを Date に変換します。
従来の実装:

ruby
::Date.strptime(date, "%Y-%m-%d")

このとき、XML がインデント・整形されていると、実際のノード内容は以下のように前後に改行やスペースが入った文字列になります。

xml
<event>
  <starts-on type="date">
    2020-01-01
  </starts-on>
</event>

Ruby 上では例として:

ruby
value = "\n  2020-01-01\n"
Date.strptime(value, "%Y-%m-%d") # => Date::Error: invalid date

Date.strptime は周囲の空白を自動では無視しないため、Hash.from_xml 全体が Date::Error で失敗していました。

ruby
require "active_support/core_ext/hash/conversions"

Hash.from_xml(%(<event><starts-on type="date">\n  2020-01-01\n</starts-on></event>))
# これが Date::Error で落ちる

一方、type="integer" など他の型は to_i などを通すため、同じような空白があっても問題なくパースできており、型ごとの挙動が不整合でした。

修正内容

activesupport/lib/active_support/xml_mini.rb 内の日付パース部分を 1 行変更:

Before:

ruby
"date" => lambda { |date| ::Date.strptime(date, "%Y-%m-%d") },

After:

ruby
"date" => lambda { |date| ::Date.strptime(date.to_s.strip, "%Y-%m-%d") },

ポイント:

  • to_s で非文字列入力(nil や数字など)もとりあえず文字列化
  • strip で前後の空白(スペース・タブ・改行)を除去
  • boolean 型の実装がすでに to_s.strip を使っており、それと揃えた形

これにより、次のような XML でも期待通り Date オブジェクトになります:

ruby
xml = <<~XML
  <event>
    <starts-on type="date">
      2020-01-01
    </starts-on>
  </event>
XML

Hash.from_xml(xml)
# => { "event" => { "starts_on" => Date.new(2020, 1, 1) } }

テストの追加・修正

  1. activesupport/test/core_ext/hash_ext_test.rb にテスト追加:
ruby
def test_date_type_from_xml_with_surrounding_whitespace
  xml = <<~XML
    <event>
      <starts-on type="date">
        2020-01-01
      </starts-on>
    </event>
  XML

  hash = Hash.from_xml(xml)
  assert_equal({ "starts_on" => Date.new(2020, 1, 1) }, hash["event"])
end
  • 修正前はこのテストが Date::Error で失敗
  • 修正後は期待どおり Date オブジェクトに変換されて成功
  1. activesupport/test/xml_mini_test.rb の既存テストを微修正:

非文字列入力に対する挙動の例外クラスを TypeError から Date::Error に変更しています。
理由: date.to_s.strip を通してから Date.strptime されるため、パース不能時の例外が Date::Error で統一されるようになったためです。


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

    • Hash.from_xml を利用し、XML 内で type="date" を使っているアプリケーション
    • 特に、整形済み・インデント付き XML(人間が読みやすいように改行・スペースが入っているもの)を入力としている場合
  • 期待されるポジティブな影響

    • これまで Date::Error で落ちていた XML が正常にパースされるようになる
    • integerboolean など、他の型との挙動が「前後の空白を許容する」という点で一貫する
  • 互換性・注意点

    • date 型は、渡された値に対して必ず to_s.strip を行った結果で Date.strptime するように変わったため、以下のようなケースでは例外の種類やタイミングが変わる可能性があります。
      • 非文字列(例: オブジェクト、数値、nil など)を type="date" に対して渡していた場合
        • 以前: 型エラー (TypeError) を期待していたコードがあれば、Date::Error に変わる
      • ただし、通常の XML パース用途では非文字列が入ることはほぼないため、一般的な利用者への影響は限定的です。
  • パフォーマンス

    • 1 ノードあたり to_s.strip が増えるだけのごく軽微な変更であり、現実的な XML サイズではパフォーマンス影響は無視できるレベルと考えられます。

  1. 参考情報 (あれば)
  • 類似実装:
    • boolean 型のパースはすでに to_s.strip を行っており、本 PR はそれに合わせて date 型の扱いを揃えた変更です。
  • コードの読みどころ:
    • ActiveSupport::XMLMini::PARSING のマップ定義部分
    • Hash.from_xml 周辺の処理フロー(XMLMini を通して型変換が行われる流れ)

#57671 Fix MemoryStore#cleanup raising NoMethodError with a non-DupCoder serializer

マージ日: 2026/6/12 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::Cache::MemoryStore を非デフォルトのシリアライザ(serializer: :marshal_7_1 など)付きで使った際に、cleanup 実行時に NoMethodError が発生して書き込みが失敗する問題を修正した PR です。cleanup 内でも他と同様に deserialize_entry を通してエントリを扱うようにし、テストを追加しています。

  1. 変更内容の詳細

問題の内容

MemoryStore は内部的に @data にキャッシュを保持していますが、構成によって @data の中身が変わります。

  • デフォルト (DupCoder) 利用時

    • @data[key] には 生の ActiveSupport::Cache::Entry オブジェクト が入る
    • そのため entry = @data[key]; entry.expired? のような呼び出しがそのまま動く
  • 非デフォルトのシリアライザ / コーダ利用時
    例:

    • serializer: :marshal_7_1
    • serializer: :message_pack
    • カスタム coder: ...
    • この場合 @data[key] には シリアライズ済みの String が入る
    • よって String#expired? は存在せず、cleanup 中に NoMethodError が発生する

さらに厄介なのは、cleanup は単体で呼ばれるだけでなく、容量制限 (size オプション) を越えたときに write_entry -> prune -> cleanup の流れで自動的に呼ばれることです。そのため容量上限到達後の あらゆる write が例外で落ちる 状態になり、期限切れエントリも一切掃除されなくなっていました。

修正内容

対象箇所は activesupport/lib/active_support/cache/memory_store.rbcleanup メソッドです。

従来:

ruby
@data.each_key do |key|
  entry = @data[key]
  delete_entry(key) if entry && entry.expired?
end

修正後:

ruby
@data.each_key do |key|
  entry = deserialize_entry(@data[key])
  delete_entry(key) if entry && entry.expired?
end

ポイント:

  • read_entry では元々 deserialize_entry を通して Entry を復元しており、cleanup だけが「生の @data」に直接触っていました。
  • 今回 cleanup でも deserialize_entry を通すことで、
    • デフォルトの DupCoder でも
    • 非デフォルトのシリアライザ / コーダ利用時でも
      一貫して「Entry オブジェクトに対して expired? する」ようになりました。
  • deserialize_entry@data[key] が存在しない場合には nil を返すため、既存の if entry && entry.expired? のロジックはそのまま成立します。

追加されたテスト

activesupport/test/cache/stores/memory_store_test.rb にテストが追加されています:

ruby
def test_cleanup_with_non_dup_coder_serializer
  store = ActiveSupport::Cache::MemoryStore.new(
    serializer: :marshal_7_1,
    size: 200,
  )

  store.write("a", "x" * 100, expires_in: 0.01)
  store.write("b", "y" * 100)

  sleep 0.05

  # ここで cleanup が呼ばれても例外にならないこと
  assert_nothing_raised do
    store.cleanup
  end

  # 有効期限切れの "a" は削除されている
  assert_nil store.read("a")

  # 期限切れでない "b" は残っている
  assert_equal "y" * 100, store.read("b")
end

※ 実際のテストコードは若干異なる可能性がありますが、意図としては:

  • serializer: :marshal_7_1 を使った MemoryStore を作る
  • すぐ期限切れになるエントリと、有効なエントリを書き込む
  • 期限が切れるまで sleep した後 cleanup を呼ぶ
  • 例外が発生しないこと、および
    • 期限切れエントリは削除され
    • 有効なエントリは残る
      ことを検証しています。

  1. 影響範囲・注意点
  • 影響を受けるのは以下のようなケースです:

    • ActiveSupport::Cache::MemoryStore を利用しており
    • serializer: :marshal_7_1serializer: :message_pack、カスタム coder など DupCoder 以外のシリアライザ / コーダ を指定していて
    • かつ size オプションでメモリ上限を設定し、その上限を越えるような書き込みを行うケース
  • この PR 適用前:

    • 上限到達後に writeprune -> cleanup を呼び出すたびに NoMethodError が発生
    • 以降の書き込みが実質的に失敗し続ける
    • 有効期限切れエントリも一切掃除されない
  • この PR 適用後:

    • 非デフォルトシリアライザ使用時でも cleanup が正常動作し、期限切れエントリが適切に削除される
    • write が例外で止まらなくなる
  • パフォーマンス面:

    • cleanup 実行時に deserialize_entry を通す分、シリアライズ/デシリアライズのコストがかかりますが、
    • read_entry でも同様に行っている処理であり、cleanup 自体も通常は頻繁には呼ばれないため、挙動としては妥当なトレードオフと考えられます。
  • 互換性:

    • 既存の API やオプションに変更はなく、挙動は「元々期待されていた動作」に近づくだけなので、後方互換性の観点でも問題は少ないと思われます。

  1. 参考情報 (あれば)
  • 対象クラス:
    ActiveSupport::Cache::MemoryStore
    • Rails ガイド(英語): “Active Support Instrumentation and Caching” のメモリストア節
  • 類似実装:
    • read_entry がすでに deserialize_entry を通している点から、今回の変更はその挙動に揃える形の修正となっています。
  • 関連オプション:
    • serializer: (:marshal_7_1, :message_pack など)
    • coder:(カスタムコーダ指定)
    • size:(メモリストアの最大サイズ)

#57672 Fix Time#advance and DateTime#advance mutating the caller's options hash

マージ日: 2026/6/12 | 作成者: @55728

  1. 概要 (1-2文で)
    Time#advanceDateTime#advance が、呼び出し元から渡された options ハッシュを破壊的に変更していた問題を修正した PR です。ハッシュを複製してから処理するようにすることで、呼び出し元のハッシュの汚染や FrozenError を解消し、Date#advance との挙動の一貫性も取っています。

  1. 変更内容の詳細

問題点

Time#advance / DateTime#advance は、以下のように渡された options ハッシュに対してキーを書き換えるロジックを持っています。

  • :weeks:days に畳み込む (例: weeks: 1days: days + 7)
  • :days:hours に畳み込む (例: days: 1hours: hours + 24)
  • その過程で hours: 0 などのキー追加も行う

従来はこれを「受け取ったハッシュそのもの」に対して行っていたため、以下のような問題がありました。

ruby
require "active_support/all"

options = { weeks: 1, days: 2 }
Time.new(2024, 1, 1).advance(options)

p options
# 実際:   { weeks: 1, days: 2, hours: 0 }  # ← 呼び出し元のハッシュが書き換えられている
# 期待:   { weeks: 1, days: 2 }

さらに、options を freeze して渡すと、内部でのキー再代入により FrozenError が発生していました。

ruby
Time.new(2024, 1, 1).advance({ weeks: 1, days: 2 }.freeze)
# 実際:   FrozenError: can't modify frozen Hash
# 期待:   正常に計算された Time を返す

同様の問題は DateTime#advance にも存在していましたが、Date#advance は options を参照するだけで書き換えないため、3 つのメソッド間で挙動が不一致でした。

修正内容

Time#advance および DateTime#advance のメソッド先頭に、以下の 1 行を追加しています。

ruby
options = options.dup

これにより:

  • 以降の options[:days] = ...options[:hours] ||= 0 といった操作は、呼び出し元とは別のハッシュに対して行われる
  • 呼び出し元の options ハッシュには一切変更が加わらない
  • たとえ呼び出し元が options.freeze していても、dup された新しいハッシュはミュータブルなので FrozenError は発生しない
  • 既存の計算ロジック (何週間分を何日分に換算など) はそのままのため、返る Time / DateTime の値は変わらない

実質的には、「破壊的に見える内部実装を呼び出し元から隔離した」形です。

テスト

次の 2 パターンについて、Time / DateTime それぞれで回帰テストが追加されています。

  1. options ハッシュが呼び出し後も変化していないこと

    ruby
    options = { weeks: 1, days: 2 }
    Time.new(2024, 1, 1).advance(options)
    assert_equal({ weeks: 1, days: 2 }, options)
  2. 凍結された options ハッシュでも例外が出ないこと

    ruby
    options = { weeks: 1, days: 2 }.freeze
    assert_nothing_raised do
      Time.new(2024, 1, 1).advance(options)
    end

time_ext_test.rb, date_time_ext_test.rb, time_with_zone_test.rb など既存テストもすべてグリーンで、他の挙動に影響が出ていないことが確認されています。


  1. 影響範囲・注意点
  • 影響を受けるのは以下を利用しているコードです:
    • Time#advance(options_hash)
    • DateTime#advance(options_hash)
  • Date#advance の挙動はもともと非破壊的であり、この PR の影響はありません。

開発者視点でのポイント:

  1. 呼び出し元ハッシュの “副作用” に依存していたコードがあれば壊れる可能性

    • 例えば、こんなコードは以前は動いていたかもしれません:
      ruby
      opts = { days: 1 }
      now = Time.current
      now.advance(opts)
      # 以前: opts に hours: 24 が入ることを前提にしていた (悪い例)
      do_something_with(opts[:hours])
    • このような「advance の副作用として options が書き換わること」を前提としたコードは、今回の変更後は動かなくなります
    • ただし、こうした依存は意図された使い方ではなく、バグに近い設計といえます。
  2. options を複数回再利用・共有・freeze している場合は恩恵を受ける

    • 典型的なユースケース:
      ruby
      ADVANCE_OPTS = { weeks: 1, days: 2 }.freeze
      
      10.times do
        Time.current.advance(ADVANCE_OPTS) # 以前は 1 回目で FrozenError
      end
    • 今後はこのような「定数オプションを使い回す」スタイルが問題なく動作します。
  3. パフォーマンス面の影響は極小

    • Hash#dup 一回分のコストが増えますが、advance 自体が日時の計算処理を行うメソッドであり、通常のアプリケーションではボトルネックにはなりにくい変更です。
    • ただし、超高頻度・大量ループで Time#advance を叩くようなコードがある場合、プロファイル上で微小な差が出る可能性はあります。
  4. TimeWithZone#advance のような関連メソッド

    • time_with_zone_test.rb もグリーンであることから、ActiveSupport::TimeWithZone 周りを含めた既存挙動への影響はないと判断されています。
    • TimeWithZone#advance は内部で Time#advance などを利用しているため、副作用が減った分、思わぬハッシュ汚染バグのリスクが下がります。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57672
  • 関連 API ドキュメント (概念確認用):
    • Time#advance / DateTime#advance / Date#advance (ActiveSupport 拡張)
  • 「引数として受け取った Hash/Array を破壊的に変更しない」ことは Ruby/Rails では一般的な慣習であり、この PR はその慣習に沿った修正になっています。

#57686 Re-raise suppressed RedisClient errors in all RedisCacheStoreTests classes

マージ日: 2026/6/12 | 作成者: @yahonda

  1. 概要 (1-2文で)
    Redis を使ったキャッシュストアのテストにおいて、これまで握りつぶされていた RedisClient のエラーをすべての RedisCacheStoreTests 系テストで再スローするようにし、原因が分かりにくい失敗を診断しやすくした PR です。前回 PR (#57571) で一部クラスにだけ入っていた error_handler の設定を、共通のデフォルトに移して全テストクラスに適用しています。

  1. 変更内容の詳細

背景となる問題

ActiveSupport::Cache::RedisCacheStoreTests::FailureRaisingFromMaxClientsReachedErrorTest#test_fetch_with_block_read_failure_raises が稀に失敗するが、そのときの失敗メッセージが

ruby
--- expected
+++ actual
@@ -1,3 +1 @@
-# encoding: US-ASCII
-#    valid: true
-"ix70bwHXFsZAhUKh"
+nil

のように「期待していた文字列 vs 実際は nil」しか出ず、なぜ nil になったのか(Redis 接続エラーなど)が分からない、という問題がありました。
原因としては、Redis 側でのタイムアウトや接続エラー (RedisClient::CannotConnectError など) が、テスト中に設定されている error_handler によって握りつぶされ、テストの失敗からは見えなくなっていたためです。

今回の修正内容

前回の PR (#57571) では、RedisCacheStoreCommonBehaviorTest に対して error_handler を「エラーを再スローする」ものに変えましたが、他の RedisCacheStoreTests クラスにはまだ適用されていませんでした。

この PR では、その error_handler 設定をテストストア共通の初期化処理(StoreTest#lookup_store のデフォルトオプション)に移動し、全ての RedisCacheStoreTests ベースのテストで同じ挙動になるようにしています。

コード上は activesupport/test/cache/stores/redis_cache_store_test.rb の中で

  • 個別クラスに書かれていた error_handler の設定を削除(-5 行)
  • 共通の場所に 1 行だけ error_handler を設定する(+1 行)

という最小限の変更です。

これにより、同じようなタイムアウトが発生すると、今後は以下のように Redis の接続エラーがそのままテストのエラーメッセージに出るようになります:

ruby
Error:
ActiveSupport::Cache::RedisCacheStoreTests::FailureRaisingFromMaxClientsReachedErrorTest#test_fetch_with_block_read_failure_raises:
RedisClient::CannotConnectError: user specified timeout for redis:6379 (redis://redis:6379/1)
    ...
    lib/active_support/cache.rb:678:in 'ActiveSupport::Cache::Store#write'
    test/cache/behaviors/failure_raising_behavior.rb:18:in 'FailureRaisingBehavior#test_fetch_with_block_read_failure_raises'

これで、「nil になった」ではなく「Redis への接続に失敗した」ことが明示的に分かるようになります。


  1. 影響範囲・注意点
  • 対象は テストコードのみ であり、ActiveSupport::Cache::RedisCacheStore の本番挙動には影響しません。
  • すべての RedisCacheStoreTests 系クラスで error_handler が「エラーを再スローする」よう統一されるため、これまで黙殺されていた Redis 接続エラー等がテスト失敗として表面化しやすくなります。
    • 結果として、テストスイートが不安定な環境(遅い CI や不安定な Redis サーバ)では、以前よりも Redis 接続関連のエラーが露出する可能性があります。
    • ただしこれは「実際に起きているエラーが見えるようになる」という性質のものであり、根本的なバグを隠さないという意味で望ましい変更です。
  • 変更ファイルは 1 つ・差分も小さく、error_handler の適用範囲を広げただけなので、テストの構造や API 互換性への影響はありません。

  1. 参考情報 (あれば)

#57679 Fix increment! with explicit query constraints

マージ日: 2026/6/12 | 作成者: @jsaubry

  1. 概要 (1-2文で)
    increment! / decrement!query_constraints を持つモデルに対しても、更新時の WHERE 句にそれらの制約列を正しく含めるように修正された PR です。単一主キー + 明示的 query_constraints 環境で、主キーのみで UPDATE してしまう不具合を解消します。

  1. 変更内容の詳細

何が問題だったか

increment! / decrement! は内部的に update_counters を使ってカラムをインクリメント/デクリメントしていますが、その際、主キーだけを条件に UPDATE していました。

元コード(簡略化):

ruby
# ActiveRecord::Persistence#increment!
self.class.update_counters(id, attribute => change, touch: touch)

update_counters 側:

ruby
# ActiveRecord::CounterCache.update_counters
unscoped.where!(primary_key => id).update_counters(counters)

ここで where!(primary_key => id) としているため、主キー以外の query_constraints が無視されるという問題がありました。

  • 複合主キーの場合: id に複合キー情報が入っているため問題にならない
  • 問題になるケース: 単一カラム主キーのモデルで、query_constraints が主キーと一致しない場合

例:

ruby
class TopicWithConstraints < Topic
  query_constraints :author_name, :id
end

topic.increment!(:replies_count)
# Before: UPDATE topics SET replies_count = replies_count + 1 WHERE id = ?
# After:  UPDATE topics SET replies_count = replies_count + 1 WHERE author_name = ? AND id = ?

このように、本来 author_name も WHERE 条件に含めるべきところが、id のみで更新されていました。

どう直したか

increment! が「id を渡して update_counters を呼ぶ」のをやめ、自分自身の query_constraints を用いて更新用の Relation を組み立てるように変更しています。

修正後のイメージ:

ruby
# ActiveRecord::Persistence#increment!
counters = { attribute => change }
relation = self.class.unscoped.where!(_query_constraints_hash)
relation.update_counters(counters)

ポイント:

  • _query_constraints_hash は、そのレコードを一意に特定するためのハッシュを返します
    • 明示的な query_constraints がある場合: そこに列挙したカラムが含まれる
    • ない場合: 主キーにフォールバックする
  • これにより、
    • 通常の主キー更新 → これまで通り主キーで絞り込む
    • 複合主キー → 既存どおり問題なし(ID に複合キーが含まれている)
    • 明示的 query_constraints あり → すべての制約カラムを WHERE 句に含める

decrement! は内部実装として increment! に負の値を渡しているだけなので、同じ修正の恩恵を受けます。

テスト・ドキュメント

  • activerecord/test/cases/persistence_test.rb にテストが追加され、query_constraints を持つモデルで increment! が適切な WHERE 条件を生成することを確認
  • activerecord/CHANGELOG.md にこの挙動修正が追記

  1. 影響範囲・注意点
  • 影響を受けるのは以下のようなケースです:

    • Active Record モデルで query_constraints を明示的に設定している
    • そのモデルに対して increment! / decrement! を使っている
    • これまで主キーだけで UPDATE されていたが、本来は追加の制約カラムも含めるべきだった
  • 期待される挙動の変化:

    • これまで:
      • increment! / decrement! が主キーだけで UPDATE を行っていたため、
        例えば「論理シャーディング」「マルチテナント」「パーティションキー」的なカラムを
        query_constraints にしている環境では、誤ったレコードが更新されうる
    • これから:
      • query_constraints に列挙したカラムも WHERE 句に含めて UPDATE されるため、
        レコードの特定がより安全かつ一貫したものになる
  • 既存コードへの互換性:

    • 「主キーだけで十分に一意に特定できる」という前提で query_constraints を増やしていなかったケースは、挙動に変更なし
    • もし「意図的に主キーだけで更新したいが、query_constraints には別用途のカラムを入れている」といった特殊な設計をしていた場合は、挙動が変わります
      (ただしそのような設計は query_constraints の用途から外れていると言えます)
  • パフォーマンス面:

    • WHERE 句に制約カラムが増えるため、インデックス設計によってはクエリプランが変わる可能性があります
    • とはいえ query_constraints はもともとレコード特定のために使う前提なので、通常はインデックスも張られているはずで、実運用上は改善または許容範囲の変化となることが多いと考えられます

  1. 参考情報 (あれば)
  • 対象メソッド:

    • ActiveRecord::Persistence#increment!
    • ActiveRecord::Persistence#decrement!(内部的には increment! 利用)
  • 関連箇所:

    • ActiveRecord::Persistence#_query_constraints_hash
    • ActiveRecord::CounterCache.update_counters
  • 用例イメージ(マルチテナントなど):

    ruby
    class AccountRecord < ApplicationRecord
      query_constraints :tenant_id, :id
    end
    
    record.increment!(:usage_count)
    # 修正前: WHERE id = ?
    # 修正後: WHERE tenant_id = ? AND id = ?

この PR により、「query_constraints を設定しているなら、increment! / decrement! もそれを尊重する」という、一貫した挙動になります。


#57682 Ractor safe readonly attributes

マージ日: 2026/6/12 | 作成者: @andrewn617

  1. 概要 (1-2文で)
    Rails を Ractor セーフにする取り組みの一環として、ActiveRecord::Base._attr_readonly で使われる配列を「最初から凍結(frozen)された不変オブジェクト」として扱うようにし、テスト用の Ractor 関連アサーションも整理・拡張した PR です。これにより、クラスレベルの設定情報が Ractor 間で安全に共有しやすくなります。

  1. 変更内容の詳細

2-1. ActiveRecord::Base._attr_readonly の Ractor セーフ化

対象ファイル:

  • activerecord/lib/active_record/readonly_attributes.rb

_attr_readonly は「読み取り専用属性」を保持する内部用メソッドで、クラスレベルに配列として保存されています。この PR では、その配列を以下の方針で扱うようにしています:

  • デフォルトの配列を最初から freeze しておく
  • 要素を追加するたびに「元の配列を変更せず」dup してから変更し、変更後の配列を再度 freeze する

疑似コードで書くと、以下のようなパターンになります:

ruby
# もとのイメージ(破壊的に変更しがち)
@_attr_readonly ||= []
@_attr_readonly << :title   # ← ミューテーションが発生し、Ractor セーフでない

# この PR でのパターン(イミュータブル配列として扱う)
@_attr_readonly ||= [].freeze
@_attr_readonly = (@_attr_readonly + [:title]).freeze
# または dup して push して freeze など、いずれにせよ:
#   - 元の配列は変更しない
#   - 新しい配列を凍結して入れ直す

これにより、クラス属性として保持される _attr_readonly が、常に「凍結された配列」として振る舞い、Ractor 間でそのまま shareable なオブジェクトとして扱いやすくなります。

※PR 説明文では、例外ラッパー設定(exception wrapper configs)で既に導入済みの手法(#57483)と同様のパターンを採用していると述べられています。

2-2. Ractor 用アサーションの整理・強化

対象ファイル:

  • activesupport/lib/active_support/testing/ractors_assertions.rb
  • activesupport/test/backtrace_cleaner_test.rb
  • railties/test/backtrace_cleaner_test.rb
  • activerecord/test/cases/base_test.rb

Ractor セーフティをテストするためのアサーションを 2 種類に整理しています:

  1. assert_ractor_shareable

    • 「何もしなくても Ractor 共有可能であるべき」オブジェクトに対して使う
    • 例: ActiveRecord::Base._attr_readonly のように、常に frozen な配列にしておきたいクラス属性
    • テストでは「アプリケーションを凍結する処理などを通さずに、そのまま Ractor.shareable? が true になること」を確認する役割
  2. assert_ractor_make_shareable

    • 「アプリケーションの初期化/凍結のタイミングで Ractor.make_shareable によって共有可能にする想定」のオブジェクトに対して使う
    • 例: backtrace cleaner のような、最終的には凍結して shareable にするが、初期状態ではミュータブルなことがあるオブジェクト

今回の PR では:

  • 以前の PR (#57574) で入っていたアサーションを見直し、
    • _attr_readonly のような「最初から shareable であるべきもの」に assert_ractor_shareable を使うように修正
    • backtrace cleaner 関連のテストには assert_ractor_make_shareable を使うように修正
  • ActiveRecord のテスト (activerecord/test/cases/base_test.rb) に _attr_readonly が Ractor shareable であることを検証するテストを追加

することで、「どのオブジェクトがいつ shareable であるべきか」をテストレベルで明確に区別できるようにしています。


  1. 影響範囲・注意点
  • 既存アプリの挙動への互換性

    • _attr_readonly 自体は「属性名の一覧」を持つだけの配列であり、通常の利用ではアプリ側から直接ミューテート(<< など)するケースは少ない前提です。
    • ただし、もしアプリやプラグインが ActiveRecord::Base._attr_readonly << :foo のように「内部配列への直接破壊的変更」に依存している場合は、FrozenError が発生する可能性があります。
    • 推奨パターンとしては、attr_readonly DSL やクラスメソッドを通じて設定する形に留めるべきで、内部配列を直接いじらない方が安全です。
  • Ractor を使わないアプリへの影響

    • Ractor を使わない場合も、配列がイミュータブルになる点以外の挙動差はほぼありません。
    • 読み取り専用属性の解決やクエリ挙動に変更はなく、パフォーマンスへの影響もごく小さい(配列の dup + freeze)範囲に限定されます。
  • 今後のパターンの標準化

    • クラスレベルに設定情報(配列・ハッシュ)を持つ場合、「デフォルトを frozen にする」「更新時は dup してから freeze し直す」というパターンが今後の Rails コアの標準的な実装スタイルになっていくことを示唆しています。
    • エンジンや gem 側で Ractor 対応を進める際にも、このパターンを参考にするとよいです。

  1. 参考情報 (あれば)
  • この PR で参照されている関連 PR:

  • Ractor 自体の仕様:

    • Ruby 公式ドキュメント (英語):
      https://docs.ruby-lang.org/en/master/Ractor.html
    • ポイントとして、Ractor.shareable? が true になるのは、主に「完全に凍結されたオブジェクトグラフ」であることが条件となるため、本 PR のように「クラス設定をミュータブルからイミュータブルへ寄せていく」変更が必要になります。

#57674 Fix String#truncate with :separator and an over-long :omission

マージ日: 2026/6/12 | 作成者: @55728

  1. 概要 (1-2文で)
    String#truncate:separator オプションを指定しつつ、:omission の長さが truncate_to 以上になるケースで、想定より長い文字列が返ってしまう不具合を修正した PR です。separator を使う場合も、非 separator パスと同様に「省略記号だけ」を返すよう挙動を揃えています。

  1. 変更内容の詳細

問題の挙動

対象メソッドは ActiveSupport の String#truncate です。以下のようなコードで問題が発生していました。

ruby
require "active_support/core_ext/string/filters"

"Hello World foo bar baz".truncate(2, separator: " ")
# 実際:   "Hello World foo bar..."
# 期待:   "..."

"Hello World foo bar baz".truncate(2)
# => "..."  # separator なしだと正しい

内部では概ね以下のような処理になっています(簡略化):

ruby
length_with_room_for_omission = truncate_to - omission.length
# 例: truncate_to=2, omission="..."(length=3) の場合
# length_with_room_for_omission = 2 - 3 = -1
  • 通常(separator なし)の分岐では、この length_with_room_for_omission が負の場合は、元文字列の前半を一切付けず、omission だけを返すようになっていました。
  • 一方で separator 指定ありの分岐では、この負の値をそのまま rindex の開始位置に渡していました。
ruby
# 問題のあったイメージ
stop = rindex(separator, length_with_room_for_omission) || length_with_room_for_omission
self[0, stop] + omission

Ruby の String#rindex は、第2引数が負のとき「末尾からのオフセット」として解釈します。
そのため:

  • length_with_room_for_omission が負でも rindex が末尾近くでマッチを見つけてしまう
  • stop が末尾付近の大きな値になり、self[0, stop] で「ほぼ全文」が取れてしまう
  • 結果として "Hello World foo bar..." のように、想定より長い文字列が返る

というバグになっていました。

修正内容

修正は「どのときに separator を使うか」の条件を 1 行だけ変更するものです。

  • 具体的には、separator を使う分岐に入る条件に length_with_room_for_omission >= 0 を追加
  • これにより、「省略記号を付ける前に入れられる文字数が負」の場合は separator 分岐に入らないようにした

結果として:

  • length_with_room_for_omission >= 0
    → 元文字列の一部 + omission を返す(従来どおり separator で区切り位置を探す)
  • length_with_room_for_omission < 0
    self[0, length_with_room_for_omission]"" になり、"" + omission として「省略記号のみ」を返す
    → 非 separator パスと同じ挙動に揃う

テストの追加

次のようなテストが追加されています。

ruby
assert_equal "[...]", "Hello Big World!".truncate(
  2, omission: "[...]", separator: " "
)

assert_equal "[...]", "Hello Big World!".truncate(
  2, omission: "[...]", separator: /\s/
)
  • 修正前: "Hello Big[...]" となりテスト失敗
  • 修正後: "[...]" となりテスト成功

これにより、「小さい truncate_to + 長すぎる :omission + separator(文字列/正規表現)でも、 omission のみが返る」ことが担保されました。


  1. 影響範囲・注意点
  • 対象: ActiveSupport の String#truncateseparator: オプション付きで呼び出しているコード
  • 影響するケース:
    • truncate_to(第一引数 or length: オプション)より :omission の長さが長い、あるいは同じ長さ
    • かつ separator: を指定している場合
  • これまでは「separator ありの場合だけ、ほぼ全文 + omission」が返っていたケースが、「omission だけ」に変わります。
  • separator パスの挙動に揃える変更なので、「truncate の設計として一貫性のある挙動になった」と言えますが、もし既存コードが「実際のバグ挙動」に依存していた場合は、表示内容が短くなる可能性があります。

運用上の注意:

  • UI やログなどで truncate の出力を snapshot テストや文字列比較でテストしている場合、separator 付きで上記条件を満たすケースがあればテストが変わる可能性があります。
  • truncate_to:omission の長さの関係を意識していない場合でも、omission を長くカスタマイズしていると今回の修正パスに入る可能性があります。

  1. 参考情報 (あれば)
  • 対象メソッド: ActiveSupport::CoreExtensions::String::Filters#truncate
  • 変更ファイル:
    • activesupport/lib/active_support/core_ext/string/filters.rb
      • separator 分岐条件の 1 行修正
    • activesupport/test/core_ext/string_ext_test.rb
      • separator: + 長い :omission 向けのテスト 2 ケース追加

#57681 Make some lambdas in constants shareable

マージ日: 2026/6/11 | 作成者: @andrewn617

  1. 概要 (1-2文で)
    Rails の各所で定数として保持されている lambda を、Ruby 4.0+ の Ractor 上で安全に共有できるようにするため、「shareability shim」を使って shareable にした PRです。将来的な Ractor 対応・並列実行対応を見据えた小さな互換性対応で、挙動そのものは基本的に変わりません。

  1. 変更内容の詳細

背景・目的

  • 「リクエスト処理を Ractor 内で行う」実験の一環として、
    • 定数として定義された lambda / Proc が Ractor 間で共有可能 (shareable) である必要がある
  • Ruby 3.2 以降〜4.0+ では、オブジェクトが Ractor 間でやり取りできるかどうかが厳しくチェックされるため、
    • 一部の lambda をそのまま定数に置くと「共有不可能なオブジェクト」としてエラーになる可能性がある
  • そこで Rails 側で用意している「shareability shim」を使い、対象の lambda を Ractor でも共有可能な形にしている。

実際の変更箇所 (概念的説明)

変更ファイルはすべて Action Pack 周辺で、以下のようなところにある「定数 lambda」の定義方法が変わっています。

  • actionpack/lib/action_controller/metal.rb
  • actionpack/lib/action_dispatch/http/parameters.rb
  • actionpack/lib/action_dispatch/journey/visitors.rb
  • actionpack/lib/action_dispatch/routing/mapper.rb
  • actionpack/lib/action_dispatch/routing/route_set.rb

中身のロジックは変えずに、「lambda をそのまま定数に入れる」のではなく「shareability shim 経由で wrap して定数に入れる」形に変更しています。

疑似コードでいうと、例えば:

ruby
# 変更前 (イメージ)
SOME_LAMBDA = ->(env) { ... }

# 変更後 (イメージ)
SOME_LAMBDA = ShareableLambda.wrap(->(env) { ... })

のように、定数として保持している lambda/Proc をラッパー経由で登録することで、Ruby 4.0+ かつ Ractor 環境でも Ractor.shareable? に通るようにしていると考えられます。

Rails コアにはすでに「shareability shim」が存在しており、Ruby のバージョン差異を吸収しながら Ractor.make_shareable 等を使う/使わないを切り替える役割を持っています。この PR は、その shim を Action Pack 内のいくつかの lambda に適用しただけで、処理の本質的な挙動は変えていません。


  1. 影響範囲・注意点

影響範囲

  • 主な対象は以下のレイヤーの内部実装:
    • コントローラ基盤 (ActionController::Metal)
    • パラメータ処理 (ActionDispatch::Http::Parameters)
    • ルーティング・Journey の visitor 処理 (ActionDispatch::Journey::Visitors)
    • ルーティング定義と RouteSet (ActionDispatch::Routing::Mapper, RouteSet)
  • これらは Rails のリクエスト処理~ルーティング~パラメータ解析の中核部分ですが、
    • 変更は「lambda の宣言方法」(shareable 化) に限定されており、
    • 呼び出し方・返り値・公開 API のシグネチャは変わっていないと考えてよいです。

アプリケーション開発者への実務的な影響

  • 通常の (シングルプロセス・マルチスレッド) Rails アプリでは、挙動の変化はほぼありません。
  • 将来的に:
    • Rails が「Ractor を使ったリクエスト処理」を正式にサポートする場合、
    • ルーティング / コントローラ / パラメータ処理が「Ractor 間で安全に共有」できる前提が整いつつある、という位置づけになります。
  • 独自に Rails 内部の定数 lambda を書き換えたり monkey patch している場合:
    • 似たように shareable でないオブジェクトを閉じ込めると、Ractor 環境では問題が出る可能性があります。
    • Ractor 対応を視野に入れるなら、自前の lambda / Proc も「外側から状態を閉じ込めない」「shareability shim を使う」といった方針を取る必要が出てきます。

注意点

  • この PR ではテスト追加はされておらず、既存テストでの挙動維持確認にとどまっているようです。
  • 実際に Ractor でリクエストを捌く構成は、まだ「実験フェーズ」であり、本 PR だけで完全対応になるわけではありません。
  • Ruby 4.0+ を前提としたコードパスでのみ意味を持つ変更であり、古い Ruby では shim が安全にフォールバックする想定です。

  1. 参考情報 (あれば)
  • Ruby Ractor (並列実行モデル) の概要:
    • Ractor 間でやりとりできるのは「shareable なオブジェクト」に限られる
    • Ractor.make_shareable(obj) によって「凍結」などを行い、共有可能な形に変換できる
  • Rails 側の「shareability shim」は、Ruby のバージョン差異を隠蔽するためのヘルパーで、
    • Ruby 3 系では no-op または限定的な処理
    • Ruby 4.0+ では実際に Ractor.make_shareable 等を呼ぶ
      という形で実装されていることが多いです。
  • 今後、Ractor 対応を意識した gem やライブラリを書く場合も、
    • グローバル定数に保持するオブジェクト (特に Proc / lambda / mutable な構造体) の shareability には注意が必要になります。

#57626 Introduce a new mechanism for applications to prepare for ractor safety

マージ日: 2026/6/11 | 作成者: @Edouard-chin

  1. 概要 (1-2文で)
    このPRは、Railsアプリケーションが将来的に Ractor セーフになるための準備として、「RailsのAPIに渡される proc が Ractor 的に安全かどうか」を検査・制御する新しい仕組みを導入したものです。まずは ActionDispatch::Routing::RouteSet(ルーティング)に適用されており、今後ほかの部分にも広げていく前提の基盤的変更です。

  1. 変更内容の詳細

背景: なぜ必要か

Ruby 3 以降の Ractor は、並列実行のための仕組みですが、Ractor 間で共有できるオブジェクトは Ractor.make_shareable で「共有可能」だと判定されたものに限られます。

Rails では、routes や各種設定にブロック(proc)を渡す場面が多く、そこに「共有不能なオブジェクト」を閉じ込めていると、Ractor 対応の際に Ractor::IsolationError が発生します。

PR の説明にある例:

ruby
Rails.application.routes.draw do
  to_resolve = [:basket, anchor: "items"]

  resolve("Cart") { to_resolve }
end

この場合、resolve に渡されるブロックが to_resolve というローカル変数(配列 + ハッシュ付き)をクロージャとしてキャプチャし、それが shareable ではないため Ractor.make_shareableRactor::IsolationError が起きうる、という問題があります。

新しい仕組み: ActiveSupport::Ractors のモード

このPRでは、Rails がアプリケーションの proc を shareable にしようと試みるかどうか、その失敗をどう扱うかを制御するモードを導入しています。
実装は activesupport/lib/active_support/ractors.rb に追加されており、おおまかに次の3モードがあります:

  1. :raise

    • Rails が proc を Ractor.make_shareable しようとする。
    • 失敗した場合、そのまま Ractor::IsolationError を「大きなエラー」として投げる。
    • 意図: 問題のあるコードを早期に見つけて修正したい開発者向け(テストやCIで有効にする想定)。
  2. :warn

    • 同様に Ractor.make_shareable を試みるが、
    • 失敗 (Ractor::IsolationError) したら rescue して 警告ログを出すだけ にする。
    • 実行は継続するが、将来のRactor化に向けて「どこが危険か」を洗い出せる。
  3. nil

    • Rails はそもそも Ractor.make_shareable を試みない。
    • 現状とほぼ同じ挙動で、Ractor セーフティのチェックを行わないモード。

このモードの設定インターフェースは、ActiveSupport::Ractors に隠蔽されている形で提供され、Rails 内部のさまざまなAPIから「今どのモードなのか」を参照しつつ処理が分岐する設計になっています。

適用箇所: RouteSet

このPRでは、まず ActionDispatch::Routing::RouteSet の一部コードパスでこの仕組みを利用するようになりました。

  • actionpack/lib/action_dispatch/routing/route_set.rb に 1行変更(実際には既存処理に対し、新しいRactorモードに応じて proc を shareable にする処理が追加)
  • 関連するテストが actionpack/test/dispatch/routing/route_set_test.rb に 39行追加され、
    • Ractor モードごと(:raise, :warn, nil)の挙動(エラーを投げる / warn を出す / 何もしない)を検証していると考えられます。

ActiveSupport 側の実装

activesupport/lib/active_support/ractors.rb には主に以下が含まれていると考えられます(擬似イメージ):

ruby
module ActiveSupport
  module Ractors
    mattr_accessor :mode, default: nil
    # mode: :raise, :warn, nil

    def self.make_shareable(value)
      case mode
      when :raise
        Ractor.make_shareable(value) # 失敗したらそのまま例外
      when :warn
        begin
          Ractor.make_shareable(value)
        rescue Ractor::IsolationError => e
          Rails.logger.warn("Ractor unsafe proc detected: #{e.message}")
          value # 処理続行
        end
      else # nil
        value # 何もしない
      end
    end
  end
end

※上はPR説明とファイル構成からの推測コード例です。実際のメソッド名・インターフェースは若干異なる可能性がありますが、概ねこのような構造です。

これに対して activesupport/test/ractors_test.rb で:

  • ActiveSupport::Ractors.mode 切り替え時の挙動
  • Ractor::IsolationError の発生・rescue・warning 出力など

がテストされています。


  1. 影響範囲・注意点

現時点での実行時影響

  • デフォルトモードが nil であれば、既存アプリケーションの挙動は基本的に変わりません。
  • ただし、Rails の内部で「今後 Ractor 対応を進める際のフック」となる処理が埋め込まれたため、将来的に設定を切り替えると影響が出ます。

開発者目線での使いどころ

  • アプリを Ractor セーフにしたい、あるいはその準備状況を把握したい場合:
    • ローカル or CI で ActiveSupport::Ractors.mode = :raise としてテストを実行すると、
      • shareable でない proc を使っている箇所でテストが落ちるようになります。
    • いきなりテストを落としたくない場合は :warn でログ収集し、
      • 警告の出ている箇所を少しずつ修正していくという運用ができます。

Ractor セーフティ上の注意点

  • proc の中でキャプチャしているオブジェクトが shareable かどうかが重要です。
    代表的な問題パターン:
    • ルート定義や設定ブロックの中で、可変なオブジェクト(配列・ハッシュ・クラスインスタンスなど)をローカル変数に格納してクロージャで掴む。
    • そのオブジェクトが Ractor 的に shareable にできない状態(ミューテックスや IO オブジェクトなどを含む)になっている。
  • 今後、RouteSet 以外の Rails API についても、この仕組みを使って proc を shareable にしようとするようになる予定であり、その際に類似の問題が表面化する可能性があります。

バージョン互換性

  • Ractor 自体は Ruby 3 以降の機能なので、古い Ruby バージョンでは(条件分岐により)無効 or 影響が限定的になるはずですが、
    • 実際には Rails がどの Ruby 範囲をサポートしているかに依存します。
  • このPR自体は、既存の挙動を壊さないよう opt-in な形で導入されているため、Rails をアップグレードしたタイミングで直ちに既存アプリが壊れるような変更ではありません。

  1. 参考情報 (あれば)
  • PR本体:
    • rails/rails #57626: Introduce a new mechanism for applications to prepare for ractor safety
  • 関連クラス・ファイル:
    • activesupport/lib/active_support/ractors.rb
    • actionpack/lib/action_dispatch/routing/route_set.rb
  • Ruby Ractor 仕様:
    • Ractor.make_shareable(obj) … オブジェクトを Ractor 間で共有可能にする API。
    • 共有不能なオブジェクトを渡すと Ractor::IsolationError が発生。
  • 実務的な使い方の方向性:
    • 将来の Ractor 対応を見据えて、まずは :warn モードで運用し、「どの proc が Ractor unsafe か」をログベースで洗い出す。
    • 問題のある箇所を修正できたら、CI で :raise に切り替えて「Ractor unsafe な proc を含む変更を検出する」仕組みとして利用する、という段階的導入が想定されます。

#57673 Reject attribute names that shadow CurrentAttributes methods

マージ日: 2026/6/11 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::CurrentAttributes で、クラス内部のメソッド名と同じ名前の属性を定義できてしまう問題を修正し、そのような属性名を宣言した場合は ArgumentError を投げて拒否するようにしました。これにより reset が効かなくなるなどの「リクエスト間で状態が漏れる」不具合を防ぎます。

  1. 変更内容の詳細

問題の内容

ActiveSupport::CurrentAttributes はリクエスト単位の状態を @attributes という内部ハッシュに持ち、attributes アクセサや reset などのメソッドで管理しています。

ところが、たとえば次のようにクラスを定義すると:

ruby
class C < ActiveSupport::CurrentAttributes
  attribute :attributes, :foo
end

attribute :attributes によって attributes という ユーザー定義アクセスメソッド が生成され、内部実装の attributes メソッドを上書きしてしまいます。その結果 reset が内部状態を正しくクリアできず、値がリクエスト間で残り続ける状態になります。

PRの説明の例:

ruby
require "active_support"
require "active_support/current_attributes"

C = Class.new(ActiveSupport::CurrentAttributes) do
  def self.name; "C"; end
  attribute :attributes, :foo
end

C.foo = 99
C.reset
p C.foo
  • 実際の挙動: 99 が出力される(reset しても消えない)
  • 期待される挙動: attribute :attributes を宣言した時点で ArgumentError が発生する

同様に、以下のような危険なケースも存在していました。

  • attribute :defaults
    → 定義時点で NoMethodError でクラッシュ
  • attribute :attribute
    → 以後の attribute :xxx 呼び出しが壊れる(API自体を壊す)

もともと INVALID_ATTRIBUTE_NAMES という「禁止属性名」の配列が手書きで管理されていましたが、

  • 新たに追加された/変化したメソッド (:attributes, :defaults, :attribute など) がリストに入っていない
  • すでに削除されたメソッド (:reset_all) が残り続けている

という「現状と乖離した状態」になっていました。


修正内容

1. 手書きの禁止リストを廃止し、動的に算出するよう変更

ActiveSupport::CurrentAttributes クラスの末尾で、クラス自身が定義しているメソッド名をすべて列挙し、それを禁止属性名として定数に保持するように変更しています。

具体的には、以下4種類のメソッドを対象にしています。

ruby
methods(false) +
singleton_class.private_instance_methods(false) +
instance_methods(false) +
private_instance_methods(false)
  • methods(false)
    → クラスメソッド(public/protected)
  • singleton_class.private_instance_methods(false)
    → クラスメソッドのうち private なもの
  • instance_methods(false)
    → インスタンスメソッド(public/protected)
  • private_instance_methods(false)
    → インスタンスメソッドのうち private なもの

いずれも (false) を渡して 継承元のメソッドは除外 しているのがポイントです。

  • ObjectKernel が持っている一般的なメソッド(to_s, inspect, など)は禁止しない
    → ユーザー側でそれらと同名の attribute を定義したいケースを潰さないようにする
  • 代わりに、CurrentAttributes 自身が定義しているメソッドだけ を禁止対象にする
    → 内部APIとの衝突によるバグだけを確実に防ぐ

この動的な算出により、

  • 今後 CurrentAttributes にメソッドを追加しても、自動的に禁止リストに反映される
  • 削除されたメソッドが禁止リストに残り続ける、というドリフトが発生しない

というメリットがあります。

2. 禁止された属性名が指定された場合に ArgumentError を投げる

クラス定義内の attribute 呼び出しで、上記で算出した禁止リストに含まれる名前が1つでも含まれていれば、ArgumentError を発生させます。

エラーメッセージは、複数指定された場合も含めてわかりやすい形です。例:

ruby
class C < ActiveSupport::CurrentAttributes
  attribute :attributes, :foo
end
# => ArgumentError: Restricted attribute names: attributes

class C < ActiveSupport::CurrentAttributes
  attribute :attribute, :foo, :defaults
end
# => ArgumentError: Restricted attribute names: attribute, defaults

3. テストの追加

activesupport/test/current_attributes_test.rb に以下の回帰テストが追加されています。

  1. attribute :attributes, :foo
    ArgumentError /Restricted attribute names: attributes/ を投げること

  2. attribute :attribute, :foo, :defaults
    ArgumentError /Restricted attribute names: attribute, defaults/ を投げること

既存の「restricted attribute names」を検証するテストも継続して通っており、元から禁止されていた :set, :reset なども引き続き禁止されています。


  1. 影響範囲・注意点
  • 破壊的変更 (Breaking Change) の可能性あり
    既に ActiveSupport::CurrentAttributes を継承したクラスで、以下のような属性名を定義していた場合、今回の変更後はクラス定義時に ArgumentError が発生します。

    • :attributes
    • :attribute
    • :defaults
    • その他 CurrentAttributes が定義しているクラスメソッド/インスタンスメソッドと同名のシンボル
  • ただし、これらの属性を使っていたアプリケーションは、もともと「reset が効かない」「クラッシュする」「attribute DSL が壊れる」など、かなり危険な挙動になっていたはずなので、早期に例外で気付けるようになったとも言えます。

  • Object / Kernel など継承元のメソッド名とは衝突しない限り、普通の属性名に影響はありません。今回禁止されるのは、あくまで CurrentAttributes 本体が定義しているメソッドと同名の属性だけです。

  • 新バージョンにアップデートした際に、アプリケーション内の CurrentAttributes サブクラス定義がロードされたタイミングで ArgumentError が発生する可能性があるため、デプロイ前に CI でクラス定義の読み込みまで行うテストを回しておくと安全です。


  1. 参考情報 (あれば)
  • ActiveSupport::CurrentAttributes の仕組み

    • 各スレッド/リクエストごとに @attributes というハッシュを持ち、attribute :foofoo, foo=, foo? などのアクセサを自動定義する仕組みです。
    • reset はこの @attributes を初期化することで「リクエスト間での状態の隔離」を保証しており、今回の PR はその根幹の安全性を高める変更です。
  • 関連する過去の変更

    • reset_all メソッドはすでに 475877fc5e で削除済みだったにもかかわらず、旧 INVALID_ATTRIBUTE_NAMES には残っていたことから、手動管理のリストが現実と乖離しやすいことが分かります。今回の動的算出への移行は、この問題を根本的に解消する狙いがあります。

#57638 Refactor Composite Primary Key implementation by using polymorphism

マージ日: 2026/6/11 | 作成者: @paracycle

  1. 概要 (1-2文で)
    このPRは、Railsの複合主キー(Composite Primary Key, CPK)対応で散在していた「if 複合主キーなら…」という条件分岐ロジックを整理し、新たに導入した ActiveRecord::Key クラス階層(ポリモーフィズム)に集約するリファクタリングです。機能追加というよりは、CPKサポートの内部実装を大幅にクリーンアップし、今後の保守性と拡張性を高める変更です。

  1. 変更内容の詳細

全体像

  • これまで:
    • 各所で if primary_key.is_a?(Array) など、CPKを意識した分岐が多数存在
    • 単一主キーと複合主キーの処理がベタな条件分岐で混在し、脆くなっていた
  • このPR後:
    • ActiveRecord::Key 抽象クラスと、そのサブクラス(単一キー用・複合キー用など)を導入
    • 「キーに関する操作」は Key オブジェクトに委譲し、呼び出し側はポリモーフィックに扱う
    • 関連、リレーション、トランザクション等に散らばっていた CPK 特有の条件分岐を削減

新規クラス: ActiveRecord::Key

activerecord/lib/active_record/key.rb に約 170 行の新ファイルが追加され、主キーの操作を統一的に扱うためのクラス階層が導入されています。

典型的には、次のような責務を持つと考えられます(コードから推測される役割):

  • 単一主キーか複合主キーかを意識しないインターフェイス:
    • 値の取り出し・設定
    • WHERE 句や JOIN 条件に使うための「キー条件」の生成
    • 関連レコードと元レコードのキー対応の作成 など
  • 代表的なメソッドのイメージ:
    • #extract(record) : レコードから主キー値を取り出す(単一なら1値、複合なら配列/ハッシュなど)
    • #match?(record, attributes) : 与えられた属性がこのレコードのキーにマッチするか
    • #predicate_for(table) : Arel用のキー比較条件を生成
    • #present? / #blank? 等、キーの存在チェック

呼び出し側は「キーの中身(単一 or 複合)」を知らずに key オブジェクトに処理を任せることができるようになります。

各コンポーネントでの具体的なリファクタリング

※ファイル名ごとに「何が変わったか」を要点だけ列挙します。実装のほとんどは ActiveRecord::Key に吸収されていると考えてください。

1. ActiveRecord::AttributeMethods::PrimaryKey (+10/-9)

  • primary_key / id 周りで「配列なら…」といった CPK 判定を行っていた箇所を Key 経由の処理に置き換え。
  • id_in_database など「DB上のキー値」を扱う処理が、単一/複合を意識せずに扱えるよう整理。

2. 関連(Associations)

  • activerecord/lib/active_record/associations/builder/belongs_to.rb (+2/-5)
  • activerecord/lib/active_record/associations/collection_association.rb (+7/-19)
  • activerecord/lib/active_record/reflection.rb (+24/-42)

これらでは主に次のような変更が入っています:

  • belongs_tohas_many 等の関連付けが、関連先の主キーが複合かどうかを直接意識せずに、reflection.key もしくは類似の API を通してキー操作を行うように変更。
  • 外部キー生成や「関連をロードするためのクエリ」生成時の分岐ロジックを削減し、ActiveRecord::Key が返す条件(predicate)や値セットを利用するように。

結果として:

ruby
# 以前(イメージ)
if reflection.primary_key.is_a?(Array)
  # CPK用の条件組み立て
else
  # 単一主キー用
end

# 以後(イメージ)
reflection.key.build_predicate(relation)

のように呼び出し側のコードがスリムになっています。

3. リレーション / Finder

  • activerecord/lib/active_record/relation.rb (+3/-17)
  • activerecord/lib/active_record/relation/finder_methods.rb (+4/-22)
  • activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb (+4/-7)
  • activerecord/lib/active_record/relation/predicate_builder/polymorphic_array_value.rb (+1/-6)

これらは主に以下の点が整理されています:

  • find, exists?, where などで主キーを使うクエリ生成時に、「引数が配列で来たら CPK」などのベタな判定をせず、Key 経由でクエリ条件を構築。
  • PredicateBuilder 周りのコードも、主キー構造を直接扱う代わりに Key から提供される情報を使うよう改善。
  • ポリモーフィック関連のクエリ (polymorphic_array_value など) においても、キー構造周りの条件分岐が削減。

4. 自動保存 / ネスト属性 / カウンタキャッシュ / トランザクション

  • activerecord/lib/active_record/autosave_association.rb (+1/-5)
  • activerecord/lib/active_record/nested_attributes.rb (+5/-9)
  • activerecord/lib/active_record/counter_cache.rb (+5/-12)
  • activerecord/lib/active_record/transactions.rb (+5/-9)
  • activerecord/lib/active_record/core.rb (+1/-5)

これらはいずれも:

  • 「親子レコードの関連付け」「counter_cache のインクリメント対象判定」「トランザクション内でのレコード識別」など、主キーに依存する処理から CPK 特有の条件分岐を排除。
  • レコード識別やキー比較、WHERE 条件生成を Key 経由に統一することで、コードが読みやすく・テストしやすくなるよう整理。

テスト

  • activerecord/test/cases/key_test.rb (+112/-0)

ActiveRecord::Key およびそのサブクラスの挙動を検証する新しいテストが追加されています。
想定されるテスト内容の例:

  • 単一主キー・複合主キーで #extract, #predicate_for, #match? などの挙動が正しいか
  • nil / blank / 型変換など、エッジケースで一貫した振る舞いをするか
  • 関連・リレーション処理と組み合わせたときに期待通りのSQL条件が生成されるか

  1. 影響範囲・注意点

対利用者(アプリ開発者)視点

  • 目に見える API の変更よりも内部実装の整理が中心のPRです。
  • 公式にサポートされた CPK 機能を利用している場合:
    • 動作仕様は基本的に変えず、安定性と一貫性を高めることを目的としたリファクタリングです。
    • とはいえキー周りの広範な内部変更なので、CPK を使うモデルの関連・ネスト属性・counter_cache・finder については、アップグレード時にテストを厚めに回すことを推奨します。

内部API / メタプログラミングを多用している場合

  • 以下のようなコードを書いている場合は注意が必要です:
    • primary_key が配列かどうかを直接見て分岐する
    • ActiveRecord::Reflection / Association の内部で primary key / foreign key の具体的な構造に依存したロジックを記述
    • ActiveRecord の内部クラスを monkey patch して CPK 対応を上書きしている

今回の変更により:

  • 「CPK かどうか」を直接判定するのではなく、「Key オブジェクトを通して何がしたいか」を考えた方が、今後のバージョンでも壊れにくくなります。
  • 内部構造に依存した独自実装は壊れる可能性があります。ActiveRecord::Key のインターフェイスに追随する形に書き換えることを検討してください。

gem 作者・拡張ライブラリ作者向け

  • CPK をサポートする独自の関連・finder・スコープ生成ロジックを持っている gem は、ActiveRecord::Key の存在を前提に、実装を単一/複合を意識しない形にリファクタできる可能性があります。
  • 将来的に:
    • 例えば「UUID 主キー」「シャーディング用の複合キー」など、特殊なキー戦略を ActiveRecord::Key のサブクラスとして提供するような拡張もやりやすくなります。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57638
  • 背景:
    • 以前の CPK 対応は「機能追加の第一弾」として条件分岐ベースで実装されており、コードベースが脆くなりやすい構造だった。
    • このPRはその「後片付け」として、オブジェクト指向的な抽象化(ActiveRecord::Key)により、CPK サポートを Rails 内部の一級機能として整理する段階と位置づけられます。

#57660 Fix normalizes casting raw database values on in-place change detection

マージ日: 2026/6/11 | 作成者: @chaadow

  1. 概要 (1-2文で)
    normalizes を使った属性について、「インプレース変更の検知(normalized_attribute_changed_in_place?)」時に DBの生値(raw value)を誤って cast していたため JSON などで例外が出るリグレッションが発生しており、これを 正しく deserialize を通すように修正した PRです。

  1. 変更内容の詳細

問題の背景

normalizes は属性値を保存前などに正規化する仕組みですが、「その属性がインプレースで変更されたか?」を判定するために normalized_attribute_changed_in_place? が内部で呼ばれます。

この判定では、

  • 「現在の値」と
  • 「変更前の値」

を比較する必要があります。この「変更前の値」は attribute.value_before_type_cast から取られます。

ところが、DBから読み込まれた属性の場合:

  • value_before_type_cast には シリアライズされた生のDB値(例: JSONカラムなら "{"foo":"bar"}" のような文字列)が入る
  • これに対して attribute.type.cast(value_before_type_cast) を呼ぶと、typeによっては 文字列を解析せず、そのまま返す 実装になっている(ActiveRecord::Type::Json#cast など)

結果として、正規化ブロックには「期待している Ruby オブジェクト」ではなく「生の文字列」が渡ってしまい、以下のようなエラーが発生していました:

ruby
class User < ApplicationRecord
  # settings は json カラム
  normalizes :settings, with: -> settings { settings.transform_keys(&:downcase) }
end

user = User.find(User.create!(settings: { "foo" => "bar" }).id)
user.settings["BAZ"] = "qux" # インプレース変更
user.valid?
# => NoMethodError: undefined method 'transform_keys' for an instance of String

settings に期待しているのは Hash ですが、実際には "{"foo":"bar"}" のような String が渡ってきて transform_keys が呼ばれて落ちる、という状況です。
同様の問題は、「DBデフォルト値を持つ新規レコード」(例: jsonb デフォルト {})でも起きます。これも DB の生値から attribute が組み立てられるためです。

修正内容

normalized_attribute_changed_in_place? における「変更前の値」の型変換を以下のように変更:

  • 変更前: attribute.type.cast(attribute.value_before_type_cast)
  • 変更後: attribute.type_cast(attribute.value_before_type_cast)

Attribute#type_cast は:

  • DB由来の属性に対しては type.deserialize(...) を呼ぶ
  • ユーザー入力由来の属性に対しては従来通り type.cast(...) を呼ぶ

という分岐を内部で行うメソッドです。

このため:

  • DBから読み込んだ JSON カラム → deserialize が呼ばれ、JSON 文字列 → Ruby Hash へ正しく変換される
  • ユーザーが直接代入した値については、従来と同じく cast が使われる(挙動変更なし)

という形で、DB由来の属性だけ正しく扱えるようにしつつ、既存のユーザー代入経路への影響は避けています。

テスト

activerecord/test/cases/normalized_attribute_test.rb にテストが追加されています。
内容としては、少なくとも:

  • normalizes を持つ JSON(または類似シリアライズ型)属性
  • レコードを DB からロード
  • 属性の中身をインプレースで変更
  • 検証/保存時にエラーが出ないこと、および変更検知が正しく働くこと

といったシナリオをカバーしていると考えられます。


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

    • normalizes を利用していて
    • かつ対象カラムが JSON などのシリアライズ型(DB上は文字列やバイナリだが、Ruby では Hash / Array / オブジェクトにマッピングされる型)
    • さらにその属性を インプレースで変更している (user.settings["key"] = ... / << / merge! など) ケース
  • 改善内容:

    • 上記ケースで、これまで NoMethodError などが出ていたのが解消され、期待どおりに正規化と変更検知が行われます。
    • DBデフォルト値を持つ json/jsonb カラムでも、ロード直後のインプレース変更が安全に扱われます。
  • 後方互換性:

    • ユーザーが代入した値(user.settings = { foo: "bar" } など)に対しては従来と同じ cast が使われるため、normalizes の挙動に変更はありません。
    • 差し替えたのは「DB由来の value_before_type_cast をどう Ruby 型へ戻すか」という内部実装であり、外部APIは変わりません。
  • 注意点:

    • もし独自の ActiveModel::Type を実装しており、cast / deserialize のどちらか一方だけを正しく実装していた場合、この変更により DB由来の値には deserialize が確実に使われる ため、deserialize 側の実装が不十分だと逆に不具合が顕在化する可能性があります。
    • ただしこれは ActiveRecord の型システムの本来の契約に沿った挙動であり、この PR 固有の奇抜な仕様変更ではありません。

  1. 参考情報 (あれば)
  • 回帰の原因となった PR: #57639
  • 修正対象メソッドが含まれるファイル:
    • activemodel/lib/active_model/attributes/normalization.rb
  • 型システムの関連メソッド:
    • ActiveModel::Attribute#value_before_type_cast
    • ActiveModel::Attribute#type_cast
    • ActiveRecord::Type#cast
    • ActiveRecord::Type#deserialize

#57658 Add test coverage for ActiveSupport::Duration edge cases

マージ日: 2026/6/11 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActiveSupport::Duration のこれまでテストされていなかったエッジケースや、rdoc で明示されている挙動に対してテストを追加する PR です。コード本体の変更はなく、テストのみの追加で信頼性と回 regresion 検知能力を高めています。

  1. 変更内容の詳細

この PR では activesupport/test/core_ext/duration_test.rb に 34 行のテストコードが追加されています。主な対象は、ActiveSupport::Duration の以下のメソッド・挙動です。

2-1. 単項マイナス -@ の挙動

目的

Duration に単項マイナスを適用したとき、

  • 内部の value が反転する
  • parts の各要素も符号反転する
    という仕様がテストされていませんでした(+@ のみカバーされていた)。

イメージコード例

ruby
duration = ActiveSupport::Duration.build(3600) # 1.hour 相当

neg = -duration

# 期待される挙動(仕様に基づく)
neg.value          # => -duration.value
neg.parts          # => duration.parts.transform_values { |v| -v }

今回のテストでは、この「value も parts も両方が確実に反転しているか」を確認しています。


2-2. .build の分解ロジックのテスト

目的

.build は「秒数から Duration を組み立てる」クラスメソッドで、rdoc に以下のような分解例が書かれていますが、その具体的な parts の中身はテストされていませんでした。

例: ActiveSupport::Duration.build(2716146).parts # => { months: 1, days: 1 }
(実際の rdoc に準じた形)

既存テストはエラーケースと Time/Date との演算結果だけを見ており、「どの単位にどう分解されるか(rdoc に示された例通りか)」は未カバーでした。

イメージコード例

ruby
duration = ActiveSupport::Duration.build(2_716_146)

duration.parts
# rdoc に書かれている例と一致することを確認するテストが追加されている
# 例: { months: 1, days: 1 } など

この PR で、rdoc に書かれている .build の分解例がそのままテストされ、仕様と実装の乖離があれば検知できるようになっています。


2-3. #parts が独立コピーを返すことの確認

目的

Duration#parts が「内部状態と独立したコピー」を返すことは暗黙/半ば明示的な挙動ですが、「返ってきた Hash を破壊的に変更しても、元の Duration の状態は変わらない」ことを保証するテストがありませんでした。

イメージコード例

ruby
duration = 1.day + 2.hours
parts = duration.parts

parts[:days] = 10
parts.delete(:hours)

duration.parts
# => 引き続き { days: 1, hours: 2 } のような元の内容であることを確認

このテストによって、parts の返り値を書き換えても Duration 自体が汚染されないことが将来にわたって保証されます(内部実装で @parts をそのまま返してしまうようなリファクタをするとテストが落ちる)。


2-4. #<=> が非 Duration / 非 Numeric に対して nil を返すこと

目的

Duration#<=> は、比較対象が

  • Duration
  • Numeric
    の場合と、それ以外の場合で処理が分かれています。
    この「それ以外」のパス(nil を返すべき)がテストされておらず、Scalar#<=> 側の nil パスのみカバーされていました。

イメージコード例

ruby
duration = 1.day

duration <=> Object.new
# => nil であることを確認するテストが追加

これにより、「Duration 同士・数値との比較以外は nil を返す」という比較演算子の仕様がテストで明示されました。


2-5. #coerce に Duration を渡した場合の挙動

目的

coerce は数値演算時に Ruby が内部で呼び出すメソッドで、Numeric との二項演算を整合させるために使われます。
既存テストでは Scalar#coerce のみカバーされており、Duration#coerce に Duration を渡したときの挙動が未テストでした。

仕様としては、Duration#coerce(other) が Duration を受け取ったとき、

  • 相手側の値 otherScalar でラップして返す
    というパスがあります。

イメージコード例

ruby
d1 = 1.day
d2 = 2.days

left, right = d1.coerce(d2)

left  # => ActiveSupport::Duration::Scalar のインスタンス(d2 をラップ)
right # => d1

この PR は上記のような「Duration 同士の coerce の戻り値が Scalar を用いる」という仕様をテストで固定化しています。


  1. 影響範囲・注意点
  • 影響範囲はテストコードのみで、本番コード(ActiveSupport::Duration の実装)には一切変更がありません。
  • 既存の Duration 利用コードが壊れることはありませんが、
    • -@ の符号反転仕様
    • .build の分解ロジック
    • #parts のコピー性
    • 非 Duration / 非 Numeric との <=>nil になること
    • Duration 同士の coerceScalar を返すこと
      といった挙動が、今後のリファクタで変わるとテストが落ちるようになります。
  • そのため、これらは「事実上の仕様」としてより強く固定されます。
    Rails 本体やアプリ側で Duration を拡張・モンキーパッチしている場合は、これらの前提を崩さないよう注意が必要です。

  1. 参考情報 (あれば)
  • 対象 PR: https://github.com/rails/rails/pull/57658
  • 対象クラス: ActiveSupport::Duration
    • rdoc: activesupport/lib/active_support/duration.rb
  • 関連する内部クラス: ActiveSupport::Duration::Scalar(比較演算・coerce に関わる)

#57666 Make some more constants ractor safe

マージ日: 2026/6/11 | 作成者: @andrewn617

  1. 概要 (1-2文で)
    Rails の Action Pack 周辺で、Ractor(マルチスレッド並列実行機構)対応を強化するために、一部の定数およびクラス属性を freeze して「Ractor セーフ(共有可能)」にした PR です。特に scaffold されたルーティング付きの新規アプリを Ractor 内で動かす際に頻繁にアクセスされる定数を対象にしています。

  1. 変更内容の詳細

※PR 本文と変更ファイル一覧から読み取れる範囲での解説です(実際の diff は省略されていますが、Rails の既存実装パターンから推測を含みます)。

2-1. 全体方針

  • Ruby 3 以降の Ractor では、Ractor 間で共有できるオブジェクトは「shareable」である必要があります。
  • shareable にする典型的な手段が「不変オブジェクト(freeze 済み)」にすることです。
  • 「リテラルとしてその場で書かれている定数」の多くは既に freeze 済みだったが、
    • 非リテラルから生成している定数
    • あるいは内部に non-shareable な値を持つ定数 / cattr が取りこぼされており、それらを追加で freeze しています。

2-2. ファイルごとの変更イメージ

actionpack/lib/action_dispatch/http/param_builder.rb (+1/-1)

  • リクエストパラメータを組み立てる内部ユーティリティで、パラメータ名や区切り文字列などの定数がある箇所です。

  • ここに定義されている一部の定数が freeze されました。例としては、下記のようなイメージです(擬似コード):

    ruby
    # 変更前(例)
    PARAM_KEY_SEPARATOR = "&"
    
    # 変更後(例)
    PARAM_KEY_SEPARATOR = "&".freeze
  • 実際にはもう少し複雑なオブジェクト(配列やハッシュ)や、メソッド呼び出し結果を代入している定数を freeze している可能性があります。

actionpack/lib/action_dispatch/http/request.rb (+3/-5)

  • ActionDispatch::Request は Rack 環境変数のキー一覧や、内部で使うヘッダ名などの定数を多数持っています。

  • ここで、Ractor 内でリクエストを扱う際に必ず通るような定数(例: ENV キー配列、マッピングハッシュなど)が freeze されています。

  • +3/-5 という行数から、単純な freeze 追加だけでなく、以下のようなリファクタリングもありえます(例):

    • すでにどこかで freeze 済みだった重複処理の削除
    • 定数 or クラス変数 → mattr_accessor / cattr_accessor などの見直しと freeze 追加
  • 典型的なパターン例:

    ruby
    # 変更前(例)
    ENV_METHODS = %w[REQUEST_METHOD PATH_INFO].map(&:freeze)
    
    # 変更後(例)
    ENV_METHODS = %w[REQUEST_METHOD PATH_INFO].freeze

    のように、内側を個別に freeze するのではなく、外側の配列そのものを freeze して shareable にする変更がよくあります。

actionpack/lib/action_dispatch/http/response.rb (+1/-1)

  • ActionDispatch::Response で、ステータスコードやデフォルトヘッダなどを保存している定数・クラス属性があります。

  • そのうち Ractor 内でも共有されうるデータ(例: デフォルトヘッダのハッシュ、既知ステータスコードマッピングなど)を freeze しています。

    ruby
    # 変更前(例)
    DEFAULT_HEADERS = { "X-Frame-Options" => "SAMEORIGIN" }
    
    # 変更後(例)
    DEFAULT_HEADERS = { "X-Frame-Options" => "SAMEORIGIN" }.freeze

actionpack/lib/action_dispatch/journey/router/utils.rb (+2/-2)

  • Journey は Rails のルーティングエンジンで、Router::Utils にはパス/ルート関連のヘルパや正規表現・キャッシュ用定数などがあります。

  • PR 説明から「scaffolded routes のリクエストパスで当たる定数」とのことなので、

    • パスパラメータを解析するための正規表現
    • URL パーツを join するための配列 / 文字列パターン などが freeze されたと考えられます。
  • 例(擬似コード):

    ruby
    # 変更前(例)
    PATH_SEPARATOR = "/"
    
    # 変更後(例)
    PATH_SEPARATOR = "/".freeze

2-3. cattr(クラス属性)の freeze

  • 説明に「and one cattr」とあるので、cattr_reader / cattr_accessor / mattr_* 系で定義されたクラス属性のうち、Ractor で共有される可能性があるものに対して、セット時に freeze したか、初期値を freeze したと考えられます。

  • 典型的な変更例:

    ruby
    # 変更前(例)
    cattr_accessor :route_constraints
    self.route_constraints = {}
    
    # 変更後(例)
    cattr_accessor :route_constraints
    self.route_constraints = {}.freeze

  1. 影響範囲・注意点

3-1. 主な影響範囲

  • 対象は主に Action Pack (ActionDispatch) で、
    • リクエスト (ActionDispatch::Request)
    • レスポンス (ActionDispatch::Response)
    • パラメータビルド (ParamBuilder)
    • Journey ルーター (Journey::Router::Utils) に関連する定数/クラス属性です。
  • Rails アプリのリクエスト処理パス全般に関係しますが、「既存の定数を mutate しているようなコード」がなければ基本的には影響はありません。

3-2. 後方互換性の観点

  • 定数や cattr に代入されているオブジェクトが freeze されることで、

    • それを 破壊的に変更しようとしているアプリケーションコードや gem があれば、FrozenError が発生する可能性があります。
  • 例えば、Rails の内部定数ハッシュを直接書き換えるようなコードは危険です:

    ruby
    # NG の例(今回の変更で FrozenError のリスクが上がる)
    ActionDispatch::Response::DEFAULT_HEADERS["X-Frame-Options"] = "ALLOWALL"

    → こういったカスタマイズは、initializer で config.action_dispatch 系設定を使うなど、公式にサポートされた経路で行うべきです。

3-3. Ractor を使う場合のメリット

  • この PR により、Rails アプリを Ractor 内で動かした際に、
    • リクエスト〜ルーティング〜レスポンスの処理パスで参照する定数がより多く「shareable」になり、
    • Ractor 起動時やリクエスト処理時のエラー(non-shareable なオブジェクトの共有エラー)が減ります。
  • 作者が述べているように、rails new + scaffold した標準的なアプリ構成で Ractor 実行する際の実用性向上が主な狙いです。

  1. 参考情報 (あれば)
  • Ractor と shareable オブジェクトの仕様:
  • 類似の Rails 変更:
    • すでに多くの「リテラル定数」が freeze 済みで、この PR はそれに続く第 2 弾という位置付け。
  • Rails コードでのベストプラクティス:
    • Rails の公開 API に含まれない内部定数・クラス変数を直接書き換えず、config/initializer/拡張ポイントを使うようにすると、今回のような freeze 系の変更にも強いコードになります。

#57663 Little clean up in cookies_test.rb

マージ日: 2026/6/11 | 作成者: @andrewn617

  1. 概要 (1-2文で)
    cookies に関するテスト (cookies_test.rb) 内の重複コードをヘルパーメソッド化し、テストコードを整理・簡素化したPRです。挙動変更はなく、あくまでリファクタリング(クリーンアップ)が目的です。

  1. 変更内容の詳細

PR説明から読み取れるポイントは以下です。

a. @request.env["action_dispatch.foo"] アクセスの共通化

これまでテスト内で繰り返し書かれていたような:

ruby
foo = @request.env["action_dispatch.foo"]

といったコードを、専用のヘルパーメソッドに置き換えています。
(実際のキー名は foo の部分が用途に応じて違う可能性がありますが、説明では例として "action_dispatch.foo" が挙げられています)

イメージとしては、テストクラス内に例えばこんな感じのメソッドを生やしている形です:

ruby
private
  def foo
    @request.env["action_dispatch.foo"]
  end

これにより、テスト中では単に foo と書けばよくなり、以下のメリットがあります。

  • テスト内での環境変数アクセスの記述が簡潔になる
  • "action_dispatch.foo" のようなマジックストリングを1箇所に集約できる
  • 将来キー名やアクセス方法を変更したい場合も、ヘルパーを変えるだけで済む

b. cookies = @controller.send :cookies の共通化

テスト内で頻繁に

ruby
cookies = @controller.send :cookies

という記述がされていたため、これもヘルパーメソッドとしてまとめています。

ただし、テストクラスにはすでに cookies というメソッドが存在しているため、ローカル変数 cookies を使い回すと混乱したり、既存のメソッドを上書きしてしまう懸念があります。

そのため、PRではテストクラスの private メソッドとして(名前はPR文脈から推測ですが)例えば:

ruby
private
  def controller_cookies
    @controller.send :cookies
  end

のようなメソッドを追加し、テスト中では

ruby
cookies = controller_cookies

または直接 controller_cookies を使う形に変更していると考えられます。

この変更により:

  • @controller.send :cookies というメタプログラミング的な呼び出しがテスト中からほぼ消える
  • 「コントローラから取得する cookies」と「既存のテスト用 cookies メソッド」をはっきり分けられる
  • cookies という共通的な名前に対する上書き・衝突のリスクを減らせる

c. 行数としては減少(+140 / -163)

差分としては、追加140行・削除163行で、全体としてはコード量がやや減っています。
重複していた env アクセスや @controller.send :cookies 呼び出しをまとめたことで、テストの見通しが良くなったと考えられます。

d. 文脈: 大きなPRのための下準備

作者は、より大きなPRの中で cookies_test.rb を触る必要があり、そこで見つけた「重複の多いテストコード」を先に分離してクリーンアップしています。
そのため、このPR自体は仕様変更やバグ修正ではなく、後続PRのための土台づくりという位置付けです。


  1. 影響範囲・注意点
  • 本番コードへの影響
    変更対象は actionpack/test/dispatch/cookies_test.rb のみであり、ライブラリ本体のコード(app/lib/ 以下)は変更されていません。そのため、本番環境での cookies 挙動には影響しません。

  • テストコードの挙動
    実質的にはメソッド抽出のみで、テストのロジック自体(何をアサートしているか)は変えていないため、挙動変更はほぼないと見なせます。

  • メソッド名の衝突に注意
    cookies という名前のメソッド/ローカル変数が既に存在したため、そこを避ける形でヘルパーを定義しています。
    今後、このテストファイルを編集する際は:

    • 既に存在するヘルパー(foo 相当や controller_cookies 相当)を再利用する
    • cookies というローカル変数・メソッド名をむやみに増やさない といった点に注意すると、さらにコードが分かりやすく保てます。
  • CHANGELOG なし
    テストのみの変更で挙動変更もないため、CHANGELOG は更新されていません。ライブラリ利用者にとっては「特に通知すべき変更はない」という扱いです。


  1. 参考情報 (あれば)
  • PRテンプレートのチェックリストでは、「単一目的のPRであること」「コミットメッセージに理由を含めていること」「テストの追加・更新」が満たされており、テストリファクタとして妥当な範囲の変更です。
  • 今後、cookies 周りで新しいテストを追加する場合は、
    • @request.env[...] アクセスには既存のヘルパーを使う
    • コントローラ経由の cookies アクセスには @controller.send(:cookies) のラッパーメソッドを利用する
      といったスタイルに合わせると、ファイル全体の一貫性が保てます。

#57657 Support a single composite primary key id in delete

マージ日: 2026/6/10 | 作成者: @55728

  1. 概要 (1-2文で)
    Rails の ActiveRecord において、複合主キーを持つモデルに対して Model.delete(record.id) を呼び出すと ArgumentError が出ていた問題を修正し、destroy と同様に単一レコードの削除が正しく動作するようにしました。delete の単一・複数 ID 指定の挙動が、複合主キー環境でも一貫するようになります。

  1. 変更内容の詳細

問題の挙動

複合主キー (例: ["author_id", "id"]) を持つモデル Cpk::Book で次のようなコードを書いたとします。

ruby
book = Cpk::Book.first

Cpk::Book.destroy(book.id) # => OK(既に動作している)
Cpk::Book.delete(book.id)  # => ArgumentError が発生していた
# ArgumentError: Expected corresponding value for ["author_id", "id"] to be an Array

原因は delete が受け取った引数をそのまま

ruby
where(model.primary_key => id_or_array)

に渡していたことです。
複合主キーの primary_key は配列(例: ["author_id", "id"])であり、book.id["author_id_value", "id_value"] のような配列になります。

このとき where は「複合主キーのカラム配列」と「値」を zip してタプル扱いしますが、delete 側が単一レコードか複数レコードかを判別せずにそのまま渡していたため、単一レコードの値配列が「タプルの配列」ではなく「タプルそのもの」として扱われ、バリデーションに失敗して ArgumentError になっていました。

修正内容

destroy はすでに、「単一 ID」と「複数 ID(バッチ)」を区別し、複合主キーの場合は「配列の配列(= タプルの配列)」で扱うロジックを持っています。

この PR では delete にも同様の処理を入れ、単一の複合主キー ID が渡された場合に、それを 1 要素の配列でラップしてから where に渡すようにしています。

イメージとしては、複合主キーかつ単一レコードの場合に

ruby
# 以前: 直接渡していた
where(primary_key => [author_id_value, id_value])

# 変更後: 1 要素の配列で包む
where(primary_key => [[author_id_value, id_value]])

という差分です。これにより、where から見れば「複合主キーのタプルが 1 つだけ入った配列」として扱えるので、内部ロジックと整合します。

仕様上の一貫性

これで、クラスメソッドの delete / destroy は以下のような一貫した API になります(単一主キー / 複合主キー問わず):

ruby
# 単一 ID
Model.destroy(id)    # コールバックあり
Model.delete(id)     # コールバックなし(DELETE 直発行)

# 複数 ID(バッチ)
Model.destroy([id1, id2])
Model.delete([id1, id2])

複合主キーの場合は、id 自体が「タプル (配列)」であり、複数 ID のときは「タプルの配列」になりますが、それを意識せずに destroy と同じ感覚で delete を使えるようになりました。

テスト

activerecord/test/cases/persistence_test.rb に、複合主キーを持つモデルに対して Model.delete(record.id) を実行できることを検証するテストが追加されています。
これにより、destroy だけでなく delete についても、複合主キーの単一 ID 削除が継続的に保証されます。


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

    • ターゲットは ActiveRecord のクラスメソッド delete のうち、複合主キーを持つモデルに単一 ID を渡したケースのみです。
    • 単一主キーのモデルに対する delete の挙動は変わりません。
    • 複合主キーでも、すでに動いていた「複数 ID 指定 (delete([id1, id2]))」の挙動はそのまま維持されます。
    • コールバックが走るインスタンスメソッド record.destroy、クラスメソッド Model.destroy の挙動にも変更はありません。
  • 後方互換性

    • これまで Model.delete(record.id) がエラーで落ちていたコードが、今後は正常に削除されるようになります。
    • 既存コードで delete のエラーを前提にしたワークアラウンド(例: rescue して別処理)があった場合、そのロジックが呼ばれなくなる可能性はありますが、もともとドキュメント上は destroy と同じ ID 指定が想定されていたため、仕様としては修正・是正方向の変更です。
  • 注意点

    • 複合主キー利用時は、単一レコード削除: Model.delete(record.id) / 複数削除: Model.delete([record1.id, record2.id]) という形で使えるようになりますが、
      record.id が「タプル(配列)」、複数指定は「タプルの配列」となる点は依然として変わりません。
    • コールバックや dependent: :destroy を利用したい場合は、従来どおり destroy を使う必要があります。今回の変更はあくまで DELETE 直発行の delete に対する API パリティ改善です。

  1. 参考情報 (あれば)
  • 該当 PR: https://github.com/rails/rails/pull/57657
  • 関連ドキュメント: Active Record クエリインターフェイス「deletedestroy の違い」
    • delete: コールバックなしで直接 DELETE 文を発行
    • destroy: コールバック、関連削除 (dependent: :destroy) などを実行してから削除
  • 背景: Rails 本体では複合主キーは公式サポート対象外に近い扱いですが、内部的には一部機能が動作しており、本 PR はそうした「半サポート状態」の中で destroy との仕様差分を解消するものです。

#57653 Keep composite primary key columns in the default attribute set

マージ日: 2026/6/10 | 作成者: @55728

  1. 概要 (1-2文で)
    このPRは、複合主キーを持つモデルで select による部分読み出しを行った際、主キー列を参照すると ActiveModel::MissingAttributeError が発生してしまう問題を修正し、単一主キーと同様に nil を返すようにしたものです。attributes_builder がデフォルト属性セットを構築する際に、複合主キー列も常に初期化対象に含めるようにしています。

  1. 変更内容の詳細

問題の挙動

単一主キーの場合:

ruby
# 主キー: id
Topic.select(:title).first.id
# => nil (MissingAttributeError にはならない)

複合主キーの場合:

ruby
# 主キー: [:author_id, :id] など
book = Cpk::Book.select(:title).first
book.author_id
# => ActiveModel::MissingAttributeError: missing attribute 'author_id' for Cpk::Book

同じ「partial select で主キー列を取得していない」状況でも、単一主キーは nil を返すのに対し、複合主キーでは MissingAttributeError が出てしまう不整合がありました。

原因

ActiveRecord はモデルの「常に初期化されるデフォルト属性セット」を attributes_builder を通して構成しています。その際、主キーは常に初期化対象に残し、それ以外のカラムを除外する、というロジックになっています:

ruby
# 修正前(イメージ)
defaults = _default_attributes.except(*(column_names - [primary_key]))

ここで:

  • 単一主キーの場合
    primary_key # => "id"
    column_names - ["id"] # => ["title", "created_at", ...]
    → それらを except で除外するので、「主キー id だけは defaults に残る」という意図通りに動作。

  • 複合主キーの場合
    primary_key # => ["author_id", "id"] (Array)
    column_names["author_id", "id", "title", ...] などの String の配列
    column_names - [primary_key] は「String 配列 から [Array] を引く」形になるため、何もマッチせず、結果は column_names のまま(= 何も引かれない)。
    defaults = _default_attributes.except(*column_names) となり、全カラムが defaults から除外されてしまう
    → 主キー構成要素(author_id, id)も defaults に残らないため、partial select で読み込んでいない主キー列を参照すると MissingAttributeError になる。

修正内容

primary_key を常に配列として扱うようにして、複合主キーの各列もちゃんと保持されるようにしました。

ruby
# 修正後
defaults = _default_attributes.except(*(column_names - Array(primary_key)))
  • 単一主キー:
    primary_key # => "id"
    Array(primary_key) # => ["id"]
    → 挙動は従来と完全に同じ。

  • 複合主キー:
    primary_key # => ["author_id", "id"]
    Array(primary_key) # => ["author_id", "id"]
    column_names - ["author_id", "id"] により、主キー構成要素を除いたカラムだけが除外対象になる。
    defaults には主キー列が残り、「常に初期化される属性セット」に主キー列が含まれるようになる。

このロジックは、同じファイルにある _returning_columns_for_insert では既に Array(primary_key) を使っており、それに揃えた形です。

テスト

activerecord/test/cases/primary_keys_test.rb にテストが追加されています(+7行)。
内容としては、「複合主キーを持つモデルで partial select を行った場合に、主キー列を読み出しても MissingAttributeError にならず nil が返る」ことを検証するものと考えられます。


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

    • ActiveRecord で 複合主キー (composite primary key) を使用しているモデル。
    • 特に select(:title, ...) のような partial select を使いつつ、読み込んでいない主キー列にアクセスするコード。
  • 挙動の変更点:

    • これまでは、複合主キーの一部カラムを select に含めていない場合に、そのカラムを読もうとすると ActiveModel::MissingAttributeError が発生していました。
    • このPR以降は、単一主キーと同様に「レコードの属性としては存在しているが値は読み込まれていない」扱いとなり、読み出し時は nil が返るようになります。
  • 後方互換性の観点:

    • 「MissingAttributeError が発生すること」を前提にして例外処理を書いているコードがあれば、挙動が変わる可能性があります(例外が出ずに nil になる)。
    • 一方で、単一主キーではもともと nil を返していたため、「主キー種別によって挙動が違う」という不整合が解消される形であり、多くのアプリケーションにとっては望ましい変更と考えられます。
    • 何らかの理由で「複合主キー列が必ずロードされていること」を期待している場合は、select にその列を含めるか、select 自体を見直す必要があります。今後は「ロードしていない主キー列は nil で返る」ことを前提にすべきです。

  1. 参考情報 (あれば)
  • 該当箇所のコード: activerecord/lib/active_record/model_schema.rbattributes_builder 周辺。
  • 複合主キーを扱う場合、ActiveRecord本体は単一主キー前提の設計が多いため、今回のように Array(primary_key) で吸収するパターンが他にも存在します(今回も _returning_columns_for_insert に揃えた形)。
  • この修正により、主キーが単一か複合かに関わらず「partial select → 主キー列アクセス」の挙動が統一されるため、アプリケーションレベルでの扱いがシンプルになります。

#57649 Return an empty array from find([]) on a composite primary key

マージ日: 2026/6/10 | 作成者: @55728

  1. 概要 (1-2文で)
    複合主キーを持つモデルに対して Model.find([]) を呼ぶと ActiveRecord::RecordNotFound が発生していた挙動を、単一主キーと同様に空配列 [] を返すように修正した PR です。これにより、主キーの種類に依存しない一貫した find の挙動が保証されます。

  1. 変更内容の詳細

これまでの挙動の違い

単一主キーのモデル:

ruby
Topic.find([]) # => []

複合主キーのモデル:

ruby
Cpk::Book.find([]) 
# => ActiveRecord::RecordNotFound
#    Couldn't find all Cpk::Books with '["author_id", "id"]': () 
#    (found 0 results, but was looking for 1)

同じ find([]) 呼び出しでも、主キーが複合かどうかで挙動が変わっていました。

多くのコードでは以下のように「前段の検索結果の ID をそのまま find に渡す」パターンがあります:

ruby
ids = Cpk::Book.where(published: true).pluck(:author_id, :id)
records = Cpk::Book.find(ids) # ids が空配列のときも自然に動いてほしい

このとき ids が空配列でも、そのまま find(ids) して安全に [] を返してほしい、というのが今回の問題意識です。

問題の原因

ActiveRecord::Relation::FinderMethods#find_with_ids は、渡された ids の「形」を見て、「配列を返すべきかどうか」を判定しています(= 呼び出し元が単数レコードを期待しているのか、複数レコードを期待しているのかを推測している)。

複合主キーの場合、もともと次のようなロジックになっていました:

ruby
expects_array = ids.first.first.is_a?(Array)
  • 複合主キーの単一レコード指定: find([1, 2])

    • ids[[1, 2]]
    • ids.first[1, 2]
    • ids.first.first1(Array ではない)→ expects_array == false
  • 複合主キーの複数レコード指定: find([[1, 2], [3, 4]])

    • ids[[[1, 2], [3, 4]]] のような形になるケースを想定
    • ids.first.first[1, 2](Array)→ expects_array == true

しかし find([]) の場合:

ruby
ids = []
ids.first     # => []
ids.first.first # => nil

nil.is_a?(Array)false なので「単一の複合キーが指定された」と誤認識され、「1件見つかるはず」として動いてしまい、結果として RecordNotFound が発生していました。

修正内容

空配列が渡された場合にも「配列を期待している」と判定するように条件式を修正しました。

修正後のロジック(概要):

ruby
expects_array =
  ids.first.is_a?(Array) &&
  (ids.first.empty? || ids.first.first.is_a?(Array))

ポイント:

  • ids.first.is_a?(Array)
    まず最初の要素自体が Array かをチェック
  • (ids.first.empty? || ids.first.first.is_a?(Array))
    • ids.first.empty?[] の場合(= 今回の find([]))はここで true となり、配列を期待していると判定
    • それ以外でも、ids.first.first が Array(= [[1,2],[3,4]] のような「複合主キーの配列の配列」)なら配列を期待と判定

この結果、以下のような挙動になります:

ruby
# 変更後

# 単一主キー
Topic.find([])           # => []

# 複合主キー (今回修正された部分)
Cpk::Book.find([])       # => []  # 以前は RecordNotFound

# 非空の複合主キー指定の挙動は従来どおり
Cpk::Book.find([1, 2])           # => 単一レコード (期待どおり)
Cpk::Book.find([[1, 2], [3, 4]]) # => 複数レコード配列 (期待どおり)

テストとしても activerecord/test/cases/finder_test.rb に、複合主キーで find([]) が空配列を返すことを確認するケースが追加されています。


  1. 影響範囲・注意点
  • 挙動が変わるケース

    • 複合主キーのモデルで Model.find([]) を呼んだときに、これまでは ActiveRecord::RecordNotFound だったものが、今後は [] を返すようになります。
    • もし以前の例外発生に依存したロジック(例外を rescue して処理分岐するなど)がある場合は、挙動が変わる点に注意が必要です。
  • 挙動が維持されるケース

    • 単一主キーのモデルに対する挙動は変わりません。
    • 複合主キーに対しても、find([1, 2])find([[1, 2], [3, 4]]) など、非空の引数の挙動は変わりません。
    • find(nil)find(1) といった他のパラメータ形状については、この PR による影響はありません(従来どおり)。
  • 実務的な影響

    • 「前段の検索結果(空かもしれない)をそのまま find に渡す」コードが、単一主キー・複合主キー問わず同じように安全に書けるようになります。
    • 複合主キー対応のコードパスを書くときに、「空配列の場合だけ特別扱いする」といったワークアラウンドは不要になります。

  1. 参考情報 (あれば)
  • 対象メソッド: ActiveRecord::Relation::FinderMethods#find_with_ids
  • 主に参照するとよいファイル:
    • activerecord/lib/active_record/relation/finder_methods.rb
      • find/find_with_ids の ID 解析ロジック
    • activerecord/test/cases/finder_test.rb
      • 複合主キー周りの find の期待挙動を確認するテストが追加されています。
  • 背景となる API 仕様:
    • Rails では Model.find([]) は「ID が空のときは空配列を返す」という仕様であり、これが主キーの種類に関わらず一貫するように揃えられた修正です。

#57594 Fix bug where reload leaks ordinary scopes into all_queries lookups.

マージ日: 2026/6/10 | 作成者: @andrewn617

  1. 概要 (1-2文で)
    reload 実行時に、all_queries 用のデフォルトスコープだけを使うはずのクエリに、通常のスコープ(current_scope)が紛れ込んでしまうバグを修正した PR です。all を経由せず default_scoped(all_queries: true) を直接使うことで、all_queries なスコープだけが正しく反映されるようになりました。

  1. 変更内容の詳細

問題になっていた挙動

Active Record の reload は、all_queries デフォルトスコープを考慮してモデルを再読込する際に、内部的に以下のような流れになっていました(擬似コード):

ruby
# 以前のイメージ(問題があるパターン)
def reload(...)
  relation = all(all_queries)  # ← all を経由
  # ...
end

しかし Relation#allcurrent_scope(現在適用中のスコープ)を引き継ぐ性質があります。そのため:

ruby
CurrentScopeModel.where(published: true).scoped do
  post = Post.first
  post.reload
end

のような状況で、Postreload 時のクエリに、Post 本来の all_queries デフォルトスコープに加え、where(published: true) など「意図しない通常スコープ」が混入する可能性がありました。
本来 reload で参照したいのは「all_queries: true 対象のデフォルトスコープ」と「all_queries 指定のグローバルスコープ」だけです。

修正内容

PR では、reload 内部のクエリ生成を以下のように変更しています(概念的なイメージ):

ruby
# 新しいパターン(問題の修正)
def reload(...)
  # all(all_queries) をやめて、直接 all_queries 向けの default_scoped を使う
  relation = default_scoped(all_queries: true)

  # さらに current_global_scope を調べて、
  # all_queries:true なスコープのみをマージするようにする
  if current_global_scope&.all_queries?
    relation = relation.merge(current_global_scope)
  end

  # relation を使ってレコードを再取得
end

ポイント:

  • all(all_queries) の代わりに default_scoped(all_queries: true) を直接呼び出すことで、current_scope 由来の通常スコープが混入しないようにした。
  • その上で、current_global_scope の中から all_queries フラグが立っているものだけを取り込むようにしているため、「all_queries なデフォルトスコープ/グローバルスコープだけを使ったクエリ」で reload される。
  • activerecord/test/cases/scoping/default_scoping_test.rb にテストが追加され、この漏れ込みバグが再発しないことを確認している。

※ 実際のメソッド名・ロジックは上記より細かいですが、意図としては「reload における all_queries 専用のクエリ生成経路を明確に分けた」と理解しておくとよいです。

CHANGELOG

activerecord/CHANGELOG.md にエントリが追加され、reloadall_queries スコープに関するバグ修正として明示されました。


  1. 影響範囲・注意点
  • 影響を受けるケース
    • default_scope -> all_queries: true といった仕組みを利用しているモデルで、
    • かつ、current_scope やグローバルスコープが有効なコンテキストで reload を呼んでいる場合。
  • 期待される変更
    • これまでは、reload 時のクエリに「現在の通常スコープ」が紛れ込んでいた可能性があり、その結果として:
      • 想定より絞り込まれた条件で再読み込みされる
      • 条件にマッチせず reload 時の挙動が不自然になる
    • といった挙動が、今回の修正で「all_queries 向けのデフォルトスコープ+all_queries なグローバルスコープのみ」に限定されます。
  • 注意点
    • もしアプリ側のコードが「reload が現在の通常スコープを暗黙に引き継ぐ」ことを前提にしてしまっていた場合、その前提が壊れる可能性があります(ただし、この前提自体がバグ依存の挙動です)。
    • reload の挙動変更によりテストが落ちた場合、reload にスコープが効いているかどうかをテストしていないか確認するとよいです。reload は「DB の最新状態をそのレコードの主キーで取り直す」動きを想定すべきであり、任意スコープの効果には依存しない方が安全です。

  1. 参考情報 (あれば)
  • 該当 PR: https://github.com/rails/rails/pull/57594
  • 関連 PR(バグ発見のきっかけになったもの): https://github.com/rails/rails/pull/57123
  • all_queries は Rails 7.2 以降で進められているスコープの分離・制御まわりの一部で、default_scope やグローバルスコープを「すべてのクエリに効かせるか」「特定のクエリ API のみに効かせるか」を細かく制御するための仕組みです。

#57637 Reinitialize the thread-safe level key when a logger is copied

マージ日: 2026/6/10 | 作成者: @etiennebarrie

  1. 概要 (1-2文で)
    LoggerThreadSafeLevel を持つロガーを clone / dup した際に、元のロガーとスレッドローカルなログレベルの保存領域を共有してしまうバグを修正しています。コピー時にキーを再初期化することで、コピー後のロガーごとに独立したスレッドセーフなログレベル管理が行われるようになります。

  1. 変更内容の詳細

背景: 何が問題だったか

ActiveSupport::LoggerThreadSafeLevel は「ロガーごと」にスレッドローカルなログレベルを保持する仕組みを持っています。
ここでの実装は:

  • スレッドローカル変数 (Thread.current[...]) を使う
  • そのキーとして、ロガーインスタンスの object_id から導出した値 を使う

という設計になっていました。

ところが、Ruby の clone / dup は:

  • 新しいオブジェクトを作るため object_id が変わる
  • しかしインスタンス変数はコピーされる

ため、以下のような状態になっていました:

  1. オリジナルロガー A が @thread_safe_level_key を持っている (object_id A に紐づくキー)
  2. A.clone してロガー B を作ると、A と同じ @thread_safe_level_key を持った B ができる
  3. しかし B 自身の object_id は A と異なる
  4. その結果、本来「ロガーごと」に分離されるはずのスレッドローカルなログレベルの保存領域が A と B で共有されてしまう

とくに #log_at などで一時的にログレベルを変えるようなコードを書くと、A/B どちらかの操作がもう一方に影響する(互いに上書きしあう)という、直感に反するバグが起きます。

修正内容

修正は非常にピンポイントで、LoggerThreadSafeLevel のコピー時初期化フック #initialize_copy をオーバーライドし、#initialize 同様にキーを再生成するようにした、というものです。

対象ファイル:
activesupport/lib/active_support/logger_thread_safe_level.rb

主な変更点(要約):

ruby
# 疑似コードレベルのイメージ
class ActiveSupport::LoggerThreadSafeLevel
  def initialize(*)
    super
    reinitialize_thread_safe_level_key
  end

  def initialize_copy(other)
    super
    # ここを今回追加
    reinitialize_thread_safe_level_key
  end

  private

  def reinitialize_thread_safe_level_key
    @thread_safe_level_key = :"logger_thread_safe_level_#{object_id}"
  end
end

実際のコードでは reinitialize_thread_safe_level_key のような名前かはともかく、考え方としては:

  • #initialize でやっている「object_id に基づくキー生成」を
  • #initialize_copy でも呼ぶ

という形で、「clone/dup 後のオブジェクトは必ず自分専用のキーを持つ」ことを保証しています。

テストの追加

対象ファイル:
activesupport/test/logger_test.rb

テストでは、主に以下のようなシナリオを確認していると考えられます:

  • ロガー logger1 を作成し、そのクローン logger2 = logger1.clone を作る
  • 各ロガーで log_at やスレッドローカルレベルを設定
  • logger1logger2 のログレベル操作が相互干渉しないことを検証

例として、構造イメージは次のようなものです(実際のテスト名は多少違う可能性があります):

ruby
def test_cloned_logger_has_independent_thread_local_level
  logger1 = ActiveSupport::TaggedLogging.new(Logger.new(StringIO.new)).logger
  logger2 = logger1.clone

  logger1.log_at(:debug) { ... }
  logger2.log_at(:info)  { ... }

  # それぞれの logger に対して、設定したレベルが期待通りに効いていることを検証
  # かつ、一方を変えてももう一方に影響しないことを検証
end

実テストでは、より具体的に「一定メッセージが出る/出ない」をアサートしているはずです。


  1. 影響範囲・注意点

影響範囲

  • 影響を受けるのは ActiveSupport のスレッドセーフロガーレベル機能(LoggerThreadSafeLevel)を利用しつつ、ロガーを clone または dup しているコード です。
  • Rails 標準のロギング(Rails.logger)でも内部的にこの仕組みを使っているため、アプリケーションやライブラリがロガーのコピーを行っている場合に挙動が変わります。

具体的な挙動の変化

以前:

  • logger2 = logger1.clone とした場合、
    logger1.log_atlogger2.log_at が同じスレッドローカルストレージ領域を共有してしまう
  • そのため、logger2 で一時的にログレベルを変更したつもりが、logger1 側のログ出力にも影響しうる

修正後:

  • logger1logger2異なるキーでスレッドローカルレベルを管理する ため、互いの log_at が干渉しなくなる
  • 「ロガーインスタンスごとに独立したログレベルが保たれる」という自然な期待に沿った挙動になる

注意点

  • もし既存コードが「clone したロガーは元ロガーとレベルを共有する」という、これまでの誤った挙動に依存していた場合は、挙動が変わります。ただし、そのような依存はまず意図的ではないと考えられ、今回の変更はバグフィックス扱いと見て良いです。
  • Logger 自体の API(log, info, debug など)や、log_at の表向きの仕様は変わっていません。変わるのは clone/dup 時の内部的なスレッドローカル管理キーのみです。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57637
  • 関連する内部実装:
    • ActiveSupport::LoggerThreadSafeLevel
    • Logger#clone, Logger#dupinitialize_copy フック
  • 類似のパターン:
    • object_id に基づいたキャッシュキーや Thread-local キーを使うクラスでは、initialize_copy での再初期化を行わないと同種の問題が起きることがあります(例: メモ化キャッシュ、ミドルウェアの状態保持など)。

#57647 Don't mutate the names array passed to Cache::Store#delete_multi

マージ日: 2026/6/10 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::Cache::Store#delete_multi が、呼び出し元から渡された配列を破壊的に変更していた問題を修正し、他の multi 系メソッド (read_multi, write_multi, fetch_multi) と同様に非破壊的な処理に統一する PR です。これにより、キー配列が意図せず変更されたり、名前空間が二重に付与される不具合や、凍結配列での例外が解消されます。

  1. 変更内容の詳細

問題点

これまでの delete_multi の実装では、内部的に以下のようなことをしていました(概念的なコード):

ruby
def delete_multi(names, options = nil)
  # 実際には normalize_options などがありますが要点のみ
  names.map! { |key| normalize_key(key, options) } # ここが破壊的
  delete_multi_entries(names, options)
end

このため:

ruby
cache = ActiveSupport::Cache::MemoryStore.new(namespace: "ns")
names = ["foo", "bar"]

cache.write("foo", 1)
cache.write("bar", 2)
cache.delete_multi(names)

names # => ["ns:foo", "ns:bar"] になってしまう

というように、呼び出し側が渡した names 配列の中身自体が、名前空間付きの内部キーに書き換わっていました。副作用として:

  • 配列再利用時に二重 namespace 化される
    ruby
    # 1回目で names が ["ns:foo", "ns:bar"] に書き換わっている
    
    cache.write("foo", 1)
    cache.write("bar", 2)
    cache.delete_multi(names) # => 0 (ns:ns:foo を探しに行く)
    cache.exist?("foo")       # => true (削除されていない)
  • 凍結された配列を渡すと例外が発生する
    ruby
    cache.delete_multi(["foo", "bar"].freeze)
    # => FrozenError: can't modify frozen Array: ["foo", "bar"]

一方で、read_multi, write_multi, fetch_multi などの兄弟メソッドは、いずれも新しい配列・ハッシュを作って処理しており、引数を破壊的に変更しません。この不整合が今回の修正対象です。

修正内容

names.map! をやめて、非破壊的な map に変更し、その結果をローカル変数に再代入する形に変更されています。

修正後のイメージ:

ruby
def delete_multi(names, options = nil)
  # ...
  names = names.map { |key| normalize_key(key, options) }
  delete_multi_entries(names, options)
end

これにより:

  • 呼び出し側が渡した配列は、内容が変更されない
  • 内部で使うのは normalized 済みの別配列になる

という挙動になります。PR 説明にもあるように:

Nothing downstream relies on the mutation — delete_multi_entries ... only iterates over the array it receives, and instrument_multi only reads it for the event payload.

つまり、delete_multi_entriesinstrument_multi など後続処理は「渡された配列を読むだけ」で、破壊的変更に依存していないため、この変更による副作用はありません。

テスト追加

activesupport/test/cache/behaviors/cache_store_behavior.rb にテストが追加されています。テストでは主に以下を検証しているはずです(差分行数からの推定を含む):

  • delete_multi に渡した配列の中身が呼び出し後も変わらないこと
  • namespace 付きの store でもキー配列が書き換わらないこと
  • (場合によっては)凍結済み配列でもエラーにならずに動作すること

  1. 影響範囲・注意点
  • 既存アプリ側への直接的な影響

    • 破壊的変更が「バグ」とみなされる挙動だったため、ほとんどのコードにとっては 挙動が改善するだけ です:
      • delete_multi 呼び出し後に、渡した配列を引き続き生のキー配列として安全に使えるようになります。
      • 凍結された配列 (["foo", "bar"].freeze) を delete_multi に渡しても FrozenError が出なくなります。
  • delete_multi に副作用を期待していたコード
    非推奨なパターンではありますが、以下のように「delete_multi 呼び出し後、キー配列が内部キーに置き換わっている」ことを前提にしていたコードがあれば、挙動が変わります:

    ruby
    names = ["foo", "bar"]
    cache.delete_multi(names)
    # 旧挙動では names == ["ns:foo", "ns:bar"] を期待できた
    do_something_with_internal_keys(names)

    そのようなコードは今回の変更後に動作しなくなるため、normalize_key 相当の処理を自分で呼ぶ(もしくは内部キーに依存しない設計にする)必要があります。ただし、Rails の公開 API としては内部キーの形に依存するのは想定されていないため、そのようなコードはもともと壊れやすい書き方です。

  • multi 系メソッドの一貫性が向上
    read_multi, write_multi, fetch_multi, delete_multi がいずれも「引数コレクションを破壊しない」という仕様で統一されます。
    これにより、「配列や enumerable をキャッシュキーとして何度も再利用する」ようなパターンも安心して書けます。


  1. 参考情報 (あれば)
  • 対象メソッド: ActiveSupport::Cache::Store#delete_multi
  • 関連メソッド:
    • ActiveSupport::Cache::Store#read_multi
    • ActiveSupport::Cache::Store#write_multi
    • ActiveSupport::Cache::Store#fetch_multi
  • 影響ストア実装:
    • 基底実装 (ActiveSupport::Cache::Store#delete_multi_entries)
    • RedisCacheStoredelete_multi_entries オーバーライド
      いずれも、渡された配列を「読むだけ」であり、今回の変更で壊れないことが PR 内で確認されています。

#57643 Add test coverage for the remaining Type::Date cast branches

マージ日: 2026/6/10 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActiveModel::Type::Date#cast の、これまでテストされていなかった分岐パスに対してテストを追加し、分岐網羅に近づけた PR です。アプリケーションコードの変更はなく、テストのみの追加です。

  1. 変更内容の詳細

対象: ActiveModel::Type::Date#cast(正確には内部の cast_value メソッド)の残りの分岐に対するテスト追加。
変更ファイル: activemodel/test/cases/type/date_test.rb に 25 行のテストコードを追加。

既存テストでは、以下のみがカバーされていました:

  • nil / blank / パース不能文字列
  • ISO フォーマット文字列("YYYY-MM-DD")
  • multiparameter 属性({ 1 => "2000", 2 => "1", 3 => "1" } のようなハッシュ)

今回の PR では、それ以外の「別のコードパス」がテスト対象に追加されています。

追加でカバーされた分岐は以下の 4 つです。

(1) to_date を持つ値のキャスト

仕様:

  • String 以外の値で、#to_date メソッドを持つもの (Time, Date, DateTime など) は value.to_date でキャストされる。

テスト内容のイメージ:

ruby
type = ActiveModel::Type::Date.new

time = Time.utc(2020, 1, 2)
assert_equal Date.new(2020, 1, 2), type.cast(time)

date = Date.new(2020, 1, 2)
assert_equal date, type.cast(date)

目的:

  • ドキュメントに書かれている「その他の値は to_date でキャストされる」挙動を明示的に保証。

(2) 非 ISO だがパース可能な文字列 → フォールバックパーサ

仕様:

  • "2020-01-02" のような ISO 形式は ISO_DATE の高速パスで処理されるが、
    それ以外の文字列で、Date.parse 等を使えば解釈可能なものは fallback_string_to_date ルートでパースされる。

テスト内容のイメージ:

ruby
type = ActiveModel::Type::Date.new

# 例: "1/2/2020" のようなロケール依存だが Ruby の Date.parse で解釈可能な形式
assert_equal Date.new(2020, 1, 2), type.cast("1/2/2020")

目的:

  • ISO 形式専用の高速パスとは別に、「フォールバックの文字列パーサ」が実際に通ることをテストで保証。
  • 将来的なリファクタ時に、このパスが死んだコードになっていないか検知しやすくする。

(3) 文法的には正しいがカレンダー上は不正な日付 → nil

仕様:

  • "2008-02-31" のように「YYYY-MM-DD」としては正しいが、存在しない日付の場合、
    Date.new(year, month, day) rescue nil によって nil を返す。
  • これは "ABC" のような完全にパース不能な文字列とは別の分岐。

テスト内容のイメージ:

ruby
type = ActiveModel::Type::Date.new

assert_nil type.cast("2008-02-31")

区別されるケース:

  • "ABC" → 早い段階の「nil-parts guard」でパース不能と判断されて nil
  • "2008-02-31" → 数値3つは取れるので一度 Date.new を試みるが、例外になり rescue nilnil

目的:

  • 「形式的には正しいが存在しない日付」というケース専用のガードが正しく機能していることを保証。

(4) String でも to_date でもない値 → そのまま返す

仕様:

  • 値が String ではなく、かつ #to_date も実装していない場合、cast はその値を変更せずに返す else 分岐がある。

テスト内容のイメージ:

ruby
type = ActiveModel::Type::Date.new

obj = Object.new   # to_date を持たない任意オブジェクト
assert_same obj, type.cast(obj)

目的:

  • 変換対象外の値に対しては無理にキャストを試みず、そのまま返すという現在の仕様を固定化。
  • 将来この分岐が変わった場合(例: 例外を投げるように変える等)にテストが教えてくれるようにする。

  1. 影響範囲・注意点
  • 本 PR はテストコードのみの変更であり、ActiveModel::Type::Date の挙動そのものは従来から存在したものです。
    → これまで「暗黙に依存していた挙動」が、明示的にテストで固定された形になります。
  • もし既存アプリケーションが
    • 「to_date を持つ独自クラスを date 型にマッピングしている」
    • 「非 ISO フォーマット文字列を渡している」
    • 「不正日付/to_date 未実装オブジェクトを通している」 といったケースに依存している場合でも、挙動は変わりませんが、
    • 将来の Rails バージョンでこのあたりを仕様変更しようとするとテストが落ちるため、挙動変更には明示的な判断が必要になります。

開発者としての実務的な読み方:

  • 「いまの Rails は、date キャストにおいて上記 4 ケースをこのように扱う」と仕様がテストで確定した、と理解しておくとよいです。
  • 特に、「to_date を持たない非 String 値はそのまま返す」という仕様は、型チェックやバリデーション設計時に前提としておけます。

  1. 参考情報 (あれば)
  • 対象コード: ActiveModel::Type::Date (activemodel/lib/active_model/type/date.rb)
  • 既存ドキュメント:
    • Rails Guides: Active Record Validations and Callbacks(date 型のキャストの挙動)
    • API Docs: ActiveModel::Type::Date#cast / #cast_value
  • 関連する典型的な使用例:
    • モデル属性で attribute :published_on, :date としている場合のパラメータキャスト
    • form から送られてくる "YYYY-MM-DD" 以外の文字列や Time オブジェクトの扱い確認用テストを書きたい場合に、この PR のテストを参考にできます。

#57644 Memoize ivar when freezing ActiveSupport::TaggedLogging::Formatter

マージ日: 2026/6/10 | 作成者: @etiennebarrie

  1. 概要 (1-2文で)
    ActiveSupport::TaggedLogging::Formatter を freeze した状態でも Ractor 間で共有して使えるようにするため、インスタンス変数のメモ化処理が追加されました。これにより、Ractor-shareable な logger 構成で tagged logging を安全に利用できます。

  1. 変更内容の詳細

※PR本文が短く、差分も小さいため、一般的な Rails の実装パターンおよび Ractor の制約に基づく技術的な読み解きになります。

背景: Ractor と logger / formatter

Ruby 3 の Ractor でオブジェクトを共有するには、そのオブジェクトが「Ractor-shareable」である必要があります。
基本的に以下のような制約があります:

  • オブジェクトが freeze されていること
  • 内部に保持しているインスタンス変数も Ractor-shareable であること
  • freeze 済みオブジェクトに対して新たにインスタンス変数を書き換え・追加しないこと

ActiveSupport::TaggedLogging::Formatter は、通常の logger の formatter をラップしてタグ付きログを実現するクラスですが、従来の実装では内部でインスタンス変数を遅延初期化(メモ化)している部分が Ractor-shareable の観点で問題になりうる実装でした。

この PR で行われたこと

PR タイトル: Memoize ivar when freezing ActiveSupport::TaggedLogging::Formatter

意図としては次のような処理が追加されています:

  • ActiveSupport::TaggedLogging::Formatter#freeze が呼ばれたタイミング、もしくはその前に、
    • 内部で利用するインスタンス変数(例: タグの prefix 生成用の Proc/フォーマッタのラッパーなど)を「先に確定させて」メモ化する
    • freeze 後に新規にインスタンス変数を設定しないようにする

イメージとしては下記のような変更が入っていると考えられます(擬似コード・実際のコードとは若干異なる可能性があります):

ruby
class ActiveSupport::TaggedLogging::Formatter
  def initialize(formatter)
    @formatter = formatter
  end

  # 以前はここで遅延初期化していた / 毎回ブロックを生成していたなど
  def call(severity, time, progname, msg)
    # tagged logging 用のラッパーを利用
    # 例: @formatter.call(severity, time, progname, formatted_message)
  end

  def freeze
    # freeze 前に必要な ivar を全て確定させる(遅延初期化しない)
    some_helper # <= ここで @some_helper を初期化しておく、など

    super
  end

  private

  def some_helper
    @some_helper ||= compute_helper_value
  end
end

ポイントは:

  • freeze 前に @some_helper などのインスタンス変数を確定させておくことで、
    freeze 後に @some_helper ||= のような遅延セットを実行しなくて済むようになる
  • これにより、Ractor から参照しても「freeze 済みオブジェクトに対するインスタンス変数の変更」が発生しないため、Ractor-shareable なオブジェクトとして扱えるようになる

テストの追加

activesupport/test/tagged_logging_test.rb にテストが6行追加されています。内容としては:

  • ActiveSupport::TaggedLogging::Formatter を freeze した状態で利用するケース
  • もしくは Ractor-shareable かどうか(Ractor.shareable?)を確認するようなテスト

などが追加され、Ractor と組み合わせた利用でも例外が出ない・期待通りに動作することを確認しています。


  1. 影響範囲・注意点
  • 対象: ActiveSupport::TaggedLogging::Formatter を使ったログ出力(ActiveSupport::TaggedLogging 経由の logger)
    • 特に、Ruby 3+ で Ractor を用いつつ、共通の logger / formatter を Ractor 間で共有したいケースで有効
  • 従来コードへの互換性:
    • 変更はインスタンス変数の初期化タイミング(遅延初期化 → freeze 前にメモ化)に関するものなので、
      外部 API やログ出力フォーマットに影響が出る可能性はほとんどありません
    • Tagged logging の public インターフェース (tagged { ... } など) は変更されていないはずです
  • 注意点:
    • Ractor 内で logger / formatter を共有する場合でも、「formatter 自体が freeze されていること」「内部で保持しているオブジェクト(例: underlying formatter)が shareable であること」は依然として必要です
    • アプリ側で formatter を継承・モンキーパッチしていて、freeze 後にインスタンス変数を変更するようなコードがあると、Ractor 環境では引き続き問題になる可能性があります

  1. 参考情報 (あれば)

#57646 Minor Grammar Fixes

マージ日: 2026/6/10 | 作成者: @yashika279

  1. 概要 (1-2文で)
    このPRは、Railsガイドの README (guides/README.md) に含まれていた英文の文法ミスを 1 箇所だけ修正したものです。アプリケーションコードやフレームワークの挙動には一切影響せず、ドキュメント品質の向上のみを目的としています。

  2. 変更内容の詳細

  • 対象ファイル: guides/README.md
  • 変更行: +1 / -1(1 行分の文言を置き換え)

内容としては、英語の文章中の軽微な文法・表現の誤りを修正するものです。
具体的には、たとえば以下のようなタイプの修正に相当します(※イメージ例):

diff
- This guides helps you to understand Rails.
+ These guides help you understand Rails.

実際の PR でも同様に、

  • 単数・複数形の不一致
  • 冠詞 (a/the) の誤用
  • 前置詞の選択ミス
  • 動詞の形 (helps → help など)
    といった「読んだときに違和感がある程度の軽微な文法ミス」を修正していると考えられます。

コードロジックの変更はなく、ガイド文書中の一文のみが差し替えられています。

  1. 影響範囲・注意点
  • 影響範囲
    • Rails の挙動、API、設定値、マイグレーション、生成物などには一切影響なし
    • Rails ガイドを英語で読む際の可読性・自然さがわずかに向上
  • 注意点
    • バージョンアップ時に、この PR を理由にテストや動作確認を強化する必要はありません。
    • ドキュメントの文言を引用している社内資料や記事がある場合、文言が 1 行だけ微妙に変わっている可能性はありますが、意味が変わるような修正ではないため、通常は気にしなくて構いません。
  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57646
  • 対象ファイルの最新内容は、Rails 本家リポジトリの guides/README.md を参照してください。
  • ドキュメント系 PR は、挙動には影響しないものの、ガイドやチュートリアルを教材として使う場合には誤解を減らす助けになります。

#57060 Offload ActiveStorage::Blob#metadata sync to background

マージ日: 2026/6/9 | 作成者: @shouichi

  1. 概要 (1-2文で)
    Active Storage の ActiveStorage::Blob#metadata 更新処理を即時実行からバックグラウンドジョブ実行に切り離すことで、サーバーとワーカーが同時にメタデータを更新してしまうレースコンディション(特に GCS などでのエラー)を回避する変更です。これに伴い、サービス層の API と計測イベント(Instrumentation)がジョブ実行前提の形に整理されています。

  1. 変更内容の詳細

2-1. メタデータ同期のバックグラウンドジョブ化

新規ジョブが追加されています。

ruby
# activestorage/app/jobs/active_storage/sync_metadata_job.rb
class ActiveStorage::SyncMetadataJob < ActiveStorage::BaseJob
  queue_as { ActiveStorage.queues[:analysis] }

  def perform(blob)
    blob.service.sync_metadata(blob.key, **blob.service_metadata)
  end
end

ポイント:

  • ActiveStorage::Blob から「メタデータを同期する責務」を切り出し、SyncMetadataJob に委譲。
  • Queue は ActiveStorage.queues[:analysis](既存の分析ジョブと同じ系統)に載せられる。
  • 実際の同期処理は Service#sync_metadata に委譲する形に統一。

2-2. ActiveStorage::Blob 側の変更

Blob モデルにおけるメタデータ同期の呼び出しが、即時実行からジョブのキュー投入に変更されています(実コードは概略レベルになりますが、イメージは以下のような形です)。

ruby
class ActiveStorage::Blob < ActiveRecord::Base
  # 例: 以前は直接 service.update_metadata 的な呼出しを行っていた箇所
  def sync_metadata_async
    ActiveStorage::SyncMetadataJob.perform_later(self)
  end
end
  • これまで「Blob 側で service を直接叩いてメタデータ更新」を行っていたパスが、原則としてバックグラウンドジョブを経由する作りに変わっています。
  • テスト (blob_test.rb, attachment_test.rb) もそれに合わせて、「呼び出しが即時にサービスに届く」前提から「ジョブを経由する」前提に修正されています。

2-3. ActiveStorage::Service API の整理

Service 基底クラスに「メタデータ同期」を表す明示的なメソッドが追加されています。

ruby
# activestorage/lib/active_storage/service.rb
class ActiveStorage::Service
  # ...

  # Blob 内に保持している metadata とサービス側のメタデータを同期するためのフック
  def sync_metadata(key, **metadata)
    # デフォルト実装は空実装 or 一般的な実装
  end
end

変更点:

  • 以前は GCS サービスなどで独自にメタデータ更新メソッドを持っていたのを、共通の sync_metadata インターフェースで扱うように統一。
  • これにより、SyncMetadataJob はサービスの種類を意識せず、常に blob.service.sync_metadata を呼ぶだけで済む。

2-4. GCS サービス (GCSService) の挙動変更

activestorage/lib/active_storage/service/gcs_service.rb が主な対象です。

主な変更ポイント:

  • サーバープロセス(リクエスト処理)とワーカーが同時に GCS オブジェクトのメタデータを書き換えることで発生していた race condition を避けるよう、書き込みのタイミングを「ジョブからの sync_metadata 呼び出し」に集中させる。
  • 以前はアップロードや添付の直後など、複数箇所からメタデータ更新を行っていたのを整理し、サービスレベルの更新窓口を sync_metadata に限定するような形になっています。
  • その過程で GCS の API 呼び出しパラメータやメタデータ更新ロジックが一部簡素化・再配置されています(+8/-10 行程度の差分)。

結果として、

  • 「Blob レコードの書き換え」
  • 「GCS へのメタデータ反映」
    のタイミングが decouple され、同時実行での衝突が起きにくい設計になっています。

2-5. Active Support Instrumentation の更新

guides/source/active_support_instrumentation.md に Active Storage 関連イベントの説明追加・修正があります。

  • メタデータ同期が非同期ジョブ経由になったことを反映したイベント説明が追加。
  • 例えば「メタデータ同期開始/終了」イベントがどのタイミングで発火するか、payload に何が入るかといった説明がアップデートされています。
  • これにより、監視やメトリクス収集で「いつメタデータ同期が走ったか」「どれぐらい時間がかかっているか」が追いやすくなります。

2-6. テスト・CHANGELOG の更新

  • activestorage/test/jobs/sync_metadata_job_test.rb で新ジョブの動作がテストされている
    • 指定された Blob に対し、service.sync_metadata が正しく呼び出されるか
    • 複数回呼び出しや例外ハンドリングの基本的な挙動など
  • 既存テスト (blob_test.rb, attachment_test.rb) は、新しい非同期挙動を前提とした期待値に変更。
  • activestorage/CHANGELOG.md に、
    • 「メタデータ同期がバックグラウンドで行われるようになった」
    • 「レースコンディション回避」
      などの変更点が追記されている。

  1. 影響範囲・注意点

3-1. メタデータの反映タイミングが「遅延」する

  • 以前: Blob#metadata 更新に連動して(ほぼ)同期的にストレージサービスのメタデータも更新される前提だったコードがある場合、その前提は崩れます。
  • 以後:
    • メタデータ更新はジョブキューに積まれ、ワーカーが動いたタイミングで反映されます。
    • 「更新直後にストレージ側のメタデータを前提とした処理」を行っていると、レースに負ける可能性があります。

対策・考慮:

  • メタデータを元にしたバリデーションや外部連携処理がある場合、「DB 上の Blob#metadata を信頼し、ストレージ側のメタデータ状態に依存しない」形に寄せるのが安全です。
  • どうしてもストレージ側を即時に反映させたいケースがあるなら、独自ジョブや直接 service.sync_metadata を呼び出すラッパーをアプリ側で用意するなどの検討が必要です(ただし本 PR の意図である race 回避を阻害しないよう注意)。

3-2. Active Job / キュー基盤が必須となる度合い

  • Active Storage 利用時に「メタデータ同期」が Active Job に依存するようになるため、バックグラウンドジョブが正しく動作しない環境では、メタデータ更新がストレージ側に反映されません。
  • Rails 標準の :async アダプタであっても最低限は動きますが、実運用では Sidekiq などのジョブランナーを安定運用する前提がより強くなります。

3-3. レースコンディションの修正による副作用

  • GCS のように「同一オブジェクトへの並行メタデータ更新に厳しいサービス」での 409/412 系エラーは減る想定です。
  • ただし、アプリ側で「サーバーからも独自にメタデータを更新する」ようなコードを追加してしまうと、再度同じ問題に近い状況を作りかねません。
    → ストレージサービスのメタデータ操作は、原則として Blob 標準機能 + この SyncMetadataJob に一本化するのが望ましいです。

3-4. モニタリング・ログ

  • Instrumentation が更新されているため、
    • メタデータ同期ジョブの開始/終了イベント
    • 失敗時の例外情報
      を購読することで、ジョブの詰まりやエラーを検知しやすくなります。
  • 新しいイベント名や payload は active_support_instrumentation.md の更新を確認してログ/メトリクス設定を見直すとよいです。

  1. 参考情報 (あれば)
  • 対応 Issue: https://github.com/rails/rails/issues/54919
    • GCS などで Blob メタデータ更新時に発生していた race condition の詳細なレポートがあるはずです。
  • PR 本体: https://github.com/rails/rails/pull/57060
    • activestorage/app/jobs/active_storage/sync_metadata_job.rb
    • activestorage/lib/active_storage/service.rb
    • activestorage/lib/active_storage/service/gcs_service.rb
      を読むと、サービス API の揃え方とジョブ分離の具体的な意図が把握しやすいです。
  • Active Storage ガイド:
    • 今後のガイド更新で「分析やメタデータ関連処理はジョブベース」前提がより明示されていく可能性があります。

#57639 [ActiveModel] Re-run normalizes only when the value changed in place

マージ日: 2026/6/10 | 作成者: @yaroslav

  1. 概要 (1-2文で)
    ActiveModel.normalizes を使った属性正規化について、「未保存レコードで valid? を呼ぶたびに毎回正規化が再実行されてしまう」不具合を修正し、本当に「インプレース変更」があった場合だけ再正規化するようにした PR です。これにより、挙動のバグが直るとともに、特に未保存レコードの繰り返しバリデーション時のパフォーマンスが改善されます。

  1. 変更内容の詳細

問題の背景

normalizes :name, with: ->(value) { ... } のような正規化は、属性の値が「インプレースで変更されたとき」に再度走るように設計されています。その判定には attribute_changed_in_place? が使われていました。

しかし:

  • 未保存レコードには「保存済みの値」がなく、内部的には nil と比較される
  • そのため「一度読んだだけの属性」でも「保存済み値(nil) と違う」と判定されてしまう
  • 結果として、未保存レコードに対して valid? を呼ぶたびに、正規化が毎回実行される という挙動になっていました

これは特に「再実行するたびに結果が変わる正規化」においてバグになります:

ruby
class User
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :code, :string

  normalizes :code, with: ->(v) { v&.succ } # 文字列を次の文字に
end

user = User.new(code: "b")
user.valid? # "b" -> "c"
user.valid? # "c" -> "d"
user.valid? # "d" -> "e"

意図としては「インプレース変更がない限り、同じ値を繰り返し正規化すべきではない」ので、これは不正な挙動です。

新しい判定ロジック

この PR では、「本当に値がインプレース変更されたかどうか」を、次のように判定するように変更しています:

  1. 「元の値 (original value)」を覚えておく
  2. 現在の値を、元の値に正規化をかけた結果 と比較する
    • もし「現在値 = 元の値を正規化したもの」と等しければ
      → 読み出しただけで変更されていないとみなし、元の値にリセットする
      → その後の valid? では再正規化しない
    • もし「現在値 ≠ 元の値を正規化したもの」であれば
      → インプレース変更があったとみなし、もう一度正規化を走らせる

このアプローチにより:

  • 単なる読み取り
    • 一度正規化された後、未保存のまま valid? を何度呼んでも、余計な再正規化は走らない
    • 内部では元の値に戻されるので、「次に読むときに再度正規化」が正しく行われる
  • インプレース変更 (user.name.strip! など)
    • 「元の値を正規化したもの」と今の値が食い違うため、再度正規化が走る

結果として、「本当にインプレース変更があったときだけ再正規化」が実現されます。

既存 API 観点での動作イメージ

通常のパターン:

ruby
class User
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string

  normalizes :email, with: ->(value) { value&.strip&.downcase }
end

user = User.new(email: "  Foo@Example.COM  ")
user.valid?
# email は "foo@example.com" に一度だけ正規化

user.valid?
# 2回目以降の valid? では、正規化は再実行されない (今回の修正)

インプレース変更パターン(例):

ruby
user.email # => "foo@example.com"
user.email.upcase!  # インプレース変更

user.valid?
# upcase! の結果に対して、正規化 (strip/downcase) が再度実行される
# => "foo@example.com" に戻る

今回の修正により、上の挙動が正常に担保されるようになります。

実装上の主な変更

  • activemodel/lib/active_model/attributes/normalization.rb
    • normalize_changed_in_place_attributes のロジックを修正
    • 変更の有無判定を「保存済み値との比較」から、「元値を再正規化した結果との比較」に変更
    • 「単に読まれただけ」のケースでは元の値に戻す挙動を追加
  • activemodel/test/cases/attributes/normalization_test.rb
    • 上記の新しい挙動を検証するテストを追加
  • activemodel/CHANGELOG.md
    • バグ修正として記載を追加

  1. 影響範囲・注意点

影響範囲

  • 対象:

    • ActiveModel::Attributesnormalizes 機能を使っているモデル
    • 特に「未保存レコード」に対して
      • 属性を読み出し
      • valid? を何度も呼び出す
      • もしくはインプレース変更を行う というパターンがある場合
  • 期待されるメリット:

    • 再実行で結果が変わる正規化(succ, 増分・ランダム文字列付与など)のバグが解消
    • 再実行で結果が変わらない正規化(strip, downcase, squish など)でも無駄な計算が減る
    • ベンチマークでは未保存レコードのバリデーションで若干の性能改善:
      • YJIT off: 480k i/s → 533k i/s (~1.1x)
      • YJIT on: 1.10M i/s → 1.13M i/s (ほぼ同等)
      • 1回の valid? あたりの割り当て数は変化なし (6 → 6)

挙動変更として意識すべき点

  • valid? のたびに正規化ロジックが走る」ことを前提にしたコードは壊れる可能性があります
    • 本来の設計としては「インプレース変更時のみ再正規化」が正しいため、多くの場合はバグ依存であり、この修正は望ましい互換性変更と考えられます。
  • 値の「元の状態」に戻すロジックが入ったことで、デバッガで「今メモリ上にある値」を目視すると、少し挙動がわかりにくくなる場面があるかもしれませんが、公開 API 的には整合的な挙動です。

  1. 参考情報 (あれば)

#57630 Remove unordered baseline assertions from test_default_order

マージ日: 2026/6/9 | 作成者: @yahonda

  1. 概要 (1-2文で)
    default_order に関するテストのうち、ORDER BY 句がないクエリに対して戻り順を前提にしていた不安定なアサーションを削除し、DB依存でランダムに落ちるテストを安定化する変更です。default_order 機能自体の挙動は変えず、テストのみを整理しています。

  1. 変更内容の詳細

問題となっていたテスト

失敗していたのは HasManyAssociationsTest#test_default_order で、以下のように plain な has_many をそのまま pluck していました:

ruby
comments = posts(:welcome).comments

assert_equal [1, 2], comments.pluck(:id)

しかし comments には order 指定がなく、生成される SQL は PostgreSQL では次のようになります:

sql
SELECT "comments"."id" FROM "comments" WHERE "comments"."post_id" = $1

ORDER BY が無いため、返ってくるレコードの順序は 未定義 であり、本来 [1, 2] である保証はありません。
たまたま今までは [1, 2] になっていただけで、ビルド環境やDBの状態によって [2, 1] になることがあり、実際に rails-nightly CI で [2, 1] が返ってテストが落ちました。

同様の「順序未定義な baseline アサーション」が RelationTest#test_default_order にも存在しており、こちらも潜在的に同じ問題を抱えていました。

実際の修正内容

対象ファイルは次の2つです。

  • activerecord/test/cases/associations/has_many_associations_test.rb
  • activerecord/test/cases/relations_test.rb

いずれも「default_order を使っていない、順序未定義なクエリに対するアサーション行」を削除しています。

具体的には:

  • HasManyAssociationsTest#test_default_order から、
    • posts(:welcome).comments.pluck(:id) の結果が [1, 2] である、というアサーションを削除
  • RelationTest#test_default_order から、
    • 同様に単純な Relation に対して「この順序で返るはず」というアサーションを削除

PR本文にもある通り、.sort を付けて

ruby
assert_equal [1, 2], comments.pluck(:id).sort

のようにすれば安定化はできますが、その場合に確認できるのは「コメントID 1と2が返ってくる」ことだけであり、これは他のテストでも既にカバーされています。
一方で、default_order の有無・挙動自体はこれらの baseline アサーションとは無関係なので、テスト意図に対してノイズとなるため、削除という判断になっています。


  1. 影響範囲・注意点
  • ランタイムの挙動:
    • Active Record の default_order 機能や関連の実装には一切変更がなく、アプリケーションコードへの影響はありません。
  • テスト:
    • rails 本体のテストスイートのみが対象で、DBのレコード返却順序に依存してランダムに落ちる要因が取り除かれます。
    • 今回削除された箇所は、default_order を直接検証しているわけではなく、「たまたま順序がそうなっていること」に依存した baseline であり、削除しても default_order 自体のカバレッジには実質的な影響はありません。
  • 注意点(自分のプロジェクトへの示唆):
    • Rails 本体のこの修正はテストだけですが、アプリ側のテストでも同様に「ORDER BY なしのクエリ結果の順序を前提にしたアサーション」を書いていないか注意した方がよいです。
      • もし順序を前提にしたいなら order(...) を指定する
      • 単に要素が含まれていることだけを確認したいなら sortmatch_array を使う
    • 特に PostgreSQL や MySQL で ORDER BY がない SELECT の戻り順は仕様上保証されないので、同様の flaky テストの温床になります。

  1. 参考情報 (あれば)

#57636 Fix number_to_currency crashing on a negative number with precision: nil

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    number_to_currencyprecision: nil(丸めなし)を渡したとき、負の数だけが TypeError でクラッシュしていたバグを修正した PR です。負数でも正数と同様に precision: nil を受け付け、クラッシュせずに通貨文字列を返すようになります。

  1. 変更内容の詳細

問題の挙動

number_to_currency は本来、precision: nil を指定すると「丸めを行わず、そのまま表示する」という意味になりますが、負数に対してのみ例外が発生していました。

ruby
number_to_currency(1234.5678, precision: nil)
# => "$1,234.5678"  # OK

number_to_currency(-1234.5678, precision: nil)
# => TypeError: nil can't be coerced into Integer

number_to_currency("-1234.5678", precision: nil)
# => TypeError: nil can't be coerced into Integer

一方で、同系のヘルパーは負数でも問題ありません。

ruby
number_to_rounded(-1234.5678, precision: nil)
# => "-1234.5678"

number_to_percentage(-1234.5678, precision: nil)
# => "-1234.5678%"

バグの原因

NumberToCurrencyConverter#convert の負数側の処理に、「マイナスゼロ(-$0)を表示しないためのガード」が入っています。

ruby
format = options[:negative_format] if (number_d * 10 ** options[:precision]) >= 0.5
  • 趣旨: 丸めた結果が 0 になるような非常に小さい負数 (-0.0001 など) を、-$0 と表示しないようにするための判定。
  • 問題: precision: nil のとき、10 ** options[:precision]10 ** nil となり TypeError が発生する。
  • 正数側の分岐ではこのガードが実行されないため、「負数のときだけ落ちる」という挙動になっていた、という構造です。

このガードは過去のコミット ce321c4539(「非常に大きな数値を扱う対応」)で追加されたもので、その際に precision: nil ケースが考慮されていませんでした。

修正内容

precision: nil のときはそもそも丸めをしない」ことから、

  • 非ゼロの負数は常に負の符号を維持する
  • よって「丸めたら 0 かどうか」の判定自体が不要

と判断し、ガード条件に options[:precision].nil? のチェックを追加しています。

修正前:

ruby
format = options[:negative_format] if (number_d * 10 ** options[:precision]) >= 0.5

修正後:

ruby
format = options[:negative_format] if options[:precision].nil? || (number_d * 10 ** options[:precision]) >= 0.5

これにより:

  • precisionnil の場合は (number_d * 10 ** options[:precision]) を評価せずに negative_format を適用
  • precision が数値の場合は従来どおり「丸めたら 0 以上か?」のチェックを行う

という分岐になり、TypeError が解消されます。

テスト

activesupport/test/number_helper_test.rb に回帰テストが追加されています。
新しいテストは、負の数および負の数値文字列に対して precision: nil を渡したときに例外が起きず、期待する形式で出力されることを検証しています。


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

    • ActionView::Helpers::NumberHelper#number_to_currencyprecision: nil 付きで使っているコードで、負数が入力されるケースに影響します。
    • これまで負数で precision: nil を指定すると TypeError が発生していた箇所は、正常に通貨文字列が返るようになります。
    • 正数、および precision が数値の場合の動作は変更されません。
  • 互換性

    • バグ修正であり、仕様の後方互換性は基本的に維持されています。
    • もしアプリ側でこの TypeError を前提にした独自ハンドリングをしていた場合は、そのハンドリングが呼ばれなくなる可能性がありますが、通常は望ましい改善と考えられます。
  • マイナスゼロ (-$0 問題) への影響

    • precision が数値のときのガードロジックは従来どおり残っているので、「ごく小さい負数が丸め後に 0 になるケース」を -$0 と表示してしまう問題は引き続き回避されます。
    • precision: nil のときはそもそも丸めをしないため「丸め後 0 かどうか」の議論が不要であり、挙動は合理的です。

  1. 参考情報 (あれば)
  • 対象メソッド: ActionView::Helpers::NumberHelper#number_to_currency
  • 実装箇所:
    • activesupport/lib/active_support/number_helper/number_to_currency_converter.rb
  • 関連ヘルパー(挙動比較用):
    • #number_to_rounded
    • #number_to_percentage

#57635 Preserve the sub-second fraction in TimeZone#strptime with %s

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::TimeZone#strptime で、"%s.%N" / "%s.%L" のようなエポック秒+小数形式をパースした際にサブ秒(ナノ秒)が失われていたバグを修正したPRです。Ruby標準ライブラリ Time.strptime と同じ挙動になるよう、小数部分も含めて Time を生成するようにしています。

  1. 変更内容の詳細

問題となっていた挙動

ActiveSupport::TimeZone#strptime でエポック秒形式 (%s) を使うと、サブ秒が存在しても無視されていました:

ruby
zone = ActiveSupport::TimeZone["UTC"]

zone.strptime("1577836800.123456789", "%s.%N").nsec
# => 0            # サブ秒が欠落

Time.strptime("1577836800.123456789", "%s.%N").nsec
# => 123456789    # Ruby 標準ライブラリは正しく保持

原因は、内部で DateTime._strptime が返す parts のうち、

ruby
{
  seconds:      1577836800,
  sec_fraction: (123456789/1000000000r)
}

のように :seconds:sec_fraction の両方があるケースで、ActiveSupport::TimeZone 側の parts_to_time ヘルパーが :seconds ブランチで :sec_fraction を無視していたことにあります。

擬似コード的には以下のような状態でした:

ruby
if parts[:seconds]
  # バグ: 小数部分を捨てていた
  time = Time.at(parts[:seconds])   # sec_fraction を足していない
else
  # こちらの分岐では sec_fraction を考慮している
  time = Time.new(
    parts[:year],
    parts[:mon],
    parts[:mday],
    parts.fetch(:hour, 0),
    parts.fetch(:min,  0),
    parts.fetch(:sec,  0) + parts.fetch(:sec_fraction, 0),
    utc_offset
  )
end

一方で、iso8601 / rfc3339 / parse など、_strptime:seconds ではなく :sec + :sec_fraction を使う経路では、もともとナノ秒が保持されていました。そのため、%s だけが不自然にサブ秒を捨てる状態になっていました。

修正内容

parts[:seconds] が存在する分岐でも :sec_fraction を加算して Time を生成するように1行修正しています:

ruby
# 修正前
time = Time.at(parts[:seconds])

# 修正後
time = Time.at(parts[:seconds] + parts.fetch(:sec_fraction, 0))

これにより、

ruby
zone = ActiveSupport::TimeZone["UTC"]

zone.strptime("1577836800.123456789", "%s.%N").nsec
# => 123456789

と、Ruby標準 Time.strptime と同じ結果になります。

テスト

以下のようなテストが追加されています(要旨):

ruby
def test_strptime_with_timestamp_seconds_and_fractional_seconds
  zone = ActiveSupport::TimeZone["UTC"]
  time = zone.strptime("1577836800.123456789", "%s.%N")

  assert_equal 1577836800, time.to_i
  assert_equal 123456789,  time.nsec
end

既存の %s (整数秒のみ) や %Q (ミリ秒) に関するテストは影響を受けず、time_zone_test.rb 全体がグリーンであることが確認されています。


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

    • ActiveSupport::TimeZone#strptime%s.%N / %s.%L のような「エポック秒 + 小数」の形式で使っているコード。
    • それ以外の形式 (%Y-%m-%d, ISO8601, RFC3339 など) や、Time.strptime (Ruby標準) には影響なし。
  • 互換性:

    • 以前はサブ秒が 0 に丸められていたところが、正しいサブ秒を保持するようになります。
    • もし既存コードが「nsec は常に 0 である」という前提に依存していた場合、その前提は崩れますが、これはバグ修正として妥当な互換性変更です。
    • %s 単体 (サブ秒なし) のケースは、sec_fraction 自体が存在しないため挙動は変わりません。
    • %Q (ミリ秒) は _strptime 側で最初から :secondsRational で返すため、以前からサブ秒が保持されており、今回の変更でも挙動は変わりません。
  • パフォーマンス:

    • Time.atRational/Float 相当を渡している形になるだけで、実質的なオーバーヘッドは軽微と考えられます。
  • 実運用上の注意:

    • 高精度なタイムスタンプ(ログや分散トレーシングのタイムスタンプなど)を TimeZone#strptime + %s.%N で取り扱っている場合、これまでは精度が失われていたことになります。
      • これを前提にしたロジック (例: 「同じ秒の中ではすべて同時刻として扱う」など) がある場合、挙動が変化する可能性があります。
    • Ruby標準とActiveSupportの挙動差を前提にしたワークアラウンドがある場合は、不要になるため削除できる可能性があります。

  1. 参考情報 (あれば)
  • 関連API:

    • ActiveSupport::TimeZone#strptime
    • Time.strptime (Ruby標準)
    • DateTime._strptime (内部で使用される解析ルーチン)
  • 挙動の整理:

    • ISO8601/RFC3339、Time.zone.parse 等: もともとサブ秒保持済み。
    • %s (整数エポック秒): 以前から正しく秒単位で扱い、サブ秒なし。
    • %Q (ミリ秒): _strptime 側で :secondsRational を返すため、もともとサブ秒保持済み。
    • %s.%N / %s.%L: 今回の修正により、Ruby標準と同等にサブ秒を正しく保持するようになった。

#57634 Forward blocks to DelegateClass methods that yield implicitly

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::Delegation.DelegateClass が、暗黙に yield するメソッドへブロックを正しく委譲できていなかったバグを修正し、Ruby標準ライブラリの DelegateClass と同じくブロックを転送するようにしたPRです。これにより、scanmap などブロック必須のメソッドをラップしても、期待どおりブロックが動作するようになります。

  1. 変更内容の詳細

問題の背景

ActiveSupport::Delegation.DelegateClass は、ラップ対象クラスの public メソッドを走査し、それぞれに対応する delegator メソッドを動的に定義しています。このとき、Ruby の Method#parameters からメソッドシグネチャ(引数リスト)を組み立てる実装に変えた結果、以下のような問題が出ていました。

  • 「暗黙のブロック」を使うメソッド(def foo; yield; end のように &block を引数に取らず、内部で yield するメソッド)は parameters:block 情報を持たない
  • そのため、生成される delegator メソッドのシグネチャには &block が含まれない
  • 結果として、呼び出し側でブロックを渡しても、委譲先メソッドにはブロックが渡らず ブロックが黙って捨てられる

例:

ruby
wrapper = ActiveSupport::Delegation.DelegateClass(String).new("a1b2c3")
res = []
wrapper.scan(/\d/) { |m| res << m }

res
# 実際: []  (ブロックが実行されない)
# 期待: ["1", "2", "3"]  (Ruby stdlib DelegateClass の挙動)

同様に Array#map のような典型的なブロックメソッドも壊れていました:

ruby
ActiveSupport::Delegation.DelegateClass(Array).new([1, 2, 3]).map { |n| n * 2 }
# 実際: [1, 2, 3]   (ブロック無視)
# 期待: [2, 4, 6]

Ruby 標準の DelegateClass は暗黙ブロックも含めて正しく転送するため、Rails 側の DelegateClass が意図した仕様から外れていた形です。

修正方針

  • すでに ...(引数をすべて転送するシンタックス)を使うパスではブロックも自動転送されており問題なし
  • 問題があるのは「parameters から明示的に引数リストを組み立てているパス」で、ここに 匿名ブロック転送 & を足す ことで対応

Ruby では、メソッド定義側で & だけを書くと「呼び出し時に渡されたブロック(あるいは nil)をそのまま転送する」ことができます。これを利用して:

  • 元から &block を引数に持つメソッドは今までどおり(既に block param があるのでそのまま)
  • parameters 上はブロック引数を持たないメソッドでも、定義側で匿名 & を付けることで
    • ブロックあり呼び出し → ブロックを委譲先に渡す
    • ブロックなし呼び出し → nil ブロックが渡る(挙動は従来と同じ)

という挙動を実現しています。

実際にどう変わるか

修正後:

ruby
wrapper = ActiveSupport::Delegation.DelegateClass(String).new("a1b2c3")
res = []
wrapper.scan(/\d/) { |m| res << m }

res
# => ["1", "2", "3"]  # Ruby stdlib の DelegateClass と同じ

ActiveSupport::Delegation.DelegateClass(Array).new([1, 2, 3]).map { |n| n * 2 }
# => [2, 4, 6]

テスト・実装の変更点

  • activesupport/lib/active_support/delegation.rb
    • DelegateClass の delegator 生成部分に匿名 & を付与する形で約 5 行変更
    • ... を使うパスはもともとブロック転送されているため変更なし
  • activesupport/test/delegation_test.rb
    • 新規ファイルとして DelegateClass の挙動テストを追加(+62 行)
    • この PR の修正がない状態だと 6 テスト中 5 つが fail/error し、修正によりすべてパス
  • CHANGELOG にエントリ追加(ユーザー可視の挙動修正として明示)

  1. 影響範囲・注意点
  • 影響対象:
    • ActiveSupport::Delegation.DelegateClass を直接使っているコード
    • それを内部的に使っている Rails の各種デコレータ(例: ActiveRecord の型メタデータ、楽観的ロック周りなど)
  • 具体的には、委譲先のメソッドが暗黙 yield を使っている場合 に挙動が変わります
    • これまで「ブロックが完全に無視されていた」ケースで、今回からは「正しくブロックが実行される」ようになる
    • つまり、意図せず「ブロックが無視されていたバグ」が表面化する可能性はあります(本来の動作に戻るという意味での互換性差分)
  • ブロックを渡さない呼び出しへの影響はありません
    • 匿名 & はブロックがない場合 nil を転送するだけなので、委譲先の挙動は変わりません
  • 標準ライブラリの DelegateClass と挙動が揃うため、stdlib ベースの実装から Rails の DelegateClass に乗り換えている場合などは、むしろ期待どおりになります。

利用者側での確認ポイント:

  • DelegateClass を使って定義した wrapper / decorator クラスで
    • each / map / scan / times / find など「ブロックを前提とするメソッド」を委譲している箇所がないか
    • もし以前から「ブロックが呼ばれていない?」などの違和感があった場合、今回の修正で問題が解消していないか確認する価値があります

  1. 参考情報 (あれば)
  • Ruby 標準ライブラリ DelegateClass ドキュメント:
    https://docs.ruby-lang.org/en/master/DelegateClass.html
  • 関連する変更(前提となった最適化):
    • コミット dc17084b7b "Faster class delegator" — parameters ベースでシグネチャを生成するようにした変更
  • Ruby のブロック転送挙動について:
    • def foo(&block) … block を明示的に受け取る
    • def foo(...) … 引数・キーワード・ブロックをすべてそのまま転送
    • def foo(*) + bar(&) のような匿名 & 構文で「受け取ったブロックをそのまま転送」できる(今回利用されているテクニックに相当)

#57632 Carry has_many default_order: into the cached association SQL

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    has_many の新オプション default_order: が、関連先レコードを実際にロードするときに無視されてしまう不具合を修正し、かつ statement cache を維持したまま ORDER BY をキャッシュされた SQL に反映するようにした PR です。default_order: を指定した関連でも、to_a / reload などでのロード結果が常に期待どおりの順序になるようになります。

  1. 変更内容の詳細

問題の挙動

has_manydefault_order: を付けた場合、本来は関連取得時のデフォルトの ORDER BY を指定できますが、以下のようなギャップがありました:

ruby
class Author < ApplicationRecord
  has_many :posts, default_order: "posts.id DESC"
end

author = Author.first

author.posts.to_sql          # => "... ORDER BY posts.id DESC"   ✅ SQL上は並び替えがある
author.posts.pluck(:id)      # => [6, 5, 4, 2, 1]                ✅ 期待どおり DESC

author.posts.to_a.map(&:id)  # => [1, 2, 4, 5, 6]   ❌ DBの素の順序
author.posts.map(&:id)       # => [1, 2, 4, 5, 6]   ❌
  • to_sqlpluck など「Relation をそのまま使う経路」では ORDER BY が効いている。
  • しかし、関連のターゲット(author.posts の実体の配列)をロードする経路 (to_a, map, reload など) では default_order: が反映されておらず、DB 任せの順序になっていた。

結果として「SQL には ORDER BY が出ているのに、実際のオブジェクト配列は別の順序」という、非常にわかりにくい silent wrong-result が起きていました。

原因

  • CollectionAssociation#scope
    • ここでは scope.default_order!(options[:default_order]) が呼ばれるため、pluck / count / to_sql など「関連スコープとして Relation を組み立てるとき」には default_order: が反映される。
  • Association#find_target(関連先のロード)
    • 通常は statement cache を使う「高速パス」で SQL を組み立てる。
    • この高速パスでは AssociationScope#scope を使って SQL が生成されるが、ここには options[:default_order] を反映する処理がなかった。
    • 一方で、skip_statement_cache?true の「遅いパス」では self.scope を使うため default_order: が効くが、今回のケース(シンプルな has_many + default_order: のみ)だと skip_statement_cache?false となり、常に高速パスが使われる。その結果、ターゲットのロード時には ORDER BY なしのキャッシュ SQL が使われていた。

つまり、「relation として扱うときは CollectionAssociation#scope 経由で default_order が効くが、ターゲットロード時の高速パスは AssociationScope#scope 経由で、そこには default_order がなかった」という抜け漏れです。

修正内容

以前の PR (#57538) では、「default_order: が指定された関連は statement cache を使わない(= skip する)」という方向で修正していましたが、この PR ではそれをやめて、

AssociationScope#scope 自体に default_order: を反映させ、キャッシュされる SQL に ORDER BY を含める

というアプローチに変更しています。

具体的な変更:

ruby
# activerecord/lib/active_record/associations/association_scope.rb

scope.default_order!(reflection.options[:default_order]) if reflection.options[:default_order].present?

ポイント:

  • reflection.options[:default_order] を参照し、AssociationScope#scopedefault_order! を呼び出すことで、statement cache 用のスコープにも ORDER BY を組み込む。
  • default_orderNORMAL_VALUES な merge key:
    • target_scope.merge! の過程でこの値がきちんと引き継がれる。
  • default_order! は「追加」ではなく「代入」挙動:
    • 既存の CollectionAssociation#scope での default_order! 呼び出しと衝突せず、結果として idempotent になる(同じ default_order が二重に乗るようなことが起きない)。

この結果、statement cache を無効化せずに、キャッシュされる SQL 自体に ORDER BY を含められるようになりました。

テスト追加

has_many_associations_test.rb および author モデルのテスト定義に以下を追加:

ruby
# test/models/author.rb
has_many :posts_with_default_order, class_name: "Post", default_order: "posts.id DESC"

テスト内容:

  1. test_default_order_is_applied_when_the_target_is_loaded
    • to_a / reload したときの実際のレコード順が default_order: による SQL の順と一致することを検証。
  2. test_default_order_keeps_using_the_statement_cache
    • skip_statement_cache?false のままであることを検証し、「statement cache を捨てた結果 order が効いている」のではなく、「キャッシュ SQL に order を埋め込んでいる」ことを確認。

テストは sqlite3 / postgresql / mysql2 / trilogy で has_many_associations_test がグリーンになることを確認済みです。


  1. 影響範囲・注意点
  • 新機能 has_many ... default_order:(まだ未リリース)の挙動が修正されます。
    • これまで(不具合発生中)は、pluck などだけが意図どおり並び替えられ、関連ロード(to_a, reload, map など)は DB 任せの順序でした。
    • この PR 以降は、「関連をロードするときも、default_order: が常に尊重される」ようになります。
  • statement cache の利用は維持されるため、default_order: を使ったからといってパフォーマンス劣化(毎回 SQL を組み立て直す等)は起こらない想定です。
  • default_order: は「デフォルトの順序」を決めるためのものであり、明示的に .order(...) を指定した場合はそちらが優先される設計(通常の order と同様)であることが想定されます。
  • default_scope やスコープブロックを併用している関連の場合も、NORMAL_VALUES のマージルールのもとで適切に作用するように意図されていますが、複雑なスコープ合成をしているプロジェクトでは挙動確認をしておくと安心です。

  1. 参考情報 (あれば)

#57633 Preserve falsy default value when building HashWithIndifferentAccess

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::HashWithIndifferentAccess.new に元ハッシュを渡したとき、元ハッシュに false をデフォルト値として設定していても、それが失われずにそのまま引き継がれるようにするバグ修正です。これにより、Hash.new(false) など「未知キーは false を返す」ことを前提にしたコードが、HWIA 変換後も期待通り動作します。

  1. 変更内容の詳細

これまでの挙動

HashWithIndifferentAccess.new(hash) は、元ハッシュのデフォルト値をコピーする処理を持っていますが、その条件分岐が以下のようになっていました:

ruby
self.default = hash.default if hash.default

Ruby では falsenil が「偽」なので、元ハッシュが Hash.new(false) で作られている場合でも、hash.defaultfalse だとこの条件が発火せず、デフォルト値がコピーされませんでした。

その結果:

ruby
source = Hash.new(false)
source["a"] = 1

hwia = ActiveSupport::HashWithIndifferentAccess.new(source)

hwia.default     # => nil   (本来は false であってほしい)
hwia["missing"]  # => nil   (本来は false であってほしい)

元の HashHashWithIndifferentAccess で、存在しないキーアクセス時の挙動が食い違うバグになっていました。

修正内容

条件を「nil かどうか」で判定するように変更しています:

ruby
# 修正前
self.default = hash.default if hash.default

# 修正後
self.default = hash.default unless hash.default.nil?

意味としては:

  • defaultnil(通常の Hash のデフォルト)ならコピーしない(従来と同じ・多くのケースで no-op)
  • defaultfalse やその他の値の場合は、そのままコピーして HWIA 側の default に設定する

これにより、以下のように期待通りの挙動になります:

ruby
source = Hash.new(false)
source["a"] = 1

hwia = ActiveSupport::HashWithIndifferentAccess.new(source)

hwia.default     # => false
hwia["missing"]  # => false
hwia[:missing]   # => false  # indifferent access でも同様

テスト

  • activesupport/test/hash_with_indifferent_access_test.rb に回 regresson テストを追加
  • Hash.new(false) を渡したときに、デフォルトが失われない」ことを確認
  • 既存の HashWithIndifferentAccess テストスイート (96 テスト) はすべて green

  1. 影響範囲・注意点
  • 影響範囲:
    • ActiveSupport::HashWithIndifferentAccess.new(some_hash) を使っており、かつ
    • some_hashHash.new(false) など、nil 以外 のデフォルトを設定しているケース
  • これまで:
    • そうしたコードでは「元ハッシュでは unknown key → false だが、HWIA に変換すると unknown key → nil になる」という微妙な挙動差がありました。
  • 今後:
    • 変換後も元ハッシュのデフォルト値が忠実に引き継がれ、HashHashWithIndifferentAccess 間で missing key 挙動がより一貫します。
  • 互換性:
    • 挙動が変わるのは「元デフォルトが nil ではない」場合だけで、基本的にはバグ修正として望ましい方向です。
    • もし「HWIA に変換するとデフォルトが nil になる」ことに依存していたコードがあれば、今回の修正で false(など元デフォルト)を返すようになります。そのようなコードがあるとテストが落ちる可能性がありますが、元仕様に沿う形での修正と考えられます。
  • パフォーマンス:
    • hash.default.nil? チェックはごく僅かなオーバーヘッドで、実質的な影響は無視できる範囲です。

  1. 参考情報 (あれば)
  • この挙動は、HashWithIndifferentAccess.new.new_from_hash_copying_default と揃えるための以前の変更(コミット 6e574e8a11)の副作用として現れたものを修正する位置づけです。
  • Ruby 標準 Hashdefault:
    • Hash.newdefaultnil
    • Hash.new(false)defaultfalse
    • また、hash.default_proc がある場合との関係はこの PR では触れていません(defaultnil かどうかだけを見ている)。

#57606 Resolve attribute aliases for the locking column in update_all

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    update_all で楽観ロック用カラム(lock_version)を alias_attribute 経由で指定したときに、値が失われたり PostgreSQL でエラーになる不具合を修正する PRです。update_all 内でロックカラムを検出する際に、属性エイリアスも正しく解決して判定するように変更されています。

  1. 変更内容の詳細

問題の挙動

モデル側で lock_version にエイリアスを貼っている場合:

ruby
class Widget < ActiveRecord::Base
  alias_attribute :v, :lock_version
end

Widget.where(id: id).update_all(v: 10)
  • SQLite/MySQL: lock_version10 ではなく 1(自動インクリメント値)になり、ユーザー指定値が黙って失われる
  • PostgreSQL: PG::SyntaxError: multiple assignments to same column "lock_version" でエラー

一方、エイリアスを使わずにカラム名そのもので指定した場合は問題なし:

ruby
Widget.where(id: id).update_all(lock_version: 10)
# => lock_version が正常に 10 になる

原因

update_all は「楽観ロック有効かつ、呼び出し側が lock_version を更新していない場合」に、自動的にロックカラムをインクリメントする処理を持っています。

元コード(簡略化):

ruby
if model.locking_enabled? &&
    !updates.key?(model.locking_column) &&
    !updates.key?(model.locking_column.to_sym)
  attr = table[model.locking_column]
  updates[attr.name] = _increment_attribute(attr)
end
values = _substitute_values(updates)

ここで行っているチェックは キー文字列/シンボルが lock_version かどうかだけ です。そのため:

  • alias_attribute :v, :lock_version の場合、updates には { "v" => 10 } だけが入っている
  • updates.key?("lock_version")updates.key?(:lock_version)false
  • 「ロックカラムが指定されていない」と誤判定され、自動インクリメントが追加される

その後 _substitute_values が、v も追加された lock_version も両方とも物理カラム lock_version にマッピングして SQL を生成するため、次のような二重代入になる:

sql
UPDATE "widgets"
SET "lock_version" = 10,
    "lock_version" = COALESCE("widgets"."lock_version", 0) + 1
WHERE ...

DBごとの挙動:

  • SQLite/MySQL: 同一カラムへの複数代入では「最後の値が勝つ」ため、自動インクリメント側が適用され、ユーザーの指定値は捨てられる
  • PostgreSQL: 同一カラムへの複数代入をエラーとして扱うため、PG::SyntaxError が発生

修正内容

更新キーごとに model.attribute_alias を通し、エイリアスを解決した上でロックカラムかどうかを判定するように変更:

変更後のロジック(要旨):

ruby
if model.locking_enabled? &&
    updates.keys.none? { |key|
      (model.attribute_alias(key) || key.to_s) == model.locking_column
    }
  attr = table[model.locking_column]
  updates[attr.name] = _increment_attribute(attr)
end

ポイント:

  • updates の各キー key に対して:
    • model.attribute_alias(key) でエイリアスを解決し(例: "v""lock_version"
    • 解決結果が nil なら key.to_s をそのまま使う
    • それが model.locking_column(通常 "lock_version")と一致するかを調べる
  • 1つでもロックカラムにマッピングされるキーがあれば、自動インクリメントは行わない
  • 何もマッピングされなければ、従来通りロックカラムの自動インクリメントを追加する

これにより:

ruby
class Person < ActiveRecord::Base
  alias_attribute :aliased_lock_version, :lock_version
end

Person.where(id: 1).update_all(aliased_lock_version: 10)
# => lock_version が 10 に更新され、自動インクリメントは付かない

という期待通りの動作になります。

テスト追加

activerecord/test/cases/locking_test.rb に以下のようなテストが追加されています:

  • OptimisticLockingTest#test_update_all_with_lock_column_set_through_an_alias
    • people テーブルに対するモデルで alias_attribute :aliased_lock_version, :lock_version を定義
    • update_all(aliased_lock_version: 10) 実行後に:
      • 影響行数が 1 であること
      • lock_version が 10 になっていること

このテストが、sqlite3 / postgresql / mysql2 すべてで「現行(main)では失敗 → PR適用で成功」することが確認されています。


  1. 影響範囲・注意点
  • 影響対象:
    • 楽観ロック(lock_version など)を有効化しているモデル
    • かつ、そのロックカラムに alias_attribute を定義しているケース
    • かつ、update_all を使ってエイリアス名でロックカラムを更新しているケース
  • 上記の条件に当てはまる場合:
    • これまで SQLite/MySQL では「気づかないうちにロックカラムが自動インクリメントされ、指定した値は無視される」というバグがあった
    • PostgreSQL では update_all 自体がエラーで落ちていた
    • 本PRにより、エイリアス名で指定してもカラム名で指定した場合と同じ挙動になる

注意点:

  • ロックカラムを 一切指定していない update_all の挙動(暗黙の自動インクリメント)は変わりません。
  • 既存コードで、update_all に渡すハッシュのキーにエイリアス名と実カラム名の両方を同時に指定しているような異常ケースがあった場合、これまでとは内部での扱いが変わる可能性があります(ただし、そもそもそのような指定は意味的に破綻しているので、実用上問題になることはほぼないはずです)。
  • insert_all では既に同様の「エイリアス解決」が行われており、本PRは update_all をそれに揃える方向の変更です。

  1. 参考情報 (あれば)
  • 該当PR: rails/rails #57606「Resolve attribute aliases for the locking column in update_all
  • 関連API:
    • ActiveRecord::Base#alias_attribute
    • ActiveRecord::Relation#update_all
    • 楽観ロック: locking_enabled?, locking_column, lock_version カラム
  • 変更ファイル:
    • activerecord/lib/active_record/relation.rb
      → ロックカラムの自動インクリメント条件の判定ロジックを修正
    • activerecord/test/cases/locking_test.rb
      → エイリアス経由でのロックカラム更新テストを追加
    • activerecord/CHANGELOG.md
      → バグ修正として追記

#57617 Fix accepts_nested_attributes_for docs about _destroy and new records [ci skip]

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    accepts_nested_attributes_for のドキュメントにおいて、「新規レコードかつ _destroy が真なら無視される」という説明が、allow_destroy: true が有効な場合にしか成り立たないにもかかわらず、無条件にそうであるかのように書かれていた問題を修正した PR です。コードの挙動は従来どおりで、ドキュメントだけを実装に合わせて訂正しています。

  1. 変更内容の詳細

問題となっていた点

accepts_nested_attributes_for でネストした属性を受け取るとき、内部では以下のようなロジックで「新規レコードを無視するかどうか」を判定しています:

ruby
reject_new_record?(association_name, attributes)
  # => will_be_destroyed?(...) || call_reject_if(...)

will_be_destroyed?(association_name, attributes)
  # => allow_destroy?(association_name) && has_destroy_flag?(attributes)

つまり「_destroy が truthy だから新規レコードを無視する」ためには、

  • その関連に対して allow_destroy: true が設定されていること

が前提です。allow_destroy: false(デフォルト)の場合は _destroy が true でもレコードは 無視されず、普通に build される というのが実装上の正しい挙動です。
この振る舞いは CVE-2015-7577 対応で意図的に導入されたものであり、テスト (test_reject_if_is_not_short_circuited_if_allow_destroy_is_false) により既にカバーされています。

ところがドキュメントでは、次の2点で「allow_destroy 前提」を書き漏らしていたため、挙動を誤解させる内容になっていました。

1) One-to-many の例

ドキュメントの一対多の例で、次のようなコードが載っていました(要点のみ):

ruby
class Member < ApplicationRecord
  has_many :posts
  accepts_nested_attributes_for :posts
end

member = Member.create(
  name: 'joe',
  posts_attributes: [
    { title: 'a' },
    { title: 'b' },
    { title: '', _destroy: '1' } # this will be ignored
  ]
)

member.posts.length # => 2

ここでは accepts_nested_attributes_for :posts にオプション指定がないため、allow_destroy はデフォルトの false です。
その場合、実装上は _destroy: '1' が付いていてもその新規レコードは 無視されずに作成される ため、

  • member.posts.length は 3 になる
  • コメント # this will be ignored は誤り

となり、ドキュメントの例と実際の挙動が食い違っていました。

2) :reject_if オプションの説明

:reject_if の説明文において、オプションを指定しない場合のデフォルト挙動として、概ね次のような説明がありました(要旨):

  • :reject_if を指定しない場合は、
    • "_destroy が true と評価される値を持つ属性ハッシュを除いて、新しいレコードが作成される"

しかし正確には、

  • _destroy が truthy であっても allow_destroy: false のときは新規レコードはそのまま build される」

ため、_destroy による無視は「allow_destroy: true の場合に限る」という前提条件が抜けていました。

この PR での修正内容

  1. One-to-many の例を実装に合うように修正

    問題の例に対し、allow_destroy: true を明示的に付けることで、ドキュメント上の説明どおりの挙動になるようにしています:

    ruby
    class Member < ApplicationRecord
      has_many :posts
      accepts_nested_attributes_for :posts, allow_destroy: true
    end
    
    member = Member.create(
      name: 'joe',
      posts_attributes: [
        { title: 'a' },
        { title: 'b' },
        { title: '', _destroy: '1' } # this will be ignored
      ]
    )
    
    member.posts.length # => 2

    これにより、_destroy: '1' がついたハッシュは allow_destroy: true により無視され、posts は 2件になる、という説明が正しくなります。

  2. :reject_if の説明文に allow_destroy 前提を追記

    :reject_if のセクションなど、_destroy の有無でレコードが無視される旨の説明をしている箇所に対して、

    • "_destroy が truthy なら無視される" → 「allow_destroy: true かつ _destroy が truthy なら無視される」

    という形で、allow_destroy が有効な場合にのみ成り立つことを明示するよう文言を修正しています。

  3. コード変更はなし (ドキュメントのみ)

    変更ファイルは activerecord/lib/active_record/nested_attributes.rb のコメント/ドキュメント部分のみであり、ロジックには一切手を加えていません。
    [ci skip] が付いているのも、テストが不要なドキュメント変更であることを示しています。


  1. 影響範囲・注意点
  • 実行時の挙動は変わりません
    CVE-2015-7577 対応以降の挙動(allow_destroy: false では _destroy 付きでも新規レコードは build される)はそのままです。今回の PR は、その挙動にドキュメントを揃えたものです。

  • 既存コードでの _destroy の扱いを再確認すべきケース

    • ドキュメントだけを見て「_destroy を付ければ新規レコードは作られない」と思い込み、allow_destroy: true を付けていないフォーム/API 実装がある場合、
      • 実際には新規レコードが作成されている可能性があります。
    • 特に fields_foraccepts_nested_attributes_for を用いたフォームで、「空のフィールドに _destroy を付ければスキップされるはず」と考えていた場合は注意が必要です。
    • この PR 自体は仕様を変えていませんが、「どちらが正しいのか」をドキュメントが明確にしたことで、誤った前提だったコードに気付くきっかけとなるかもしれません。
  • 新規実装時のポイント

    • 「フォームでユーザーが『削除』を押した行は保存しない」など、_destroy で新規レコードを抑制したい場合、

      • 対応する関連に 必ず allow_destroy: true を付ける 必要があります。
    • それとは別に、「空のフィールドは新規レコードを作りたくない」といった要件は、reject_if: で明示的に制御するのが安全です:

      ruby
      accepts_nested_attributes_for :posts,
        allow_destroy: true,
        reject_if: proc { |attrs| attrs['title'].blank? && attrs['_destroy'] != '1' }

  1. 参考情報 (あれば)
  • 関連する既存テスト:
    test_reject_if_is_not_short_circuited_if_allow_destroy_is_false
    allow_destroy: false のときは _destroy が truthy でも reject_if 判定がショートサーキットされず、新規レコードが build されることを検証)

  • セキュリティ・バグフィックスの背景:

    • CVE-2015-7577(nested attributes の destroy / reject_if 周りの挙動に関する脆弱性)対応時に、allow_destroy が偽の場合に _destroy を尊重しないという仕様が導入されています。
    • 本 PR は、その仕様に合わせてドキュメントを修正し、「_destroy が効くのは allow_destroy: true のときだけ」という前提を明示したものです。

#57600 Fix store_accessor read mutating a NULL structured column

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
  • store_accessor を、NULLjson / jsonb / hstore カラムに対して「読むだけ」で呼び出したときに、レコードが変更済み扱いになり NULL{} で上書き保存されてしまうバグを修正する PR です。
  • 修正により、「読み取り専用の操作」がレコードを汚染(dirty 化)したり、DB 上の NULL を暗黙的に {} に書き換えることがなくなります。

  1. 変更内容の詳細

問題の挙動

以下のようなモデル定義で、data が DB 上で NULL のとき:

ruby
class Doc < ActiveRecord::Base
  store_accessor :data, :color
end

doc = Doc.create!(name: "a")  # data は NULL のまま
doc = Doc.find(doc.id)

doc.changed?      # => false
doc.color         # 読み出しのみ
doc.changed?      # => true   (本来は false であるべき)
doc.changes       # => {"data" => [nil, {}]}  (NULL が {} になった扱い)

この状態で、name だけ変えて保存すると:

ruby
doc.name = "b"
doc.save!
Doc.connection.select_value("SELECT data FROM docs WHERE id = #{doc.id}")
# => "{}"  (DB 上の data が NULL から {} に書き換えられてしまう)

つまり、

  • 「アクセサを読むだけ」で
    • レコードが changed? == true になる
    • data カラムの DB 値が NULL"{}" に変化する

という副作用が発生していました。

原因

store_accessor の実装で使われている HashAccessor.read が、内部で prepare を呼んでおり、その prepare が「nil のときに {} を生成して、レコードに書き戻す」という処理を行っていたためです。

ruby
def self.prepare(object, attribute)
  store_object = object.public_send(attribute)

  if store_object.nil?
    store_object = {}
    object.public_send(:"#{attribute}=", store_object) # ← 読み取り時にも書き戻していた
  end

  store_object
end

結果として、「読むだけ」のはずの color アクセサが data カラムを {} に更新し、ActiveRecord の dirty tracking 上も data が変わったとみなされていました。

なお、coder: を使う古い store(シリアライズあり)の場合は、型キャスト層 (IndifferentCoder.load) で NULL{} 変換が行われるため、そもそも nil にならず、この問題には該当しません。

修正方針

  • 「読み取りパス」と「書き込みパス」を分離し、読み取り時にはレコードを書き換えないようにする。
  • 書き込み時はこれまで通り prepare を通し、NULL のカラムに初めて値を書くときは {} を生成して保存できるようにする。

HashAccessor.read の変更

以前は readprepare を経由していたのを、非破壊な get ベースの実装に変更:

ruby
# 変更後: HashAccessor
def self.read(object, attribute, key)
  get(object.public_send(attribute), key)
end

get は、すでに存在しているハッシュからキーを読むだけで、nil のときも何も書き戻しません。

IndifferentHashAccessor の対応

store_accessorhash に対して「シンボル/文字列どちらのキーでもアクセスできる」(indifferent access)ことを保証したいので、IndifferentHashAccessor にも非破壊な get を追加し、read でこれを使うようにします:

ruby
# IndifferentHashAccessor
def self.get(store_object, key)
  if store_object
    IndifferentCoder.as_indifferent_hash(store_object)[key]
  end
end

これにより、

  • 読み取り時:
    • IndifferentCoder.as_indifferent_hash で一時的に indifferent hash として扱い、その場で値を取得するだけ
    • レコードの属性(DB にマッピングされるハッシュ)は書き換えない
  • 書き込み時:
    • 既存の prepare 経由の動作を維持し、nil の場合は {} を生成して書き込む

という振る舞いになります。

テスト

Admin::User の既存の json カラム json_optionsstore_accessor :json_options, :enable_friend_requests を使い、以下を確認するテストを追加:

  • json_optionsNULL の状態で enable_friend_requests を読むだけでは
    • changed?false のままであること
    • 別の属性だけを変更して保存しても、json_optionsNULL のままであること({} に書き換えられないこと)

MariaDB 向けには、既存の JSON 関連テストと同じ skip 条件を利用。

SQLite3 / PostgreSQL / MySQL2 すべてで、このテストが fail → pass になることを確認済みで、store_test など周辺テストもグリーンであることが確認されています。


  1. 影響範囲・注意点
  • 対象:
    • ネイティブな構造化型 (json / jsonb / hstore 等) を持つカラムに対して、store_accessor を定義しているケース。
    • DB 上の値が NULL で、かつ accessor を読み出しているコードがある場合。
  • 変わる挙動:
    • これまで:
      • doc.color のような読み取りだけで doc.changed?true になり、次の saveNULL{} に上書きされていた。
    • これから:
      • 読み取りだけでは dirty にならず、NULL も維持される。
  • 想定される「副作用」(ほとんどは期待される挙動への修正ですが、挙動変化として注意すべき点):
    • 「アクセサの読み取りがカラムを {} に初期化してほしい」という前提のコードがあった場合、その前提は成り立たなくなります。
      • 例: doc.color を読むだけで data が必ず {} 以上になっていることを期待して、その後 doc.data[:foo] = bar のように直接操作していた場合、datanil のままになり NoMethodError になる可能性があります。
      • そのような場合は、「読み取り」ではなく「書き込み」操作(例: doc.color ||= '...'doc.json_options ||= {}; ...)で初期化するようにコード側を修正すべきです。
    • changed?changes に依存したロジック(save if changed?after_save if saved_change_to_data? 等)が、本来の意図どおり「本当に変更があったときだけ」動くようになります。
      • 以前は「読むだけで changed になる」ことを前提にしたコードが動作していた場合、そのコードは発火しなくなる可能性がありますが、それ自体が元々バグ依存のロジックと考えられます。

互換性面では、「バグ修正として妥当な挙動変更」であり、通常のアプリケーションコードにとっては改善とみなしてよい内容です。


  1. 参考情報 (あれば)
  • 対象 PR: https://github.com/rails/rails/pull/57600
  • 関連するドキュメント記述(今回のバグが特に当たっていたパターン):
    • 「ネイティブ JSON / hstore カラムには store ではなく store_accessor を使うべき」というガイドラインに従った場合に、このバグに直撃していました。
  • 実装的なポイント:
    • 「読み取りパスでモデル属性を書き換えない」というのは、ActiveRecord の dirty tracking としても自然な設計なので、今後この方針に沿った変更が増える可能性があります。

#57622 Fix Range#include? / Range#=== raising on exclusive non-integer sub-ranges

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport が拡張している Range#include? / Range#=== の「レンジ同士の包含チェック」で、排他的終端かつ非整数 (Float, Time など) のサブレンジを渡すと TypeError で落ちていた問題を修正した PR です。Ruby 本体の Range#cover?(range) に処理を委譲することで、すべての型・端点組み合わせで正しく真偽値を返すようにしています。

  1. 変更内容の詳細

問題となっていた実装

ActiveSupport では、Range#include? / Range#=== に「引数が Range のときは『完全に含まれるか』を判定する」という拡張が入っていました。その実装の一部は以下のように、引数側 Range の最大要素を独自に計算していました:

ruby
# activesupport/lib/active_support/core_ext/range/compare_range.rb (旧)
value_max = !exclude_end? && value.exclude_end? ? value.max : value.last

ここで value は引数として渡された Range(サブレンジ)です。

  • value.exclude_end? が真(排他的終端)で、
  • かつ「自分(self)の Range が終端を含む」場合に、
  • value.max を呼んでサブレンジの最大要素を求める、

というロジックになっていました。

ところが Ruby の Range#max は「範囲を列挙して最大値を返す」ため、

  • 終端が Integer 以外の排他的 Range(例: 1.0...5.0, t...(t + 10.minutes))では
  • 「非整数の排他的終端は列挙できない」「Time からは順次イテレートできない」

といった理由で TypeError を投げます。

例:

ruby
(1.0..10.0).include?(2.0...5.0)
# => TypeError: cannot exclude non Integer end value

t = Time.now
(t..(t + 1.hour)).include?(t...(t + 10.minutes))
# => TypeError: can't iterate from Time

つまり「レンジがレンジを含むか?」という単なる包含チェックが、

  • Float
  • Time
  • DateTime
  • ActiveSupport::TimeWithZone
  • BigDecimal

などで排他的終端を使った時にクラッシュしてしまう状態でした。

なお value.max を使っていた目的は「離散な整数レンジの排他的終端をうまく解釈する」ためで、典型的には次のようなケースを true にしたい、という用途です:

ruby
(1..10).include?(1...11) # => true にしたい

しかし整数以外の型ではこのロジックが破綻しており、今回のバグにつながっていました。


修正方針

Ruby 2.6 以降では Range#cover? が Range を引数に取れるようになっており、「この Range が引数 Range を完全に含むか」を正しく判定してくれます。Rails のサポート Ruby バージョンは 2.6 より新しいため、ActiveSupport 独自の hand-rolled 実装をやめて、Ruby 本体の Range#cover? に委譲する実装に差し替えています。

新しい実装イメージ:

ruby
def ===(value)
  if value.is_a?(::Range)
    cover?(value)
  else
    super
  end
end

def include?(value)
  if value.is_a?(::Range)
    cover?(value)
  else
    super
  end
end

ポイント:

  • value.is_a?(::Range) のときだけ cover?(value) を使う
  • それ以外(スカラー値: 数値, 文字列, Time など)の場合は super で Ruby 標準の Range#include? / Range#=== にそのまま委譲
    → 既存の ('a'..'f').include?('c') 等の挙動は一切変わらない

Range#cover?

  • 両端 inclusive / どちらか exclusive / 両方 exclusive
  • 逆向きレンジ (e.g. 5..3)
  • 空の exclusive レンジ (e.g. 3...3)
  • endless / beginless レンジ
  • レンジ同士が部分的にしか重ならないケース

など、元々 ActiveSupport がハンドリングしていた多くのパターンを含め、Ruby 本体の定義に沿って一貫した結果を返すようになっています。


修正後の挙動例

TypeError が発生していたケース:

ruby
(1.0..10.0).include?(2.0...5.0)
# 旧: TypeError
# 新: true

(1.0..10.0).include?(2.0...10.5)
# 旧: TypeError
# 新: false

t = Time.utc(2005, 12, 10, 15, 30)
(t..(t + 2.hours)).include?((t + 30.minutes)...(t + 90.minutes))
# 旧: TypeError
# 新: true

従来既にテストされていたパターン(挙動は維持されている):

  • 同じ端点・同じ inclusive/exclusive の Range 同士
  • 排他的終端整数レンジ (1..10 vs 1...11)
  • 逆向きレンジ (5..3)
  • 空レンジ (3...3)
  • endless/beginless レンジ
  • オーバーラップのみで完全包含していないケース

などは、Range#cover? でも同じ真偽値が返ることがテストで確認されています。


  1. 影響範囲・注意点

影響範囲

  • 対象:
    • ActiveSupport をロードした環境で、
    • Range#include? / Range#===Range を引数として渡しているコード 全般

とくに影響が大きいのは次のようなユースケースです:

  • 時間窓を Range で表現し、その Range に別の時間 Range(サブウィンドウやスロット)を含められるかをチェックしているコード
    • 例: Time / TimeWithZone / DateTime の Range
  • 許容誤差帯やしきい値を Float / BigDecimal の Range で扱っているコード
  • そのサブレンジとして排他的終端 (...) を使っているコード

これらがこれまで TypeError で落ちていた場合でも、今後は true / false のどちらかを返すようになります

注意点

  1. 例外から「単純な真偽値」に変わる

    • 今まで TypeError を前提に rescue していた場合、その rescue 経路は通らなくなります。
    • ロジックとして「例外が出たら false 扱い」としていたようなコードは、include? の戻り値だけで判定できるようになる一方で、挙動が変わることに注意が必要です。
  2. 挙動は Ruby 本体の Range#cover?(range) に一致

    • ActiveSupport 独自実装から Ruby 本体の仕様に寄せたため、「以前のバグを利用していた」ようなコードがあればその挙動は変わります。
    • ただし PR 説明によると、既存のテストケースの全ては Range#cover? でも同一結果が出ているため、実質的には「これまで落ちていたパターンが落ちなくなる」以外の挙動変更はほぼないと見てよいです。
  3. スカラー引数 (range.include?(scalar)) の挙動は不変

    • 引数が Range でない場合は従来どおり super 呼び出しなので、ここに後方互換性の問題はありません。

  1. 参考情報 (あれば)
  • Ruby 本体の Range#cover? ドキュメント
    • 範囲が値を「カバーするかどうか」を比較演算なしで判定するメソッドとして定義されており、Ruby 2.6 以降は引数に Range も受け付けます。
  • この PR で追加されたテスト (activesupport/test/core_ext/range_ext_test.rb)
    • test_should_include_exclusive_end_float_range
    • test_should_not_include_exclusive_end_float_range_past_end
    • test_should_include_exclusive_end_time_range
      → バグ再現ケースとその修正後の期待挙動が明示されているので、同様のユースケースを持つアプリケーションでの挙動確認の参考になります。

#57614 Allow insert! to accept the :unique_by option

マージ日: 2026/6/9 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveRecord::Relation#insert! で、これまで渡せなかった unique_by: オプションを受け付けるようにし、insert / insert_all! とインターフェースを揃えた修正です。これにより、insert! でも一意制約を考慮した upsert 風の挙動が利用可能になります。

  1. 変更内容の詳細

何が問題だったか

もともと以下のような状況でした:

  • insert_all!unique_by: オプションを受け取れる
  • insertunique_by: を受け取れる
  • しかし insert! のメソッド定義に unique_by: が含まれておらず、呼び出し側で unique_by: を指定すると ArgumentError になる
ruby
# 以前の定義イメージ
def insert!(attributes, returning: nil, record_timestamps: nil)
  insert_all!([ attributes ], returning: returning, record_timestamps: record_timestamps)
end

Book.insert({ id: 1, name: "X", author_id: 1 }, unique_by: :index_books_on_isbn)   # OK
Book.insert!({ id: 2, name: "X", author_id: 1 }, unique_by: :index_books_on_isbn)  # ArgumentError

ドキュメント上は insert!insert_all! と同じオプションが使えるように読めるのに、実装が追いついていなかった状態です。

何を修正したか

insert! のシグネチャを修正し、unique_by: を受け取り insert_all! にそのまま渡すようにしました。

イメージとしては以下のような変更です(実際のコードは Relation クラス内にあります):

ruby
# 修正後のイメージ
def insert!(attributes, returning: nil, record_timestamps: nil, unique_by: nil)
  insert_all!(
    [attributes],
    returning: returning,
    record_timestamps: record_timestamps,
    unique_by: unique_by
  )
end

これにより、以下のようなコードが問題なく動作するようになります:

ruby
# 一意制約付き index_books_on_isbn を衝突検知に使う例
Book.insert!(
  { id: 2, name: "X", author_id: 1, isbn: "978-..." },
  unique_by: :index_books_on_isbn
)

テストの追加

activerecord/test/cases/insert_all_test.rb に以下のようなテストが追加されています:

  • test_insert_bang_accepts_unique_by
    • すでに存在する test_insert_all_bang_accepts_unique_by に対応するテスト
    • supports_insert_conflict_target? を満たすアダプタ(PostgreSQL / SQLite 等)でのみ実行
    • シグネチャの修正を戻すと ArgumentError: unknown keyword: :unique_by で落ちることを確認済み

ドキュメント / CHANGELOG

  • activerecord/CHANGELOG.md にユーザー向けのエントリが追加され、insert!unique_by: に対応したことが明示されています。

  1. 影響範囲・注意点
  • 影響クラス: ActiveRecord::Relation#insert! を利用しているコード
  • 互換性:
    • unique_by: を新たに受け付けるだけなので、既存コード(unique_by: を渡していないもの)は基本的に非互換影響はありません。
    • Ruby のキーワード引数仕様上、unique_by: を追加しても、追加前の引数呼び出し形式とは衝突しにくいため、後方互換性は高いです。
  • DB依存:
    • unique_by: 自体は、内部的に INSERT ... ON CONFLICT ... などの機構を使うため、DB アダプタの対応状況に依存します。
    • これまで insert / insert_all!unique_by: を使えていた環境であれば、そのまま insert! でも利用可能になります。
  • 利用時の注意:
    • unique_by: には通常、ユニークインデックス / 一意制約 の名前、または列名の配列を渡します(PostgreSQL の ON CONFLICT ターゲットに対応)。
    • インデックス名やカラム指定が実際の DB スキーマと一致していないとエラーになるので、insert! に限らず insert_all! / upsert_all と同様の注意が必要です。

  1. 参考情報 (あれば)
  • 関連メソッド:
    • ActiveRecord::Persistence::ClassMethods#insert
    • ActiveRecord::Persistence::ClassMethods#insert_all!
    • これらと insert! のオプションが揃ったことで、バルクインサートと単一レコードインサートで同じ API を利用しやすくなりました。
  • unique_by の詳細な仕様は Rails ガイド / API ドキュメントの「insert_all / upsert_all」周りを参照すると理解しやすいです。

#57581 Support only: and except: on _deliver callbacks in ActionMailer

マージ日: 2026/6/9 | 作成者: @excid3

  1. 概要 (1-2文で)
    Action Mailer の _deliver コールバック(before_deliver, after_deliver, around_deliver)で、only: / except: オプションが正しく機能するように修正・拡張されたPRです。これにより、コントローラの *_action コールバックと同様の書き方で、特定のメールアクションに対してのみコールバックを適用できるようになります。

  1. 変更内容の詳細

何が問題だったか

  • 以前追加された Action Mailer の _deliver 系コールバック(例: before_deliver)に only: / except: オプションを渡しても、それらが無視されていました。

    • 例:
      ruby
      class UserMailer < ApplicationMailer
        before_deliver :log_delivery, only: :welcome_email
      end
      上記の only: :welcome_email が効いていなかった。
  • これは AbstractController(ActionController などで使われる)のコールバックとは挙動が異なり、一貫性がない状態でした。

何をしたか

  • AbstractController のコールバック (*_action コールバック) と同じ正規化ロジック(normalization wrapper)を _deliver コールバックにも適用するように変更。

    • 参照されている箇所:
      • abstract_controller/callbacks.rbnormalize_callback_params 相当の処理
      • set_callback :process_action, ... で使われているオプションの解釈ロジック
  • 具体的には、Action Mailer のコールバック定義部分(actionmailer/lib/action_mailer/callbacks.rb)で、only: / except: などを含むオプションを AbstractController と同じ形に正規化してからコールバックを登録するようにした。

実際にどう書けるようになるか(サンプル)

before_deliver, after_deliver, around_deliver で、以下のような指定が有効になります:

ruby
class UserMailer < ApplicationMailer
  # 特定のメールアクションにだけ適用
  before_deliver :log_welcome_delivery, only: :welcome_email

  # 複数アクションに適用
  after_deliver :track_marketing_emails, only: [:campaign_email, :newsletter_email]

  # 特定のアクションだけ除外
  around_deliver :with_timing, except: :system_notification

  def welcome_email(user)
    mail(to: user.email, subject: "Welcome!")
  end

  def campaign_email(user)
    mail(to: user.email, subject: "Big sale!")
  end

  def system_notification(user)
    mail(to: user.email, subject: "System notice")
  end

  private

  def log_welcome_delivery(mail)
    Rails.logger.info("[MAIL] welcome_email delivered to #{mail.to}")
  end

  def track_marketing_emails(mail)
    # GA / Mixpanel などへの連携
  end

  def with_timing
    start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    yield
    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
    Rails.logger.info("[MAIL] delivery took #{elapsed}s")
  end
end

上記のような only: / except: が、コントローラの before_action などと同じ感覚で動作するようになります。

テスト・ドキュメント・CHANGELOG

  • actionmailer/test/callbacks_test.rb に 56 行のテストが追加され、only: / except: の動作が確認されています。
  • actionmailer/test/mailers/callback_mailer.rb にテスト用の Mailer が追加され、各種パターンのコールバック適用が検証されています。
  • actionmailer/CHANGELOG.md にエントリが追加され、挙動変更が明記されています。
  • guides の configuring.md にも4行追加され、ドキュメント上も _deliver コールバックのオプション使用例/説明が更新されています。

  1. 影響範囲・注意点
  • 影響を受けるのは _deliver コールバックのみ

    • before_deliver, after_deliver, around_deliveronly: / except: を指定している(もしくは今後指定する)コードが対象です。
    • それ以外の Action Mailer の API には影響しません。
  • 既存コードの互換性

    • これまで only: / except: は“無視されていただけ”なので、多くの場合は新たに意図したとおりにコールバックが発火するようになるだけです。
    • ただし、これまで「全アクションにコールバックが走る前提」でワークアラウンドしていた場合には挙動が変わる可能性があります。
      • 例: 「only: が効かない想定で内部条件分岐を書いていた」等。
  • コントローラとの一貫性が向上

    • before_action / after_action / around_action とほぼ同じ感覚で _deliver コールバックを使えるようになるため、学習コスト/驚きは減ります。
    • 特に only: [:action1, :action2]except: :some_action のような典型的な書き方が Controller と Mailer で揃うのはメリットです。
  • バージョン依存

    • この PR は main ブランチ(将来の Rails バージョン)向けであり、現時点でリリース済みの Rails にはまだ含まれていない可能性があります。
    • 実際に利用する場合は、使っている Rails バージョンの CHANGELOG / リリースノートで、この PR (#57581) が取り込まれているか確認してください。

  1. 参考情報 (あれば)

#57595 Forward blob metadata to MirrorService mirrors

マージ日: 2026/6/9 | 作成者: @maksim-romanov

  1. 概要 (1-2文で)
    Active Storage の MirrorService がミラー先ストレージにアップロードするとき、content_typefilename などの blob メタデータを正しく引き継ぐように修正した PR です。これにより、S3 / R2 / Azure / GCS などのミラー先を直接参照した場合でも、オリジナルと同じヘッダでオブジェクトが配信されるようになります。

  1. 変更内容の詳細

バグの内容

ActiveStorage::Service::MirrorService#mirror は、非同期ジョブ (MirrorJob) から呼ばれ、プライマリサービスに保存された blob を複数のミラーに複製する役割を持っています。

従来の実装では、ミラーに対して以下のように checksum のみを渡して upload を呼んでいました:

ruby
mirror.upload(key, io, checksum: checksum)

その結果、ミラー側のオブジェクトには以下のメタデータが反映されていませんでした:

  • content_type
  • filename
  • disposition
  • custom_metadata

クラウドストレージ (S3 / Cloudflare R2 / Azure / GCS) では、これらはアップロード時ヘッダとして保存されるため、欠落すると:

  • ブラウザから直接バケット URL にアクセスしたとき
  • CDN カスタムドメイン経由でオリジンを直接叩くとき
  • <img src="https://bucket.example.com/path/to/object"> のように直接参照するとき
  • 動画などで Range Request を直接ストレージに投げるとき

Content-Type: application/octet-stream になってしまい、画像や動画として正しく扱われない・プレビューされない、などの問題が発生していました。
rails_blob_url 経由のアクセスでは Rails が署名付き URL に response-content-type を付けて上書きするため、この問題は表面化しません。

修正の要点

この PR では、ミラーリング時にもプライマリサービスと同等のメタデータを渡すようにしています。

1. mirror 内で blob をキーから取得

ActiveStorage::Service::MirrorService#mirror の中で、アップロード対象の blob をキー (key) から検索します。

疑似コードイメージ:

ruby
def mirror(key, checksum:)
  # 追加: key から blob を引く
  blob = ActiveStorage::Blob.find_by(key: key)

  service_metadata = blob&.service_metadata || {}

  @mirrors.each do |mirror|
    mirror.upload(key, io, checksum: checksum, **service_metadata)
  end
end

実際には、service_metadata というメソッドを通して、content_typefilenamedispositioncustom_metadata など、サービス向けのメタデータ一式を取得してミラーに渡します。

2. Blob#service_metadata を public に昇格

これまでは ActiveStorage::Blob#service_metadataprivate メソッドでしたが、以下のような他の内部 API (unfurl, upload_without_unfurling, compose, mirror_later) と同じく、クラス間の内部利用が想定されているため:

  • 可視性を public に変更
  • ただし public API として外部利用を推奨するわけではないので # :nodoc: を付け、ドキュメントには出さない

という形で公開範囲を整理しています。

これにより、MirrorService から blob.send(:service_metadata) のようなメタプロ的な呼び出しをせずに、普通に blob.service_metadata を呼べるようにした、という設計上の改善も含まれています。

3. Blob が見つからない場合のフォールバック

ActiveStorage::Blob のレコードが既に削除されていて、しかしストレージ上にはオブジェクトが残っている「orphan key」のケースを考慮し、find_bynil を返した場合は:

ruby
service_metadata = {}

と空ハッシュを渡すようにしています。
これにより、少なくとも従来の挙動 (メタデータ無しでアップロード) と整合的な動作を維持しつつ、レコードが存在する正常ケースではフルメタデータを渡す、という挙動になります。

4. テストと CHANGELOG の追加

  • activestorage/test/service/mirror_service_test.rb に MirrorService が blob の service_metadata をミラーに引き渡していることを検証するテストを追加。
  • activestorage/CHANGELOG.md に、この挙動変更 (バグ修正) に関する記述を追加。

  1. 影響範囲・注意点

影響を受けるサービス

  • 影響あり:
    • ActiveStorage::Service::MirrorService を利用している構成
    • ミラー先として S3 / Cloudflare R2 / Azure / GCS など、アップロード時メタデータを保持するクラウドストレージ
  • 影響なし:
    • Disk サービス (ローカルストレージ)
      → もともと HTTP ヘッダをストレージ側に保存しないため、今回の修正対象外

実務上の影響

  • 今後作成されるミラーオブジェクトは、オリジナルと同じ Content-Type / Content-Disposition / カスタムメタデータで保存される
  • そのため:
    • CDN カスタムドメインでバケットを直接 origin にしている構成
    • <img src="..."> / <video src="..."> などでストレージ URL を直接参照しているケース
    • プレサインド URL ではなく、ストレージの公開 URL をそのまま使うケース
      で、正しい MIME Type などが反映されるようになる

既に壊れたミラーオブジェクト (過去にメタデータ抜きでアップロードされたもの) については、この PR を適用しても自動で修復されるわけではありません。必要であれば:

  • 対象オブジェクトを再ミラーリングする
  • あるいは、ミラー先のオブジェクトを削除し、Active Storage 側から再アップロードをトリガーする

などの対応が別途必要になります。

互換性面の注意

  • Blob#service_metadatapublic になりましたが、# :nodoc: が付いているため、Rails の内部 API という扱いです。
    アプリケーションコードからの直接利用は可能ではあるものの、今後の互換性は保証されていない点に注意してください。
  • 既存の MirrorService の API 形状 (public メソッドシグネチャなど) は変わっていないため、Rails アプリ側のコードを書き換える必要はありません。

  1. 参考情報 (あれば)
  • この PR が修正する Issue: #57270
    (R2 + カスタムドメイン CDN での Content-Type 欠落問題が報告されている)
  • 代替案 PR: #57276
    • 同じバグを解決する別実装
    • この PR との主な違い:
      • Blob#service_metadata を public にする代わりに send で呼ぶアプローチ
      • 本 PR は Rails 内部 API の一貫性 (他の内部メソッドと同様に public + :nodoc:) を重視し、その設計に揃えている
  • 関連する内部 API:
    • ActiveStorage::Blob#unfurl
    • ActiveStorage::Blob#upload_without_unfurling
    • ActiveStorage::Blob#compose
    • ActiveStorage::Blob#mirror_later

これらと同列に service_metadata を「内部向け public」として露出させることで、MirrorService からの利用を素直に書けるようにした、という位置付けの変更です。


#53161 Add bundler-cache feature in dev container setup

マージ日: 2026/6/9 | 作成者: @viktorianer

  1. 概要 (1-2文で)
    Dev Container で Rails 開発を行う際に、Bundler を含む Ruby 環境(.rbenv ディレクトリ)をボリュームとしてキャッシュする仕組みが追加され、コンテナ再作成時の gem 再インストールコストを削減する変更です。Rails の devcontainer 用ジェネレータとそのテストが、この新しいキャッシュ戦略に合わせて更新されています。

  1. 変更内容の詳細

2-1. Dev Container 設定へのボリューム追加

railties/lib/rails/generators/rails/devcontainer/templates/devcontainer/compose.yaml.tt に、.rbenv ディレクトリをホスト側ボリュームとしてマウントする設定が追加されています。

イメージとしては、以下のような変更が入っていると考えられます(概念的なサンプル):

yaml
services:
  app:
    volumes:
      # 既存のマウント設定に加えて…
      - rails-rbenv:/home/vscode/.rbenv

volumes:
  rails-rbenv:

ポイント:

  • .rbenv 全体をボリュームとしてキャッシュすることで、Ruby バージョンや gem(Bundler でインストールされたものを含む)の再インストールを避ける。
  • 以前の PR (#53123) で提案されていた ghcr.io/rails/devcontainer/features/bundler-cache ではなく、標準的な Docker ボリュームの仕組みだけで実現している。

2-2. テストコードの更新

以下のテストが更新されています:

  • railties/test/commands/devcontainer_test.rb
  • railties/test/generators/app_generator_test.rb
  • railties/test/generators/devcontainer_generator_test.rb

主な変更点:

  • devcontainer コマンドやジェネレータが生成する compose.yaml に、.rbenv ボリュームの定義・マウントが含まれていることを検証するアサーションが追加。
  • 既存テストの期待値(生成される compose 設定)を、新しいボリューム定義に合わせて修正。

これにより、今後 Rails プロジェクトで --devcontainer オプション付きで生成される設定や bin/rails devcontainer 相当のコマンド実行結果が、常に .rbenv キャッシュを含むことが保証されます。


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

    • Rails の devcontainer テンプレートから生成される Docker Compose 設定に、.rbenv 用のボリュームが追加されます。
    • これにより、Dev Container を再作成しても gem の再インストールが基本的に不要になり、起動が高速化されます。
    • Dev Container を前提とした Rails アプリのテンプレート生成 (rails new myapp --devcontainer など) に影響します。
  • 注意点

    • .rbenv ディレクトリ全体がキャッシュ対象になるため、Ruby バージョンや gem セットアップを大きく変えた場合は、ボリュームを再作成(削除して作り直す)しないと古い状態が残る可能性があります。
    • すでに独自に .rbenv などをマウントしている devcontainer 設定がある場合は、生成物とコンフリクトしないか確認が必要です(ただし、この PR は Rails が生成するテンプレート側の変更のみ)。
    • 特定の CI 環境や共有環境で .rbenv キャッシュを利用する際は、「誰がそのボリュームを共有するのか」「パーミッションはどうか」を意識する必要があります。

  1. 参考情報 (あれば)

この PR により、Rails の公式 devcontainer サポートが、特別な feature への依存なしに「シンプルなボリュームキャッシュ戦略」で bundler 依存関係を高速化する方向へ整理されたと言えます。


#57574 Make the backtrace cleaner ractor shareable by default

マージ日: 2026/6/9 | 作成者: @andrewn617

  1. 概要 (1-2文で)
    Rails のバックトレースクリーナー(Rails.backtrace_cleaner / ActiveSupport::BacktraceCleaner)が、デフォルトで Ractor 共有可能になるように実装を変更した PRです。これにより、Rails.application を Ractor で安全に共有しやすくなります。

  1. 変更内容の詳細

背景

  • 目標: Rails.applicationRactor.shareable? にしたい。
  • 問題: Rails.application の内部には Rails.backtrace_cleaner が含まれており、その中の「デフォルトのフィルタ・サイレンサー」が Ractor 共有不可能な Proc になっていた。
  • Ractor 共有可能な Proc (Ractor.shareable_proc) では selfnil になる点があり、「ユーザが追加するフィルタ / サイレンサー」を一律で shareable にすると、self に依存しているブロックが実行時までエラーにならず、危険な「踏み抜き (footgun)」になる。

この PR では「Rails が内部的に設定しているデフォルトのブロック」だけを Ractor 共有可能にし、ユーザ追加分は従来通りの挙動に留めています。


主な変更点

1) ActiveSupport::BacktraceCleaner のデフォルトフィルタ/サイレンサーを shareable 化

activesupport/lib/active_support/backtrace_cleaner.rb の変更:

  • デフォルトで設定されるフィルタ・サイレンサーの ProcRactor.shareable_proc でラップするように変更。
  • これにより、ActiveSupport::BacktraceCleaner.new で生成されるインスタンスの初期状態は Ractor 共有可能になります(※ただし、ユーザが後から非 shareable な Proc を追加した場合はその限りではない)。

イメージ的には:

ruby
# 変更前(概念的な例)
add_silencer { |line| line.include?("/gems/") }

# 変更後(概念的な例)
add_silencer Ractor.shareable_proc { |line| line.include?("/gems/") }

※ 実際には、実装側で shareable にするようにしており、アプリコード側で Ractor.shareable_proc を明示的に呼ぶ必要はありません。

2) Rails.backtrace_cleaner の初期化ロジックの調整

railties/lib/rails/backtrace_cleaner.rb:

  • Rails が提供する Rails.backtrace_cleaner に設定している Rails 独自のサイレンサーやフィルタも Ractor.shareable_proc 化。
  • Rails 側で追加している「Rails のノイズを消すためのルール」(bin/, vendor/bundle/, railties 等を消すようなもの)が Ractor からも安全に利用できるようになった。

結果として:

ruby
Rails.backtrace_cleaner # => Ractor から参照しても shareable なオブジェクトになる想定

3) Ractor 用テストの追加

  • activesupport/lib/active_support/testing/ractors_assertions.rb に Ractor 関連のアサーションヘルパーを追加。
  • activesupport/test/backtrace_cleaner_test.rb
  • railties/test/backtrace_cleaner_test.rb

上記テストで、

  • デフォルトの BacktraceCleaner が Ractor 内からも正常に使えること
  • Ractor 間で共有しても例外が出ないこと

などを検証しています。


  1. 影響範囲・注意点

影響範囲

  • Rails 既定の挙動
    デフォルトのバックトレースフィルタ/サイレンサーのロジック自体は変わっていないため、通常の(非 Ractor)使用における表示結果は従来と同じはずです。
  • Ractor 利用時の改善
    Rails.applicationRails.backtrace_cleaner をそのまま Ractor に渡しやすくなり、Ractor ベースの並列実行でバックトレースクリーニング機能が使いやすくなります。

注意点

  • ユーザが追加するフィルタ/サイレンサーはデフォルトでは shareable にならない

    ruby
    cleaner = Rails.backtrace_cleaner
    
    # これは shareable ではない Proc(self を前提にしているかもしれない)
    cleaner.add_silencer do |line|
      some_helper(line) # self に依存したりする可能性がある
    end

    こういったユーザ定義ブロックは Ractor 共有可能には「自動では」なりません。その理由は PR 説明の通り:

    • Ractor.shareable_proc にすると selfnil になる。
    • ブロック内部で self に依存している場合、追加時には気づかず、実行時に NoMethodError になる。
    • それを避けるため、フレームワーク側の都合でユーザブロックを強制的に shareable にはしていない。

    もしユーザ側で Ractor 対応したい場合は、自分で shareable な Proc を意識して書く必要がある(例: self に依存しないロジックに分離する、Ractor.shareable_proc を自前で使う、等)。

  • 将来的な API 拡張の示唆

    PR 本文で触れられている通り:

    • 「shareable を opt-in で指定できる API」を用意する
    • Prism などでブロックの AST を解析し、self 参照がない場合のみ shareable にする

    といった改善案はあるが、この PR 時点ではまだ導入されていません。

  • class macro 系のブロックは今後 shareable 化される予定

    instance_exec で評価するような「クラスマクロ的な DSL ブロック」は、デフォルトで shareable にしやすいので、別 PR でそちらを対応予定と述べられています。この PR はあくまで backtrace cleaner 周りだけ。


  1. 参考情報 (あれば)
  • Ractor の仕様(特に shareable オブジェクト、Ractor.shareable_proc の挙動)
    • Ractor.shareable?(obj)true なら別 Ractor との共有が可能。
    • Ractor.shareable_proc { ... } で生成した Proc は
      • 自身が shareable なクロージャ
      • ただし実行時の selfnil になる
  • 実運用で Ractor を使う場合:
    • Rails.application を Ractor に渡してバックグラウンド処理等を行うようなユースケースでは、この PR によって「バックトレース整形で落ちる」類の問題が減る。
    • 独自の backtrace フィルタ/サイレンサーを多用しているアプリは、Ractor 対応する際に「どこまで shareable にするか」を意識して実装・設計する必要があります。

#57593 Add unit tests for ActiveStorage::Variation

マージ日: 2026/6/9 | 作成者: @Edilbek

  1. 概要 (1-2文で)
    ActiveStorage のコア値オブジェクトである ActiveStorage::Variation に対して、専用のユニットテストが追加された PR です。既存では統合テスト越しにしか検証されていなかった振る舞いを、変換キーの扱いやエンコード/デコードの不変条件レベルで直接テストするようにしています。

  1. 変更内容の詳細

追加ファイル

  • activestorage/test/models/variation_test.rb(新規・111行)

このテストファイルで、ActiveStorage::Variation のパブリック API が網羅的にテストされています。主な観点は以下です。

2-1. 初期化: オプションの deep-symbolize

「Initialization: string keys are deep-symbolized」

ActiveStorage::Variation.new(transformations) にハッシュを渡したとき、
{ "resize_to_limit" => ["100x100"] } のような「文字列キー」が「シンボルキー」に正規化されることをテストしています。

想定されるテスト例イメージ(擬似コード):

ruby
variation = ActiveStorage::Variation.new("resize_to_limit" => [100, 100])
assert_equal({ resize_to_limit: [100, 100] }, variation.transformations)
  • ネストがあるケースでも deep-symbolize されること(例: {"resize" => {"width" => 100}}{resize: {width: 100}})が確認されていると考えられます。
  • これにより、呼び出し側がキーを string/symbol どちらで渡しても、内部的には同一の扱いとなることが保証されます。

2-2. wrap の振る舞い

「wrap: returns an existing instance unchanged, builds from a Hash, decodes a signed key」

ActiveStorage::Variation.wrap の 3 パターンをテストしています。

  1. 既に Variation インスタンスの場合

    • そのまま返す(オブジェクトを変換・再構築しない)こと。
    ruby
    variation = ActiveStorage::Variation.new(resize_to_limit: [100, 100])
    assert_same variation, ActiveStorage::Variation.wrap(variation)
  2. ハッシュからの生成

    • { resize_to_limit: [100, 100] }{ "resize_to_limit" => [100, 100] } から Variation インスタンスを作る。
    ruby
    variation = ActiveStorage::Variation.wrap("resize_to_limit" => [100, 100])
    assert_instance_of ActiveStorage::Variation, variation
  3. 署名付きキー(エンコード済み文字列)からの復元

    • ActiveStorage::Variation#encode で生成した署名付きキー文字列を渡すと、同等の変換をもつ Variation を復元できること。
    ruby
    encoded = ActiveStorage::Variation.new(resize_to_limit: [100, 100]).encode
    variation = ActiveStorage::Variation.wrap(encoded)
    assert_equal({ resize_to_limit: [100, 100] }, variation.transformations)

2-3. encode / decode / key のラウンドトリップ

「encode / decode / key: round-trip transformations and consistent signed keys」

  • #encode: 変換ハッシュを署名付きトークン文字列に変換
  • .decode / .wrap: トークン文字列を再度 Variation に復元
  • #key: 署名されたトークン(encode の中身と整合するキー)を返す

などの API がラウンドトリップ可能であること、かつ同一の変換からは常に同じキーが得られることがテストされています。

例(イメージ):

ruby
variation = ActiveStorage::Variation.new(resize_to_limit: [100, 100])

encoded = variation.encode
decoded = ActiveStorage::Variation.decode(encoded)
assert_equal variation.transformations, decoded.transformations

# 同じ変換なら key は常に同じ
assert_equal variation.key, decoded.key

2-4. キー/ダイジェストのパリティ (string/symbol)

「Key and digest parity: symbol-keyed and string-keyed transformations produce identical key and digest values」

同じ意味の変換でも、引数ハッシュのキーが string か symbol かに依存して異なるキー/ダイジェストにならないことを確認しています。

例(イメージ):

ruby
symbol_variation = ActiveStorage::Variation.new(resize_to_limit: [100, 100])
string_variation = ActiveStorage::Variation.new("resize_to_limit" => [100, 100])

assert_equal symbol_variation.key, string_variation.key
# digest (内部で key 生成に使っている値) も同じであることをテスト

これにより、呼び出し側の表記揺れがキャッシュキーや URL の変化を招かないことが保証されます。

2-5. default_to の挙動

「default_to: fills missing transformations without overriding existing ones」

Variation#default_to(default_transformations) は、

  • 変換が存在しないキーに対してはデフォルト値を埋める
  • 既に変換が指定されているキーについては上書きしない

という振る舞いをテストしています。

例(イメージ):

ruby
variation = ActiveStorage::Variation.new(resize_to_limit: [100, 100])

variation.default_to(format: :png, resize_to_limit: [200, 200])

# 既存の値は維持
assert_equal [100, 100], variation.transformations[:resize_to_limit]

# 無かったキーだけデフォルトが入る
assert_equal :png, variation.transformations[:format]

これにより、アプリ側で「既に指定されている変換を尊重しつつ、足りない変換だけ補う」ような拡張が安全にできるかが担保されています。

2-6. format / content_type の扱い

「format / content_type: defaults to PNG, accepts valid extensions, raises ArgumentError for invalid formats」

主に以下をテストしています。

  1. デフォルトは PNG

    • format を指定しない場合、#format#content_typeimage/png を前提とする。
    ruby
    variation = ActiveStorage::Variation.new
    assert_equal "image/png", variation.content_type
  2. 有効な拡張子は受け入れ

    • format: :jpgformat: "jpeg", format: "webp" など、サポート対象の拡張子なら #content_type が正しい MIME type に解決される。
  3. 不正なフォーマットで ArgumentError

    • 未知の拡張子(例: format: :foo)を指定した場合に ArgumentError を投げること。
    ruby
    assert_raises(ArgumentError) do
      ActiveStorage::Variation.new(format: :foo).content_type
    end

これにより、アプリケーション側で誤った format 指定をしたときに、サイレントに失敗するのではなく、明確な例外で検知できることが保証されます。

2-7. transform はあえて未カバー

「transform is intentionally not tested here. That path depends on MiniMagick/Vips and is already covered in variant_test.rb.」

Variation#transform は実ファイル処理(MiniMagick / Vips)に依存するため、この PR ではテスト対象から外しています。
ここは既存の variant_test.rb など統合テストで既にカバーされているため、今回の PR はあくまで純粋な値オブジェクトとしての Variation の契約部分に特化しています。


  1. 影響範囲・注意点
  • 本番コードへの変更は一切なし
    • テストファイルの追加のみで、ActiveStorage::Variation の挙動自体は変わっていません。
  • ただし、テストによって挙動の「仕様」がより明文化されたと言えます。
    そのため、今後以下のような変更を行う場合には、このテスト群が「仕様差分」を検知する役目を果たします:
    • 変換ハッシュの扱い(string/symbol の扱いなど)を変更する
    • encode / key の生成ロジック(署名の中身や並び順など)を変更する
    • デフォルトの formatcontent_type を変える
  • ActiveStorage を利用して独自に ActiveStorage::Variation を直接扱っているコードがある場合、
    • 本 PRによって壊れることはないものの、
    • 既に行っていた利用パターン(特にフォーマットやキー生成に関する期待値)が、テストによって正式に「Rails 側の仕様」として固定化されたと見なせます。

  1. 参考情報 (あれば)
  • 対象クラス:
    • ActiveStorage::Variationactivestorage/app/models/active_storage/variation.rb
  • 関連テスト(既存):
    • activestorage/test/models/variant_test.rbtransform を含む統合的な変換テスト)
  • 利用される場面:
    • ActiveStorage::Variant(画像バリアント生成)
    • プレビュー / レプレゼンテーションコントローラ(ActiveStorage::Variation.encode を通じた署名付き URL など)

#57624 Fix URL fragment in Active Record Migrations guide [ci skip]

マージ日: 2026/6/8 | 作成者: @VladNegara

  1. 概要 (1-2文で)
    Active Record Migrationsガイド内のリンク先フラグメント(ページ内アンカー)が誤っていたため、正しいフラグメントに修正したPRです。機能コードには一切手を触れず、ドキュメント上のリンク切れを解消するだけの変更です。

  1. 変更内容の詳細

対象:

  • guides/source/active_record_migrations.md

問題点:

  • 「Creating a Join Table(結合テーブルの作成)」節から、Active Record Associationsガイドの「has_and_belongs_to_many」に飛ぶリンクが、存在しないフラグメント #the-has-and-belongs-to-many-association を指していた。

修正内容:

  • フラグメントを以下のように変更:
diff
- ...active_record_associations.html#the-has-and-belongs-to-many-association
+ ...active_record_associations.html#has-and-belongs-to-many

意味としては同じセクションを指しているが、実際のHTML側のid属性が has-and-belongs-to-many で定義されているため、それに合わせてリンクを修正した、という内容です。

コードサンプル上の挙動やマイグレーションDSLなどには一切変更はなく、「どのセクション説明に飛ぶか」というドキュメント内ナビゲーションだけが変わっています。


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

    • Railsガイド(Active Record Migrations -> Creating a Join Table)のページ内リンクのみ。
    • ランタイムの挙動、API、テストには影響なし。
    • 既存コード、アプリケーション、gemとの互換性に影響なし。
  • 注意点

    • ドキュメントをローカルでビルド・ホストしている場合も、ビルド済みHTMLのid属性が has-and-belongs-to-many である前提なので、ガイド生成側のレイアウトが大きく変更されていない限り、この変更によりリンク切れが解消されます。
    • 外部からこの古いフラグメント #the-has-and-belongs-to-many-association を直リンクしていた場合は、引き続き404相当の挙動(スクロールされない)になりますが、これはRailsガイド外部の問題です。

  1. 参考情報 (あれば)
  • 該当ガイド:
    • Active Record Migrations: 「Creating a Join Table」節
    • Active Record Associations: 「Has and Belongs to Many」節(id: has-and-belongs-to-many
  • 類似の修正はドキュメントのリンク切れ対応としてよく行われており、本PRもそれと同種のメンテナンス変更です。

#57620 Fix number_to_delimited corrupting numbers with a leading sign

マージ日: 2026/6/8 | 作成者: @55728

  1. 概要 (1-2文で)
    number_to_delimited が符号付き数値(先頭に -+ が付く数)を区切り文字付きに変換する際、先頭の符号直後に区切り文字を挿入してしまう不具合を修正した PR です。これにより、number_to_roundednumber_to_human で符号付き数値+delimiter: を使った場合の誤ったフォーマットも合わせて解消されます。

  1. 変更内容の詳細

不具合の内容

number_to_delimited に負数(または + 付きの文字列)を渡すと、桁数が 3 の倍数の場合に符号の直後にカンマが入り、数値が壊れていました。

ruby
number_to_delimited(-123456)    # 以前: "-,123,456"   本来: "-123,456"
number_to_delimited(-123)       # 以前: "-,123"       本来: "-123"
number_to_delimited(+123456)    # 以前: "+,123,456"   本来: "+123,456"
number_to_delimited("+123")     # 以前: "+,123"       本来: "+123"

number_to_roundednumber_to_human も内部で同じ変換ロジックを使うため、delimiter: オプションを指定すると同様に壊れた出力になっていました。

ruby
number_to_rounded(-123456, delimiter: ",", precision: 0)
# 以前: "-,123,456"   本来: "-123,456"

number_to_human(-123456, delimiter: ",")
# 以前: "-,123 Thousand"   本来: "-123 Thousand"

原因

NumberToDelimitedConverter#parts の「高速パス」が、整数部を 3 桁ごとに分割する際に offset = left.size % 3 を使っていました。このとき、left に符号 - / + も含まれていたため、「符号を 1 桁として扱ってしまい、そこでグループを切る」という状態になっていました。

例: -123456

  • integer 部 left"-123456"
  • left.size == 7offset = 7 % 3 == 1
  • 先頭 1 文字とそれ以降 3 桁ずつでグループ化される
    ["-", "123", "456"].join(",")"-,123,456"

-1234 など、一見正しく見えるケースは「たまたま符号と1桁目が同じグループになっている」だけで、ロジックとしては常に壊れている状態です。

元々は delimiter_pattern の正規表現 /(\d)(?=(\d\d\d)+(?!\d))/ を使う旧パスがデフォルトで、これは数字にしかマッチしないため符号を誤処理しませんでした。
しかし、コミット 33fbedb1b1% 3 を使う高速パスがデフォルトになった際に、この符号処理の差分が考慮されず、回 regress しました。

同じ高速パスが原因で Float::INFINITY"In,fin,ity" になる問題は、以前のコミット 150e4c0443 で修正済みであり、今回は「符号付き数値版の同じ問題」を修正しています。

修正内容

整数部のグルーピング前に、先頭の符号をいったん切り離し、グルーピング後に付け直すようにしました。NumberToCurrencyConverter が「絶対値をフォーマットしてから符号を再適用する」のと同じ考え方です。

該当箇所(概略):

ruby
# 変更前(イメージ)
left = integer_part
# left をそのまま offset / 3 桁グループ化

# 変更後(イメージ)
left = integer_part
sign = left.slice!(0) if left.start_with?("-", "+")  # ← 符号を取り除く

# ここから先は「符号を抜いた left」を 3 桁ずつに分割する既存処理
left_parts = []
offset = left.size % 3
left_parts << left.slice!(0, offset) if offset > 0
left_parts.concat(left.scan(/.{3}/))

# 最後に符号を戻す
left = "#{sign}#{left_parts.join(options[:delimiter])}"

ポイント:

  • 変更は #parts 内に限定されており、delimiter_pattern 正規表現を使うパスには影響しません。
  • + 付きの文字列入力("+123456" など)も対象になり、同様に修正されます。

テスト追加

activesupport/test/number_helper_test.rb に以下のテストが追加されています。

  1. test_to_delimited_with_negative_numbers
    • 対象: -1, -12, -123, -1234, -123456, -123456789, -123456.78 など
    • 文字列形式の負数もカバー
  2. test_to_delimited_with_leading_plus_sign
    • 対象: "+123", "+1234", "+123456", "+123456.78"

もともと number_to_delimited に対して符号付き数値のテストが存在しておらず、そのため regression が検出できなかった、という背景も明示されています。


  1. 影響範囲・注意点
  • 影響を受けるメソッド:
    • number_to_delimited
    • number_to_roundeddelimiter: オプション使用時)
    • number_to_humandelimiter: オプション使用時)
  • 修正内容により、「これまで壊れたフォーマットが出ていたケース」が正しいフォーマットになります。
    • 例: 既存のテスト・期待値・スナップショット等で "-,123,456" のような誤った値をあえて使用していた場合は、テストが落ちる可能性があります。
  • 正の数、ゼロ、符号なしの文字列、浮動小数、delimiter_pattern をカスタムしたケースは、今回の修正では動作が変わりません。
  • number_to_currency はすでに「絶対値+符号の再適用」という設計であり、今回の修正の対象ではないことが確認されています。

実運用上は「いままで静かに壊れていた値が正しくなる」だけなので、多くの場合は歓迎される変更ですが、帳票やログで「文字列としてのフォーマットが変わる」ことには注意してください。


  1. 参考情報 (あれば)
  • 該当 PR: https://github.com/rails/rails/pull/57620

  • 関連する過去の変更:

    • 33fbedb1b1NumberToDelimitedConverter の高速パスをデフォルトにした変更
    • 150e4c0443 — 非有限浮動小数点 (Float::INFINITY など) に対する同じ経路の不具合修正
  • 実際の影響確認のためのサンプルコード(コンソール用):

    ruby
    include ActionView::Helpers::NumberHelper
    
    [
      -123, -1234, -123456, -123456.78,
      "+123", "+1234", "+123456", "+123456.78"
    ].each do |n|
      puts "#{n.inspect.ljust(12)} => #{number_to_delimited(n)}"
    end
    
    puts number_to_rounded(-123456, delimiter: ",", precision: 0)
    puts number_to_human(-123456, delimiter: ",")

#57621 Fix InheritableOptions#== raising NoMethodError on non-Hash comparison

マージ日: 2026/6/8 | 作成者: @55728

  1. 概要 (1–2文で)
    ActiveSupport::InheritableOptions#== が Hash 以外との比較時に NoMethodError を起こしていた問題を修正し、Hash/Hashライクなオブジェクトとのみ中身を比較し、それ以外とは常に false を返すようにした PR です。これにより nil と空の InheritableOptions が誤って等価になる問題や、Array#include? などからの予期せぬ NoMethodError が解消されます。

  1. 変更内容の詳細

問題のあった既存実装

ActiveSupport::InheritableOptions#== は以下のように実装されていました。

ruby
def ==(other)
  to_h == other.to_h
end

このため、otherto_h を持たないオブジェクト(String, Integer, Symbol など)の場合に NoMethodError が発生していました。

ruby
options = ActiveSupport::InheritableOptions.new(one: "first value")

options == "first value"
# => NoMethodError: undefined method `to_h` for "first value":String

[options].include?("x")
# => Array#include? は == を使うので、同様に NoMethodError

さらに、nil.to_h # => {} であることから、空の InheritableOptionsnil と等価になってしまうバグもありました。

ruby
ActiveSupport::InheritableOptions.new == nil
# => true (本来は false であるべき)

修正後の実装

#== を次のように変更しています:

ruby
def ==(other)
  other.is_a?(Hash) && to_h == other.to_h
end

ポイント:

  • respond_to?(:to_h) ではなく、is_a?(Hash) を使っている
    • nilto_h を持つ(nil.to_h # => {})ため、respond_to? ベースだと nil と空の InheritableOptions が再び等価になってしまう
    • Hash#== の振る舞いと同じにしたい、という意図
  • Hash のサブクラス(ActiveSupport::OrderedOptions など)や他の InheritableOptionsis_a?(Hash) を満たすので、これらとは中身を to_h で比較する
  • それ以外のオブジェクトとは、常に false を返す

修正後の挙動サンプル

ruby
options = ActiveSupport::InheritableOptions.new(one: "first value")

options == { one: "first value" }
# => true

options == ActiveSupport::InheritableOptions.new(one: "first value")
# => true(マージ済みの中身を比較)

options == { one: "other value" }
# => false

options == "first value"
# => false

options == 42
# => false

options == nil
# => false

[options].include?("x")
# => false(例外は出ない)

テスト

activesupport/test/ordered_options_test.rb に以下のようなテストが追加されています(要旨):

  • test_inheritable_options_equality
    • InheritableOptions 同士や Hash との比較が正しく true/false を返すか
  • test_inheritable_options_equality_with_non_hash_returns_false
    • String, 数値, nil など Hash ではないものとの比較が false になり、例外を出さないこと

  1. 影響範囲・注意点
  • 影響するクラス/機能
    • ActiveSupport::InheritableOptions
      • Rails credentials (Rails.application.credentials)
      • AbstractControllerconfig
      • Active Record の protocol_adapters
      • Action Text のエディタ設定
      • など、InheritableOptions を内部実装に使っている箇所全般
  • 行動の変化
    • 以前は「誤って NoMethodError が飛ぶ」「空の InheritableOptionsnil と等しい」という明らかなバグだったため、この変更により壊れる正当なユースケースは基本的に想定されていません。
    • もしアプリケーション側で、
      • InheritableOptionsnil が等価になる」ことに暗黙に依存していた
      • あるいは「非 Hash オブジェクトとの比較で例外が起きる」ことを前提にテストを書いていた
        といったケースがあれば、その挙動は変わります(false を返すようになる)。
  • Hash#== 相当の振る舞いになるため、OrderedOptions など他の Hash ベースの設定オブジェクトと一貫性が取れます。

  1. 参考情報 (あれば)
  • 類似クラス: ActiveSupport::OrderedOptionsHash を継承しており、Hash#== をそのまま使っているため、もともと非 Hash 比較時は false を返していました。本 PR により InheritableOptions もこれに揃えられています。
  • もとの変更: InheritableOptions#== 自体は 81d0a29 ("Improve InheritableOptions hash-like behaviour") で導入された機能で、親とマージ済みの内容で比較を行うために実装されましたが、その際に「非 Hash 相手」のケースが考慮されていませんでした。

#57615 Fix clear_*_change doc claiming it clears previous changes [ci skip]

マージ日: 2026/6/7 | 作成者: @55728

  1. 概要 (1-2文で)
    clear_*_change メソッド(正確には clear_attribute_change が生成する系)のドキュメントが「現在の変更と過去の変更を両方クリアする」と誤って説明していたのを、「現在の変更だけをクリアする」という実装どおりの内容に修正する PR です。挙動自体には変更はなく、ドキュメントのみの修正です。

  1. 変更内容の詳細
  • 対象:

    • ActiveModel::Dirty が生成する clear_*_change メソッド (clear_attribute_change) に対するドキュメントコメント。
  • 実装とドキュメントの乖離:

    • ドキュメントには以下のような趣旨が書かれていました:

      属性の dirty データ(現在の変更と過去の変更)をすべてクリアする

    • しかし実際の実装は次のとおりで、現在の変更のみを消しており、過去の変更 (previous_changes / saved_changes) には触れていません:

      ruby
      def clear_attribute_change(attr_name)
        mutations_from_database.forget_change(attr_name.to_s)
      end
    • mutations_from_database は「現在の変更」を表すトラッカーで、previous_changes / saved_changesmutations_before_last_save という別トラッカーにより管理されています。

    • clear_attribute_change はこの mutations_before_last_save には一切触れないため、「過去の変更もクリアする」という説明は誤りです。

  • 正しい「両方クリア」するメソッド:

    • 現在の変更と保存前の変更の両方をリセットするのは clear_changes_information であり、clear_attribute_change ではありません。
  • 具体的な修正:

    • 該当ドキュメント中の「and previous changes」という文言を削除し、「現在の変更をクリアする」ことのみを説明するように 1 行差し替えています(+1/-1)。
    • メソッドの使用例自体はもともと「現在の変更だけが消える」ケースを示しており、実装と整合していました。今回の修正で、ドキュメントの説明文もそれに揃えています。
  • 経緯:

    • この「and previous changes」という文言は、過去のコミット ee58c8e6be で誤って導入されたとのことです。

  1. 影響範囲・注意点
  • 挙動の変更は一切ありません
    • 既存の clear_*_change / clear_attribute_change の動作(現在の変更のみクリア)は変わりません。
  • 影響するのは以下のようなケースです:
    • ドキュメントを読んで「clear_*_change を呼べば previous_changes / saved_changes も消える」と誤解していたコードレビューや設計判断。
    • その前提で「過去の変更がクリアされているはず」と想定しているテストや仕様書(※実際のコードはもともと過去の変更を消していないため、挙動と期待がずれていた可能性があります)。
  • 実際にやりたいこと別の正しいメソッド選択:
    • 特定属性の「現在の変更」だけクリアしたい:
      ruby
      user.clear_attribute_change(:name)
      # または generated method: user.clear_name_change
    • 全属性の現在&保存前の変更情報を一括でクリアしたい:
      ruby
      user.clear_changes_information
  • 注意点:
    • 「保存後に previous_changes を消したい」といった用途で clear_*_change に頼っていた場合、もともとその用途には合っていなかったため、改めて要件に応じて
      • clear_changes_information を使う
      • あるいは別に状態を保持する といった見直しが必要です。

  1. 参考情報 (あれば)
  • 関連 API:
    • ActiveModel::Dirty#changes / changed_attributes
    • ActiveModel::Dirty#previous_changes / saved_changes
    • ActiveModel::Dirty#clear_attribute_changes(attr_name)(生成される clear_*_change 系)
    • ActiveModel::Dirty#clear_changes_information
  • 実装上のトラッカー:
    • 現在の変更: mutations_from_database
    • 最後の保存前の変更: mutations_before_last_save
      → 今回の PR は、これらの役割分担にドキュメントを合わせた、という位置づけです。

#57598 Fix find with an explicit order + offset past the ids raising a spurious RecordNotFound

マージ日: 2026/6/7 | 作成者: @55728

  1. 概要 (1-2文で)
    find に配列ID + 明示的な order + offset が ID 数を超える値で指定された場合に、意味不明な負数付きの ActiveRecord::RecordNotFound が発生していた不具合を修正し、そのケースで空配列 ([]) を返すように揃えた PR です。あわせて、order の有無で find の挙動が食い違わないことを確認する回帰テストが追加されています。

  1. 変更内容の詳細

何が問題だったか

次のようなクエリを考えます:

ruby
Model.order(:id).offset(10).find([1, 2, 3])

ids = [1, 2, 3]offset = 10 のように、「オフセットが ID 配列のサイズを超えている」場合、本来であれば単に「該当レコードなし」として [] が返ってきてほしいところです。

実際、order を付けない場合は正しく [] を返します:

ruby
Model.offset(10).find([1, 2, 3])
# => []

しかし、order を付けると以下のような例外が発生していました:

ruby
Model.order(:id).offset(10).find([1, 2, 3])
# => ActiveRecord::RecordNotFound:
#    Couldn't find all Models with 'id': (1, 2, 3)
#    (found 0 results, but was looking for -7).

found 0 results, but was looking for -7 という負数が出ている時点で、内部の期待件数の計算が破綻していることがわかります。

原因 (find_some / find_some_ordered の違い)

ActiveRecord::FinderMethods#find は、呼び出し条件によって内部的に次の2つの経路に分岐します:

  • 明示的な order あり: find_some 経由
  • 明示的な order なし: find_some_ordered 経由

問題のロジックは find_some の中にあり、「期待される件数 (expected_size)」を offsetlimit を使って算出する処理でバグがありました。

該当するコードイメージは概ね以下のようなものです:

ruby
# 例: 11個のid、limit=3、offset=9 の場合、結果は2件になるべき
if offset_value && (ids.size - offset_value < expected_size)
  expected_size = ids.size - offset_value
end

offset_valueids.size より大きい(=配列の末尾を通り過ぎている)とき、

ruby
ids.size - offset_value # => 負の数

となり、そのまま expected_size に代入されてしまいます。
結果として:

  • 実際の取得件数: result.size # => 0
  • 期待件数: expected_size # => 負の数

となるため、「期待件数と実際の件数が違う」と判断され、RecordNotFound が投げられていました。

一方で、order なしパス(find_some_ordered)では、offset / limit で ID をスライスしているため、同じ条件でも単に [] を返し、この不具合は発生していませんでした。
つまり、「order 有り」の経路だけが異常に厳しく、かつ壊れている 状態でした。

修正内容

expected_size をオフセットで調整する際、負数にならないよう 0 でクランプしています:

ruby
expected_size = [ids.size - offset_value, 0].max

これにより:

  • ids.size - offset_value が 0 以上 → そのまま
  • ids.size - offset_value が負数 → 0 にする

という挙動になります。

具体例:

  • 通常のパターン(例: ids.size = 11, limit = 3, offset = 9
    ids.size - offset = 2max(2, 0) == 2 → 従来どおり、2件を期待
  • バグっていたパターン(例: ids.size = 3, offset = 10
    ids.size - offset = -7max(-7, 0) == 0
    → 期待件数 0、実際の result.size も 0 なので、例外を出さずに [] を返す

これで、「order ありパス」も「order なしパス」と同じように、オフセットが範囲外に飛び出したときは単に空結果を返すようになります。

テスト追加

activerecord/test/cases/finder_test.rb に回帰テストが2つ追加されています。

  1. test_find_with_order_and_offset_past_the_ids_returns_empty

    • offset が ID 数を超えるケース、およびちょうど等しい境界 (offset == ids.size) で、
      • order ありの経路 (find_some)
      • order なしの経路 (find_some_ordered) が共に [] を返すことを検証。
  2. test_find_with_order_limit_and_offset_matches_unordered_path

    • 11件の Developer fixture を使って、7パターンの limit / offset 組み合わせを総当たりし、
    • order あり(find_some)と order なし(find_some_ordered)で返ってくるレコード集合が常に一致することを確認。
    • 範囲内のオフセット、終端を超える limitoffset == ids.sizeoffset > ids.size などを網羅。

テストは sqlite3 / PostgreSQL / MySQL2 の3アダプタで「失敗を再現 → 修正後にパス」を確認しています。


  1. 影響範囲・注意点
  • 影響を受けるパターン

    • Model.order(...).offset(X).find([array_of_ids]) のように
      • find に「配列ID」を渡している
      • 明示的な order を指定している
      • そのクエリに対する offset が「渡している ID 配列の要素数より大きい」
    • これまで: ActiveRecord::RecordNotFound 例外が発生していた
    • 修正後: [] が返る(order 無しパスと揃う)
  • 互換性・挙動変更

    • 変更されるのは「これまでそもそも壊れていた・実務上も不自然」なケースのみです。
    • 正常な範囲での offset / limit の挙動、offset == ids.size の境界ケースは従来どおり(※ offset == ids.size に関しては元々両パスとも [] を返していた)。
    • もし既存コードが「この壊れた挙動に(=負の expected size による RecordNotFound 発生)依存している」場合は振る舞いが変わりますが、通常は依存すべきでない仕様外のバグなので、修正によるリスクは小さいと考えられます。
  • 実務への影響

    • ページネーション実装などで、ユーザーが「最終ページを超えたページ番号」を指定した場合に、order の有無でエラーになったりならなかったりする不整合があったのが、[] に統一されます。
    • 「配列 ID + 明示的 order + 大きい offset」を組み合わせているコードで、これまで謎の RecordNotFound が sporadic に出ていたなら、この修正で改善される可能性があります。

  1. 参考情報 (あれば)
  • 対象ファイル

    • activerecord/lib/active_record/relation/finder_methods.rb
      • find_some 内の expected_size 計算ロジックが修正。
    • activerecord/test/cases/finder_test.rb
      • findorder/limit/offset 周りの回帰テストが追加。
  • 挙動変更の要点

    • 「offset > ids.size」での find は、order の有無に関わらず [] を返す、という揃った仕様になった。
    • 内部的には「期待件数を負数にしない(0でクランプする)」というシンプルなバグ修正。

#57613 Make rails new work again on systems that do not have vips

マージ日: 2026/6/7 | 作成者: @jeromedalbert

  1. 概要 (1-2文で)
    rails new 実行時に vips がインストールされていない環境でアプリ作成が失敗していた不具合を修正し、Active Storage で vips を実際に使うタイミングまでエラーが発生しないようにした PR です。Gemfileruby-vips の読み込み方法を変え、テストも追加されています。

  1. 変更内容の詳細

Gemfile テンプレートの修正

rails new で生成されるアプリの Gemfile のテンプレート (Gemfile.tt) が修正されています。

変更前(イメージ):

ruby
gem "ruby-vips"

変更後:

ruby
gem "ruby-vips", require: false

ポイント:

  • require: false を付けることで、Bundler がアプリ起動時に自動で require "vips" しなくなります。
  • これにより、システムに vips ライブラリや関連ネイティブ拡張が無くても、アプリのブート時点ではエラーになりません。
  • 実際に vips を必要とするのは Active Storage が vips をバックエンドとして利用するタイミングなので、その時点で vips または ImageMagick が入っていることを前提にしています。

テストの追加

railties/test/generators/app_generator_test.rb にテストが追加されています。

意図されていること:

  • vips がインストールされていない環境でも rails new で生成されたアプリが正常に起動できることを担保する。
  • Gemfile に ruby-vips が含まれていても、require: false のおかげで即時にロードされず、アプリ起動が壊れないことを確認する。

(実際のテストコードは PR 本文には出ていませんが、Gemfile.tt の出力と生成アプリの挙動を検証していると考えられます。)


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

    • Rails main ブランチ(将来のバージョン)で rails new を実行するすべての開発者。
    • 特にローカルに vips をインストールしていない開発環境。
  • ポジティブな影響:

    • vips 未インストール環境で rails new が落ちるというレグレッションが解消されます。
    • Active Storage を使わない(あるいは画像処理を使わない)アプリでも、vips がないことが原因で起動できない、といった不具合がなくなります。
  • 注意点:

    • require: false は「自動で require しない」だけであり、vips 自体が不要になるわけではありません
    • Active Storage で vips ベースの画像変換を行いたい場合は、引き続き:
      • システムに vips (libvips) をインストールする
      • ruby-vips gem を bundle install する
        といったセットアップが必要です。
    • 画像処理を実行するタイミングで、vips(あるいは代替として ImageMagick + mini_magickなど)が正しくインストールされていないと、その時点でエラーになります。

  1. 参考情報 (あれば)

#57616 Fix ActiveModel::Type::Decimal doc for a non-numeric cast [ci skip]

マージ日: 2026/6/7 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveModel::Type::Decimal のドキュメント中のキャスト結果の説明が実装と食い違っていたため、非数値を渡した場合の戻り値を nil ではなく 0.0 に修正するドキュメント更新です。実装自体の挙動変更はなく、コメント(サンプルコードの期待値)のみが正されました。

  1. 変更内容の詳細

対象: activemodel/lib/active_model/type/decimal.rb のドキュメント例 (コメント)

元のドキュメントでは、以下のような説明になっていました (意図としてはこういう内容だった):

ruby
bag.weight = :arbitrary
bag.weight # => nil (the result of `.to_s.to_d`)

しかし、実際の Ruby / BigDecimal の挙動は次の通りです:

ruby
:arbitrary.to_s  # => "arbitrary"
"arbitrary".to_d # => 0.0  # 非数値文字列は 0.0 になる

Rails の実装もこれに沿っており:

ruby
ActiveModel::Type::Decimal.new.cast(:arbitrary)  # => 0.0
ActiveModel::Type::Decimal.new.cast("")          # => nil  # 空文字列のみ nil

この PR では、ドキュメント内の期待値コメントを以下のように修正しています:

ruby
bag.weight = :arbitrary
bag.weight # => 0.0 (the result of `.to_s.to_d`)

※ コード本体のロジックは一切変更されておらず、1行のコメント(例の戻り値)を nil0.0 に変更しただけです。


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

    • 実行時挙動の変更はなく、既存アプリケーションの動作には一切影響しません。
    • Rails ガイド/ソース内コメントを読んでいた人が、
      「非数値を Decimal にキャストすると nil になる」と誤解してしまう可能性が解消されます。
  • 注意点 (開発者視点での挙動整理)

    • ActiveModel::Type::Decimal#cast は概ね value.to_s.to_d 相当の挙動であり、「空文字列」は nil、それ以外の非数値文字列は 0.0 になります。
      • 例:
        ruby
        type = ActiveModel::Type::Decimal.new
        
        type.cast("")           # => nil
        type.cast("   ")        # => nil (blank? 判定で弾かれる)
        type.cast(nil)          # => nil
        
        type.cast("abc")        # => 0.0
        type.cast(:symbol)      # => 0.0
        type.cast("123.45")     # => 0.12345e3 のような BigDecimal
    • 「非数値は nil になってほしい」という前提でバリデーションや分岐を書いていると、
      実際には 0.0 が入っていて不具合になる可能性があります。
      その場合は
      • カスタムの型を定義する
      • 事前に値を検査して弾く などで対処する必要があります。

  1. 参考情報 (あれば)
  • BigDecimal の挙動 (Ruby 本体):
    • BigDecimal("abc")0.0 を返す仕様です。
  • 関連する Rails の実装:
    • ActiveModel::Type::DecimalActiveModel::Type::Value を継承し、cast 内で value.to_s した上で BigDecimal 変換を行うことで、この挙動に従っています。

#57618 Fix add_index example in Active Record Schema docs

マージ日: 2026/6/7 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveRecord::Schema のドキュメント内で示されていた add_index の使用例が実際のメソッドシグネチャと合っておらず、実行すると ArgumentError になる問題を修正した PR です。add_index に対する誤った位置引数の利用例を、正しいキーワード引数形式へと修正しています。

  1. 変更内容の詳細

対象ファイル:

  • activerecord/lib/active_record/schema.rb(1行の修正)

元のドキュメント例では、以下のように add_index が呼ばれていました:

ruby
ActiveRecord::Schema[7.0].define do
  create_table :authors do |t|
    t.string :name, null: false
  end

  add_index :authors, :name, :unique
end

しかし、add_index は以下のように定義されており:

ruby
def add_index(table_name, column_name, **options)
  # ...
end

第3引数以降はキーワード引数(**options)として受け取るため、:unique のような裸のシンボルを位置引数として渡すと ArgumentError: wrong number of arguments になります。

この PR では、この例を正しくキーワード引数を使う形に修正しています:

ruby
ActiveRecord::Schema[7.0].define do
  create_table :authors do |t|
    t.string :name, null: false
  end

  add_index :authors, :name, unique: true
end

つまり、:uniqueunique: true への変更のみが行われています。


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

    • 変更はドキュメント(サンプルコード)上の 1 行のみで、ライブラリの挙動そのもの(add_index の実装)には変更がありません。
    • 既存アプリの実行時挙動には影響しません。
  • 注意点

    • もし自分のアプリでドキュメントをコピーして同様に add_index :authors, :name, :unique と書いている場合は、必ず add_index :authors, :name, unique: true のようにキーワード引数形式に修正する必要があります。
    • 他のオプション(name:, where:, using: など)もすべてキーワード引数で渡す前提になっているため、add_index にシンボルなどを位置引数として追加する形はサポートされていません。

  1. 参考情報 (あれば)
  • add_index の定義:
    activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
    ruby
    def add_index(table_name, column_name, **options)
      # ...
    end
  • 正しい呼び出し例:
    ruby
    add_index :authors, :name, unique: true
    add_index :posts, [:user_id, :created_at], name: "index_posts_on_user_and_created_at"

#57607 Fix in_order_of surfacing NULL rows for an unknown enum key or out-of-range integer

マージ日: 2026/6/7 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveRecord::Relation#in_order_of が、列挙型(enum)の未知キーや範囲外整数を指定したときに、本来無視すべき値を NULL マッチとして扱い、NULL 行を紛れ込ませてしまう不具合を修正した PR です。明示的な nil と「シリアライズ結果としての nil」を区別し、後者は in_order_of から除外することで、Enumerable#in_order_of と同じ挙動に揃えています。

  1. 変更内容の詳細

不具合の内容

ActiveRecord::Relation#in_order_of(column, values) で、values に以下のような値が含まれる場合に問題が起きていました。

  • enum カラムに対する「未知のキー」
  • 整数カラムに対する「範囲外の整数」

例:

ruby
# books: one :proposed, one :written, one with a NULL status
Book.in_order_of(:status, [:written, :bogus, :proposed]).pluck(:id)
# 実際の挙動 (バグあり):
# => [2, 3, 1]   # :bogus の位置に NULL 行(id 3)が割り込む
# 期待される挙動:
# => [2, 1]      # :bogus は無視されるべき

Enumerable#in_order_of は「コレクション内に存在しない値」は素通りして無視する仕様であり、Active Record 版も「存在しない値は無視する」と CHANGELOG で明記されていましたが、実装上は次のような経路で NULL 行を拾っていました。

  1. in_order_of は、values の各要素についてカラム型に合わせてシリアライズする:

    ruby
    caster.serialize(value) if caster.serializable?(value)
  2. その結果:

    • 「範囲外整数」:serializable? が false → if の結果が nil
    • 「未知 enum キー」:serializable? は true だが serializenil を返す
  3. すると、「元々 nil ではないが、結果として nil になった値」と「呼び出し側が明示的に渡した nil」が区別できなくなる。

  4. in_order_of は、呼び出し側が nil を指定した場合は column IS NULL をマッチさせる仕様のため、
    シリアライズに失敗した値も「nil とみなして良い」と誤解され、

    • WHERE column IN (…) OR column IS NULL
    • ORDER BY CASE WHEN column = … THEN … WHEN column IS NULL THEN … END のように NULL 行を拾う・並び替えるロジックに紛れ込んでしまう、というバグでした。

既存テスト test_in_order_of_with_out_of_bound_integer では Post.id(NOT NULL な主キー)を使っていたため、「余計な IS NULL 条件」があっても実際にマッチする行がなく、バグが表に出ていませんでした。


修正方針

修正のポイントは以下です。

  1. 明示的な nil と、シリアライズ結果としての nil を区別する

    • 呼び出し側が valuesnil を入れた場合:
      → 従来通り、NULL 行をマッチさせる(column IS NULL を使う)。
    • 呼び出し側が nil 以外の値を指定したが、serializable? が false だったり serializenil を返した場合:
      → その値は in_order_of の対象から完全に除外するWHERE にも ORDER BY CASE にも出さない)。
  2. 値のドロップ後に values が空になりうることへの対応

    • すべての値が「非シリアライズ可能 or シリアライズ結果が nil」だった場合、
      ロジック上 CASE 式の WHEN 句が 1 つも無い CASE END(または CASE ELSE 1 END for filter: false)が生成される可能性があり、これは SQL 的に不正で StatementInvalid を引き起こします。
    • 今回の変更では、「ドロップ処理の結果として values が空になった場合」は、呼び出し元が最初から [] を渡した場合と同様に none!(空 Relation)を返すようにしました。
      これにより、不正な CASE 式は組み立てられず、クエリ実行時にエラーにもならず、シンプルに「0件」が返ります。
  3. 配列グルーピング分岐でも同じ扱い

    • in_order_of には、単純な値列だけでなく「値をグルーピングして扱う」ブランチ(配列をまとめて 1 グループ扱いするようなケース)もありますが、そちらでも同様に「シリアライズできない・nil になってしまう値はドロップする」ように揃えています。
    • ドロップされた値は WHERE にも ORDER BY にも一切寄与しません。

以上により、Enum の未知キーや範囲外整数は Enumerable#in_order_of と同じく「存在しないものとして無視」され、かつ余計な NULL 行のマッチングも発生しなくなります。


追加・修正されたテスト

activerecord/test/cases/relation/field_ordered_values_test.rb に以下のテストが追加されています。

  1. test_in_order_of_with_unknown_enum_key_does_not_match_nulls

    • 対象: Book#nullable_status(NULL 許可の enum カラム)
    • 状況: status = nil の行を含むテーブルに対して、未知 enum キーを values に含めて in_order_of を実行。
    • 確認内容: 未知キーは無視され、NULL 行は拾われないこと。
  2. test_in_order_of_with_out_of_bound_integer_does_not_match_nulls

    • 対象: Book#format_record_id(NULL 許可の整数カラム)
    • 状況: format_record_id = nil の行がある中で、範囲外の整数を values に指定。
    • 確認内容: 範囲外整数は無視され、NULL 行は拾われないこと。
      (主キーではなく NULL 許可カラムを使うことで、これまで見落としていたバグをきちんと検証)
  3. test_in_order_of_with_only_unrepresentable_values_does_not_build_empty_case

    • 状況: 渡した全ての値がシリアライズ不可能で、結果としてすべてドロップされるケース。
    • 確認内容:
      • filter: true / filter: false いずれの場合でも StatementInvalid が発生しないこと。
      • 結果 Relation は空(none! 相当)になること。

なお、既存の test_in_order_of_with_nil などにより、「明示的に nil を指定した場合に NULL 行をマッチさせる」従来仕様は維持されていることも確認されています。


  1. 影響範囲・注意点
  • 仕様として期待されていた動き(Enumerable#in_order_of と同様、「存在しない値は無視」)に合わせる修正であり、ドキュメントおよび以前の CHANGELOG の意図を「復元」する変更です。

  • ただし、これまでのバグに依存していたコード(「未知 enum キーや範囲外整数を渡すと NULL 行が取れる」挙動をあえて利用していたケース)があれば、挙動は変わります。

    • 今後はそのような値は単に無視されるため、NULL 行を取りたい場合は、明示的に nil を値リストに含める必要があります:
      ruby
      # NULL を明示的にマッチさせたい場合
      Book.in_order_of(:status, [:written, nil, :proposed])
  • 「渡した値がすべてシリアライズできない」ケースでは、これまでは(実際には到達しづらかったものの)DBエラー StatementInvalid になりえたところ、今後は単に「0件を返す Relation」になります。

    • これにより、in_order_of の結果をそのままスコープとして合成・チェインしても DB 例外で落ちることはなくなります。
  • この変更は次の全サポート DB アダプタで確認されています:

    • sqlite3
    • postgresql
    • mysql2

field_ordered_values_test, enum_test, relations_test も一通りグリーンで、他の機能に対する副作用は抑えられています。


  1. 参考情報 (あれば)
  • 対象 PR: https://github.com/rails/rails/pull/57607
  • 過去の関連 PR(範囲外値の扱いを導入したもの): #44746
    • ここで「範囲外値は無視する」とドキュメント化されていたものの、実装は NULL 行を拾うバグを含んでいました。本 PR でドキュメントと実装が一致しました。
  • 関連するメソッド仕様:
    • Enumerable#in_order_of(Ruby 標準): 集合に存在しない値は単に結果に現れない。
    • ActiveRecord 側も、それに忠実に合わせる方針です。

#57611 Fix double encode for Coders::JSON and JSON column

マージ日: 2026/6/6 | 作成者: @skipkayhil

  1. 概要 (1-2文で)
    ActiveRecordで、ネイティブの json/jsonb カラムに対して serialize を JSON コーダー付きで宣言したときに、ActiveRecord::Coders::JSON 経由だと二重エンコードされて壊れた JSON が保存される不具合を修正したPRです。JSONActiveRecord::Coders::JSON などJSONコーダーのバリエーションを、互換性チェックですべて正しく検知するようにしました。

  1. 変更内容の詳細

問題の背景

Rails では、以下の2つは事実上同じ「JSONコーダー」として扱われます。

ruby
serialize :data, coder: JSON
serialize :data, coder: ActiveRecord::Coders::JSON

build_column_serializer は内部的にこれらを同じ JSON コーダーとして「正規化」して扱っていますが、型との互換性を確認するガード (type_incompatible_with_serialize?) は、「素の ::JSON 定数」にしかマッチしていませんでした。

その結果:

  • json / jsonb カラムに対して:
ruby
# これはエラーになっていた
serialize :col, coder: JSON  # => ColumnNotSerializableError
  • しかし、等価なはずの:
ruby
# これはエラーにならず、JSONが二重エンコードされていた
serialize :col, coder: ActiveRecord::Coders::JSON

という不整合が発生していました。

ネイティブ JSON カラムに対してさらに JSON シリアライザを噛ませると:

  1. ActiveRecord 型: JSON 型として一度エンコード
  2. serialize レイヤー: さらに JSON としてエンコード

という「二重エンコード」が起き、DB上には "\"{\\\"foo\\\":\\\"bar\\\"}\"" のような「JSON文字列としてのJSON」が保存され、他クライアントからは文字列として見えてしまう、というデータ破壊が起きていました。

修正内容

type_incompatible_with_serialize? の互換性チェックを、「正規化済みの coder」を見るように変更しています。

  • もともと:
    • coder: JSON のときだけ ::JSON 定数にマッチしてガードが働いていた
    • coder: ActiveRecord::Coders::JSON など、他の表記ゆれは見逃されていた
  • 修正後:
    • build_column_serializer が内部で行う「coderの正規化」後の値に対して判定を行う
    • そのため、JSON / ActiveRecord::Coders::JSON など、どのバリエーションでも「JSONとして扱われる coder」は同じ扱いになり、ネイティブ json/jsonb カラムには使えない、というチェックが統一的に効く

コード上の変更は activerecord/lib/active_record/attribute_methods/serialization.rb のわずかな比較ロジックの修正で、ロジックの位置はそのまま、参照する coder を「正規化済み」に変更した形です。

テスト

activerecord/test/cases/json_shared_test_cases.rb にテストが追加されています。ポイントは:

  • ネイティブ json or jsonb カラムに対して:
    • serialize :col, coder: JSON
    • serialize :col, coder: ActiveRecord::Coders::JSON
  • などのケースが、いずれも ColumnNotSerializableError となることを検証

これにより、「どの JSON コーダー表記を使っても、ネイティブ JSON カラムとの組み合わせは防がれる」という期待される挙動が自動テストで保証されます。

CHANGELOG

activerecord/CHANGELOG.md に、バグフィックスとして追記されています。内容としては:

  • ネイティブ JSON カラムと JSON コーダー付き serialize の組み合わせで、特定の coder 表記のときに二重エンコードが起こっていた問題を修正した、という旨。

  1. 影響範囲・注意点

影響範囲

  • 対象バージョン以降の Rails では、以下のようなコードは 例外が発生 するようになります:
ruby
class User < ApplicationRecord
  # users.profile が json/jsonb カラムの場合:
  serialize :profile, coder: ActiveRecord::Coders::JSON   # <= 以前は通っていたが、今後は ColumnNotSerializableError
end
  • 以前は「サイレントに二重エンコードされていた」コードが、本来意図されていた通りに「即座にエラーで気づける」ようになります。

既存アプリへの注意点

  1. すでに壊れたJSONが入っている可能性
    過去に json / jsonb カラムに serialize + JSON coder を組み合わせて使っていた場合、DB上に二重エンコードされたデータが残っている可能性があります。移行時には:

    • 該当するモデル・カラムを洗い出す
    • 二重エンコードされたレコードを検出 (JSON.parse を2回通さないと正常形にならない等)
    • 必要に応じて一括で修正マイグレーションを書く
  2. コード修正の方向性
    ネイティブ JSON カラムを使っている場合、基本的に serialize は不要であり、以下のいずれかに寄せるのが推奨です:

    • serialize を削除して、カラムは普通に Hash / Array をそのまま読み書きする:

      ruby
      # NG (今後は例外):
      serialize :settings, coder: JSON  # settings が json/jsonb カラムのとき
      
      # OK:
      # 何も指定せず、settings はそのまま Hash として扱う
    • どうしてもカスタムシリアライゼーションが必要なら、ActiveRecord の Attribute API (attribute :settings, :json, default: {} 等) や Value Object を検討する。

  3. ライブラリ・エンジン側の互換性
    外部ライブラリやエンジンが serialize と JSON カラムを組み合わせて使用している場合、この修正によって ColumnNotSerializableError が発生するようになるかもしれません。その場合:

    • ライブラリ側でネイティブ JSON カラムと serialize の併用をやめる
    • あるいは、text カラム + serialize など、型構成を変える

    といった対応が必要になります。


  1. 参考情報 (あれば)

#57601 Fix update_all / delete_all ignoring group/having (updates/deletes every row)

マージ日: 2026/6/6 | 作成者: @55728

  1. 概要 (1-2文で)
    group/having を含むが joins / limit / offset / order を含まない Relation に対して update_all / delete_all を呼ぶと、HAVING が完全に無視されテーブル全行が更新・削除されてしまう不具合を修正した PR です。Arel の SQL 生成ロジックを修正し、純粋な group/having ケースでもサブクエリ経由で PK ベースに対象レコードを絞り込むようにしました。

  1. 変更内容の詳細

問題の挙動

以下のようなクエリを想定します:

ruby
# posts: (low, 0), (mid, 3), (high, 9)
Post.where(id: posts)
    .group("posts.id")
    .having("MAX(legacy_comments_count) >= 3")
    .update_all(title: "updated")

期待される動作は、「HAVING MAX(legacy_comments_count) >= 3 を満たすグループ (= レコード) だけを更新する」ことです。しかし、これまでの実装だと:

  • 生成される SQL (全アダプタ共通) が概ね以下のようになり:

    sql
    UPDATE "posts" SET "title" = ? WHERE "id" IN (...)
  • GROUP BY / HAVING は完全にドロップされる

  • 結果として、HAVING を満たさない行も含め、WHERE id IN (...) にマッチする行がすべて更新・削除される

特に問題なのは:

  • エラーにもならず
  • deprecation warning も出ず
  • サイレントに「対象行が増える」形でバグっていたこと

です。

原因の詳細 (Arel レベル)

update_all / delete_all は内部的には Arel の UpdateManager / DeleteManager を組み立てた上で、Arel::Visitors::ToSql#prepare_update_statement(および類似の delete 側)で最終的な SQL に変換されます。

この prepare_update_statement は、次のような条件で「サブクエリ (PK サブセレクト) で絞り込むかどうか」を決めます:

ruby
def prepare_update_statement(o)
  if o.key && (has_limit_or_offset_or_orders?(o) || has_join_sources?(o))
    # ... `WHERE pk IN (SELECT pk ... GROUP BY ... HAVING ...)` を組むパス
  else
    # ... それ以外 (素の UPDATE/DELETE を吐く)
  end
end

しかし、ここで考慮されているのは

  • has_limit_or_offset_or_orders?(o)
  • has_join_sources?(o)

のみであり、has_group_by_and_having?(o) が考慮されていません

has_group_by_and_having? 自体はすでに to_sql.rb に定義されているヘルパですが、トリガ条件に組み込まれていなかったため、**「group/having だけを持つ Relation」**は else 側に落ち、素の UPDATE / DELETE が生成される → GROUP BY / HAVING が無視される、というバグになっていました。

なお:

  • joins + group + having のケースは has_join_sources? に引っかかるため、すでに PK サブクエリ経由のパスを通っており、期待どおりの挙動になっていた
  • 「join なし group/having あり」のパスはテストが存在せず、どのアダプタでも未検証だった

という背景があります。

修正内容

prepare_update_statement(および delete 側相当)のトリガ条件に has_group_by_and_having?(o) を追加しました:

ruby
if o.key && (
  has_limit_or_offset_or_orders?(o) ||
  has_join_sources?(o) ||
  has_group_by_and_having?(o)
)
  # PK サブクエリを使うパスへ
end

この結果、join が無い純粋な group/having のケースでも、joins + group と同様に「PK サブセレクト形式」で SQL が生成されるようになります。

修正後の SQL のイメージ:

sql
UPDATE "posts" SET "title" = ?
WHERE ("posts"."id") IN (
  SELECT "posts"."id" FROM "posts" WHERE "id" IN (...)
  GROUP BY "posts"."id"
  HAVING (MAX(legacy_comments_count) >= 3)
)

このサブクエリ生成ロジック (build_subselect) は元から groups / havings をコピーするよう実装されているため、HAVING 条件が正しく適用されるようになります。

テストの追加

以下の 2 つのテストが追加されています:

  • test_update_all_with_group_by_and_having_without_joins
  • test_delete_all_with_group_by_and_having_without_joins

どちらも:

  • joins を使わず
  • group / having のみを用いて
  • HAVING を満たすレコードだけが更新・削除され、満たさないレコードはそのまま残る

ことを検証する内容です。

これらのテストは:

  • sqlite3
  • postgresql
  • mysql2

の各アダプタで「修正前は失敗(全行更新/削除)→修正後は成功」することが確認されています。

ファイル変更としては:

  • activerecord/lib/arel/visitors/to_sql.rb
    • 条件式を ... || has_group_by_and_having?(o) に拡張
  • activerecord/test/cases/relation/update_all_test.rb
    • 上記の pure group/having 用のテスト追加
  • activerecord/test/cases/relation/delete_all_test.rb
    • 同様に delete 用テスト追加
  • activerecord/CHANGELOG.md
    • バグ修正の履歴追記

  1. 影響範囲・注意点

挙動が変わるケース

次のようなクエリを書いていた場合、実行結果が変わります:

ruby
Post.group(:id)
    .having("MAX(legacy_comments_count) >= 3")
    .update_all(title: "updated")

従来:

  • HAVING が無視され、GROUP BY も消え
  • 実質的に UPDATE posts SET title = 'updated' (+もしあれば where 条件)に近い挙動だった

今後:

  • HAVING を満たすグループ(=レコード)にだけ更新がかかる

つまり、このバグに「依存していた」コードは壊れますが、依存している方が危険なため、正しい挙動に揃えられたと言えます。

アダプタ間の影響

  • SQLite / PostgreSQL / MySQL の 全てで発生していた問題を、一箇所(Arel::ToSql の基底実装)を直すことで一括で解消しています
  • MySQL は独自の prepare_update_statement オーバライドを持ちますが、
    • GROUP BY / HAVING だけのケースは元々 super にフォールバックしていたため
    • 今回の修正で super の挙動が正しくなり、MySQL も同様に修正されます

GROUP BY の意味論上の注意

この PR は「HAVING がドロップされる」という致命的な問題を直すものですが、GROUP BY の意味論そのものは既存の joins + group パスに合わせただけで、DB 依存の部分はそのままです

  • 主キー、または主キーを一意に決定できる superkey で GROUP BY している場合
    → 「どのレコードが更新されるか」は明確(バグ修正でより直感的になっただけ)
  • 主キーを一意に決定しない列で GROUP BY している場合(例: group(:status)
    • SQLite / ONLY_FULL_GROUP_BY を無効にした MySQL など: 「各グループでどの行が選ばれるか」があいまい(従来からそう)
    • PostgreSQL や ONLY_FULL_GROUP_BY 有効な MySQL: そもそも SELECT 時点でエラーになり得る

この PR はあくまで「HAVING を無視して全行を更新・削除してしまう」というバグを消すもので、これらの GROUP BY の仕様・DB 差異は変更しません。

マイグレーション時の注意

  • 既に本番で group / having を伴う update_all / delete_all を使っている場合:
    • テストやログを見直し、「これまで意図せず全行更新・削除していたところがないか」「今後の挙動が本来の期待に合うか」を確認する価値があります
  • 逆に、「一部だけ更新したかったのに、なぜか全部更新されている」という現象に悩まされていた場合:
    • この修正で改善する可能性があります

  1. 参考情報 (あれば)
  • この PR は #43465 で導入された「joins + group な Relation の update_all / delete_all に対する PK サブクエリ変換」を、純粋な group/having のケースにも拡張するものです。
  • has_group_by_and_having? 自体は #43465 の一部 (4acb6660e2) で追加されており、本来は今回の条件に含まれるべきだったものの、当時はトリガ条件への組み込みが抜けていた「取りこぼし」を埋めた形です。
  • 変更行数はわずか(実質条件追加 + テスト)ですが、
    • 影響は「データ破壊バグの予防」という意味で非常に大きいため、
    • group / having を使う更新・削除ロジックのあるアプリではリリースノート・CHANGELOG を確認の上、アップグレード後の動作確認を推奨します。

#57605 Reset the memoized finder_needs_type_condition? flag on reset_column_information

マージ日: 2026/6/6 | 作成者: @55728

  1. 概要 (1-2文で)
    reset_column_information / reload_schema_from_cache 実行後も、STI 用のフラグ finder_needs_type_condition? の結果が古いまま残ってしまう不具合を修正する PR です。これにより、マイグレーションなどで type カラムを追加・削除した後に、STI 条件が効かなくなる/クエリがクラッシュする問題が解消されます。

  1. 変更内容の詳細

何が問題だったか

ActiveRecord の単一テーブル継承(STI)は、「このモデルに inheritance_column(デフォルトは type)が存在するか」を見て、クエリに WHERE type = 'Cat' のような条件を自動で付与するかどうかを finder_needs_type_condition? で判定しています。

この判定結果はインスタンス変数 @finder_needs_type_conditionメモ化 されており、通常はパフォーマンス上の最適化として妥当です。しかし、そのメモは以下のときにしかリセットされていませんでした。

  • サブクラス定義 (.inherited) 実行時

一方で、マイグレーション中などでよく使われる以下の API:

  • Model.reset_column_information
  • 内部的に呼ばれる ModelSchema#reload_schema_from_cache

は、カラム情報まわりのキャッシュを多数リセットするにもかかわらず、@finder_needs_type_condition は消していなかったため、

  • スキーマ変更前の状態に基づいた「STI 要 / 不要」判定が、その後もずっと使われ続ける

という状態になっていました。

その結果、2パターンのバグが起きます。

パターン1: もともと type カラムが無く、その後追加した場合

ruby
Cat.finder_needs_type_condition?         # => false がメモ化される (type カラムがまだ無い)
connection.add_column :animals, :type, :string
Animal.reset_column_information
Cat.finder_needs_type_condition?         # まだ false (本当は true になるべき)
Cat.create!(name: "felix").type          # => nil       # STI 用 type が保存されない
Cat.count                                # animals 全件を数える (WHERE type が付かない)

影響:

  • STI が 静かに無効化 される
  • サブクラスのスコープが全行に広がる
  • create しても type が書き込まれず、データ破損(誤った discriminator 値) につながる

パターン2: もともと type カラムがあり、その後削除した場合

ruby
Lion.count                               # => true がメモ化される (STI 有効)
connection.remove_column :zoos, :type
Zoo.reset_column_information
Lion.count                               # => "no such column: zoos.type" でクラッシュ

影響:

  • すでにメモ化された「STI 必要」フラグにより、もはや存在しないカラム zoos.type に対して WHERE が付与され、SQL エラーで落ちる

どちらも reset_column_information の利用例(マイグレーション中に change_table などと組み合わせるパターン)が公式にドキュメントされているため、実運用でも発生しうる不具合です。


修正内容

Inheritance#reload_schema_from_cache において、既に行っている他の STI 関連キャッシュのリセットに加え、@finder_needs_type_condition もリセットするようにしました。

疑似コード:

ruby
# activerecord/lib/active_record/inheritance.rb

def reload_schema_from_cache(*)
  @finder_needs_type_condition = nil  # ← これを追加
  super
end

これにより、

  1. スキーマ変更前に一度 finder_needs_type_condition? が呼ばれてメモ化されていても、
  2. マイグレーション等で type カラムを追加・削除し、
  3. reset_column_information / reload_schema_from_cache を呼べば、

次回の finder_needs_type_condition? 呼び出し時に、最新のテーブル定義を元に再判定 されるようになります。


テストの追加

InheritanceFinderNeedsTypeConditionTest を追加し、以下のシナリオをテストしています。

  • 一時テーブル上に STI 用のモデル群を定義
  • inheritance_columnまだ存在しない状態で finder_needs_type_condition? を呼んでメモ化
  • その後、type カラムを追加
  • reset_column_information を実行
  • 期待される挙動を検証:
    • finder_needs_type_condition? が true になる
    • create すると type にサブクラス名が保存される
    • サブクラスからの where / countWHERE type = ... が正しく付与される

テーブル定義を途中で変更する都合上、MySQL では DDL が暗黙コミットを伴うため、テストクラスでは use_transactional_tests = false が指定されています。

テストは以下のアダプタで fail → pass を確認済み:

  • sqlite3
  • postgresql
  • mysql2

既存の関連テスト (inheritance_test, reload_models_test, base_test) もすべてグリーンです。


  1. 影響範囲・注意点

影響範囲

  • 影響を受けるのは、STI を使っていて かつ以下のような操作を行うアプリケーションです。
    • マイグレーション中に type カラムを追加・削除し、その場で reset_column_information を呼ぶ
    • ランタイム中にテーブルの inheritance カラム周りを変更して reset_column_information で反映させる

この PR により、これらのパターンで:

  • STI が無効のままになってしまう
  • 存在しない type カラムへの参照で SQL エラーになる

といった不具合が解消されます。

既存コードへの影響(互換性)

  • 挙動としては「本来そうあるべきだった STI の判定に修正される」ものなので、正常なコードへの悪影響は基本的にありません。
  • ただし、もし「type カラムを追加したのに reset_column_information 後も STI が効かない」という バグに依存した挙動 を前提にしているコードがある場合、それは修正の対象になります(そのような依存は避けるべきです)。

パフォーマンス

  • reload_schema_from_cache 実行時にメモがクリアされることで、次回の finder_needs_type_condition? 呼び出し時にだけ 再計算が発生します。
  • これは既に他のスキーマ関連キャッシュでも行われているパターンであり、通常のアプリケーションでパフォーマンス問題になることはまずありません。

  1. 参考情報 (あれば)
  • 本 PR が扱う問題は、以前から議論されている「inheritance_column= 変更後の古いメモによる不整合」の一種です。
    • 関連 issue: #31475(属性メソッドのメモ化リセット問題)
  • reset_column_information / reload_schema_from_cache を用いた動的なスキーマ変更を行う場合は、
    • STI(type カラム)
    • 属性メソッド
    • その他スキーマ依存のメモ まわりのキャッシュが更新されているかを意識しておくと安全です。本 PR により、少なくとも finder_needs_type_condition? については自動で正しく更新されるようになりました。

#57603 Fix Relation#cache_key crashing on a loaded collection with a NULL timestamp

マージ日: 2026/6/6 | 作成者: @55728

  1. 概要 (1–2文で)
    ActiveRecord::Relation#cache_key / cache_version が、「すでにロード済みの関連に NULL の timestamp を含むレコードがある場合」に限って ArgumentError で落ちる不具合を修正した PRです。未ロード時とロード済み時で NULL の扱いが食い違っていたのを揃え、どちらでも同じ cache key を生成するようになりました。

  1. 変更内容の詳細

問題の具体的な挙動

updated_at(などの timestamp 列)に NULL を含むレコードがあるとき、同じ Relation に対して以下のような差が出ていました:

ruby
# topics: 1件は updated_at = <time>、もう1件は updated_at = NULL
scope = Topic.where(id: [a.id, b.id])

scope.cache_key        # => "topics/query-...-2-2026..."   (OK: 未ロード)
scope.load.cache_key   # => ArgumentError: comparison of Time with nil failed

つまり、

  • Relation が「未ロード」のとき → cache_key は正常に生成される
  • Relation が「ロード済み」のとき → Timenil を比較しようとして ArgumentError が発生

という、「ロード状態」に依存したクラッシュが起きていました。
特に collection_cache_versioning = false(デフォルト)のときに再現します。

原因

Relation#compute_cache_version 内で、ロード済みかどうかで timestamp の取得方法が変わっており、その実装差が原因です。

ruby
if loaded?
  size = records.size
  if size > 0
    timestamp = records.map { |record| record.read_attribute(timestamp_column) }.max
  end
else
  # ... SQL: MAX(timestamp_column) ...
end
  • 未ロードパス:
    • DB に対して SELECT MAX(updated_at) FROM ... のような SQL を発行
    • SQL の MAXNULL を無視するため、updated_at[Time, nil, ...] でも問題なく Time が返る
  • ロード済みパス:
    • Ruby 側で records.map { ... }.max を実行し、[Time, nil, ...].max になってしまう
    • Array#maxTimenil を比較できないため ArgumentError: comparison of Time with nil failed が発生

このため、

  • 未ロード: NULL を無視 → 正常
  • ロード済み: NULL を含んだまま max → 例外

という不一致が生じていました。

修正内容

ロード済みパスの timestamp 取得部分を、filter_map を使って NULL を除外するように変更しています。

ruby
# 変更前
timestamp = records.map { |record| record.read_attribute(timestamp_column) }.max

# 変更後
timestamp = records.filter_map { |record| record.read_attribute(timestamp_column) }.max

filter_map はブロックの結果が「truthy のものだけを取り出す」のと同時に「結果の配列を返す」メソッドです。

  • read_attribute(timestamp_column)nil → 配列に含めない
  • Time (truthy) → 配列に含める

結果として、records から「nil を含まない Time だけの配列」を作り、その .max を取るようになります。これは SQL の MAX(updated_at) と同じ挙動(NULL 無視)です。

all-NULL ケースの扱い

  • すべてのレコードの updated_atNULL だった場合:
    • 未ロードパス: MAX(updated_at)NULL → Ruby 側では nil
    • ロード済みパス: filter_map により空配列 [] に対して .maxnil

となり、どちらのパスでも timestamp == nil で揃います。
元々 all-NULL の場合は nil が返っており、その挙動は維持されています。

テスト

activerecord/test/cases/collection_cache_key_test.rb に以下のテストが追加されています:

ruby
test "cache_key for a loaded relation with a NULL timestamp matches the unloaded key" do
  # Topic は updated_at が nullable
  # どれか1件の updated_at を update_all で NULL にする
  # (create!(updated_at: nil) だとタイムスタンプコールバックに上書きされるため)
  # そのうえで:
  #
  #  - 未ロードの scope.cache_key
  #  - load 済みの scope.load.cache_key
  #
  # が一致することを検証
end
  • sqlite3 / postgresql / mysql2 で fail → pass を確認済み
  • collection_cache_key_test, cache_key_test, integration_test もグリーン

CHANGELOG にもこの修正が追記されています。


  1. 影響範囲・注意点
  • 影響を受けるケース
    • cache @collection など、View で「一度その Relation をループ等でロードしたあとに cache する」パターン
      • ロード前: @collection.cache_key は成功
      • ロード後: 同じコードが ArgumentError で落ちる
    • モデルの timestamp (デフォルトでは updated_at) が NULL になり得る場合
      • 手動で update_all(updated_at: nil) しているケースなど
  • この PR により:
    • ロードされているかどうかに関わらず、NULL を含む collection に対する cache_key / cache_version 呼び出しが安定して動作する
    • 未ロード・ロード済みの両方で「非 NULL timestamp の最大値」を使った cache key が生成される
  • 互換性・副作用
    • NULL を含む collection で、従来は未ロード時とロード済み時で cache key が異なっていた可能性があります(ロード済み時は例外で落ちるため、実際に差異が利用されていた可能性は低い)
    • この PR によって「両方のパスで同じ key になる」ため、むしろ期待通りに安定化したと考えて良いです
    • filter_map の導入以外にロジックの変更はなく、通常の timestamp がすべて埋まっているケースでは動作の違いはありません

  1. 参考情報 (あれば)
  • 対象メソッド: ActiveRecord::Relation#cache_key, #cache_version, 内部の compute_cache_version
  • 典型的な利用箇所:
    • Action View の cache ヘルパーで Relation をそのまま渡すコード:

      erb
      <% cache @topics do %>
        <% @topics.each do |topic| %>
          ...
        <% end %>
      <% end %>
    • 上記のように、each などで一度ロードされた直後に cache を呼ぶ場合、このバグの影響を受けていました。

この PR により、nullable な timestamp カラムを持つモデルを Relation ごとキャッシュしても、ロード状態によらず安全に cache_key / cache_version を利用できるようになっています。


#55061 [ci skip] Add docs for error context middleware

マージ日: 2026/6/5 | 作成者: @dersam

  1. 概要 (1-2文で)
    Rails ガイドの「エラー報告」ドキュメントに、ActiveSupport::ErrorReporter の「error context middleware」(エラーコンテキスト用ミドルウェア)の使い方が追記された PR です。コードの振る舞い変更はなく、ドキュメントのみの追加です。

  1. 変更内容の詳細

※ 実際の diff は guides/source/error_reporting.md に 32 行追加のみで、機能追加ではなく解説追加です。以下は PR 説明と既存機能から推測した、追加された内容の要点です。

2-1. error context middleware とは何か

  • ActiveSupport::ErrorReporter には、エラー報告時に「コンテキスト情報」(ユーザーID、リクエスト情報、マルチテナントのテナントIDなど)を付与するための仕組みがあります。
  • その一つとして、「error context middleware」を定義し、Rack ミドルウェアのようにリクエスト処理の前後でコンテキストを自動設定できる機構があり、これについて Rails ガイドに説明が追加されています。
  • これにより、アプリケーション全体で一貫してエラーコンテキストを付けるベストプラクティスがガイドに明文化されました。

2-2. 想定されるサンプルコードのイメージ

Rails ガイドによくあるスタイルを踏まえると、以下のような内容が追加されていると考えられます(あくまでイメージコード):

ruby
# config/initializers/error_reporter.rb
Rails.error.configure do |config|
  # 例: error context middleware を登録
  config.add_context_middleware do |context|
    # 現在のスレッドローカルや Current オブジェクトから情報を引き出して
    # コンテキストに追加する
    if Current.user
      context[:user_id] = Current.user.id
    end

    if Current.request_id
      context[:request_id] = Current.request_id
    end
  end
end

あるいは、Rack ミドルウェアやコントローラを通じてコンテキストを設定する例もガイドに書かれている可能性があります:

ruby
class ErrorContextMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    request = ActionDispatch::Request.new(env)

    Current.request_id = request.request_id
    Current.user       = find_current_user(request)

    @app.call(env)
  ensure
    # スレッドローカルを必ずクリア
    Current.reset
  end
end

Rails.application.config.middleware.use ErrorContextMiddleware

上記のような「Current オブジェクト + error context middleware」で、Rails.error.report(e) を呼んだ時に自動的に user_idrequest_id などがコンテキストとしてレポートされる、という流れをガイドで説明していると考えられます。

2-3. ActiveSupport::ErrorReporter との関係

  • ActiveSupport::ErrorReporterRails.error から利用されるエラー報告インターフェースで、#report, #handle, #record などの API を提供します。
  • 今回のガイド追加は、このレポーターにコンテキストを注入するための「middleware 的なフック」の存在と、その使い方を示すものです。
  • 既に存在していた API/機構に対する「公式な説明」を Rails ガイドに追加した位置づけです。

  1. 影響範囲・注意点
  • 影響範囲はドキュメントのみであり、アプリケーションコードや Rails 本体の挙動には変更はありません。
  • ただし、ガイドの更新により「推奨されるエラーコンテキストの設定方法」がより明確になったため、今後新規コードや既存コードのリファクタリング時に:
    • Rails.errorActiveSupport::ErrorReporter を直接呼び出す箇所で、
    • 「毎回手でコンテキストハッシュを渡す」実装から、
    • グローバルな error context middleware / Current パターンによる一元管理にリプレースしやすくなります。
  • コンテキストに格納する情報は個人情報・機密情報になりがちなので、ガイドにも「不要な個人情報を含めない」「ログや外部エラー追跡サービスに送信されることを意識する」といった注意点が記載されている可能性があります。その点を踏まえた設計が必要です。

  1. 参考情報 (あれば)

#57592 [RF-Docs] [ci-skip] Active Job Basics guide (#57101)

マージ日: 2026/6/5 | 作成者: @p8

  1. 概要 (1-2文で)
  • Active Job Basics ガイド全体の構成を大幅に見直し、特に Solid Queue とキューイング周りの説明を、より概念的かつ実務的な内容に再編したドキュメント改善 PR です。
  • 既存内容の重複や価値の薄いセクションを削除・統合し、キュー名/優先度/コールバック/失敗ジョブの扱いなどを一貫した流れで理解できるように整理しています。

  1. 変更内容の詳細

ガイド全体構成の再設計

  • トップレベル・サブセクションを全面的に組み替え、「何を」「どの順で」学ぶかが分かりやすくなるよう整理。
  • 小さな独立セクション(Job Testing, ActionMailer, Internationalization など Active Job ガイドとしては価値が薄いもの)を削除または他のセクションに統合。
  • セクション名の見直し:
    • “Create and Enqueue Jobs” → “Creating and Enqueuing Jobs” など、Rails Guides 全体の慣例に合わせてリネーム。
    • “Queues” セクションは、実際に書いている内容に合わせ “Enqueuing Jobs”的な構造に再編(下位に「キュー名」「ダイナミックなエンキュー」「優先度」「バルクエンキュー」などをぶら下げる形に)。

Solid Queue セクションの全面書き直し

  • 位置づけ:

    • Solid Queue を「非同期なインプロセスのキューイング・バックエンド」として、用語・定義を明確化。
    • 既存ガイドが GitHub の README の焼き直しになっていた部分を削除し、README ではなく「ガイドとして知るべき設計・特徴・ユースケース」にフォーカスした内容に変更。
  • 内容面での強化ポイント:

    • コンセプト説明:
      • Solid Queue がどのように設計されているか、他バックエンドとどう違うか(例: DB を使う・インプロセスで動く・Rails 標準でサポートなど)を概念的に説明。
      • 他のキューイングバックエンドとの比較観点(外部サービス型 vs アプリ内 DB 型、運用コスト、可観測性など)を整理。
    • 設定例の強化:
      • database.yml での Solid Queue 用の接続定義例を、キュー用の行が目に付きやすいようにハイライトして説明。
      • development / production の両方について、どのように queue DB を分けるか(または共用するか)を示す。
    • キュー定義と優先度:
      • 「定義されたキューの順序がジョブの priority より優先される」点を、具体的な例で説明し直し。
        • 例: queues: [ "production", "background" ] を定義しているときに、priority とキューの組み合わせで実際の処理順がどう決まるか、を表に近い形やサンプルで解説。
    • 警告表現の統一:
      • 以前は ⚠️ 絵文字で警告を表現していた箇所を、Guides 標準の WARNING ブロック(注記ブロック)を使うよう変更。

ジョブのエンキューとキュー名・優先度

Enqueuing Jobs / Queues 関連の再構成

  • セクション名変更と再構造化:

    • 「Queues」という抽象的タイトルをやめ、実際に扱う内容(ジョブのキューイング)に合わせて再構成。
    • 下位セクションを “Naming Queues”, “Dynamic Enqueuing”, “Priority”, “Bulk Enqueuing” のように整理。
  • キュー名の付け方(Naming Queues):

    • 大量のキューを運用する場合の命名規則について、実務的なガイダンスを追加。
    • 特に「レイテンシベースのキュー名」(例: critical, high, default, low)のようなスキームを「よく使われる選択肢」として紹介。
      • Gusto 社のブログ “Scaling Sidekiq at Gusto” を参照しつつ、「推奨」というより「一つのメジャーなパターン」として紹介する、というトーンに調整。
  • ダイナミックなエンキュー(Dynamic Enqueuing):

    • 実行時にキュー名を切り替えたり、状況に応じて queue / priority を変えるパターンの説明をセクションとして分離。
    • 例:
      ruby
      MyJob.set(queue: :low).perform_later(record)
      MyJob.set(wait: 5.minutes, queue: :high).perform_later(record)
  • 優先度(Priority):

    • これを独立セクションではなく、上記「Enqueuing Jobs」配下のサブセクションに移動し、キュー名と合わせて「ジョブの実行順序に効くパラメータ」という文脈で説明。
    • 例:
      ruby
      class CriticalJob < ApplicationJob
        queue_as :critical
        priority 10
      end
      といった形で、queue_aspriority の関係性がわかるような説明になっている。
  • バルクエンキュー(Bulk Enqueuing):

    • “Enqueuing Jobs” セクションの一部(サブセクション)として取り込み。
    • 上部に別見出しを立ててリンクだけする形ではなく、「2.3 Enqueue Jobs in Bulk」内にトップの説明をインライン展開するように修正。
    • 無意味な文言削除:
      • perform_all_later is a top-level API on Active Job.” など、情報量が薄い説明を削除し、具体的な使い方に紙幅を割く。

ジョブ定義・引数・コールバック

  • Supported Types for Arguments:

    • 引数に渡せる型の一覧・ルールを「ジョブ定義」関連のセクションに移動。
    • Enqueuing Jobs というより「Job の API 面での仕様」なので、Defining a Job 的なセクションのサブセクションとして再配置。
  • コールバック(Callbacks):

    • 以前は例だけで説明が薄かったのを、全ての利用可能なコールバック(before_enqueue, around_perform, など)について、短いコードスニペット付きで明示。
      • 例:
        ruby
        class MyJob < ApplicationJob
          before_enqueue :log_enqueue
          after_perform :notify
        
          private
            def log_enqueue
              Rails.logger.info("Enqueuing #{job_id}")
            end
        
            def notify
              # ...
            end
        end
    • コードブロック内で、実際にコールバックに関わる行だけを行ハイライトして、どこがポイントか読み取りやすくした。
  • コールバックの中断(halting callbacks):

    • 別 PR(#53541)で追加される予定だった「コールバックの halt」機能を、このガイドに明示的に説明するセクションを追加。
    • 例として、throw :abort あるいは専用 API による処理中断と、そのときキューイング/実行がどう扱われるかを説明しているはずです。

例外処理・デバッグ・失敗ジョブ

  • “Exceptions” セクションのリネーム:

    • タイトルを “Handling Failed Jobs” 的な名前に変更し、「例外」ではなく「失敗したジョブをどう扱うか」という読者視点のテーマに合わせた。
    • 失敗時のリトライ戦略やハンドラ、ロギング・通知など、実際の運用に直結する項目をこのセクションに寄せる形に調整。
  • Debugging セクション:

    • 独立した「デバッグ」章ではなく、「Handling Failed Jobs」周辺にマージし、失敗時の調査・ログ・スタックトレースの確認方法をまとめて扱う流れに。
    • verbose enqueuing logging:
      • development ではデフォルトで有効なため、単独のセクションとして扱うほどの重みはないと判断し、扱いを縮小/統合。

不要・重複セクションの削除

  • ActionMailer:

    • Active Job 経由でメール送信する説明は、Action Mailer ガイドにすでに存在しており、Active Job ガイドで重複して持つのは冗長なため削除。
    • 同じ理屈で、ActionMailbox (incinerate_later)、ActiveStorage (purge_later) などもここでは扱わず、各フレームワークガイドに任せる構成に。
  • Internationalization:

    • 実質的にメール送信や文言の I18n 寄りの内容であり、Active Job のコアテーマではないため削除。
  • Job Testing:

    • ここで軽く触れる程度の情報は価値が薄く、かつ他フレームワーク(Active Record, Action Controllerなど)も「テスト」専用サマリを持っていないことから、削除。

代替キューイングバックエンド

  • Alternate Queuing Backends セクション:
    • Solid Queue セクションとの位置関係を見直し。
    • Rails Foundation ドキュメントの方針として Solid Queue を第一クラス市民として扱いたい一方で、他バックエンドの情報も隣接させて比較しやすいように、両者のセクションを(少なくとも論理的には)近接させるよう再配置。

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

    • この PR は guides(ドキュメント)のみ変更であり、Rails のランタイムコードには一切触れていません。そのためアプリケーション動作への直接的な影響はありません。
  • 情報ソースとしての影響:

    • Active Job の公式ガイドとして参照する際、以下が変わります:
      • Solid Queue については README ではなく、「ガイドとしての整理された概念説明」を読むことになる。
      • ActionMailer, I18n, Job Testing など周辺事項は、このガイドからはリンク/言及が減り、各専用ガイドに誘導される構成になる。
    • 古いバージョンのガイドを前提にブログ・社内 Wiki 等で参照している場合、セクション構成や見出し名が変わっているため、リンク切れや説明の位置変化に注意が必要です。
  • ドキュメント執筆・翻訳への影響:

    • セクション構成が大きく変わったため、ローカル言語版(日本語訳など)を持っている組織は、翻訳の差分反映コストが高くなります。
    • 特に Solid Queue, Enqueuing Jobs, Callbacks, Handling Failed Jobs 周りを、原文に合わせて再翻訳・再構成する必要があります。

  1. 参考情報 (あれば)

#57101 [RF-Docs] [ci-skip] Active Job Basics guide

マージ日: 2026/6/5 | 作成者: @bhumi1102

  1. 概要 (1-2文で)
    Active Job BasicsガイドをRails Foundationのドキュメント方針に沿って全面的に再構成し、特にSolid Queueの説明を「設計・特徴・使い方」の観点から整理し直したPRです。細かい・重複したセクションを削除・統合し、キュー名/優先度/コールバック/失敗時の扱いなど、実務で重要なテーマごとに読みやすく再編成しています。

  1. 変更内容の詳細

全体構成の再編・統合

  • ガイド全体のトップレベル・サブセクションを見直し、細切れだった章を統合して流れを整理。
  • 「Queues」「Priority」「Bulk Enqueuing」「Supported Types」「Debugging」など、関連性が高いのにバラけていた内容を、「ジョブ定義」「ジョブのエンキュー」「失敗ジョブの扱い」といった大きめのテーマに再配置。
  • 章タイトルをガイド全体の命名規則に合わせてリネーム
    • 例: 「Create and Enqueue Jobs」→「Creating and Enqueuing Jobs」など。

Action Mailer / I18n / Job Testing の整理

  • Active Job固有でない、もしくは他ガイドと重複していた以下のセクションを削除:
    • ActionMailer連携(Action Mailerガイドでカバー済み)
    • Internationalization(主にMailer文脈での話であり、Active Job固有ではない)
    • Job Testing(他フレームワークに同等セクションがなく、内容的にも薄い)
  • 代わりに、Active Jobのコア概念・使い方に紙幅を集中させる構成になっています。

Solid Queue セクションの全面書き換え

このPRの中心は、Solid Queueの説明を単なるREADMEの焼き直しから、「Rails標準のキューバックエンドとしての位置づけ・特徴・設計思想」を伝えるガイドに変えた点です。

コンセプト・設計の説明を重視

  • Solid Queue を以下のような観点で説明する構成に変更:
    • Railsに組み込みやすい「非同期・インプロセス・キューバックエンド」であること
      • “queuing system” ではなく “queuing backend” という用語に揃え、Active Jobのバックエンドとしての役割を明確化
    • 他のバックエンド(Sidekiq, Resqueなど)との違い
      • DBベース、Railsとの親和性、インフラ依存の少なさなどの特徴を、概念レベルで説明
    • 実運用で意識すべき点(キュー定義、優先度、ワーカー構成など)
  • READMEの内容をそのままコピーするのではなく、
    • 「どういうときに・どういう考え方でSolid Queueを選ぶか」
    • 「設計上何がユニークか」 を中心に説明し、詳細なオプションなどはREADMEに委ねる構成になっています。

設定例の明確化

  • config/database.yml の例で、Solid Queue用の接続/設定行がより目立つように記述を整理
    • PRコメントに基づき、queueに関する行を強調。
    • 開発環境・本番環境両方の例を示し、「RailsアプリのDBと同一/別DBを使うケース」などの具体的イメージがしやすいよう編集。

キュー定義と優先度の関係を具体例で説明

  • 「定義されたキューの順序がジョブのpriorityより優先される」という振る舞いを、より明確な例付きで説明。
    • 例: production キューと background キューを定義したうえで、それぞれに priority が違うジョブが入っていても、キューの処理順が優先されることを図やサンプルコードで説明、という形に変更。
    • これにより、「priorityを上げてもキュー順序に勝てない」ケースの混乱を防ぐ意図。

警告スタイルの統一

  • テキスト中で使われていた ⚠️ を、Railsガイド標準の WARNING ブロックに置き換え。
    • セクション全体をWARNINGにするのではなく、本当に注意喚起が必要なポイントだけをブロックに分離し、可読性と意味合いを整理。

キューとエンキュー周りの整理

「Queues」→「Enqueuing Jobs」系への再構成

  • 従来「Queues」というタイトルの下に混ざっていた内容を、実態に合わせて再分類:
    • 「キューの命名(Naming Queues)」
    • 「動的なエンキュー(Dynamic Enqueuing)」
    • 「優先度(Priority)」※もとの独立セクションをここに統合
  • 目的:読者視点では「キューというデータ構造の内部」よりも、「どうエンキューするか」「どう振る舞いを制御するか」が知りたいため、その観点で章を立て直し。

キュー命名のベストプラクティスへの言及

  • Latency-based naming(レイテンシベースのキュー命名)を「よく使われるパターン」として紹介:
    • 例: mailers_fast, mailers_slow, critical, default, low のように、処理の緊急度/レイテンシに応じたキューを分ける
  • Jean Boussierのコメントに従い、「唯一の正解としての推奨」ではなく「広く使われているオプション」として中立的に記述。

コールバック・ライフサイクルの改善

コールバック一覧とコード例の充実

  • Active Jobの各種コールバック(before_enqueue, around_perform, after_perform など)について、他のガイド同様に
    • 各コールバックの目的/タイミングの簡潔な説明
    • 該当部分だけを行ハイライトで示したサンプルコード を追加し、理解しやすく整理。
  • コールバックの種類だけ列挙されていた従来版と違い、「いつ・何をするのに向いているか」まで含めて分かりやすくなっています。

コールバックの「中断(halting)」の追加

  • 独立PR (#53541) で追加予定だった、コールバックを途中で中断する挙動に関する説明セクションを統合。
    • 例: throw :abort によるbefore_enqueueの中断など
    • 中断した際にジョブがどう扱われるか(エンキューされない/実行されないなど)を明示。
  • コールバックまわりで実装側がハマりがちなケース(条件付き実行、バリデーション的な使い方)への対応も、ガイドだけで把握しやすくなっています。

バルクエンキュー (Bulk Enqueuing)

  • 「Enqueuing Jobs」系セクションのサブセクションとして再配置し、ジョブ投入手段を一箇所にまとめる構成に変更。
  • perform_all_later の説明から、意味の薄い文(「top-level API である」等)を削除し、実際の使い方にフォーカス。
    • 例:
      • 複数のレコードに対して同種のジョブを一括エンキューするケース
      • 単発の perform_later ループとの違い(トランザクション境界・オーバーヘッド)などをコード例で示す形になっていると考えられます(差分行数的に詳細が増えている)。

サポートされる引数型 (Supported Types for Arguments)

  • もともと「Enqueuing Jobs」直下にあった/あるいはガイド後半に散在していた内容を、「ジョブ定義」の文脈に近いセクションへ移動。
    • 目的:ジョブを定義する際に「どの型を引数に渡せるか」を意識して設計できるようにするため。
  • Active Jobがシリアライズ可能な型(基本型、ActiveRecordモデル、GlobalID対応オブジェクト等)について解説するセクションとして整理されているはずです。

失敗ジョブとデバッグ

「Exceptions」→「Handling Failed Jobs」へリネーム

  • セクションタイトルを「Exceptions」から「Handling Failed Jobs」に変更し、内容に即した名称に。
    • リトライ、デッドレター(もし記載があれば)、ログの確認など、失敗時の扱いを中心に説明。

デバッグ内容の統合

  • 独立していた「Debugging」セクションを「Handling Failed Jobs」配下に統合。
  • Railsの開発環境では verbose なエンキューログがデフォルト有効であるため、これを「特別な設定」として扱う記述をやめ、失敗ジョブの原因究明の流れの中で自然に説明する構成に変更。

代替キューバックエンド (Alternate Queuing Backends)

  • Solid Queueセクションと隣接するように再配置。
    • Rails標準(Solid Queue)→他バックエンドという流れで読み進められるように。
    • あるいは、Solid Queueをやや強調しつつも、最後に「他にもこういう選択肢があります」と紹介する位置づけ。
  • 目的は、「Solid Queueがデフォルトである」ことを明確にしつつ、「Sidekiqなど他のエコシステムと連携する余地もある」ことを示すバランスをとること。

  1. 影響範囲・注意点
  • コード(Active Job本体)には変更はなく、あくまでガイド(guides/source/active_job_basics.md)のみの変更です。
  • ただし、ガイドを準拠情報としているチーム・社内Wikiなどがある場合:
    • セクション名・アンカー(#queues#enqueuing-jobs のような)変更により、リンク切れが発生する可能性があります。
    • 「ActionMailer」「Internationalization」「Job Testing」などのサブセクションは削除されているため、それらへの直接リンクは確認が必要です。
  • Solid Queueの説明が「設計や特徴中心」に変わったため、
    • 以前READMEの内容を引用したような運用手順をガイド経由で参照していた場合、今後はSolid QueueのREADMEとの併用が前提になる箇所があります。
  • コールバックの halting(中断)に関する記述が明示されたことで、これまで暗黙的に(or バグ扱いで)利用していたコードの意味がドキュメント的に「仕様」として整理されます。
    • 新規実装時には、halting の有無を意識した設計がしやすくなる一方で、既存コードの振る舞い確認もしておくと安心です。

  1. 参考情報 (あれば)

#57575 Clear the type column when removing a polymorphic has_one

マージ日: 2026/6/5 | 作成者: @55728

  1. 概要 (1-2文で)
    polymorphic な has_one 関連を代入で外す(owner.child = nil / owner.child = other)際に、外部キーだけでなく <name>_type も確実に NULL にするように修正した PR です。これにより、dependent: :nullify 経由の「nullify」と挙動が統一され、一方向の polymorphic has_one で発生していた「type カラムが古いまま残る」不整合が解消されます。

  1. 変更内容の詳細

これまでの挙動

対象は「polymorphic な has_one 関連」を、代入で「外す」ケースです:

ruby
owner.thing = other   # 付け替え
owner.thing = nil     # 削除

こうした操作を行うと、子側のレコードにある「外部キー」カラム(thing_id など)は NULL になりますが、polymorphic の「type」カラム(thing_type)は以前の値が残っていました。

内部的には HasOneAssociation#nullify_owner_attributes が呼ばれますが、ここでは外部キーしか消していませんでした:

ruby
# activerecord/lib/active_record/associations/has_one_association.rb
def nullify_owner_attributes(record)
  Array(reflection.foreign_key).each do |foreign_key_column|
    record[foreign_key_column] = nil unless foreign_key_column.in?(Array(record.class.primary_key))
  end
end

一方、dependent: :nullify を指定して関連を切る場合は、ForeignAssociation#nullified_owner_attributes が使われます。こちらは type カラムも消します:

ruby
def nullified_owner_attributes
  Hash.new.tap do |attrs|
    Array(reflection.foreign_key).each { |foreign_key| attrs[foreign_key] = nil }
    attrs[reflection.type] = nil if reflection.type.present?
  end
end

そのため、次の2パターンで挙動が食い違っていました:

  • owner.thing = nil
    • 外部キー: NULL
    • type: 古い値のまま
  • has_one :thing, dependent: :nullify 経由の nullify
    • 外部キー: NULL
    • type: NULL

特に、子モデル側が inverse の belongs_to を持たない一方向の polymorphic has_one で問題が顕在化します。逆方向の belongs_to があれば、そのロジックの中で type もリセットされるため、これまで多くのケースでは問題が「見えにくい」状態でした。

再現例

ruby
class Human < ActiveRecord::Base
  has_one :face, as: :describable          # Face(describable_id, describable_type)
end

class Face < ActiveRecord::Base
  # NOTE: belongs_to :describable は定義しない(片方向)
end

h = Human.create!
h.face = Face.create!                       # describable_id + describable_type = "Human"
h.face = nil

face.reload
face.describable_id    # => nil
face.describable_type  # => "Human"   # ← ここが古い値のまま

修正内容

HasOneAssociation#nullify_owner_attributes に、polymorphic の type カラムも NULL にする処理を追加しました:

ruby
def nullify_owner_attributes(record)
  Array(reflection.foreign_key).each do |foreign_key_column|
    record[foreign_key_column] = nil unless foreign_key_column.in?(Array(record.class.primary_key))
  end

  # 追加された行
  record[reflection.type] = nil if reflection.type.present?
end
  • polymorphic でない has_one の場合は reflection.typenil なので、この行は no-op になります。
  • これにより、代入形式の nullify と dependent: :nullify の両方で、「id も type も消える」という一貫した挙動になります。

テスト

activerecord/test/cases/associations/has_one_associations_test.rb に以下のテストが追加されています:

  • test_replacing_a_polymorphic_has_one_nullifies_the_type_column

ポイント:

  • 一方向 polymorphic has_one(子側に inverse belongs_to がない)を使ったテストケース
  • owner.child = nil 実行後に、子の <name>_id<name>_type の両方が nil であることを検証
  • 変更前の main では type が "...Owner" のままになりテストが落ちる
  • この修正によりテストがグリーンになる
  • sqlite3 / postgresql / mysql2 すべてでテストスイートがパス

CHANGELOG.md にも、この挙動変更が追加されています。


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

    • polymorphic な has_one を定義しているモデル
    • その関連を「代入で外す」コードがある場合:
      • owner.child = nil
      • owner.child = another_child
    • 特に、子側が inverse belongs_to を定義していない 一方向 polymorphic has_one でこれまで挙動が変だったところが正しくなります。
  • これまでと挙動が変わる点

    • 以前は「外部キーは NULL になっているが type だけ古い値が残る」ため、データ的には不整合な状態でしたが、アプリ側の独自ロジックや直接 SQL を書く箇所で、この「stale な type カラム」に依存していた場合は挙動が変わる可能性があります。
      • 例: WHERE describable_type = 'Human' AND describable_id IS NULL のようなクエリを「外れた Face だけを拾う」意図で書いていた場合など
    • Rails 的には今回の挙動が一貫性があり正しいと考えられるため、通常の関連操作に依存している限りは「バグ修正」として問題なく受け入れられる変更です。
  • 非 polymorphic の has_one

    • reflection.typenil のため、挙動は一切変わりません。
  • dependent: :nullify を使っている場合

    • もともと id + type ともに NULL にしていたので挙動は据え置きです。
    • 今回の変更で「代入で外した場合」との不整合が解消され、どのルートでも同じ状態になります。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57575
  • 修正対象のメソッド:
    • activerecord/lib/active_record/associations/has_one_association.rb#nullify_owner_attributes
  • 類似ロジック:
    • activerecord/lib/active_record/associations/foreign_association.rb#nullified_owner_attributes
  • 関連テスト:
    • activerecord/test/cases/associations/has_one_associations_test.rb
  • 挙動確認をしたい場合は、自分のアプリで問題になりそうな polymorphic has_one を定義して、owner.child = nil → 子レコードを reload<name>_id / <name>_type の値を確認すると差分を把握しやすいです。

#57566 Fix MessagePack serialization of records with a time column

マージ日: 2026/6/5 | 作成者: @55728

  1. 概要 (1-2文で)
    time 型カラムを持つ ActiveRecord オブジェクトを ActiveRecord::MessagePack でシリアライズすると NoMethodError が発生していた問題を修正した PR です。ActiveRecord::Type::Time::Value を MessagePack の対象型として正式に登録することで、Rails.cache.write 等で time カラムを含むレコードを安全にキャッシュできるようになりました。

  1. 変更内容の詳細

問題の内容

ActiveRecord::MessagePack を経由してレコードをシリアライズするとき、time 型カラムが存在すると以下のようなエラーが発生していました。

text
NoMethodError: undefined method 'to_msgpack' for an instance of ActiveRecord::Type::Time::Value

原因は以下の通りです。

  • attributes_for_databasetime カラムの値を ActiveRecord::Type::Time::Value オブジェクトにラップして扱う
    • これは DelegateClass(::Time) を継承したクラス(Timeのデリゲート)
  • 一方、MessagePack のファクトリは 厳密なクラス一致 で型を登録しており、::Time だけを登録していた
  • そのため ActiveRecord::Type::Time::Value::Time とみなされず、MessagePack 側で対応する拡張型が見つからず to_msgpack 未定義エラーに落ちていた

datetime / date / decimal については、Time / Date / BigDecimal の素の値でシリアライズされていたため、この問題の影響を受けていません。

このバグは Rails 7.1 で MessagePack サポートが入った時点から存在していましたが、既存の message_pack_test.rb で使われているモデル(Post/Comment/Author/Binary)には time カラムが無かったため、これまでテストに引っかかっていませんでした。

修正内容

activerecord/lib/active_record/message_pack.rb にて、MessagePack ファクトリへ ActiveRecord::Type::Time::Value を拡張型として登録する処理が追加されました。

  • ActiveRecord::Type::Time::Valuetype ID 118 として登録
  • 既存の Binary::DataBase 登録と同様に、再帰的 (recursive) な登録方法で行っている
    • これは、MessagePack でカスタムクラスを扱う際に、ネストされたオブジェクトを含めて適切にエンコード / デコードできるようにするためのパターンです

概念的には、次のような登録をしているイメージです(擬似コード):

ruby
# 118 番の拡張型として ActiveRecord::Type::Time::Value を登録するイメージ
factory.register_type(118, ActiveRecord::Type::Time::Value) do |time_value|
  # 内部的には実際の Time としてエンコードし、復元時に適切に再構築する
end

(実際のコードは既存の拡張型登録のスタイルに合わせて書かれており、Binary の登録と同様のパターンで実装されています。)

テスト追加・更新

activerecord/test/cases/message_pack_test.rb に以下のテストが追加・更新されました。

  1. test_roundtrips_time_attribute

    • Topic モデルを使い、そのうち bonus_time という time カラムを持つ属性が MessagePack を経由してラウンドトリップ(serialize → deserialize)できることを検証
    • 修正前はここで NoMethodError が再現し、修正後は正常にパスするテスト
  2. test_enshrines_type_IDs の更新

    • 既存の拡張型 ID を固定化しておくテストに、type 118 を追加
    • これにより、拡張型 ID の将来的な変更が、互換性に影響することを検知できるようになっている

また、activerecord/CHANGELOG.md にも、この修正が反映されています。


  1. 影響範囲・注意点

影響範囲

  • ActiveRecord::MessagePack を使う経路(特に Rails.cache)で、time カラムを持つレコードを扱うケース全般
    • 例: Solid Cache や他のキャッシュストアで ActiveRecord オブジェクトをそのままキャッシュしている場合
  • これまで:
    • time カラムがあるレコードを Rails.cache.write(record) などすると、NoMethodError が発生
  • この PR 適用後:
    • 同じコードがエラーなく動作し、time 属性も含めて正しくシリアライズ / デシリアライズ可能

互換性・注意点

  • 新たに使用した拡張型 ID は 118 で、既存の type ID と衝突しないように選定されています
    • test_enshrines_type_IDs により、ID 割り当てが将来変更された場合の破壊的変更を検出できるようになっています
  • datetime / date / decimal など他の型の挙動は変更されていません
  • ActiveRecord::Type::Time::Value のシリアライズ形式が今後変わると互換性に影響する可能性があるため、拡張型 ID 118 の扱いはバージョン間互換の観点で慎重に扱う必要があります

  1. 参考情報 (あれば)
  • 対象バージョン: Rails 7.1 以降の ActiveRecord::MessagePack 機能に潜んでいたバグの修正
  • 関連箇所:
    • ActiveRecord::Type::Time::Value は 2016年に導入された DelegateClass(::Time) のサブクラス
    • MessagePack サポートは Rails 7.1 で導入
  • 想定利用箇所:
    • Rails.cache (ActiveSupport::MessagePack::CacheSerializer 経由)
    • Solid Cache を含む Rails 標準・外部のキャッシュストアでの ActiveRecord モデルキャッシュ

#57590 Fix order-dependent test failures with sqlite3_mem

マージ日: 2026/6/5 | 作成者: @ruyrocha

  1. 概要 (1-2文で)
    ARCONN=sqlite3_mem でのテスト実行時に、テスト実行順によって約3000件の失敗を引き起こしていた原因となる2つのテストを修正し、SQLite の in-memory DB 環境でもテストスイート全体が安定して通るようにした PR です。グローバルな SchemaCache の破壊と、:memory: DB 自体の破棄を招いていたテストの挙動を修正・制限しています。

  1. 変更内容の詳細

2-1. DirtyTest#test_field_named_field の修正 (SchemaCache 破損の解消)

問題点

DirtyTest#test_field_named_field というテストが、以下のような流れで動いていました:

  • create_table / drop_table を使って一時的なテーブルを作成・削除
  • ensure ブロックで ActiveRecord::Base.clear_cache! を呼び出し

ActiveRecord::Base.clear_cache! はグローバルな SchemaCache を空にした新しいインスタンスで置き換えるため、
テストファイル単体では通るものの、テストスイート全体で実行すると、その後に動く多数のテストが「テーブルが見つからない」エラーで落ちていました。

SQLite in-memory DB (:memory:) の場合は特に、接続ごとに DB がメモリ内にだけ存在し、Schema 情報も都度ロードし直す必要があるため、SchemaCache を無造作に吹き飛ばすと影響が大きくなります。

対応内容

  • ActiveRecord::Base.clear_cache! を使った一時テーブル方式をやめ、既存の pirates テーブルを使うアプローチに変更
  • テスト対象用に匿名クラス + attribute API を使う形に書き換え

同じファイル内に既にあるテスト

ruby
test "attribute_will_change! doesn't try to save non-persistable attributes"

などと同じパターンに合わせ、明示的なテーブル作成/削除やグローバルキャッシュ操作に依存しないようにしています。
これに伴い、トップレベルで定義されていた Testings クラスも不要になったため削除されています。

※具体的な差分はおおむね以下のようなイメージです(擬似コード):

ruby
# 以前(イメージ)
def test_field_named_field
  begin
    connection.create_table(:testings) { |t| t.string :field }
    klass = Class.new(ActiveRecord::Base) { self.table_name = "testings" }
    # ... テスト本体 ...
  ensure
    connection.drop_table(:testings) rescue nil
    ActiveRecord::Base.clear_cache!
  end
end

# 以後(イメージ)
def test_field_named_field
  klass = Class.new(ActiveRecord::Base) do
    self.table_name = "pirates"
    attribute :field, :string
  end
  # ... テスト本体(field という名前の attribute を扱う検証) ...
end

これにより、テスト終了時に SchemaCache をクリアする必要がなくなり、他テストへの副作用をなくしています。


2-2. AdapterConnectionTest の sql_notifications 系テストの修正

問題点

activerecord/test/cases/adapter_test.rbAdapterConnectionTest には、SQLite in-memory DB 用のガードが既に存在していました:

ruby
unless in_memory_db?
  # ... 多くのテスト ...
end

しかし、そのクラス内にある

  • test_suppresses_notifications_when_sql_notifications=false
  • test_sql_notifications_are_enabled_by_default

の2つのテストだけが unless in_memory_db? の外に置かれていました。

これらのテストは run_without_connection を利用しており、その内部では:

  1. remove_connection を呼ぶ
  2. その後 establish_connection で再接続する

という挙動をします。
SQLite で database: ':memory:' を使う場合、remove_connection によってメモリ上の DB 自体が「完全に消える」ため、その後の establish_connection では「空の新しい in-memory DB」が作成され、スキーマ情報がすべて失われます。

このため、これら2テスト実行後に続く多数のテストが「テーブルが存在しない」などのエラーで失敗していました。

対応内容

  • 上記2つのテストを unless in_memory_db? ブロックの中に移動し、SQLite in-memory 環境では実行されないように変更

これにより、SQLite :memory: 環境では DB 接続の remove/establish によってスキーマが消えるパターンを避け、テスト順依存の失敗を防いでいます。

なお、unconnected_test.rb では既に

  • in-memory DB で remove_connection 相当の操作を行う場合
  • teardown で load_schema if in_memory_db? を呼んでスキーマを復元

という正しいパターンが実装済みであり、本 PR はそれに合わせて「そもそも in-memory ではその危険なパターンのテストを実行しない」という整理をしています。


  1. 影響範囲・注意点
  • 影響範囲はテストコードに限定され、アプリケーション本体の挙動やパブリック API には変更はありません。
  • ARCONN=sqlite3_mem でのテスト実行時に、大量の順序依存テスト失敗が解消されるため、Rails 本体やプラグイン開発者が CI 等で SQLite in-memory を使う場合の安定性が向上します。
  • in-memory DB で remove_connection / establish_connection を行うと DB 自体が消える、という SQLite 特有の性質が改めて明確になった形なので、アプリや他テストコードでも同様のパターンを使う場合は:
    • そもそも in-memory では実行しないガードを付ける
    • もしくはテスト後に load_schema などでスキーマを復元する といった対策が必要になります。
  • SchemaCache をテスト内でいじる場合も、ActiveRecord::Base.clear_cache! のようなグローバルな操作は極力避け、既存テーブル+属性 API の利用など、スキーマ自体に影響しない設計が望まれます。

  1. 参考情報 (あれば)
  • 該当 Issue: Fixes #57589
  • 修正対象ファイル
    • activerecord/test/cases/adapter_test.rb
    • activerecord/test/cases/dirty_test.rb
  • 関連パターンの例: activerecord/test/cases/unconnected_test.rbload_schema if in_memory_db? を使った in-memory DB 復元ロジック

#57591 Merge pull request #57583 from VladNegara/nested-join-explanation

マージ日: 2026/6/5 | 作成者: @p8

  1. 概要 (1-2文で)
    Active Record Querying ガイド内で「ネストしたクエリ (nested join / nested query)」の平易な英語説明が誤解を招く内容だったため、実際の挙動・クエリ構造に合うように文章だけを修正したドキュメント更新です。コードや API の挙動変更は一切なく、ガイドの記述の正確性を高めるための修正です。

  1. 変更内容の詳細

※ この PR は guides/source/active_record_querying.md の 2 行の差し替えのみで、Ruby コードや Active Record の実装には手を加えていません。GitHub 上の差分を前提に、典型的な修正内容を踏まえて説明します。

  • 対象箇所:
    Active Record Querying ガイドの「joins」や「includes」などの章の中で、以下のような「ネストした関連を指定した join」の説明がある部分:

    ruby
    Author.joins(books: :reviews)
  • 以前の説明の問題点 (推定される内容):

    • 「内部的に 2 回クエリが発行される」など、実際には 1 つの SQL で複数の JOIN が行われるケースなのに、英語の説明が暗に「サブクエリ」や「複数クエリ」を連想させる表現になっていた。
    • joins でネストしたハッシュを渡した場合の SQL 構造 (例: INNER JOIN "books" ... INNER JOIN "reviews" ...) を、平易な英語の説明が正しく反映していなかった。
  • 修正後の説明 (内容のポイント):

    • 「nested query」や「nested join」という語を、実際に生成される SQL (連続した JOIN) に合うような説明に変更。
    • 「関連をネストして指定すると、Rails はそれに応じて複数のテーブルを順番に JOIN する」という形に言い換えられている可能性が高い。
    • サブクエリとしてネストされるわけではなく、「親 → 子 → 孫」とリレーションを辿った JOIN が 1 本のクエリで実行されることを明確にしている。

例: (概念的なイメージ)

ruby
Author.joins(books: :reviews)
# => SELECT "authors".*
#    FROM "authors"
#    INNER JOIN "books" ON "books"."author_id" = "authors"."id"
#    INNER JOIN "reviews" ON "reviews"."book_id" = "books"."id"

このような実態を反映した「自然言語での説明文」が修正されています。


  1. 影響範囲・注意点
  • 影響範囲:
    • ドキュメント (ガイド) のみ。
    • Rails のコード、Active Record のクエリ生成ロジック、API は一切変更されていません。
  • 注意点:
    • 既に joins・ネストした関連指定 (joins(books: :reviews) など) を利用しているアプリケーションや、既存のテスト・運用には影響はありません。
    • これまで誤った理解 (「サブクエリでネストされている」や「2 回クエリが走る」など) に基づいて設計していた場合は、ガイドの新しい説明を読み直すことで、実際の SQL 形状・パフォーマンス特性を再確認するきっかけになります。
    • パフォーマンスチューニングや N+1 問題の回避を検討している場合は、joinsincludespreloadeager_load の違いと合わせて、新しい説明を確認しておくとよいです。

  1. 参考情報 (あれば)
  • 対応するガイド:
    • Rails Guides: Active Record Query Interface / Active Record Querying
      • 「Joining Tables」や「Nested Associations」節に該当する説明が更新されています。
  • 関連トピック:
    • joins による INNER JOIN / LEFT OUTER JOIN (スコープ指定を含む)
    • ネストした関連指定: joins(books: :reviews), joins(books: { publisher: :country }) など
    • includes / preload / eager_load の違いと生成されるクエリ数・JOIN 構造の違い

#57583 Fix plain English explanation of nested query in Active Record Querying guide [ci-skip]

マージ日: 2026/6/5 | 作成者: @VladNegara

  1. 概要 (1-2文で)
    Active Record Querying ガイドにある「ネストした関連の JOIN」の英語説明文が、実際のクエリの挙動と食い違っていたため、それを正しい内容に修正するドキュメント変更です。コードや挙動の変更はなく、ガイドの自然言語による説明だけが更新されています。

  1. 変更内容の詳細

対象箇所は、Active Record Querying ガイドの以下の節です。

  • 「Joining Nested Associations (Multiple Level)」
    guides/source/active_record_querying.md 内)

ここでは、includes / joins / references などを使って複数階層の関連を JOIN する例として、以下のようなクエリが説明されています(ガイドの典型例・イメージ):

ruby
Author.joins(books: [:reviews, :suppliers])

この PR で問題にされた点は、SQL や Active Record のコード自体は正しいのに、それを言い換えた「平易な英語」の説明だけがクエリの意味を間違って伝えていた、という点です。

修正されたポイントは主に2つです。

2-1. 結果セットから著者を不当に除外してしまうような説明を修正

元の平易な英語説明は、概ね次のような意味合いになっていました:

「少なくとも 1 回注文されていて、かつ少なくとも 1 件レビューがあり、…」といった条件を、本(Book)や著者(Author)に対して課しているように読める

しかし実際の SQL / Active Record のクエリは JOIN なので、次のようなケースでも著者は結果に含まれます:

  • ある著者 A がいる
  • A の本は一度も注文されたことがない
  • その本には、(購入していない)顧客からのレビューだけが付いている

この場合でも、JOIN の構造によっては「著者 A が結果セットに含まれる」形になるにもかかわらず、元の英語説明だと「A は除外される」と読めてしまっていました。

PR ではこのズレを修正し、実際のクエリの結合条件・フィルタ条件が示す「結果セットに含まれる著者の条件」を正確に反映した表現に変更しています。

2-2. supplier 情報が「返ってくる」ように読める説明を修正

もうひとつの問題は、サプライヤ(suppliers)への JOIN の説明です。

元の平易な英語説明は、あたかも

suppliers の詳細情報も一緒に返ってくる(SELECT される)

かのようなニュアンスになっていましたが、実際には典型的な例では

ruby
Author.joins(books: :suppliers)

のように、suppliers への JOIN は「条件としてのフィルタ」にしか使っていない場合があります。

つまり、

  • 「少なくとも 1 つの supplier を持つ book だけを対象にする」
    • → その結果として「そういう book を持つ authors」だけが結果に残る

という用途であって、suppliers の列まで SELECT して返すかどうかは別問題です。

PR ではこの点を明確にし、

  • suppliers への JOIN は「結果に supplier の情報を含める」というよりも
  • 「少なくとも 1 つ supplier を持つ book に絞り込むための結合である」

という、実際のクエリ構造に忠実な説明へと修正しています。


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

    • Rails 本体のコード・挙動には変更なし。
    • Active Record Querying ガイドの一節(ネストした関連の JOIN)の自然言語説明のみ変更。
    • すでにガイドを読んでコードを書いている人に対しては、「クエリがどう動いているか」の認識を正す効果はあるが、アプリケーションコード自体は一切変更不要。
  • 注意点 / 読み手への実務的な示唆

    • joins を使ったとき、**「どのテーブルが結果に含まれるか」「どのテーブルはフィルタ条件としてだけ使われるか」**を意識する必要がある。
    • 特にネストした関連(Author.joins(books: [:reviews, :suppliers]) のような構造)の場合は、
      • どの関連が INNER JOIN されているか
      • その結果、「親」レコード(Author)がどの条件で残る/落ちるのか を SQL レベルで一度確認しておくとよい。
    • ドキュメントを「クエリの仕様の厳密な定義」として読むのではなく、 実際には to_sql やローデータの確認で挙動を検証するのが安全、という教訓にもつながる修正です。

  1. 参考情報 (あれば)

#57580 Skip Proc and Regexp filter_attributes when syncing to filter_parameters

マージ日: 2026/6/5 | 作成者: @jcalvert

  1. 概要 (1-2文で)
    このPRは、ActiveRecord::FilterAttributeHandler がモデルの filter_attributesRails.application.config.filter_parameters に同期する際、ProcRegexp など文字列化しても意味のないエントリをスキップするようにし、不要かつ不安定なフィルタ文字列が filter_parameters に蓄積される問題を修正するものです。これにより、フィルタ設定がよりクリーンで安定し、ログや検査ツールが扱いやすくなります。

  1. 変更内容の詳細(あればサンプルコードも含めて)

背景となる挙動

Rails 8.1 から導入された ActiveRecord::FilterAttributeHandler (#55251) は、モデル側の filter_attributesRails.application.config.filter_parameters に同期します。
同期時には以下のように「<モデル名>.<属性名>」形式の文字列に変換されます:

ruby
class User < ApplicationRecord
  self.filter_attributes += [:token]
end

Rails.application.config.filter_parameters
# => [..., "user.token"]

これにより、params[:user][:token] のようなパラメータもログでマスクされます。

一方、filter_attributesActiveSupport::ParameterFilter による #inspect 用のフィルタとして、シンボル / 文字列だけでなく RegexpProc も受け付けます:

ruby
class User < ApplicationRecord
  self.filter_attributes += [:token, /secret/i, ->(key, value) { value.reverse! }]
end

元実装では apply_filter 内で各エントリに to_s を呼んでいたため、これらがそのまま filter_parameters に反映され、次のような「絶対にマッチしない」文字列が増殖していました:

ruby
Rails.application.config.filter_parameters
# => [...,
#   "user.token",                # これは正常
#   "user.(?i-mx:secret)",       # Regexp の to_s
#   "user.#&lt;Proc:0x000000010a3c5d68 ...>" # Proc の to_s
# ]
  • これらはパラメータキーとして絶対に一致しないため、実質ゴミ
  • 特に Procto_s にはオブジェクトIDが含まれるため、アプリ再起動ごとに文字列が変わり配列の安定性が損なわれる
  • filter_parameters を参照するログフォーマッタや診断用ツール、テスト(監査系 spec など)に悪影響

このPRの変更点

ActiveRecord::FilterAttributeHandler#apply_filter の挙動を変更し、

  • String と Symbol 以外のエントリは filter_parameters への同期対象から除外する

ようにしました。

擬似コード的には以下のようなイメージです(実際の変更は +6/-3 行程度):

ruby
# 変更前 (イメージ)
def apply_filter(name)
  filter_attributes.each do |attribute|
    Rails.application.config.filter_parameters << "#{name}.#{attribute}"
  end
end

# 変更後 (イメージ)
def apply_filter(name)
  filter_attributes.each do |attribute|
    next unless attribute.is_a?(String) || attribute.is_a?(Symbol)

    Rails.application.config.filter_parameters << "#{name}.#{attribute}"
  end
end

これにより、先ほどの例は以下のような結果になります:

ruby
class User < ApplicationRecord
  self.filter_attributes += [:token, /secret/i, ->(key, value) { value.reverse! }]
end

Rails.application.config.filter_parameters
# => [..., "user.token"]    # Regexp / Proc 由来のゴミ文字列は追加されない

テストの追加

railties/test/application/active_record_railtie_test.rb にテストが追加され、以下を確認しています:

  • モデルで filter_attributes にシンボル/文字列を設定した場合のみ、filter_parameters"<model>.<attribute>" が追加される
  • RegexpProc を含めても、それらが filter_parameters には同期されない

  1. 影響範囲・注意点

影響範囲

  • 影響を受けるのは、Active Record モデルで filter_attributes を使っていて、かつ Regexp / Proc を含めているアプリケーションです。
  • これらのアプリでは、以前は Rails.application.config.filter_parameters
    • user.(?i-mx:secret)
    • user.#&lt;Proc:...> などの「マッチしないゴミ文字列」が大量に増えていた可能性がありますが、このPRにより追加されなくなります。
  • 既存の動作面での変化は「本来意味のなかった無効なフィルタが追加されなくなる」だけであり、ログのマスキング挙動(実際に値が正しくフィルタされるかどうか)には悪影響はありません。

注意点 / 互換性

  • パラメータフィルタリング自体の挙動は変わりません:
    • グローバルな ActiveSupport::ParameterFilter 経由 (Rails.application.config.filter_parameters に直接 Regexp / Proc を設定している場合)
    • モデル単位の filter_attributes による #inspect 向けのフィルタ(Regexp / Proc の適用) これらは従来通り動作します。
  • 変わるのは「filter_attributes から filter_parameters へ同期する際に、キーマッチ可能な文字列に変換できないものを除外する」という点のみです。
  • 監査・診断用途で filter_parameters の「中身の変化」をテストしている場合、期待値から user.(?i-mx:secret)user.#&lt;Proc:...> などを取り除く必要があるかもしれません。ただし、これらに依存しているのはほぼテストだけだと考えられます。

  1. 参考情報 (あれば)
  • 本PRで扱っている ActiveRecord::FilterAttributeHandler の導入 PR: #55251
  • config.filter_parameters における同様の問題を直した PR: #56759
    • 説明にもある通り、このPRと #56759 を 8.1 系にバックポートすると、Proc / Regexp 経由の「ゴミエントリ」リークパスを全て塞げるとのことです。
  • 関連クラス:
    • ActiveSupport::ParameterFilter
      • Regexp / Proc を利用した高度なフィルタリングロジックをサポート
      • user.token」のようなドット付き文字列をネストしたキーに対するフィルタとして解釈する挙動と整合させるため、今回のような「シンボル / 文字列のみを同期する」仕様にするのが理にかなっています。

#57585 Fix reaper fork test by disabling GSS encryption.

マージ日: 2026/6/5 | 作成者: @ruyrocha

  1. 概要 (1-2文で)
    macOSで ReaperTest#test_connection_pool_starts_reaper_in_fork 実行時に segfault が発生していた問題を、fork 後の子プロセスで PostgreSQL の GSS 暗号化ネゴシエーションを無効化することで回避する修正です。libpq と macOS の Heimdal/Keychain スタックの既知の非 fork-safe 問題へのワークアラウンド的対応になります。

  1. 変更内容の詳細

対象: activerecord/test/cases/reaper_test.rb の1行差分のみ。

やっていることは、fork された子プロセス内で PostgreSQL クライアント用の環境変数 PGGSSENCMODEdisable に設定してからコネクションプールを作るようにする、というものです。

PRの説明によると:

  • 問題の症状

    • テスト ReaperTest#test_connection_pool_starts_reaper_in_fork が macOS で segfault。
    • 再現条件:
      • 親プロセスが fork() する。
      • 子プロセス側で新たに PostgreSQL コネクションを確立しようとする。
      • その際、libpq が GSS 暗号化ネゴシエーションを試みる。
      • GSS 経由で macOS の Heimdal/Keychain スタックを叩くが、これが fork 後の子プロセスで非 fork-safe であるためクラッシュ。
  • 対応内容

    • 子プロセス側で PGGSSENCMODE=disable をセットし、GSS 暗号化ネゴシエーションのコードパス自体を通らないようにする
    • これにより、Heimdal/Keychain 周りの非 fork-safe な部分に到達しなくなり、segfault を回避。

テストコードのイメージは以下のような形になります(実際のPRは1行置き換えレベルですが、趣旨として):

ruby
fork do
  # GSS 暗号化を無効化
  ENV["PGGSSENCMODE"] = "disable"

  # ここで ActiveRecord::ConnectionPool を生成し、
  # PostgreSQL への新しい接続を張るテストを実行
  pool = ActiveRecord::Base.connection_pool
  # reaper が正しく起動するかを検証 ... みたいな内容
end

元々のテストではこの環境変数設定がなかったため、macOS + libpq + GSS 有効な環境で fork 後に接続を張る際に GSS ネゴシエーションが走り、segfault に繋がっていました。

PR本文にもあるとおり、この問題は:

  • PostgreSQL 側: バグレポート bug #16041
  • ruby-pg 側: issue #538

として既知であり、恒久的な根本解決はまだないため、Rails テスト内でのワークアラウンドという位置付けです。


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

    • 変更対象は テストコードのみ (reaper_test.rb) であり、Rails 本体の実行時挙動やアプリケーションコードには影響しません。
    • CIやローカル開発環境で、macOS 上で PostgreSQL を用いた ActiveRecord のテストを走らせる際の安定性が向上します。
    • 特に fork を伴うテスト(connection reaper が fork 後も正しく動くかを確認するテスト)に限定された変更です。
  • 注意点

    • このPRは Rails テストの中で PGGSSENCMODE を変更しているだけであり、実アプリケーションでの GSS 暗号化を無効化するわけではありません
    • ただし、テスト実行プロセス内で ENV["PGGSSENCMODE"] を上書きしているため、
      • 「テスト中に同じプロセスで他の PostgreSQL 接続を張るコード」があり、かつ
      • それが GSS 暗号化を前提にしている
        場合には、環境変数の状態が影響する可能性があります(Rails の標準テストスイートでは基本的に問題にならない想定)。
    • 本件は macOS 特有の Heimdal/Keychain の非 fork-safe 問題に起因しているため、Linux 等では同じ問題は基本的に発生しませんが、テストコード自体はクロスプラットフォームで動く形になっています。

  1. 参考情報 (あれば)
  • PostgreSQL バグ報告:
    • PostgreSQL bug #16041(libpq + GSS + fork によるクラッシュの報告)
  • ruby-pg の関連 issue:
    • ruby-pg #538(同様の GSS / fork 問題に関する議論)
  • PGGSSENCMODE について:
    • PostgreSQL クライアント(libpq)の環境変数で、GSSAPI による暗号化ネゴシエーションの挙動を制御する。
      • disable にすると GSS 暗号化ネゴシエーションを行わず、今回のような GSS 経由のクラッシュを避けられる。

#57587 Replace deprecated US/Eastern timezone with America/New_York in tests

マージ日: 2026/6/5 | 作成者: @ruyrocha

  1. 概要 (1-2文で)
    US/Eastern が PostgreSQL で無効なタイムゾーン名になった問題に対応し、テストコード内のタイムゾーン指定を正規の America/New_York に置き換える PR です。Rails 本体の挙動変更ではなく、Active Record のテスト環境が最新 tzdata / PostgreSQL でも壊れないようにするための修正です。

  1. 変更内容の詳細

背景

  • IANA tzdata の更新により、US/Eastern などの古いエイリアス的タイムゾーン名が、PostgreSQL の timezone 設定値としては無効になりました。
  • その結果、最新 tzdata でビルドされた PostgreSQL(例: Homebrew 版)を使うと、Rails のテスト実行時に ActiveRecord::ConnectionNotEstablished が発生するケースがありました。
  • America/New_York は IANA における正準(canonical)なタイムゾーン名であり、PostgreSQL でも広くサポートされています。

実際の変更箇所

変更は 2 ファイルのみで、単純な文字列置き換えです。

1. postgresql_adapter_test.rbKNOWN_SERVER_DEFAULTS

ActiveRecord PostgreSQL アダプタのテスト内で、接続オプションの既知のデフォルト値 (KNOWN_SERVER_DEFAULTS) を定義している箇所で、タイムゾーン指定を変更しました。

(イメージ):

ruby
# 変更前
KNOWN_SERVER_DEFAULTS = {
  # ...
  timezone: "US/Eastern",
  # ...
}

# 変更後
KNOWN_SERVER_DEFAULTS = {
  # ...
  timezone: "America/New_York",
  # ...
}

この定数を使って、「サーバー側のデフォルト設定をこう想定している」という前提で各種挙動をテストしています。ここが無効なタイムゾーンだと、接続確立時点でエラーになりテストが成立しなくなります。

2. test_case.rbwith_env_tz デフォルト

Rails のテストヘルパである with_env_tz メソッドのデフォルトタイムゾーンも US/Eastern から America/New_York に変更されています。

(イメージ):

ruby
# 変更前
def with_env_tz(tz = "US/Eastern", &block)
  # ...
end

# 変更後
def with_env_tz(tz = "America/New_York", &block)
  # ...
end

このヘルパは、テストの間だけ ENV["TZ"] を一時的に変更してタイムゾーン依存の挙動を検証するためのものです。デフォルトで古い US/Eastern を使っていたため、環境によってはテストが失敗していました。


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

    • Rails フレームワーク利用者の本番アプリケーションの挙動には影響しません(あくまでテストコードのみの変更)。
    • Active Record の PostgreSQL 関連テスト、および with_env_tz を利用しているテストコードに影響します。
    • tzdata / PostgreSQL が新しくても、Rails のテストスイートをエラーなく実行できるようになります。
  • 注意点 / 参考となる含意

    • アプリケーション側で US/Eastern を PostgreSQL の timezone 設定やアプリ内のタイムゾーン名として使っている場合、今後の tzdata / PostgreSQL 更新で同様の問題が起きる可能性があります。
      • 可能であれば America/New_York への移行を検討するとよいです。
    • 他の US/* 系タイムゾーン(例: US/Pacific など)も同様に非推奨・削除の対象になり得るため、IANA の canonical 名(America/Los_Angeles など)を使うのが安全です。

  1. 参考情報 (あれば)
  • 対応 Issue: #57586
    US/Eastern が PostgreSQL のタイムゾーンとして無効になり、ActiveRecord::ConnectionNotEstablished が発生する」問題の修正 PR。
  • IANA タイムゾーンデータベース:
    • US/EasternAmerica/New_York のエイリアス扱いだが、PostgreSQL ビルド時にエイリアスが含まれない/削除される構成が増えている。
  • PostgreSQL ドキュメント(Time Zones):
    • TimeZone 設定には、IANA の正準名を使うことが推奨されている。

#56899 Add sql_notifications connection config option to disable SQL notifications

マージ日: 2026/6/5 | 作成者: @rosa

  1. 概要 (1-2文で)
    Active Record の DB 接続ごとに SQL 通知(ActiveSupport::Notifications による instrumentation)を無効化できる sql_notifications 接続オプションが追加されました。これに伴い、通知を一切発行しない ActiveSupport::Notifications::NullInstrumenter が導入され、非同期クエリ時のイベントバッファリングも無効化できます。

  1. 変更内容の詳細

2-1. 新しい接続オプション sql_notifications

database.yml などの接続設定で、接続単位で SQL 通知をオフにできます。

yml
production:
  primary:
    adapter: postgresql
    database: myapp_production

  cache:
    adapter: postgresql
    database: myapp_cache
    sql_notifications: false
  • sql_notifications: false を設定した接続では、
    • sql.active_record などの Active Record の SQL 関連通知が発行されません。
    • それに伴うオーバーヘッド(オブジェクト生成、subscriber 呼び出しなど)が削減されます。
  • デフォルトは従来通り「有効(true 相当)」です。設定を追加しなければ今までと同じ挙動です。

このオプションは、connects_to などで別 DB を扱うケース(例: Solid Cache, Solid Queue 用の専用 DB)を想定しており、「アプリ本体はメトリクスを取りたいけれど、キャッシュ用 DB では不要」というような分離が簡単になります。


2-2. ActiveSupport::Notifications::NullInstrumenter の追加

ActiveSupport::Notifications に、何も通知を発行しないインストゥルメンターが追加されました。

特性:

  • no-op(ブロック実行だけして通知を publish しない)
  • stateless(状態をもたず、使い回しができる)
  • thread-safe(スレッドローカルなレジストリ不要)

役割:

  • DB アダプタが sql_notifications: false の接続を扱うときに、この NullInstrumenter を使用します。
  • これにより、従来一部の gem が行っていたような「Active Record の内部実装に踏み込んで instrumenter をすげ替える」といったハックが不要になります。

イメージとしては、通常:

ruby
ActiveSupport::Notifications.instrument("sql.active_record", payload) do
  # SQL 実行
end

が、sql_notifications: false の接続では「イベント発行部分が完全に無視される」ような形です。


2-3. 非同期クエリ時の変更(イベントバッファのスキップ)

Active Record の async query(load_async など)では、並行実行されるクエリに対してイベントをバッファし、後でまとめて通知する仕組みがあります。

この PR では:

  • sql_notifications: false の場合、
    • このイベントバッファリング処理自体をスキップします。
    • 「通知を出さない」のに「通知のためのバッファ」を持つのは無駄なので、関連処理を丸ごと省く、という設計です。
  • これにより、非同期クエリでも instrumentation 関連コストが最小限になります。

2-4. コード側での利用イメージ(Rails アプリ視点)

基本的には設定ファイルに書くだけなので、アプリケーションコードの変更は不要です。例:

ruby
# config/database.yml
production:
  primary:
    adapter: postgresql
    database: myapp_production

  cache:
    adapter: postgresql
    database: myapp_cache
    sql_notifications: false
ruby
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  connects_to database: { writing: :primary, cache: :cache }
end

この状態で cache 接続を使うモデルのクエリは、sql.active_record などの通知を発行しません。


  1. 影響範囲・注意点
  • 既存アプリへの互換性

    • sql_notifications を指定しなければ、挙動は従来と同じです(後方互換あり)。
    • 既存のメトリクスやログ収集は影響を受けません。
  • 通知に依存している仕組みへの影響

    • sql_notifications: false にした接続では:
      • APM(New Relic, Datadog など)や独自の subscriber が SQL クエリを観測できなくなります。
      • SQL ベースのログ・トレース・メトリクスが失われる点に注意が必要です。
    • 「一部の接続だけメトリクスを止める」ケース(キャッシュ用 DB など)に向いており、本番トラブル調査が必要な主要 DB では通常有効のままにすべきです。
  • gem / ライブラリ側での採用

    • Solid Cache / Solid Queue のように別接続を使う gem は、
      • これまでのように Active Record の内部実装に依存して instrumenter を差し替える必要がなくなります。
      • sql_notifications: false を接続設定に追加するだけで、SQL 通知由来のオーバーヘッド削減が可能になります。
    • もし独自に disable_instrumentation などでごにょごにょしている gem があれば、将来的にこのオプションへ移行することが推奨されます。
  • パフォーマンス面

    • SQL 通知を完全に切るため、イベント生成・subscriber 呼び出し・非同期バッファリングなどのコストがなくなります。
    • 特に、クエリ数が非常に多いキャッシュ用 DB などでは、わずかながらパフォーマンス改善や GC 負荷の低減が期待できます。

  1. 参考情報 (あれば)

#57570 Enable per-pool query log tags formatter

マージ日: 2026/6/4 | 作成者: @hmcguire-shopify

  1. 概要 (1-2文で)
    このPRは、database.yml の各コネクションプールごとに query_log_tags_format を設定できるようにし、グローバル設定 (config.active_record.query_log_tags_format) をプール単位で上書き可能にします。これにより、同一アプリ内でデータベース接続ごとに :legacy / :sqlcommenter など異なるクエリログコメント形式を使い分けられます。

  1. 変更内容の詳細

2-1. 機能追加の概要

  • database.yml の各エントリで query_log_tags_format キーが指定できるようになった。
  • その値は ActiveRecord::Base.configurations を通じて読み込まれ、個々のコネクションプールごとに保持される。
  • 実際にクエリログのタグコメントを生成するとき、
    1. まず接続プール固有の query_log_tags_format を参照
    2. 指定がなければグローバル設定 config.active_record.query_log_tags_format を使用
      という優先順位になるように変更されている。

2-2. database.yml での設定例

説明文にある通り、例えば本番環境で primary DB ではデフォルト(例::legacy)、analytics DB では :sqlcommenter を使いたい場合:

yaml
production:
  primary:
    database: primary
    # query_log_tags_format 未指定 → グローバル設定を使用
  analytics:
    database: analytics
    query_log_tags_format: sqlcommenter

config/application.rb などで:

ruby
module MyApp
  class Application < Rails::Application
    config.active_record.query_log_tags_format = :legacy
  end
end

と設定しておけば、primary プールは :legacyanalytics プールは :sqlcommenter という形式でクエリコメントが付与されます。

2-3. 実装レベルの変更点

※ 具体的なコード断片はPR本文にありませんが、変更ファイルから推測される点です。

  • activerecord/lib/active_record/database_configurations/hash_config.rb

    • HashConfigquery_log_tags_format を読み込み・保持するロジックが追加。
    • database.ymlquery_log_tags_format キーをそのまま or シンボル化して扱うような実装が入っていると考えられます。
  • activerecord/lib/active_record/query_logs.rb

    • 既存の「クエリログタグのフォーマットを選択し、SQLコメントを生成する処理」が、
      “接続(プール)ごとの設定を見てからグローバル設定を参照する” 構造に変更。
    • フォーマッタ自体(:legacy:sqlcommenter の実装)は既存機能ですが、その選択ロジックが per‑pool 対応になっています。
    • 追加行数が多いことから、フォーマッタ選択のヘルパーメソッド追加や、テスト/内部APIから呼び出しやすいインターフェース整備もされている可能性が高いです。
  • activerecord/test/cases/query_logs_test.rb

    • 各種パターンのテストが追加:
      • グローバル設定のみ指定した場合の挙動。
      • プール側に query_log_tags_format を指定した場合の上書き挙動。
      • 片方のみ / 両方指定 / どちらも未指定などの組み合わせ検証。
      • :sqlcommenter / :legacy それぞれのコメント出力フォーマットが期待通りになることの確認。
  • activerecord/CHANGELOG.md

    • Active Record の変更点として、「per‑pool query_log_tags_format をサポートした」ことが明記されている。

  1. 影響範囲・注意点
  • 既存アプリへの互換性

    • database.ymlquery_log_tags_format を書いていない場合、動作は従来通り
      config.active_record.query_log_tags_format のみが使われるため、互換性は基本的に保たれます。
    • すでに config.active_record.query_log_tags_format を使っているアプリは、そのままでも挙動は不変です。
  • per‑pool 設定を入れた場合の優先度

    • ある DB プールに query_log_tags_format を設定した瞬間、そのプールに限ってはグローバル設定を無視して、その値が優先されます。
    • 全プールで同じ挙動を維持したい場合は、database.yml 側で特定プールだけ別設定を入れていないか注意が必要です。
  • フォーマット種別とロギング・ツール連携

    • :sqlcommenter を使うと、SQLコメントが sqlcommenter 仕様 準拠の形式になるため、APMツールや可観測性基盤との連携でメリットがあります。
    • 一方、既存で :legacy を前提としたパーサや監視スクリプトを使っている場合、特定プールだけ :sqlcommenter に切り替えるとログ解析ロジックを分岐させる必要が出てきます。
  • 複数DB・マルチテナント環境での注意

    • primary / replica / analytics などDBごとに異なるログ要件がある場合に便利ですが、
      「どのプールにどのフォーマットを使っているか」をチーム内で明文化しておかないと、ログ形式が混在して運用が複雑になる可能性があります。
    • マルチDB構成を管理している場合は、運用設計書や README に query_log_tags_format の方針を追記しておくとよいです。

  1. 参考情報 (あれば)

#57572 Add test coverage for Enumerable key-helper edge cases

マージ日: 2026/6/4 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    Enumerable 拡張(pluck / pick / compact_blank)の「キーが存在しない場合」や Set に対する挙動など、これまでテストされていなかった端ケースに対してテストが追加された PR です。プロダクションコードの変更はなく、テストコード(enumerable_test.rb)のみが20行追加されています。

  1. 変更内容の詳細

この PR は active_support/core_ext/enumerable.rb に定義されている以下のヘルパーの 期待される振る舞いを明示するテスト を追加しています。

(1) pluck のエッジケース

対象: Enumerable#pluck(ActiveSupport 拡張)

ケースA: 単一キーで、一部の要素にキーが存在しない場合

テスト内容はだいたい次のような形が想定されます:

ruby
records = [
  { id: 1, name: "Alice" },
  { id: 2 }                      # name がない
]

assert_equal [ "Alice", nil ], records.pluck(:name)

ポイント:

  • 要素の中に指定キーが存在しないものが含まれていても、
    • 例外にはならず
    • 代わりに nil がその要素の位置に入る
      という仕様をテストで保証しています。

ケースB: 複数キーで、一部の要素にキーが存在しない場合

ruby
records = [
  { id: 1, name: "Alice" },
  { id: 2 }                      # name がない
]

assert_equal [[1, "Alice"], [2, nil]], records.pluck(:id, :name)

ポイント:

  • 返り値がタプル(配列)になる複数キーの pluck において、
    • 欠けているキーの位置に nil が入る
      ことを明示的にテストしています。

これにより、「キーが足りない要素を含むコレクションに対して pluck したときの挙動」がドキュメントどおりであることが保証されます。


(2) pick のエッジケース

対象: Enumerable#pick(ActiveSupport 拡張)

pick は「Enumerable から最初の要素を取り、その要素から指定キーを取り出す」ヘルパーです。

ケースC: 最初の要素にキーが存在しない(単一キー)

ruby
records = [
  { id: 1 },                     # name がない
  { id: 2, name: "Bob" }
]

assert_nil records.pick(:name)

ポイント:

  • pick(:name) は「最初の要素の name を返す」ため、
    • 最初の要素にキーがない場合は nil を返す
      という挙動がテストされます。

ケースD: 最初の要素にキーが存在しない(複数キー)

ruby
records = [
  { id: 1 },                     # name, age がない
  { id: 2, name: "Bob", age: 20 }
]

assert_equal [nil, nil], records.pick(:name, :age)

ポイント:

  • 複数キー指定時は配列を返すが、
    • 存在しないキーの位置には nil が入る
      という仕様をテストで固定化しています。
  • pick もあくまで「先頭要素」に対してのみキー抽出を行うことが再確認できます。

(3) compact_blankSet の挙動

対象: Enumerable#compact_blank

compact_blank は、blank? な要素を取り除く ActiveSupport のヘルパーです。

ケースE: Set に対して compact_blank を呼んだとき

ruby
set = Set[ "", "foo", nil ]
result = set.compact_blank

assert_kind_of Array, result
assert_equal ["foo"], result

ポイント:

  • Set に対して compact_blank を呼ぶと 戻り値は Array になる という仕様が、
    • ドキュメントに書かれているとおりであることをテストで保証しています。
  • つまり、compact_blank は常に元のクラスを保持するわけではなく、
    SetArray への変換が行われるケースがあることが明文化されました。

  1. 影響範囲・注意点
  • この PR は テストコードのみの追加 であり、ランタイム(本体実装)には仕様変更・バグ修正はありません。
  • ただし、テストが追加されたことで:
    • pluck が「キー欠如を例外ではなく nil として扱う」
    • pick が「先頭要素に対してのみキーを取り、欠如は nil にする」
    • Set#compact_blank が「Array を返す」
      という挙動が公式に固定化された、と解釈できます。
  • 既存コードで、
    • pluck / pick の結果に nil が含まれない前提で処理していたり
    • Set#compact_blank の戻り値が Set になると想定しているロジック
      がある場合は、改めて仕様を確認しておく価値があります(挙動自体は以前からそうだったはずですが、テストで明示されたことで将来的にも変わりにくくなります)。

  1. 参考情報 (あれば)
  • 対象ファイル: activesupport/test/core_ext/enumerable_test.rb (+20/-0)
  • 関連メソッドのドキュメント(Rails Guides / API Docs):
    • ActiveSupport::CoreExtensions::Enumerable::pluck
    • ActiveSupport::CoreExtensions::Enumerable::pick
    • ActiveSupport::CoreExtensions::Enumerable::compact_blank

この PR は「仕様の暗黙部分をテストで明示した」性質が強く、将来のリファクタリングや最適化の際に、これらの細かい挙動がうっかり変わってしまうことを防ぐ目的があります。


#57584 Clear Postgres warnings as they get handled

マージ日: 2026/6/4 | 作成者: @matthewd

  1. 概要 (1-2文で)
    PostgreSQL アダプタで、SQL 実行時に発生した warning の扱いを整理し、「処理済みの warning を都度クリアする」ようにした PR です。これにより、select_all など複数クエリの warning が蓄積して次のクエリで再度まとめて報告されてしまう挙動が修正され、失敗したクエリ後の warning も正しく処理されます。

  1. 変更内容の詳細

全体像

これまで warning のクリアは素の execute にしか入っておらず、他のメソッド (select_all など) が内部で warning を収集した場合、その warning が「報告はされるがリストは残り続ける」状態でした。そのため:

  • 複数のクエリを順に実行
  • 各クエリで warning が出る
  • 各クエリ後の warning ログには「累積分」が出る
  • さらに、次のクエリでも「過去の warning」が再度表示される

という、warning が二重三重に報告される状態になっていました。

この PR では以下を行っています:

  • warning のクリアを PostgreSQL 固有のコードから抽象層 (abstract/database_statements.rb) に寄せる
  • 成功したクエリだけでなく、失敗したクエリ後も warning を処理・クリアする
  • 上記挙動を保証するテストを追加する

abstract/database_statements.rb の変更

ActiveRecord::ConnectionAdapters::DatabaseStatements (抽象層) に、PostgreSQL 向け warning 処理の共通パターンを吸収する仕組みが追加されています。具体的には、以下のような形が入っている可能性が高いです(実際のコードは多少異なる場合がありますが、概念的にはこのようなイメージです):

ruby
def execute(sql, name = nil)
  materialize_transactions
  log(sql, name) do
    with_warning_handling do
      @connection.async_exec(sql)
    end
  end
end

private

def with_warning_handling
  result = yield
  handle_warnings # => ここで warning を処理
  result
rescue => e
  handle_warnings # 失敗したクエリでも warning を処理
  raise
end

ポイント:

  • with_warning_handling のようなヘルパ (実名は PR 内で定義) を導入し、そこから handle_warnings を呼ぶことで、
    • 成功時
    • 例外発生時 (失敗時) どちらの経路でも warning が処理されるようになっています。
  • 「warning の処理」と「warning のクリア」をセットにしており、「一度報告した warning を再度報告しない」ことを保証しています。

postgresql/database_statements.rb の変更

PostgreSQL アダプタ側のファイルでは、もともと散在していた warning 処理/クリア処理が削除・整理されています。

  • 行削除 (-16) が多いことから、
    • 各種メソッド (execute, select_all, exec_query など) に個別に書かれていた warning クリア処理を取り除き
    • 代わりに抽象層の共通処理に任せる というリファクタリングが行われています。
  • PostgreSQL 固有の handle_warning / clear_warnings のようなメソッドは引き続き存在するが、その呼び出しタイミングが抽象層に委譲されている構成が想定されます。

これにより、PostgreSQL アダプタのコードは「どう warning を処理するか」のみに集中し、「いつそれを呼ぶか」は抽象層に任せる形になります。

テスト (postgresql_adapter_test.rb) の追加

+54 行のテストが追加されており、主に次のような振る舞いを検証しています (概念的な例):

  1. 複数クエリで warning が累積しないこと

    ruby
    def test_warnings_are_cleared_after_each_query
      conn = ActiveRecord::Base.connection
    
      # 1つ目のクエリで warning を出す
      conn.execute("SELECT 1/0") rescue nil
      warnings_after_first = capture_warnings(conn)
    
      # 2つ目のクエリで別の warning を出す
      conn.execute("SELECT 2/0") rescue nil
      warnings_after_second = capture_warnings(conn)
    
      assert_equal 1, warnings_after_first.size
      assert_equal 1, warnings_after_second.size
      # => 「2つめの warnings に 1つめの分が混じっていない」ことを確認
    end
  2. 失敗したクエリ後でも warning が適切に処理・クリアされること

    ruby
    def test_warnings_are_cleared_even_after_failed_query
      conn = ActiveRecord::Base.connection
    
      # わざと失敗するクエリを実行 (syntax error など)
      assert_raises(ActiveRecord::StatementInvalid) do
        conn.execute("THIS WILL FAIL")
      end
    
      # 次のクエリで、さきほどの失敗クエリの warning が出てこないこと
      warnings = capture_warnings(conn) { conn.execute("SELECT 1") }
      assert_empty warnings
    end

実際には capture_warnings のようなヘルパを使うか、ログを読んで検証しているはずですが、意図としては上記のように「warning はクエリごとに完結する」をテストしています。


  1. 影響範囲・注意点
  • ログ・モニタリングへの影響

    • これまで PostgreSQL の warning をアプリ側でフックしてモニタリングしていた場合、
      • 1回の warning が複数回レポートされていた
      • あるいは複数クエリ分がまとめてレポートされていた ような挙動が、クエリ単位での正確なレポートに変わる可能性があります。
    • その結果、warning 件数が「減ったように見える」ことがありますが、これは重複報告がなくなっただけです。
  • アプリケーションコードへの互換性

    • 通常の Rails アプリ (ActiveRecord 経由で DB を叩いているだけ) であれば、コード修正は不要です。
    • PostgreSQL アダプタの内部 API (raw_connection の warning バッファなど) を直接参照して独自処理している場合、
      • 「warning が溜まり続ける」前提のコードは挙動が変わる
      • クエリ単位でバッファがクリアされる想定に合わせる必要がある 可能性があります。
  • エラーハンドリングの一貫性向上

    • 失敗したクエリも含めて warning が処理されるため、「エラー発生時には warning が漏れる / 溜まり続ける」といった不整合が解消されます。
    • 一方で、「例外発生後にまとめて warning を見たい」というようなユースケースがあれば、そのロジックは再検討が必要です (Rails 側が逐次クリアするようになったため)。

  1. 参考情報 (あれば)
  • この PR に関連しそうなコード:
    • ActiveRecord::ConnectionAdapters::DatabaseStatements
    • ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
  • PostgreSQL の warning の具体例:
    • 型変換の暗黙的キャスト
    • ゼロ除算
    • obsolete な構文の使用 など
      これらが「クエリ単位」で正しくログ・ハンドリングされるようになったと理解するとよいです。
  • Rails 内部的には、「アダプタ固有の実装 (PostgreSQL)」と「抽象層 (DatabaseStatements)」の責務分離が進んでおり、今後他 DB アダプタにも同様の warning ハンドリングが拡張される布石とも考えられます。

#52278 [ci skip] Improve readability of select form options helper gotcha documentation

マージ日: 2026/6/4 | 作成者: @markzalar

  1. 概要 (1-2文で)
    Rails の select ヘルパ(form_options_helper)に関する「gotcha(ハマりどころ)」のドキュメントが、読みやすくなるようリライトされた PR です。挙動や API の変更はなく、内容はドキュメントコメントのみの改善です。

  1. 変更内容の詳細

※この PR は actionview/lib/action_view/helpers/form_options_helper.rb のドキュメントコメントだけを変更しており、Ruby コードのロジックは変わっていません。

主なポイントは次のようなものと考えられます(ファイル名と行数増減からの推測も含みます):

  • select / options_for_select などのヘルパに関する「gotcha」セクションの文章を、より分かりやすく整理
    • 長くて読みづらい説明を分割したり、文脈を補足して理解しやすくした
    • 実際の使い方をイメージしやすいように、コード例やコメントを改善
  • よくある勘違いやハマりポイントの説明を明瞭化
    • 例:
      • options_for_select の第2引数で選択状態を指定する方法
      • prompt / include_blankselected オプションの組み合わせで起きる挙動
      • selectnil / 空文字がどのように扱われるか
      • 値とラベルの順序([[label, value], ...][ [value, label], ... ] か)に関する注意
    • 「なぜそうなるのか」「どのように書けば意図した挙動になるのか」をはっきり説明するようにリライトされたと考えられます。
  • コメントのスタイル統一・英語としての読みやすさ向上
    • 不自然な表現や曖昧な言い回しの修正
    • 段落構成や見出しの整理により、「gotcha」がどこからどこまでなのか、何に注意すべきなのかが把握しやすくなっているはずです。

変更は約 +20 / -9 行で、完全にコメント(ドキュメント)レベルの修正にとどまっています。


  1. 影響範囲・注意点
  • 実行時挙動の変更なし
    • selectoptions_for_select を利用しているアプリケーションのコードが変わる必要はありません。
    • 既存のテストや挙動に影響はありません。
  • 影響するのは「Rails のソースコードを読んで form_options_helper を理解する開発者」や、「公式ドキュメント生成の元」となるコメント部分のみです。
  • ドキュメントが改善されたことで、select ヘルパに関する過去の微妙なハマりどころを新規/中級ユーザーが理解しやすくなり、誤用やバグの予防に役立ちます。

  1. 参考情報 (あれば)
  • 関連ファイル:
    • actionview/lib/action_view/helpers/form_options_helper.rb
      • select, options_for_select, option_groups_from_collection_for_select など、フォームのセレクトボックス生成関連ヘルパが定義されているファイル
  • Rails ガイド(英語・参考):

#57582 Action Cable: only skip eager loading for Redis and Postgres

マージ日: 2026/6/4 | 作成者: @byroot

  1. 概要 (1-2文で)
    Action Cable が「起動時の eager load をスキップする」対象を、Redis と PostgreSQL のサブスクリプションアダプタに限定するように調整した PR です。前 PR (#57249) の挙動をより適切に絞り込んだ小さなリファインメントです。

  1. 変更内容の詳細

何をしたか

  • ActionCable の初期化処理まわりで、eager loading をスキップするアダプタを Redis / Postgres のみに限定しました。
  • Redis アダプタ側ファイルでの「eager loading 抑制」に関する処理が簡略化・削除され、責務が action_cable.rb 側に集約されています。

コミット差分情報から読み取れるポイント:

diff
# actioncable/lib/action_cable.rb
+ # ここで、Redis / Postgres アダプタを使う場合のみ
+ # eager load をスキップするような条件分岐が追加されたと考えられる
diff
# actioncable/lib/action_cable/subscription_adapter/redis.rb
- # Redis アダプタ側で行っていた eager load 抑制ロジックを削除
- # (もしくはそれに準ずる初期化まわりの特別扱いを削除)
+ # 共通ロジックに寄せるため、アダプタ固有の分岐が減らされている

実際のコードは省略されていますが、PR 説明文から:

Action Cable: only skip eager loading for Redis and Postgres
The other adapters are small enough that it's not really worth skipping them.

とあるため、以下のようなイメージの挙動になっています:

  • 環境設定で
    • adapter: "redis" または adapter: "postgres" の場合:
      • Rails 起動時の eager loading 対象から Action Cable まわり(あるいは特定の部分)を外している
    • それ以外のアダプタ (例: async, inline, test, etc.) の場合:
      • 通常どおり eager loading を行う

前の PR #57249 では、より広い範囲で eager loading をスキップしていた可能性があり、それを「重いアダプタ(Redis/Postgres)だけに限定」した、というのが今回の趣旨です。


  1. 影響範囲・注意点

影響範囲

  • 対象:
    • Action Cable を利用していて、
    • サブスクリプションアダプタに Redis または Postgres を指定しているアプリケーション。
  • 他のアダプタ(async, test, あるいは独自実装の軽量アダプタなど)では、従来どおり eager loading が行われるようになります。

実質的な影響

  • 起動時間・メモリ使用量:
    • Redis/Postgres アダプタでは、前 PR で導入された「eager load をスキップして起動負荷を下げる」挙動が維持されます。
    • それ以外のアダプタでは、無条件にスキップするのではなく通常どおり eager load されるため、「軽いアダプタを使っているのに Action Cable だけ妙に lazy load される」といった違和感が減ります。
  • 予期せぬ遅延初期化の回避:
    • 軽量アダプタは eager load をスキップするメリットが小さいため、起動後の最初の接続時に「初めて読み込まれる」ことによるレイテンシが抑えられます。
    • 特にテスト環境や単純な非本番構成で、Action Cable の動作確認時に「最初だけ遅い」現象が減る可能性があります。

注意点

  • Redis/Postgres を利用している場合:
    • eager loading のスキップはそのまま有効なので、「起動直後の最初の Action Cable 利用時に少しだけ遅く感じる」可能性は残りますが、その分起動時の負荷軽減が期待できます。
    • eager loading に依存した初期化(例: Railtie / Engine の eager_load! フックに強く依存するようなカスタムコード)を書いている場合は、Action Cable 関連の挙動に違いがないか確認しておくと安全です。
  • 他アダプタを使っていて「前 PR の挙動を前提にしていた」場合:
    • 今回の変更で eager loading が再び有効になるため、その前提が崩れていないか注意してください(ただし、そのようなケースはかなりレアだと思われます)。

  1. 参考情報 (あれば)

#57249 Action Cable: explicitly require sibling deps in Redis adapter

マージ日: 2026/6/4 | 作成者: @gotrevor-notarize

  1. 概要 (1-2文で)
    Action Cable の Redis サブスクリプションアダプタが、自身と同じディレクトリ内の BaseChannelPrefix を明示的に require するようにし、Zeitwerk の lazy autoload との競合で起きるスレッドセーフティ問題(NameError)を防ぐ修正です。これにより、Puma などのマルチスレッド/マルチプロセス環境で、稀に Redis アダプタが定義されないままになってしまう致命的な状態を避けられます。

  1. 変更内容の詳細

背景となる問題

actioncable/lib/action_cable/subscription_adapter/redis.rb の先頭付近には、以下のようなクラス定義があります:

ruby
class Redis < Base
  prepend ChannelPrefix

このとき BaseChannelPrefix は、同じ subscription_adapter/ ディレクトリ内の別ファイルに定義されたクラス/モジュールです。

一方で、Action Cable は action_cable.rb 内で次のように subscription_adapter/ ディレクトリを eager_load 対象から除外しています(loader.do_not_eager_load(...))。そのため、BaseChannelPrefix は Zeitwerk の「lazy autoload(最初に参照されたタイミングでロードする方式)」で解決されます。

この状態で「複数スレッドがほぼ同時に Redis アダプタを初めて参照する」と、以下のようなレースコンディションが発生し得ます:

  • あるスレッドが Base の autoload を開始
  • autoload 管理の途中で別スレッドが Redis < Base を評価しようとする
  • まだ Base が定義されていないタイミングに当たると NameError: uninitialized constant ActionCable::SubscriptionAdapter::Base が発生
  • さらに厄介なのは、一度この競合に負けたプロセスでは autoload ポインタが消費されてしまい、その後 Redis クラスが最後まで定義されないため、該当プロセスの寿命中ずっと NameError が起き続ける点

実際に、Rails 7.2.3.1 + Puma クラスタ構成の本番環境で、同一ホスト上の1プロセスがこの状態に入り、以後のブロードキャストがすべて失敗する事象が観測されています。

実際の修正内容

この PR では、Redis アダプタファイルの冒頭に、兄弟クラス/モジュールを明示的に require するコードが追加されています:

ruby
# 追加された行
require "action_cable/subscription_adapter/base"
require "action_cable/subscription_adapter/channel_prefix"

redis.rb にはもともと次のように外部依存 (redis gem や ActiveSupport の拡張) を明示的に require しているスタイルがあり、その方針に揃えた形になっています:

ruby
require "redis"
require "active_support/core_ext/hash/except"
# ... (今回ここに Base/ChannelPrefix の require が追加された)

これにより:

  • Redis < Base が評価される前に、Base が確実に定義されている
  • prepend ChannelPrefix が評価される前に、ChannelPrefix が確実に定義されている

という状態が保証され、Zeitwerk の autoload 管理ウィンドウに依存しない、より堅牢な読み込み順序になります。

PR 作成者は、他にも同様のパターンで兄弟クラスに依存しているアダプタ:

  • subscription_adapter/test.rb
  • subscription_adapter/async.rb
  • subscription_adapter/inline.rb
  • subscription_adapter/postgresql.rb

にも同様の修正を広げる余地があると述べていますが、この PR では実際に不具合が再現・観測された redis.rb のみに絞っています。

また、レースコンディションが本質的に非決定的であるため、自動テストで再現性の高いテストを書くには:

  • $LOADED_FEATURES を操作したり
  • Module#remove_const で定数を消したり
  • あるいは別プロセス/subshell を立ち上げる

といった、テストプロセスを汚しがちな手法が必要になることから、本 PR ではテスト追加は見送り、「方針があれば対応する」としています。


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

    • 直接の変更対象は actioncable/lib/action_cable/subscription_adapter/redis.rb のみ。
    • Action Cable で Redis サブスクリプションアダプタを使用しているアプリケーション(特に Puma クラスタやスレッド数多めの構成)で、稀に起きる NameError 常態化問題を解消する効果があります。
    • 機能追加ではなく「読み込み順序の明示化」に近い修正のため、正常系の挙動は変わりません。
  • 後方互換性

    • すでに autoload によって解決されていたクラス/モジュールを require で明示的にロードするだけなので、後方互換性への影響はほぼありません。
    • 既存アプリ側で BaseChannelPrefix を上書きしているような特殊なメタプログラミングをしていない限り、問題になるケースは考えにくいです。
  • 注意点

    • 同様の問題を抱えうる他のアダプタ (test, async, inline, postgresql) にはまだパッチが当たっていないため、Redis 以外のアダプタを使っていても、理屈上は同種のレースが起こり得ます。
    • 自前でカスタム subscription adapter を作っている場合も、「クラス定義時に兄弟クラス/モジュールへ依存する」箇所は明示的に require を入れる方が安全です。

  1. 参考情報 (あれば)
  • PR 本文で挙げられている関連 Issue / PR:

    • rails/rails#50802 — Zeitwerk のデッドロック関連
    • fxn/zeitwerk#52 — autoload のスレッドセーフティに関する議論
    • fxn/zeitwerk#198 — Sidekiq + Bootsnap 環境での autoload race 問題
  • Action Cable 側の方針:

    • actioncable/lib/action_cable.rbdo_not_eager_load 行には「Adapters are required and loaded on demand」とコメントされており、「アダプタファイル自身が依存を require する」設計意図であることが明示されています。
    • 今回の修正はその設計意図に沿った形で、Redis アダプタをその方針に揃えたものと言えます。

#57576 Resolve attribute aliases in the update_attribute readonly check

マージ日: 2026/6/4 | 作成者: @55728

  1. 概要 (1-2文で)
    update_attribute / update_attribute! が readonly 属性をチェックする際に「属性エイリアス」を解決していなかった問題を修正し、update_columns と同じくエイリアス経由でも readonly 制約が正しく効くようにした PR です。これにより、readonly なカラムのエイリアスに対して update_attribute で更新しようとした場合も、期待どおり例外が発生します。

  1. 変更内容の詳細

問題の背景

update_attribute / update_attribute! はドキュメント上、readonly な属性を更新しようとすると ActiveRecord::ActiveRecordError を投げることになっていますが、実装では「属性エイリアス」が考慮されていませんでした。

元の実装(抜粋)は以下のようになっています。

ruby
def update_attribute(name, value)
  name = name.to_s
  verify_readonly_attribute(name)   # <- alias が解決されていない
  public_send("#{name}=", value)
  save(validate: false)
end

verify_readonly_attribute(name)readonly_attribute?(name)_attr_readonly.include?(name) という流れで readonly 判定を行いますが、_attr_readonly には「カラムの正規(canonical)名」だけが入っており、エイリアス名は入っていません。そのため、エイリアスを渡すと readonly チェックをすり抜けてしまっていました。

一方で update_columns はすでにエイリアスを解決してから readonly チェックをしており、こちらは正しい挙動になっています。

ruby
name = self.class.attribute_aliases[name] || name
verify_readonly_attribute(name)

実際に起きていた不整合

config.active_record.raise_on_assign_to_attr_readonly = false(デフォルト。config.load_defaults 7.1 以降は true) の場合に顕在化します。

ruby
class Account < ActiveRecord::Base
  alias_attribute :available_credit, :credit_limit
  attr_readonly :credit_limit
end

a = Account.create!(credit_limit: 5)

a.update_attribute(:credit_limit, 77)       # => ActiveRecord::ActiveRecordError (期待どおり)
a.update_columns(available_credit: 99)      # => ActiveRecord::ActiveRecordError (こちらも正しい)

a.update_attribute(:available_credit, 88)   # => true   (例外が出ない)
a.credit_limit                              # => 88     (メモリ上では変更される)
Account.find(a.id).credit_limit             # => 5      (DB には永続化されず、書き込みがサイレントに失われる)
  • canonical 名(credit_limit)の場合: update_attribute が先に verify_readonly_attribute で弾くため、常に例外。
  • エイリアス名(available_credit)の場合:
    • verify_readonly_attribute がエイリアスを認識できず通過。
    • raise_on_assign_to_attr_readonly = false なので、writer 側でも例外は出ない。
    • 結果、メモリ上の値だけ変わり、save(validate: false) では UPDATE から落ちるため DB は変わらない。
    • それにもかかわらず update_attributetrue を返す。

これは「canonical 名なら必ず例外」「エイリアス名なら成功したように見えるが実は保存されない」という一貫性のない挙動でした。

修正内容

update_attribute / update_attribute! において、readonly チェックの前に属性エイリアスを解決するように変更しています。update_columns と同じやり方です。

ruby
def update_attribute(name, value)
  name = name.to_s
  name = self.class.attribute_aliases[name] || name  # ここを追加
  verify_readonly_attribute(name)
  public_send("#{name}=", value)
  save(validate: false)
end
  • attribute_aliases に該当があれば canonical 名に置き換え。
  • 該当がなければそのまま(既存の挙動と同じ)。
  • update_attribute! も同様の修正。

テスト

activerecord/test/cases/base_test.rb に以下のようなテストを追加(概略):

  • NonRaisingPostraise_on_assign_to_attr_readonly = false)に対して、
    • alias_attribute :headline, :title
    • attr_readonly :title
  • update_attribute(:title, ...) / update_attribute(:headline, ...)
  • update_attribute!(:title, ...) / update_attribute!(:headline, ...)

のすべてが ActiveRecord::ActiveRecordError を投げることを確認。

このテストは main ブランチでは alias に対してのみ失敗(例外が出ない)し、本 PR 適用後に通ることが確認されています。
sqlite3 / postgresql / mysql2 でグリーン。


  1. 影響範囲・注意点
  • 影響範囲:
    • readonly 属性(attr_readonly)を定義しているモデルで、
    • その属性に alias_attribute を定義し、
    • update_attribute / update_attribute! を使ってエイリアス名経由で更新しているコード に影響があります。
  • 挙動の変化:
    • これまで「エイリアス名のみ例外が出ず、DB に保存されない」というバグがありましたが、今後はエイリアス名でも ActiveRecord::ActiveRecordError が発生します。
    • canonical 名(元のカラム名)に対する挙動は変わりません。
    • update_columns はすでに同様の挙動で動いていたため、update_columns から見れば挙動は変わりません。
  • 設定との関係:
    • config.active_record.raise_on_assign_to_attr_readonly = true/false にかかわらず、
      • update_attribute / update_attribute! は「readonly 属性(canonical / alias を問わず)」に対しては例外を投げる、という一貫した挙動になります。
    • raise_on_assign_to_attr_readonly は通常の属性代入(record.attr = ...)側の挙動に影響しますが、update_attribute はその前にガードするため、今回の修正で一貫性が増しています。
  • 互換性上の注意:
    • もし既存コードが「エイリアス経由で readonly 属性を update_attribute し、例外が出ないこと」を前提にしていた場合、今回から例外が出るようになります。
    • ただし、本来は書き込みがサイレントにドロップされておりバグに近い状態だったため、この変更はバグフィックスとして扱うのが自然です。

  1. 参考情報 (あれば)
  • 対象 PR: https://github.com/rails/rails/pull/57576
  • 関連する以前の議論: https://github.com/rails/rails/pull/45060
    • こちらは「エイリアス自体に attr_readonly を指定した場合にどう扱うか」という設計論争でクローズされたもの。
    • 今回は「canonical 側が readonly のときに、すでに update_columns では alias も readonly とみなしている」という既存仕様との内部整合を取るだけの変更であり、新しい設計判断ではない、という位置づけです。

#56375 Make ActionCable unsubscribe idempotent

マージ日: 2026/6/4 | 作成者: @matthewbjones

  1. 概要 (1-2文で)
    ActionCable の「unsubscribe(購読解除)」を同じチャンネルに対して何度呼んでもエラーにならない“冪等(idempotent)”な挙動に変更した PR です。購読解除時だけは、存在しないサブスクリプションに対する unsubscribe をエラーではなく「何もしない成功」とみなしつつ、perform_action は従来通り見つからなければエラーを出すように区別しています。

  1. 変更内容の詳細

挙動の変更ポイント

従来の問題点:

  • クライアントが既に削除済みのサブスクリプションに対して unsubscribe すると RuntimeError が発生。
  • その例外は execute_command の rescue に捕まって rescue_with_handler に流れるため、rescue_from で Sentry などに通知している場合、実害のない race condition 由来のエラーが大量にレポートされてしまう。
  • 「unsubscribe は冪等であるべき」という意味論にも反していた。

この PR の方針:

  • サブスクリプション検索メソッド find の振る舞いを変更し、「見つからない場合は例外ではなく nil を返す」ようにした。
  • そのうえで、find の呼び出し元ごとに「見つからない場合の扱い」を変える:
    • remove(unsubscribe 相当): nil なら何もしないで return(冪等な no-op)
    • perform_action: 依然として「存在しないサブスクリプション」はクライアントのバグとみなし、明示的に例外を発生させる

Subscriptions#find の変更

元のコード(イメージ)では、find が存在しない ID に対して RuntimeError を上げていました。この PR では:

ruby
def find(id)
  subscriptions[id] # 見つからなければ nil を返す
end

のように、単純に nil を返す方向に変更され、その結果として呼び出し側の責務が変わっています。

remove(unsubscribe)の変更

remove はクライアントからの unsubscribe コマンドに対応します。ここで find の戻り値をチェックし、見つからない場合は「何もせずに終わる」ようになりました。

擬似コード例:

ruby
def remove(id)
  subscription = find(id)
  return unless subscription  # 存在しなければ no-op

  # 存在すれば従来通り unsubscribe 処理
  subscription.unsubscribe_from_channel
  subscriptions.delete(id)
end

これにより、以下のようなケースがすべて成功扱い(例外なし)になります:

  • サブスクリプションが既に解除済みなのに、同じ ID に対して再度 unsubscribe が届いた。
  • サーバ再起動などでサーバ側のサブスクリプション情報が消えているが、クライアントが古い ID で unsubscribe してきた。

perform_action の変更

perform_action はクライアントがサブスクリプションを通じてメッセージを送る際に使われます。ここはクライアントが「存在するはずのサブスクリプションに対し」アクションを実行する前提のため、見つからなければ引き続きエラーにします。

擬似コード例:

ruby
def perform_action(data)
  subscription = find(data["identifier"])
  raise "Subscription not found" unless subscription

  subscription.perform_action(data["data"])
end

この区別により:

  • 「unsubscribe だけ」は冪等でノイズにならない
  • 「存在しないサブスクリプションに対する perform_action」は依然としてアプリのバグとして報告される

テストの追加・変更

actioncable/test/connection/subscriptions_test.rb:

  • すでに存在しないサブスクリプションに対して unsubscribe しても例外が発生しないこと(no-op で終わること)を確認するテストを追加。
  • perform_action が存在しないサブスクリプション ID を指定した際には例外が上がることを確認するテストも追加/更新。

actionpack/test/dispatch/content_disposition_test.rb:

  • 行数増加がありますが、この PR の主目的(ActionCable の挙動変更)とは直接関係しない微調整・テスト整備と思われます(CI 通過や関連テストの整合性確保のため)。

  1. 影響範囲・注意点

影響範囲:

  • ActionCable を使っているアプリのうち、
    • Turbo Streams や独自の JS クライアントなどで「短い間隔で subscribe/unsubscribe を繰り返す」ようなケース
    • 接続の再試行やタブの閉じ開けなどで race condition が起きやすいケース で、これまで unsubscribe 時に発生していた RuntimeError が発生しなくなります。
  • rescue_from を使って ActionCable の例外をエラー監視サービスに飛ばしている場合、「unsubscribe 関連のノイズ」が減ることが期待できます。

注意点:

  • もしアプリ側が「unsubscribe でサブスクリプションが見つからないこと自体を何らかのロジックで検知」していた場合、今回の変更でその検知はできなくなります(単なる成功扱いになる)。
    • ただし、そのようなユースケースは通常想定されておらず、「unsubscribe は冪等である」という意味論に沿った変更なので、多くのアプリでは問題にならないはずです。
  • 一方で、perform_action での「サブスクリプション見つからず」は引き続き例外となるため、ここに依存したエラーハンドリングは今まで通り動作します。

  1. 参考情報 (あれば)
  • 関連 Issue:
    • #25381: 2016 年から報告されていた「ActionCable unsubscribe のエラー」問題
  • 関連 PR:
    • #30702: エラーメッセージは改善したものの、「見つからないと例外を上げる」挙動自体は維持していた PR
  • この PR #56375 での位置づけ:
    • 過去 PR でのメッセージ改善に加えて、unsubscribe の意味論(冪等性)を実装レベルで正しく反映し、「本当にバグであるケース(perform_action)」だけを例外として残す、という最終的な整理になっています。

#57577 Fix incorrect callback ordering note in Active Record callbacks docs

マージ日: 2026/6/4 | 作成者: @55728

  1. 概要 (1-2文で)
    Active Record のコールバック順序に関するドキュメントの記述ミスを修正し、「メソッドで定義したコールバックだけ最後に呼ばれる」という誤った注記を削除した PR です。実際の挙動通り、すべてのコールバックが「定義された順に実行される」とだけ記載されるようになりました。

  1. 変更内容の詳細(あればサンプルコードも含めて)

対象: activerecord/lib/active_record/callbacks.rb のドキュメントコメントのみが変更されています。
実装コードの変更は一切なく、「キャンセル可能なコールバック (Canceling callbacks)」節の文章を修正しています。

修正前の問題となっていた記述

以前のドキュメントには、概ね次のようなニュアンスの文言がありました:

コールバックは一般に定義された順番で実行されますが、モデル上のメソッドとして定義されたコールバックだけは最後に呼び出されます。

この「メソッドとして定義されたものは最後に呼ばれる」という例外ルールが、現行の Rails では事実と異なります。

実際の挙動

PR では、以下のような検証コードを示しています:

ruby
class Widget < ActiveRecord::Base
  attr_reader :log
  after_initialize { @log = [] }

  before_save -> { log << :proc1 }
  before_save :method1
  before_save -> { log << :proc2 }
  before_save :method2

  def method1 = log << :method1
  def method2 = log << :method2
end

Widget.create!.log
# => [:proc1, :method1, :proc2, :method2]
  • before_save に対して
    • Proc 形式 (-> { ... })
    • シンボルで指定したメソッド (:method1, :method2) を交互に登録していますが、実行順は 定義順どおり になっています。

もし古いドキュメントの記述が正しければ、
[:proc1, :proc2, :method1, :method2]
のように「メソッドで定義したコールバックが最後に固まって実行」されるはずですが、実際にはそうなっていません。

歴史的背景

PR では、この誤記がどこから来たかも説明しています。

  • 2005 年ごろ(pre Rails 1.0)の古い実装では、

    • 「マクロで登録されたコールバック (before_save :foo のようなもの)」
    • 「オーバーライド可能なインスタンスメソッド (def before_save; end)」 という “二系統” の仕組みがあり、
    • まずマクロ登録されたコールバック群を実行し、
    • そのあとでオーバーライドされたメソッド本体を最後に send で呼ぶ
      という挙動でした。
      これはドキュメントが言っている「メソッドが最後に呼ばれる」挙動と一致します。
  • Rails 2.3 の API ドキュメントまでは、この二つのスタイルが共存していました。

  • その後、ActiveSupport::Callbacks によってコールバック機構が統一され、

    • すべてのコールバックが「登録された順に」実行されるモデルに変更。
    • しかし、古い挙動を説明した文言だけが 20 年近く残り続けていました。

今回の変更

該当の文言をシンプルに次のように修正しています:

コールバックは定義された順番に実行されます。

つまり「例外(メソッドだけ最後に呼ばれる)」という一文を削除しただけです。


  1. 影響範囲・注意点
  • コードの挙動は一切変わりません
    以前から Rails の実際の実装は「定義順で実行」になっており、その仕様にドキュメントを合わせただけです。

  • すでに「メソッドで定義したコールバックは最後に寄せられるはず」と誤解していたコードがある場合:

    • その前提はもともと成立しておらず、Rails の動作としては 常に定義順だった ことになります。
    • この PR をきっかけにドキュメントを読み直した際、「アプリの期待と違うのでは?」と感じた場合は、アプリ側のコールバック定義順を見直す必要があります。
  • コールバックの種類(シンボル/Proc/ブロック)に関係なく、登録順がそのまま実行順になる、というのが現在の正しい仕様です。


  1. 参考情報 (あれば)

コールバック順序を前提にしたロジックを書いている場合は、
「定義順で実行される」「メソッド定義だからといって別枠で最後に呼ばれたりしない」
という点を明確にしておくと、安全です。


#57088 Disconnect pools while cycling tests' connection handlers

マージ日: 2026/6/4 | 作成者: @matthewd

  1. 概要 (1-2文で)
    CI で頻発していた「FATAL: sorry, too many clients already」(PostgreSQL の接続数上限超え)を避けるため、テスト実行中に接続ハンドラを切り替える際に、既存の接続プールを明示的に切断するようにした PR です。テスト用の connection handler のライフサイクル管理を強化し、使い終わった DB 接続が確実に解放されるようにしています。

  1. 変更内容の詳細

※実際の diff 構造からの推測を含みますが、Rails の既存テストコードと整合的な内容です。

a. test_fixtures 周りでの connection handler 切り替え時に disconnect を実行

変更ファイル: activerecord/lib/active_record/test_fixtures.rb

  • Rails のテストで use_transactional_tests や fixtures を使うとき、テストごと/スレッドごとに ActiveRecord::Base.connection_handler を入れ替える処理があります。
  • これまでは「新しい connection handler をセットする」だけで、古い handler が保持していた connection pool を積極的に閉じていませんでした。
  • この PR では、connection handler を「サイクル(入れ替え)」するときに、古い handler が持っている全ての connection pool を disconnect! する処理を追加しています。

イメージ的な処理(概念的なサンプル):

ruby
def cycle_connection_handler
  old_handler = ActiveRecord::Base.connection_handler
  new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new

  ActiveRecord::Base.connection_handler = new_handler

  # ★ ここが今回追加されたポイント
  # 以前の handler が管理していた各プールを明示的に切断
  old_handler.connection_pool_list.each do |pool|
    pool.disconnect!
  end
end

ポイント:

  • 「handler を捨てるだけ」だと、PostgreSQL などのサーバ側から見るとコネクションが残り続け、CI のように大量テストを走らせる環境で「too many clients already」が発生し得る。
  • disconnect! を呼ぶことで、Ruby プロセス内でも DB サーバ側でもコネクションがクローズされるようになります。

b. テストケース基底クラス側での接続ハンドラ管理を強化

変更ファイル: activerecord/test/cases/test_case.rb

  • ActiveRecord のテストケース基底クラスで、setup / teardown 周りに connection handler の切り替え・復元・切断の処理が追加・整理されています。

  • 例えば、テストごとに新しい handler を使う場面で:

    • テスト開始前に original_handler を保存
    • テスト中は専用の handler を使用
    • 終了時に:
      • テスト用 handler の pool を disconnect!
      • original_handler に戻す

という流れをきちんと保証するようなコードが追加されています。

c. connection handler の挙動を確認するテスト追加

変更ファイル:

  • activerecord/test/cases/connection_adapters/connection_handler_test.rb
  • activerecord/test/cases/test_fixtures_test.rb

主なテスト内容(要約):

  1. connection handler を差し替えた際に、古い handler の connection pool が適切に disconnect! されるかを確認するテスト

    • handler 入れ替え前後で、connection_pool_listactive_connections? などの状態を確認。
    • 入れ替え後に「古い方の接続が残っていない」ことを assertion しています。
  2. fixtures / transactional tests と接続サイクルの組み合わせのテスト

    • fixtures を使うテストケースを擬似的に実行し、テスト終了後にプールが切断されているかを検証するテストが追加されています。
    • 特に並列テスト実行や複数 DB (multi-DB) のケースを意識したテストが含まれている可能性が高いです。

  1. 影響範囲・注意点

影響範囲

  • **対象は主に Rails 自身のテスト基盤(ActiveRecord のテストケース・fixtures 用ユーティリティ)**であり、アプリケーションコードのランタイム挙動には基本的に影響しません。
  • ただし、以下のようなケースでは間接的に影響を受ける可能性があります:
    • Rails アプリ側で Rails の内部テストヘルパ (ActiveRecord::TestFixtures / ActiveRecord::TestCase) をそのまま流用している場合
    • 独自に ActiveRecord::Base.connection_handler を差し替えてテストしているような高度なユースケース

技術的ポイント・注意点

  • connection handler を切り替えるときに、不要になったプールは必ず disconnect! すべきという方針が明確になりました。
  • これにより:
    • テスト実行時間中に開きっぱなしになる DB 接続数が減る
    • CI やコンテナ環境のように接続上限の小さい環境で、too many clients already が起きにくくなる
  • 逆にいうと、アプリや独自テストで:
    • ActiveRecord::Base.connection_handler = ... のように handler を差し替えているにも関わらず
    • 古い handler を保持しっぱなし+disconnect! もしていない といったパターンがある場合、同様の接続リークが起こりうるため、この PR の方針を参考にして明示的に disconnect! を呼ぶのが安全です。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57088
  • CI エラーの例:
    ActiveRecord::ConnectionNotEstablished: connection to server at "172.16.0.4", port 5432 failed: FATAL: sorry, too many clients already
  • 関連概念:
    • ActiveRecord::Base.connection_handler
    • ActiveRecord::ConnectionAdapters::ConnectionHandler#connection_pool_list
    • ActiveRecord::ConnectionAdapters::ConnectionPool#disconnect!

この PR は、「connection handler を切り替えるときに古いプールを必ず閉じる」という運用ルールをテスト基盤に実装したものと捉えると理解しやすいです。


#57562 Add test coverage for String filter boundary inputs

マージ日: 2026/6/4 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    String拡張のフィルタ系メソッド(remove, remove!, truncate_words)について、これまでテストされていなかった「何もしない境界ケース(no-op)」に対するテストが追加されました。アプリケーションコードには一切変更がなく、テストコードのみの追加です。

  1. 変更内容の詳細

対象は active_support/core_ext/string/filters.rb にある以下のメソッドの挙動確認用テストです。

String#remove の境界ケース

  • ケース: 引数にパターンを一切渡さない場合
  • 期待挙動:
    • 文字列内容は変わらない(no-op)
    • 返り値は元の文字列とは別オブジェクト(コピー)

テストのイメージ(実テストに沿った形で表現すると):

ruby
str = "abc"
result = str.remove

assert_equal "abc", result         # 内容は同じ
refute_equal str.object_id, result.object_id  # 別オブジェクト

Rails の String#remove は通常以下のように使いますが:

ruby
"hello world".remove("l")  # => "heo word"

今回のテストは「引数なしで呼んだとき」に何も削除されず、かつコピーが返ることを保証しています。

String#remove! の境界ケース

  • ケース: 引数にパターンを一切渡さない場合
  • 期待挙動:
    • 自身の内容は変わらない(no-op)
    • 戻り値は self(破壊的メソッドとしての一貫した挙動)

テストイメージ:

ruby
str = "abc"
result = str.remove!

assert_same str, result     # self を返す
assert_equal "abc", str     # 内容は変わらない

既存テストは「マッチするパターンを渡した場合」だけだったため、「引数なしで呼び出した場合の no-op 性」と「self を返すこと」が今回新たにカバーされています。

String#truncate_words の境界ケース

  • ケース1: length に 0 を渡した場合
  • ケース2: length に負の値を渡した場合

どちらも 「元の文字列をそのまま返す(no-op)」 という挙動をテストしています。

テストイメージ:

ruby
str = "one two three"

# length = 0
assert_equal str, str.truncate_words(0)

# length < 0
assert_equal str, str.truncate_words(-1)

既存テストでは、

  • 正の値
  • かつ、単語数以上のカウント

といった通常系・上限系のみがカバーされていたため、ゼロ・負値という「境界かつ不正寄りの引数」に対する挙動が明示的に確認されました。

変更ファイル

  • activesupport/test/core_ext/string_ext_test.rb にテストが 25 行追加
  • 本体コード (filters.rb) には変更なし

  1. 影響範囲・注意点
  • 機能面の影響: 既存の挙動を確認するテスト追加のみのため、実行時挙動に変更はありません。
  • 将来のリグレッション検知:
    • remove が「引数なしで呼んだときに別オブジェクトを返す」という仕様
    • remove! が「引数なしで no-op + self を返す」という仕様
    • truncate_words が「0 もしくは負の length で no-op」という仕様
      これらが、将来の refactor などで壊れた際に CI で検知できるようになります。
  • 利用側での前提にできること:
    • truncate_words に 0 や負数が渡っても例外にはならず、そのまま文字列が返る前提でロジックを書いてよい(ただし仕様依存になるので、異常値をアプリ側で弾きたいかどうかは別途設計判断)。
    • remove を引数なしで呼んでも安全(no-op)で、かつ破壊はされない。
    • remove! を引数なしで呼んでも安全(no-op)で、オブジェクトは同一。

  1. 参考情報 (あれば)
  • 対象メソッド実装:
    • active_support/core_ext/string/filters.rb
  • 類似 API:
    • String#delete / String#delete!(Ruby 標準)とのインターフェイス類似性・破壊/非破壊の挙動差分を確認する際に、今回のテストの期待値が参考になります。
  • この PR(#57562)は、テストカバレッジ向上が目的であり、仕様追加・変更ではなく「仕様のドキュメンテーションとしてのテスト」の意味合いが強いです。

#57571 Re-raise suppressed RedisClient errors in RedisCacheStore behavior tests

マージ日: 2026/6/4 | 作成者: @yahonda

  1. 概要 (1-2文で)
    RedisCacheStoreCommonBehaviorTest において、Redis 接続のタイムアウトなどで RedisClient::ConnectionError が発生した場合に、failsafe によって握りつぶされていたエラーをテスト専用の error_handler で再スローするようにし、テスト失敗時に原因が分かりやすくなるようにした PR です。
    本番コードの挙動は変えず、遅い CI 環境での「原因不明の nil 返却によるテスト失敗」を「RedisClient のタイムアウトなどが見える失敗」に置き換えるためのテスト改善です。

  1. 変更内容の詳細

背景

  • RedisCacheStoreCommonBehaviorTest では以下のような条件でテストが実行されている:
    • pool: false
    • timeout: 0.1
  • Ruby trunk の debug ビルドなど、非常に遅い環境では
    • Redis への connect / read が 0.1 秒を超えることがある
    • その結果 RedisClient::ConnectionError(具体的には ReadTimeoutErrorCannotConnectError)が発生
  • しかし RedisCacheStore#failsafe は、こうした RedisClient のエラーを rescue して nil を返す仕様になっているため、
    • たとえば @cache.write(...)nil を返し、テストアサーションが「truthy であること」を期待していると
      • Expected nil to be truthy. という、原因が分かりづらい失敗になる
  • テスト環境では suppressed されたエラーがログにも出ないため、「0.1 秒の timeout が原因」というヒントが出ないのが問題だった。

どのような修正か

変更ファイル: activesupport/test/cache/stores/redis_cache_store_test.rb (+4行)

RedisCacheStoreCommonBehaviorTest 内で lookup_store をオーバーライドし、RedisCacheStore 生成時に error_handler オプションを付与するようにしています。この error_handler は、通常 failsafe によって握りつぶされるはずの RedisClient 関連エラーを再スローします。

イメージとしては以下のような変更です(実際のコードから概念的に再構成):

ruby
class RedisCacheStoreCommonBehaviorTest < ActiveSupport::TestCase
  private
    def lookup_store(*args)
      super(*args, error_handler: ->(method:, returning:, exception:, **) do
        # failsafe が rescue したエラーをここで再スローする
        raise exception
      end)
    end
end

ポイント:

  • error_handlerRedisCacheStore に元々用意されているフックで、
    failsafe 内部で「エラーが発生した時にどう扱うか」を差し込める仕組み。
  • 通常は
    • 本番などでは「ログに出して nil で握りつぶす」などの使い方をする
  • この PR ではテストクラスに限定して
    • failsafe が握りつぶそうとしているエラーを、そのまま再スローする」ハンドラを設定
  • これにより、例えば @cache.write('key', 'value') が timeout により失敗した場合、
    • 以前: nil が返って Expected nil to be truthy となるだけ
    • 以後: RedisClient::ReadTimeoutError としてテストが Error で落ちる

変更前後の出力の違い

  • 変更前:

    text
    Failure:
    ActiveSupport::Cache::RedisCacheStoreTests::RedisCacheStoreCommonBehaviorTest#test_retains_encoding:
    Expected nil to be truthy.
  • 変更後:

    text
    Error:
    ActiveSupport::Cache::RedisCacheStoreTests::RedisCacheStoreCommonBehaviorTest#test_retains_encoding:
    RedisClient::ReadTimeoutError: user specified timeout for redis:6379 (redis://redis:6379/1)

これにより、どのテストで、どの Redis 接続に対して、どの種類のエラー(例: ReadTimeoutError)が起きたかが明示されます。

なぜ RedisCacheStoreCommonBehaviorTest のみにスコープしているか

  • FailureSafety / FailureRaising といったテスト群は、わざと到達不能な Redis を向くようにして
    • 「失敗時にどう振る舞うか」をテストしている
  • そこに「エラーを必ず再スローする error_handler」を差し込んでしまうと
    • 本来の挙動テストの意味が失われる
    • 期待している「失敗時に nil を返す」「ログに残す」などが確認できなくなる
  • そのため、本 PR の変更は RedisCacheStoreCommonBehaviorTest クラスの lookup_store のみに限定されており、他のテストには影響しないようになっている。

  1. 影響範囲・注意点
  • 影響範囲は テストコードのみ:
    • 変更ファイルは activesupport/test/cache/stores/redis_cache_store_test.rb 1 ファイル
    • 行数変更も +4 行のみで、本番環境の RedisCacheStore 実装には手を加えていない
  • 通常の(十分高速な)環境では、タイムアウトや接続エラーが発生しない限り error_handler は呼ばれないため、
    • 既存のテストが通っている状況では挙動は変わらない
  • 目的は「不安定な CI や遅いビルド環境で、失敗の原因を可視化すること」であり、
    • 不安定さを解消するためのリトライや timeout 値の調整ではなく、
    • あくまで「原因を明示する」ための改善である点に注意
  • エラーが再スローされることで、これまで Failure としてカウントされていたケースが Error になる可能性はあるが、
    • そもそも本質的には Redis タイムアウトによるエラーなので、より正しい状態に近づいたといえる

  1. 参考情報 (あれば)
  • 該当 PR: https://github.com/rails/rails/pull/57571
  • 影響するテストクラス:
    • ActiveSupport::Cache::RedisCacheStoreTests::RedisCacheStoreCommonBehaviorTest
  • 関連クラス / 機能:
    • ActiveSupport::Cache::RedisCacheStore
    • RedisCacheStore#failsafe
    • RedisClient::ConnectionErrorReadTimeoutError, CannotConnectError など)
    • error_handler オプション(RedisCacheStore のエラー処理フック)

#57549 Test Type::Boolean#serialize and #serialize_cast_value

マージ日: 2026/6/4 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    このPRは、ActiveModel::Type::Boolean#serialize および #serialize_cast_value に対するテストを追加し、既存の挙動(#cast と同等の振る舞い)を明示的に担保するものです。プロダクションコードの変更は一切なく、テストコードのみが追加されています。

  1. 変更内容の詳細

対象クラス

  • ActiveModel::Type::Boolean

これまでは #cast のみがテストされており、以下のメソッドは暗黙的にしかカバーされていませんでした。

  • #serialize
  • #serialize_cast_value

このPRでは、これら2つのメソッドに対して、実際の仕様どおりの動作が行われていることを確認するテストが追加されています。

#serialize の仕様確認

PR説明文から、#serialize の期待される挙動は次のとおりです。

  • 「偽」とみなされる値 → false にシリアライズ
  • 空文字列/nilnil にシリアライズ
  • 上記以外の値 → true にシリアライズ

ここでの「偽」とみなされる値は、#cast と同じルールで判定されます。代表的には次のようなものです。

ruby
type = ActiveModel::Type::Boolean.new

type.serialize(false)     # => false
type.serialize("false")   # => false
type.serialize(0)         # => false(false 相当とみなされる値)

type.serialize(nil)       # => nil
type.serialize("")        # => nil

type.serialize(true)      # => true
type.serialize("1")       # => true
type.serialize("abc")     # => true
type.serialize(42)        # => true

今回のテストでは、#cast と同等のマッピングを行っていることが確認されます。つまり、「DBなどに書き出す直前の値も、キャスト時と同じように true/false/nil に正規化される」という仕様をテストで固定しています。

#serialize_cast_value の仕様確認

#serialize_cast_value は、すでに #cast 済みの値を受け取る想定のメソッドです。
PR説明文によると、その挙動は「渡された値をそのまま返す(パススルー)」です。

ruby
type = ActiveModel::Type::Boolean.new

casted_true  = type.cast("1")      # => true
casted_false = type.cast("0")      # => false
casted_nil   = type.cast(nil)      # => nil

type.serialize_cast_value(casted_true)   # => true
type.serialize_cast_value(casted_false)  # => false
type.serialize_cast_value(casted_nil)    # => nil

今回のテストでは、この「変換を行わずに値をそのまま返していること」を保証します。

追加されたテストファイルの内容

変更は以下の1ファイルのみです。

  • activemodel/test/cases/type/boolean_test.rb (+17/-0)

中身としてはおおむね次のような構成が想定されます(擬似コード):

ruby
class ActiveModel::Type::BooleanTest < ActiveModel::TestCase
  def setup
    @type = ActiveModel::Type::Boolean.new
  end

  test "serialize mirrors cast semantics" do
    assert_equal false, @type.serialize("false")
    assert_nil @type.serialize(nil)
    assert_nil @type.serialize("")
    assert_equal true, @type.serialize("something")
    # ...など、false/nil/true 各パターンの確認
  end

  test "serialize_cast_value is pass-through" do
    assert_same true,  @type.serialize_cast_value(true)
    assert_same false, @type.serialize_cast_value(false)
    assert_nil @type.serialize_cast_value(nil)
  end
end

実際にはもう少し具体的な値がテストされているはずですが、意図としてはこのレベルです。


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

    • コード変更はテストファイルのみであり、ランタイムの挙動は変わりません。
    • 既存アプリケーションに対する互換性への影響はありません。
    • CIでのテストカバレッジが向上し、Boolean 型シリアライズ周りのリグレッション検知能力が上がります。
  • 注意点 / 読み取り方

    • #serialize#cast が事実上同じルールで true/false/nil を決定する前提が、テストによって固定化されました。
      今後この仕様を変えようとする場合には、テスト修正が必要になります。
    • #serialize_cast_value が「必ずパススルーである」という仕様も固定されるため、ここで追加の正規化や変換を行う設計は、今後行いにくくなります(行う場合はテストとの矛盾を明示的に解消する必要があります)。
    • 逆に言えば、「DBに書き込むタイミングで Boolean が意図せず再解釈される」ということはなく、cast された値がそのまま保存されることがテストで保証された、と捉えることもできます。

  1. 参考情報 (あれば)
  • Rails ガイド(英語): Active Model Basics – Type Cast
    ActiveModel::Type 系の仕組み・キャストの流れがまとまっています。
  • 実際の ActiveModel::Type::Boolean 実装:
    • activemodel/lib/active_model/type/boolean.rb
      (このPRでは変更されていませんが、#cast, #serialize, #serialize_cast_value の実装を確認する際の参照になります)

#57565 Preserve index attributes when renaming an index without native support

マージ日: 2026/6/3 | 作成者: @55728

  1. 概要 (1-2文で)
    rename_index のフォールバック実装(ネイティブな index rename を持たないアダプタ用)で、partial index の WHERE や列ソート順などの属性が失われていた問題を修正し、元の index 属性を保ったままリネームされるようにした PR です。主に SQLite や古い MySQL/MariaDB で、開発・テスト時の index 挙動が意図せず変わる問題を解消します。

  1. 変更内容の詳細(あればサンプルコードも含めて)

何が問題だったか

ネイティブな ALTER INDEX … RENAME を持たないアダプタでは、rename_index は以下のようなフォールバック実装になっていました(概念的に):

ruby
def rename_index(table, old_name, new_name)
  old_index = indexes(table).find { |i| i.name == old_name }

  remove_index(table, name: old_name)
  add_index(table, old_index.columns, name: new_name, unique: old_index.unique?)
end

このとき add_index に渡しているのは columnsunique だけなので、元の index が持っていた以下のような属性が すべて失われる という問題がありました。

  • where(partial index の WHERE 句)
  • orders(列ごとの ASC/DESC)
  • lengths(プレフィックス長など)
  • opclasses
  • using(USING btree 等)
  • type
  • include(covering index の INCLUDE 列)
  • nulls_not_distinct
  • comment(MySQL/MariaDB の index コメント)

問題例:

ruby
add_index :posts, :views,
          where: "title IS NOT NULL",
          name: "idx",
          order: { views: :desc }

rename_index :posts, "idx", "idx2"
# 修正前:
#  - WHERE 句が消える
#  - order 情報も消える
#  - 結果として単なる (views) の index になる

特に SQLite では rename_column 実装の一部として index の付け替えに rename_index が使われるため、開発・テスト環境の SQLite で:

  • unique な partial index の「どの行が一意制約の対象か」が変わってしまう
  • 本番(PostgreSQL など)とは違う挙動になる

という、かなり危険なズレが起きうる状態でした。

今回の修正内容

フォールバックパス(super による実装)で index を作り直す際に、IndexDefinition が持っている各種属性を add_index に引き継ぐようにしました。

具体的には以下の属性を、nil / 空でなければ渡します:

  • where
  • orders
  • lengths
  • opclasses
  • using
  • type
  • include
  • nulls_not_distinct
  • comment(SQLite では無意味だが、MySQL/MariaDB のフォールバック向けに保持)

イメージ:

ruby
def rename_index(table_name, old_name, new_name)
  index = indexes(table_name).find { |i| i.name == old_name }

  options = {
    name: new_name,
    unique: index.unique
  }

  options[:where]               = index.where               if index.where
  options[:order]               = index.orders              if index.orders.present?
  options[:length]              = index.lengths             if index.lengths.present?
  options[:opclass]             = index.opclasses           if index.opclasses.present?
  options[:using]               = index.using               if index.using
  options[:type]                = index.type                if index.type
  options[:include]             = index.include             if index.include.present?
  options[:nulls_not_distinct]  = index.nulls_not_distinct  if index.nulls_not_distinct
  options[:comment]             = index.comment             if index.comment

  remove_index(table_name, name: old_name)
  add_index(table_name, index.columns, **options)
end

(実際のコードは既存の抽象実装に最小限の変更を加える形ですが、ロジックとしては上記のように「元 index の属性をすべて合成して渡す」方針になっています。)

テスト

以下のテストが追加されています:

  • test_rename_index_preserves_where_clause
    • supports_partial_index? を満たすアダプタでのみ実行
    • rename 前後の where 属性を比較し、保持されていることを確認
  • test_rename_index_preserves_order
    • supports_index_sort_order? を満たすアダプタでのみ実行
    • rename 前後の orders 属性({ column_name => :asc/:desc })を比較

SQLite3 では修正前に wherenilorders{} に変わることを確認し、修正後は preserved になることを確認済み。PostgreSQL ではもともとネイティブ rename (ALTER INDEX ... RENAME) を使っているため挙動に変更はなく、テストもグリーンのままです。

CHANGELOG にも「rename_index が index 属性を保持するようになった」旨が追記されています。


  1. 影響範囲・注意点

影響を受けるアダプタ

Adapterrename_index の実装影響
PostgreSQLALTER INDEX … RENAME(ネイティブ)もともと問題なし(変化なし)
MySQL ≥ 5.7.6 / MariaDB ≥ 10.5.2ALTER TABLE … RENAME INDEX(ネイティブ)もともと問題なし(変化なし)
SQLiteフォールバック (super)今回の修正で挙動が改善
MySQL < 5.7.6 / MariaDB < 10.5.2フォールバック (super)今回の修正で挙動が改善

特に現代的な開発環境で多いパターン:

  • 開発・テスト: SQLite
  • 本番: PostgreSQL / MySQL

この構成で、SQLite 上の rename_column / rename_index 実行後も、Partial Index やソート順などが本番とずれない 方向に直っています。

どんなケースで効いてくるか

  • SQLite で以下のようなマイグレーションをしている場合:

    ruby
    add_index :posts, :views,
              where: "title IS NOT NULL",
              unique: true,
              name: "idx_posts_on_views_partial"
    
    rename_index :posts,
                 "idx_posts_on_views_partial",
                 "idx_posts_on_views_uniq"

    修正前: idx_posts_on_views_uniqwhere が無視され、全行に対して unique 制約がかかる
    修正後: where: "title IS NOT NULL" を保ったまま rename される

  • rename_column が内部で index の張り替えを行う際に rename_index が使われるアダプタ(SQLite など)の場合、
    修正前は「カラム名変更に伴い暗黙に index 属性が失われる」ことがあったが、修正後は属性が維持される

注意点

  • 仕様としては「正しい動作」に近づいている変更であり、後方互換性破壊というより、今まで silently バグっていた挙動の修正 と捉えるのが適切です。
  • ただし、もし「partial index の WHERE が消える前提」でワークアラウンドしていたようなコードがある場合は、その前提が崩れます(通常はそのような前提はバグ寄りのため、修正は歓迎されるはずです)。
  • SQLite は index コメントをサポートしませんが、MySQL/MariaDB のフォールバックパスに備えて comment もコピーされます。実際にコメントが保存されるか・どう表現されるかはアダプタ依存です。

  1. 参考情報 (あれば)
  • 対象 PR: rails/rails #57565 「Preserve index attributes when renaming an index without native support」
  • 関連 Issue: #16619(2014 年の報告。「抽象実装は where をサポートしないアダプタ向け」という理由で当時はクローズされていたが、現在は SQLite などが partial index をサポートするため前提が変わっている)
  • 対応ファイル:
    • activerecord/CHANGELOG.md
    • activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb
    • activerecord/test/cases/migration/index_test.rb

#57563 Fix reset_column_sequences! for a table in a quoted schema

マージ日: 2026/6/3 | 作成者: @55728

  1. 概要 (1-2文で)
    PostgreSQL でスキーマ名をクォートしたテーブル(例: "App".widgets)に対して reset_column_sequences!NoMethodError で落ちていた不具合を修正した PR です。これにより、quoted schema を使う環境でも reset_column_sequences! / reset_pk_sequence! / fixture ロードが正常に動作するようになります。

  1. 変更内容の詳細

不具合の症状

例えば以下のように CamelCase なスキーマをクォートして作り、そのテーブルに対して reset_column_sequences! を呼び出すと例外が発生していました。

sql
CREATE SCHEMA "App";
CREATE TABLE "App".widgets (id serial primary key);
ruby
connection.reset_column_sequences!([['"App".widgets']])
# => NoMethodError: undefined method 'column=' for nil

reset_pk_sequence! や fixture ロードも内部で reset_column_sequences! を呼ぶため同様にクラッシュします。

根本原因

SequenceResetreset_column_sequences! の内部実装)がテーブルを管理するために、以下の2種類のテーブル名を扱っていました。

  1. 呼び出し側から渡されるテーブル名(map のキー)
  2. PostgreSQL カタログから取得する regclass::text の文字列(schema-qualified かつ必要に応じてクォートされた名前)

regclass::text は例えば以下のような文字列になります。

  • "App".widgets
  • public.widgets

元のコードでは、regclass::text からスキーマ名を外したりクォートを剥がしたりするために、

ruby
delete_prefix('"').delete_suffix('"')

といった「外側のダブルクォートだけを削る」処理を使っていました。そのため:

  • "App".widgets
    • delete_prefix('"')App".widgets
    • delete_suffix('"') → 末尾は " ではないのでそのまま
    • 結果: App".widgets という壊れた文字列になる

この壊れた文字列で @tables のハッシュを引こうとするため、キーが見つからず nil が返り、nil.column = ...NoMethodError が発生していました。

同じ問題が「主キー用のシーケンス」と「UUID 用のシーケンス」の両方のループで起きていました。既存のテストは小文字スキーマなど quoted schema のケースをカバーしていなかったため、今まで検知されていませんでした。

修正内容

テーブル名のパースと正規化に ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name を使うように変更し、以下を「常に同じロジック」で処理するように統一しました。

  • @tables ハッシュのキーにする名前
  • regclass::text から逆引きする際に使う名前

具体的には、壊れた独自の「クォート剥がし」ロジック(delete_prefix / delete_suffix)を削除し、代わりに

ruby
Utils.extract_schema_qualified_name(...).to_s

で得られた名前を使うようにしています。

extract_schema_qualified_name は、スキーマ付き・クォート付きのテーブル名を安全にパースして、内部的に「スキーマ」と「テーブル」の構造を理解した上で正規化した文字列を返すユーティリティです。これにより:

  • "App".widgets のような CamelCase スキーマ
  • public.widgets のような unquoted / lowercase の一般的なケース
  • "weird-Name"."weird.Table" といった複雑なクォートケース

のいずれでも整合性のあるキー生成と逆引きが行われます。

補足として、他の unquote_identifier 利用箇所も監査されましたが、

  • そこでは「カラム名など単一の identifier」を対象にしている
  • もしくは既に extract_schema_qualified_name を使用している

ため、今回のようなバグは起きないことが確認されています。

テスト

新たに以下のテストが追加されています。

ruby
test_reset_column_sequences_with_quoted_schema

テスト内容:

  1. "Test_CamelSchema" という CamelCase の quoted schema にテーブルを作成
  2. id = 100 の行を手動挿入
  3. reset_column_sequences! を呼び出し
  4. 次に挿入されるレコードの id101 になることを確認

このテストは:

  • 現在の main では NoMethodError が発生して red になる
  • 本 PR の修正後は 101 にシーケンスが進んで green になる

ことが、実際の PostgreSQL 上で確認されています。


  1. 影響範囲・注意点
  • 影響を受けるケース:
    • PostgreSQL を使用していて
    • CamelCase や大文字を含むスキーマ名を "SchemaName" のようにクォートして定義し
    • そのスキーマ内のテーブルに対して reset_column_sequences! / reset_pk_sequence! / fixtures ロードを実行している場合
  • この修正により、これらのケースで発生していた NoMethodError が解消され、シーケンスが正しく「既存データの最大 ID + 1」にリセットされます。
  • lowercase かつ unquoted なスキーマ・テーブル名のみを使っている一般的なアプリケーションでは、動作の変更はほぼありません(内部実装がより堅牢になっただけ)。
  • スキーマ名・テーブル名・カラム名などにクォートが絡む環境で、シーケンスのリセット処理がより安全になります。独自タスクや Rake タスクで reset_column_sequences! を利用している場合も恩恵があります。

  1. 参考情報 (あれば)
  • 関連 PR:
    • #57561: foreign_keys corrupting to_table in a quoted schema
      同じ「quoted schema 名の扱い」に起因する別箇所のバグ修正で、foreign_keysto_table がおかしくなる問題に対応しています。本 PR とは独立にマージ可能。
  • 変更ファイル:
    • activerecord/CHANGELOG.md
    • activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
    • activerecord/test/cases/adapters/postgresql/schema_test.rb
  • テスト: schema_test.rb 全体で 82 tests, 0 failures が確認されています。

#57560 Fix nested attributes :limit miscounting a single-record hash

マージ日: 2026/6/3 | 作成者: @55728

  1. 概要 (1-2文で)
    accepts_nested_attributes_for ..., limit: N 使用時に、1件分のレコードを表す id キー付きハッシュが「N件を超えた」と誤判定されて TooManyRecords が発生するバグが修正されました。レコード数ではなくハッシュのキー数を数えていた実装を、正しく「レコード数」を数えるように変更しています。

  1. 変更内容の詳細

バグの内容

accepts_nested_attributes_forlimit オプションを指定しているとき、以下のような「1件の関連レコードを更新するためのハッシュ」を渡すとエラーになっていました。

ruby
class Pirate < ApplicationRecord
  has_many :parrots
  accepts_nested_attributes_for :parrots, limit: 2
end

pirate.parrots_attributes = {
  "id"    => 1,
  "name"  => "Polly",
  "color" => "green",
  "breed" => 1
}
# 期待: 単一レコード更新として成功する
# 実際: TooManyRecords: Maximum 2 records are allowed. Got 4 records instead.

原因は、**「レコード数のチェックが、正規化(ハッシュ → 配列化)より前に実行されていた」**ことです。

Rails は nested attributes で以下のような2パターンを受け取ります:

  1. 「複数レコード」形式(ハッシュ of ハッシュ or 配列)

    ruby
    {
      "0" => { "name" => "Polly" },
      "1" => { "name" => "Cracker" }
    }
    # => Hash#size == 2 (レコード数と一致)
  2. 「単一レコード」形式(id を含むフラットなハッシュ)

    ruby
    {
      "id"    => 1,
      "name"  => "Polly",
      "color" => "green",
      "breed" => 1
    }
    # => Hash#size == 4 (これは「属性数」であってレコード数ではない)

従来の実装では check_record_limit! がこの正規化より前で Hash#size を見ていたため、
ケース2では「1レコード」ではなく「4レコード」と誤カウントされていました。

修正内容

activerecord/lib/active_record/nested_attributes.rb において:

  • check_record_limit! の実行位置を、入力パラメータの「ハッシュ → 配列」正規化に移動
    • id キー付き単一ハッシュは内部的に [attributes_hash] のような1要素配列に包まれてからチェックされる
    • この結果、単一レコードの場合は常に「1件」として数えられる

これにより、以下の動作になります:

ruby
pirate.parrots_attributes = {
  "id"    => 1,
  "name"  => "Polly",
  "color" => "green",
  "breed" => 1
}
# => 正常に1件の更新として処理される(limit: 2 に抵触しない)

一方で、以下のような genuine な「レコード数オーバー」ケースは従来通りエラーになります:

ruby
pirate.parrots_attributes = [
  { "name" => "Polly" },
  { "name" => "Cracker" },
  { "name" => "Kiwi" }
]
# limit: 2 の場合 => TooManyRecords が発生(期待通り)

テスト

activerecord/test/cases/nested_attributes_test.rb に以下のテストが追加されています:

  • test_limit_does_not_count_the_attributes_of_a_single_record_hash

このテストは共有モジュール NestedAttributesLimitTests に追加されており、

  • limit: 2(数値)
  • limit: :some_symbol
  • limit: -> { ... }(Proc)

といった3種の limit 設定すべてで同じケースが検証される構成です。

テスト内容:

  • limit: 2 の関連に、4つのキーを持つ id 付きハッシュで1レコード更新を行う
  • TooManyRecords が発生しないこと
  • レコードが期待通り更新されていること

nested_attributes_test.rb 全体(170テスト)でグリーンであることが確認されています。

activerecord/CHANGELOG.md にもこの修正内容が追記されています。


  1. 影響範囲・注意点
  • 対象: accepts_nested_attributes_forlimit: を指定しているすべてのコード
    • 特に、「既存レコードの更新」を id キー付きフラットハッシュで行っているケースに影響
  • 期待される挙動:
    • これまで 誤って TooManyRecords が出ていたケース(単一レコード更新)が正常に通るようになります
    • 実際にレコード数が limit を超えている入力(配列 or hash-of-hashes)は、従来通り TooManyRecords が発生します
  • 互換性:
    • レコード数カウントのタイミングが変わっただけで、ビジネスロジック上の制約(何件まで許可するか)は変わっていません
    • 「これまでバグを前提にしていた」ようなコード(例: 単一レコード更新をあえて弾かせていた…など)があれば挙動が変わりますが、通常は望ましい修正と考えてよいです

確認したほうがよいポイント:

  • 管理画面や API で nested attributes を使って既存子レコードを更新している箇所があり、
    • limit: を設定している
    • id: ... を含む単一ハッシュでリクエストしている
      これらが以前から「なぜか TooManyRecords になる」などの問題を抱えていた場合、この修正で解消されている可能性が高いです。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57560
  • 該当コード: ActiveRecord::NestedAttributesactiverecord/lib/active_record/nested_attributes.rb
  • 概念整理:
    • nested attributes の入力形式
      • 単一レコード更新: {"id" => 1, "attr1" => "x", ...}
      • 複数レコード: [{...}, {...}] または {"0" => {...}, "1" => {...}}
    • limit は「レコード数の上限」であり、「属性数の上限」ではない
      → 今回の修正はこの意図に沿ったカウント方法に是正したものです。

#57550 Make Ractor shareability methods only available on 4.0 and above.

マージ日: 2026/6/3 | 作成者: @andrewn617

  1. 概要 (1-2文で)
    Rails の Ractor 用ヘルパーメソッド(ractor_make_shareable / ractor_shareable_proc など)を、「Ruby 4.0 以上では本物を提供し、Ruby 3.x では一括で no-op(何もしない)にする」という方針に整理した PR です。
    これにより Ruby 3.x で Ractor.make_shareable だけが中途半端に働いて例外が出る、といった不整合を避けます。

  1. 変更内容の詳細

※実際の diff は activesupport/lib/active_support/ractors.rb のみ(+14 / -20 行)で、主に条件分岐ロジックの修正です。

背景となる問題

Rails では Ractor 対応のため、以下のようなヘルパーを ActiveSupport に用意しようとしていました:

  • ractor_make_shareable(obj): Ractor.make_shareable(obj) 相当のラッパー
  • ractor_shareable_proc(&block): Ractor で共有可能な Proc を生成するヘルパー

ところが Ruby 3.x 系では:

  • Ractor.make_shareable は存在する
  • しかし Ractor.shareable_proc は存在しない(Ruby 4.0 で導入予定)

そのため:

  • Rails 側で make_shareable については shim(互換関数)を用意
  • shareable_proc は Ruby 3.x では意味のある実装ができないので no-op にする

という構成にすると、次のようなコードが Ruby 3.4 では例外を起こす、という問題があります。

ruby
# Ruby 3.x 環境でのイメージ

proc = ractor_shareable_proc { foo } # 実際には「共有可能」になっていない no-op

ractor_make_shareable(Bar.new(proc)) # => Bar 内に「共有不可能な proc」が含まれているので
                                     #    Ruby 3.4 の Ractor.make_shareable 呼び出しで例外

一方で、Ruby 4.0 では Ractor.shareable_proc が存在するため、同じコードが正常に動く、というギャップが生じます。

この PR の方針

PR では、Rails での Ractor サポートは Ruby 4.0 を前提とし、Ruby 3.x では「Ractor 関連のヘルパーはすべて no-op に寄せる」方向へ整理しています。

実装イメージとしては:

ruby
# 疑似コード: バージョンは例
if RUBY_VERSION >= "4.0.0"
  # Ruby 4.0 以上: Ractor をきちんと使う
  def ractor_make_shareable(obj)
    Ractor.make_shareable(obj)
  end

  def ractor_shareable_proc(&block)
    Ractor.shareable_proc(&block)
  end
else
  # Ruby 3.x: Ractor サポートは「存在するが何もしない」扱い
  def ractor_make_shareable(obj)
    obj # そのまま返す (no-op)
  end

  def ractor_shareable_proc(&block)
    block # そのまま返す、実際には shareable にはならない
  end
end

ポイントは:

  • Ruby 4.0 以上
    • Rails は Ruby が提供する本物の Ractor.make_shareable, Ractor.shareable_proc を利用
    • Ractor ベースで本番運用できることを目標とした設計
  • Ruby 3.x
    • すべての Ractor ヘルパーを no-op にする
    • make_shareable だけ実体があって shareable_proc は no-op」という中途半端な状態を避ける
    • その結果、3.x では「Ractor をちゃんと使える」ことは保証しない

これによって、Ruby 3.x で

  • ractor_shareable_proc が共有不可能な Proc を返す
  • それを含んだオブジェクトに対して ractor_make_shareable (実体は Ruby の Ractor.make_shareable) を呼ぶ

という「内部的に矛盾した状態」がそもそも発生しないようにしています。


  1. 影響範囲・注意点

影響範囲

  • 対象: ActiveSupport の Ractor 関連ヘルパーを利用しているコード
    • ActiveSupport::Ractors(またはそれを内部で利用するコンポーネント)
  • 実行環境: Ruby 3.x / Ruby 4.0 以上で挙動が分岐

Ruby 4.0 以上での挙動

  • Ractor 関連ヘルパーは Ruby 標準の機能に紐づいて、実際に Ractor セーフなオブジェクトや Proc を生成します。
  • Ractor を本格的に利用した並行処理(マルチ Ractor)を Rails 上で実用的に構成できる前提になります。

Ruby 3.x での挙動と注意点

  • Ractor ヘルパーは 存在するがすべて no-op(何もしない) になる想定です。
    • 例外は極力発生しないようにしてあるが、その代わり「Ractor 対応」にはなっていません。
  • 3.x 上で Ractor を前提とした並列処理を Rails 内部やアプリケーションコードで行うのは推奨されず、将来的に Ruby 4.0 をターゲットにすべき、というメッセージと言えます。
  • Rails 側はこの挙動を前提に実装していくため、
    • Ruby 3.x で「Ractor をちゃんと使えること」を期待したテストや本番運用は壊れる可能性があります。
    • Ractor 前提のコードを書く場合は、Ruby 4.0 をターゲットに準備する必要があります。

移行上のポイント

  • すでに独自に Ractor.make_shareable を直接呼び出しているコードには、この PR は直接影響しません(Rails のヘルパー周りの整理なので)。
  • ただし、今後 Rails 側の設計として「Ractor 対応 = Ruby 4.0 前提」が強まるため:
    • 新規に Ractor を利用する並列処理を書きたい場合
      • Ruby 4.0 を最低ターゲットとして検討する
      • Rails の Ractor ヘルパーを使う場合は「3.x では no-op」と割り切る
    • ライブラリ作者は、Ractor 対応をうたう際は「Ruby 4.0 以上でテスト済み」であることを明確にしておくとよいです。

  1. 参考情報 (あれば)
  • この PR がフォローアップしている PR:
  • Ruby 本体側の Ractor 関連メソッド:
    • Ractor.make_shareable(obj)
      • オブジェクトを Ractor 間で共有可能にする。共有不可能な要素を含むと例外が発生。
    • Ractor.shareable_proc(&block)(Ruby 4.0 以降で導入予定)
      • Ractor 間で共有可能な Proc を生成する、Proc 用の特別なコンストラクタ的メソッド。

#57567 Read mysql2 affected_rows during perform_query

マージ日: 2026/6/3 | 作成者: @matthewd

  1. 概要 (1-2文で)
    MySQL 用の ActiveRecord アダプタで、クエリ実行時に affected_rows(影響を受けた行数)を確実に取得できるようにする変更です。mysql2nil を返すケースでも、ActiveRecord::Result を使って影響行数を運べるようにし、テストもそれを検証しやすい形に調整しています。

  1. 変更内容の詳細

背景

  • mysql2 のクエリ実行結果は、
    • 行とカラムがある「通常の SELECT 結果」 → Mysql2::Result
    • UPDATE / DELETE などで行数のみが意味を持つ結果 → nil(結果セットなし)
      という形になることがあります。
  • ActiveRecord 側では、affected_rows(更新・削除された行数など)をテストしているが、perform_query のタイミングで正しく読み取れておらず、ローカルの AR テストで半分くらいの確率で落ちる状況があったとのことです。

生の結果型を変更

これまで:

  • Mysql2Adapter の「raw な結果」の型は Mysql2::Resultnil のどちらかだった。
  • mysql2 が結果セットを返さない(nil を返す)ケースでは、ActiveRecord 内で影響行数を運ぶためのオブジェクトがなく、「影響行数だけを持っている結果」を表現しづらかった。

これから:

  • 「raw 結果」の型を
    • Mysql2::Result(行とカラムを持つ結果)
    • ActiveRecord::Result(影響行数などを表現できる AR の結果オブジェクト)
      のどちらかに変更。
  • 行とカラムを持つ「フルな」結果については、今まで通り Mysql2::Result を返し、その後の段階で ActiveRecord::Result に変換するフローは変更しない。
  • 一方、mysql2nil を返すケース(典型的には affected_rows だけ意味がある結果)では、その場で ActiveRecord::Result を生成し、そこに affected_rows を保持させるようにする。

PR 説明のポイントを言い換えると:

  • 生の結果型を Mysql2::Result | nilMysql2::Result | ActiveRecord::Result に変更
  • nil で済ませていた「影響行数だけある結果」を、ActiveRecord::Result に載せて扱う
  • すでに Sqlite3Adapter は同様に ActiveRecord::Result を “raw” として再利用しており、その方針に合わせている

perform_query 内で affected_rows を読むタイミングを調整

PR タイトルにもある通り、「perform_query 中に mysql2affected_rows を読む」ようにしています。
これにより、MySQL クライアントの状態が変わる前に確実に行数が取得され、後段の処理で値が取り違えられたり、0 に見えてしまうといった不具合を防ぎます。

(PR 本文からは細かいメソッド呼び出しの変更までは読み取れませんが、database_statements.rb の差分 (+21/-17) で、perform_query or その周辺で affected_rows を読んで ActiveRecord::Result に詰める処理を挿入・整理していると考えられます。)

テストの変更

対象テスト:
activerecord/test/cases/adapters/abstract_mysql_adapter/count_deleted_rows_with_lock_test.rb

  • LOCK を絡めた削除クエリで削除行数をきちんとカウントできるか」をテストしているファイル。
  • ローカル環境で「半分くらいの頻度で落ちる」不安定テストだったため、
    • 失敗を再現しやすいようにテスト内容を少し強める/明確にする
    • あるいは、今回の affected_rows の扱い変更に合わせて期待値の取り方を揃える
      といった形で +6/-2 行の修正が入っています。
  • 説明文には「将来のリグレッション時に失敗が起きやすくなることを期待してテストを書き換えた」とあるので、
    • 以前はバグがあってもテストが通ってしまうケースがあった
      → 仕様どおりでなければ落ちるようにテストを強化した
      と理解しておくと良いです。

  1. 影響範囲・注意点
  • 対象: mysql2 アダプタ (Mysql2Adapter) を使っている MySQL / MariaDB 環境の Rails アプリケーション。
  • 影響する処理:
    • UPDATE / DELETE / INSERT など、結果セットを返さず affected_rows だけ意味を持つクエリ。
    • 特に LOCK を絡めた削除(テスト名からすると DELETE ... LOCK IN SHARE MODE 的なもの)での削除行数カウント。
  • 互換性:
    • 「通常の SELECT 結果」は引き続き Mysql2::Result ベースで扱われるため、既存コードが Mysql2::Result を前提にしていても挙動に変化はないはずです。
    • 結果セットが無いケースで、内部的に nil ではなく ActiveRecord::Result が流れるようになるため、アダプタ内部やプライベート API に依存しているコード(独自 monkey patch やメトリクス用途のラッパなど)があれば、nil 前提ロジックが壊れる可能性はあります。
  • 利点:
    • affected_rows を確実に取得できるようになることで、
      • delete_all / update_all の戻り値
      • lock 付きクエリ実行時の削除件数のカウント
        などの信頼性が向上します。
    • Sqlite3Adapter 同様に、ActiveRecord::Result を「raw」として再利用する設計に揃えたため、アダプタ間の一貫性が増しています。
  • テスト面:
    • 影響行数周りのリグレッション(戻りバグ)が将来発生した際には、今回変更されたテストがより高い確率で検知してくれるようになります。

  1. 参考情報 (あれば)
  • 対象 PR: https://github.com/rails/rails/pull/57567
  • 関連箇所:
    • activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb
    • activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb
    • activerecord/test/cases/adapters/abstract_mysql_adapter/count_deleted_rows_with_lock_test.rb
  • 設計上の類似点:
    • Sqlite3Adapter もすでに ActiveRecord::Result を「raw 結果」として再利用しており、MySQL アダプタもそれに寄せた形になっています。

#57561 Fix PostgreSQL foreign_keys for a target table in a quoted schema

マージ日: 2026/6/3 | 作成者: @55728

  1. 概要 (1-2文で)
    PostgreSQL でスキーマ名がクォートされたテーブルに対する外部キーを扱う際、foreign_keys が参照先テーブル名(to_table)を壊してしまい、rails db:schema:dump から復元できないスキーマが生成されていた問題を修正する PR です。regclass::text の結果に対して不適切なアンクォート処理をしていたのを、スキーマ付き・クォート付きのテーブル名を正しく扱えるパーサに差し替えています。

  1. 変更内容の詳細

問題の具体例

PostgreSQL で、クォートされたスキーマにテーブルがある場合:

sql
CREATE SCHEMA "App";
CREATE TABLE "App".customers (...);
CREATE TABLE orders (
  customer_id bigint REFERENCES "App".customers(id)
);

Rails から:

ruby
connection.foreign_keys("orders").first.to_table
# 修正前: "App\".customer"  (壊れている)
# 期待値: "App.customers"

この to_table がスキーマダンプにそのまま使われるため、schema.rb には壊れた add_foreign_key が出力され、rails db:schema:load で失敗する、というバグでした。

根本原因

  • 参照先テーブル名は PostgreSQL の regclass::text から取得している。

  • regclass::text は「スキーマ付き + 必要部分のみクォートされた識別子」を返す:

    regclass::text の例
    "App".customers
    public."Mixed"
    "Schema"."Table"
    "Mixed"
    customers
  • これに対して Utils.unquote_identifier をそのまま適用していたが、このメソッドは「単一の識別子だけを想定して、先頭と末尾の1文字を剥がす」実装だったため、スキーマ+テーブルのような複合名を壊してしまっていた:

    入力 (regclass::text)unquote_identifier の結果正しい期待値
    "App".customersApp".customerApp.customers
    public."Mixed"public."Mixed" (変化なし)public.Mixed
    "Schema"."Table"Schema"."TableSchema.Table
  • スキーマなし("Mixed", customers)のケースだけはたまたま動いていたため、長年見落とされていた。

修正内容

  • foreign_keys 内で参照先テーブル名を扱う箇所を変更:

    • Before: Utils.unquote_identifier(raw_name_from_regclass)
    • After: Utils.extract_schema_qualified_name(raw_name_from_regclass).to_s
  • extract_schema_qualified_name は、すでに他の場所で使われているヘルパで、

    • スキーマ名 + テーブル名
    • 必要な部分のみクォートされた識別子
      を正しくパースし、schema.table の形の文字列を返せるようになっている。

結果として:

ruby
# 修正後
connection.foreign_keys("orders").first.to_table
# => "App.customers"

となり、schema.rb にも正しい add_foreign_key "orders", "App.customers", ... が出力されます
(実際にはアダプタ内部表現に準じた形式ですが、少なくとも壊れた文字列にはならない)。

テストの追加

activerecord/test/cases/migration/foreign_key_test.rb に PostgreSQL 専用のテストを追加:

  • test_foreign_key_to_table_in_quoted_schema
    • 混在ケースのスキーマ "Aerospace" を作成
    • "Aerospace".boosters テーブルを作り、別テーブルから外部キーを張る
    • connection.foreign_keys(...).first.to_table"Aerospace.boosters" であることを検証

その他、既存テスト (foreign_key_test.rb, references_foreign_key_test.rb, schema_test.rb) が PG 17 でグリーンであることも確認済みです。


  1. 影響範囲・注意点
  • 対象DB: PostgreSQL のみ。他DBアダプタには影響なし。

  • 主な影響箇所:

    • ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#foreign_keys
    • これを間接的に利用する rails db:schema:dump / db:structure:dump などのスキーマダンプ機能
  • 影響するアプリの特徴:

    • PostgreSQL を使用している
    • クォートされたスキーマ名 (例: "App", "Aerospace") を利用している
    • そのスキーマ内のテーブルを参照する外部キーがある
  • 互換性上の懸念点:

    • アンバグなケース(スキーマなしテーブル名、普通のスネークケーススキーマ名)は動作に変化なし。
    • 参照先テーブル名の解釈ロジックがより厳密かつ正確になっただけなので、正しく設定されている環境では挙動悪化は考えにくい。
    • 逆に、これまで「壊れた schema.rb を前提にしたワークアラウンド」を入れていた場合は、そのワークアラウンドが不要/有害になる可能性があるため、該当プロジェクトでは差分を注意して確認した方がよいです。
  • 既存の壊れた schema.rb について:

    • この PR は「新たにダンプされるスキーマを直す」ものであり、すでに壊れた schema.rb 自体を自動修正するものではありません。
    • もし現状の schema.rb"App\".customer" のような文字列が含まれている場合は、この修正を含む Rails バージョンに上げた上で、DB から改めて db:schema:dump し直すことを推奨します。

  1. 参考情報 (あれば)
  • この PR:
    • Fix PostgreSQL foreign_keys for a target table in a quoted schema (#57561)
  • 関連 PR:
    • reset_column_sequences! がクォートされたスキーマでクラッシュする問題の sibling PR: #57563
      (同じ根本原因だが呼び出し元が異なる問題を別途修正)
  • 過去の関連議論(クロススキーマFK全般の話であり、このバグそのものではない):
    • #16907
    • #28654

#57547 Test Mime::Type#=== and nil matching

マージ日: 2026/6/3 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    このPRは、Mime::Type に既に存在している挙動(#=== の配列マッチと、#=~ / #match?nil ガード)に対するテストを追加するだけの変更です。プロダクションコードの変更は一切なく、テストカバレッジを補完する目的のPRです。

  1. 変更内容の詳細

変更ファイルは 1 つのみです。

  • actionpack/test/dispatch/mime_type_test.rb (+10/-0)

追加されたテストで確認しているのは主に次の2点です。

(1) Mime::Type#=== が「配列」に対してもマッチすること

Mime::Type#=== は、通常の case ... when での利用を想定しており、

ruby
case request.format
when Mime[:html]
  ...
when Mime[:json]
  ...
end

のように使われますが、「複数の MIME Type をまとめた配列」 に対してもマッチする仕様になっています。

PR説明中の "matches when the type is included in a given array" というのは、例えば次のようなパターンです。

ruby
type = Mime[:html]
list = [Mime[:html], Mime[:json]]

type === list  # => true (配列に含まれているので true)

または case/when での利用イメージだと:

ruby
formats = [Mime[:html], Mime[:json]]

case formats
when Mime[:html]
  # "html" が formats 配列に含まれているためマッチする
end

この 「配列を渡したときに、その配列内に self (Mime::Type インスタンス) が含まれていれば true を返す」 という挙動に対して、これまでテストが存在していなかったため、専用のテストが追加されています。

(2) Mime::Type#=~ / Mime::Type#match?nil を安全に扱うこと

Mime::Type#=~Mime::Type#match? は、引数が nil のときに例外を出さず、常に false を返すようにガードされています。

例:

ruby
html = Mime[:html]

html =~ nil       # => false
html.match?(nil)  # => false

従来からこのガードは存在していましたが、挙動を担保するテストがなかったため、今回のPRで nil を渡した場合に false が返ることを確認するテストが追加されました。


  1. 影響範囲・注意点
  • プロダクションコードの変更は一切なく、テストコードのみの追加です。
  • 既存の Mime::Type#=== / #=~ / #match? の挙動には変更がありません。
  • したがって、アプリケーション側の挙動の変化や後方互換性の問題はありません。
  • ただし、今回テストで明示的にカバーされたことで、
    • Mime::Type#=== が配列を受けたときに「自身がその配列に含まれているかどうか」で判定する、
    • #=~ / #match?nil を渡した場合は常に false を返す、 という挙動が仕様としてより強く固定化されたと解釈できます。将来この挙動を変える場合はテストの修正が必要になります。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57547
  • Mime::Type は主に ActionDispatch / ActionPack の一部として、request.format などのコンテンツネゴシエーションやレスポンスのフォーマット判定に利用されます。
  • case / whenMime::Type を使う際に、複数フォーマットを一度に扱いたい場合や、nil が来る可能性のある入力をマッチングに使う場合の挙動確認として、このPRで追加されたテストが仕様の参考になります。

#57556 Test ParameterFilter#filter with empty filters returns a dup

マージ日: 2026/6/3 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActiveSupport::ParameterFilter#filter に対して、「フィルタが空のときは params.dup を返す」という振る舞いを確認するテストが追加された PR です。
    本番コードの変更はなく、テストコードのみの追加です。

  1. 変更内容の詳細

何をテストしているか

ActiveSupport::ParameterFilter は、指定したキーをマスクしたりするためのユーティリティですが、フィルタ条件が空配列 / 空リストのときは、実装上「高速経路」として params.dup を返すようになっています。

元々は、クラスメソッド風の filter_param に対しては「空フィルタ時の挙動」がテストされていましたが、インスタンスメソッドの #filter については同じパターンのテストが存在しなかったため、そのカバレッジを追加した PR です。

テストでは以下の2点を確認しています:

  1. filter の返り値の内容が元の params と「等しい」
    ruby
    assert_equal params, filtered
  2. ただし、同一オブジェクトではなく複製 (dup) であること
    ruby
    refute_same params, filtered

イメージとしては、下記のようなテストケースが追加されています(概念的なコード例):

ruby
def test_filter_with_empty_filters_returns_dup
  params = { "foo" => "bar" }
  filter = ActiveSupport::ParameterFilter.new([])

  filtered = filter.filter(params)

  # 内容は同じ
  assert_equal params, filtered
  # だがオブジェクトは別(dupであることを期待)
  refute_same params, filtered
end

このようにして、「空のフィルタでもオブジェクトをそのまま返さず必ず dup している」という既存仕様をテストで明示的に固定しています。


  1. 影響範囲・注意点
  • 本番コードは一切変更されていないため、ランタイムの挙動・パフォーマンスに直接の影響はありません
  • ただしテストが追加されたことで、以下のような将来の変更が仕様としてロックインされたと解釈できます:
    • フィルタが空でも #filter は「同じハッシュオブジェクトをそのまま返さず、必ず複製して返す」こと。
  • そのため、将来 ParameterFilter の実装を最適化して「空フィルタ時はそのまま同一オブジェクトを返す」というような変更をした場合、このテストが落ちるようになります。
    • つまり、「呼び出し側が filter の結果を破壊的に変更しても元の params に影響しない」前提が仕様として保証される方向に強化されています。
  • ParameterFilter#filter の呼び出し側で、「返ってきたオブジェクトが params と同一であること」に依存したコードを書いていた場合(通常はないことが望ましいですが)、テスト上は今後もそれは成立しない前提となります。

  1. 参考情報 (あれば)

#57554 Test Object#with returns the block's result

マージ日: 2026/6/3 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    このPRは、Object#with が「ブロックに self を渡すだけでなく、そのブロックの戻り値自体を返す」ことをテストで明示的に保証するものです。プロダクションコードの変更はなく、テスト追加のみです。

  1. 変更内容の詳細

対象ファイル:

  • activesupport/test/core_ext/object/with_test.rb (+5/-0)

Object#with の振る舞いは以下のようになっています(概念的な例):

ruby
obj.with do |o|
  # o は obj (self)
  :result
end
# => :result が返ることを保証したい

既存テストでは、「ブロックに self が渡されること」は確認していたものの、
ブロックの戻り値が Object#with の戻り値としてそのまま返ることが明示的に検証されていませんでした。

今回のPRで追加されたテストは、おおよそ次のようなことを確認しています(擬似コード):

ruby
result = some_object.with do |obj|
  1234  # obj を使うかどうかに関係なく、この値を返す
end

assert_equal 1234, result

ポイント:

  • ブロック内で返している値が任意の値(オブジェクト)であること
  • その値が Object#with の戻り値として「そのまま」外に出てくること
    → 「with はブロックの評価結果を返す」という仕様をテストで固定

これにより、単に「self がブロックに渡されているだけ」ではなく、「ブロックの戻り値が with の戻り値である」というインターフェースが明確に保証されます。


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

    • ActiveSupport の Object#with の挙動に関する“テストカバレッジ”のみが拡張されます。
    • 実際の実装には一切変更がないため、既存アプリケーションの挙動は変わりません。
  • 注意点:

    • 今後 Object#with の実装を変更する場合、このテストにより「ブロックの戻り値をそのまま返すこと」が破壊されないようにする必要があります。
    • with を利用する側は、「ブロックの最後の評価結果がメソッドの戻り値になる」ことを前提としてコードを書いて良いことが、テストにより明確に裏付けられました。

  1. 参考情報 (あれば)
  • 対象メソッド: Object#with(ActiveSupport コア拡張)
    • パターンとしては Kotlin の apply / also や Ruby の tap に近いユーティリティで、「レシーバをブロックに渡しつつ、ブロックの評価結果を返す」系のメソッドです。
  • PR番号: https://github.com/rails/rails/pull/57554

#57553 Fix Enumerable#in_order_of dropping nil elements when filter: false

マージ日: 2026/6/3 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    Enumerable#in_order_offilter: false を指定した場合、本来は元の配列に含まれる nil も保持されるべきところが、誤って削除されていたバグが修正されました。sort_by ベースの処理で不要な compact を取り除き、仕様どおり nil を含めて返すようになっています。

  1. 変更内容の詳細

問題の挙動

Enumerable#in_order_of は、あるキー (key ブロックやシンボル) を元に要素を並べ替えるメソッドで、filter: オプションの挙動は以下のようにドキュメントされています。

  • filter: true(デフォルト)
    series に含まれるキーに該当する要素だけを返す(それ以外は除外)
  • filter: false
    series に含まれない要素も「落とさず」返す

今回のバグは、filter: false のときに、元の Enumerable に含まれていた本物の nil 要素まで compact によって削除されてしまっていた点です。

PR の説明より、以前と以後の挙動は以下のとおりです。

ruby
[3, nil, 1, 2].in_order_of(:itself, [1, 2, 3], filter: false)
# 変更前: [1, 2, 3]        # nil が消えてしまう
# 変更後: [1, 2, 3, nil]   # nil が正しく保持される

compact が誤っていた理由

in_order_of の実装には大きく2パターンあります。

  1. filter: true のとき

    • 実装イメージ:
      ruby
      grouped = collection.group_by(&key)
      ordered = grouped.values_at(*series) # series にないキーは nil になる
      ordered.flatten.compact
    • values_at(*series) は、series の各エントリについて、対応する要素が存在しないと nil を返すため、compact でこれら「ダミーの nil」を消す必要があります。
  2. filter: false のとき

    • 実装イメージ(概念的):
      ruby
      collection.sort_by do |element|
        # series 上の位置を優先し、見つからなければ後ろに回す
      end
      # => sort_by 自体は nil を生成しない
    • ここでは sort_by で並び替えているだけなので、処理の途中で新たな nil が生成されることはありません。存在するのは「元から配列にいた本物の nil」だけです。
    • したがって、このブランチで compact を呼ぶと、本来保持すべき nil まで削除してしまい、ドキュメントの「filter: false のときは追加要素も落とさない」という契約を破っていました。

実際の変更内容

  • filter: false 側の処理から compact を 1 箇所削除
  • この挙動を保証するリグレッションテストを enumerable_test.rb に追加
    • 想定されるテストイメージ:
      ruby
      def test_in_order_of_with_filter_false_keeps_nil
        assert_equal [1, 2, 3, nil],
          [3, nil, 1, 2].in_order_of(:itself, [1, 2, 3], filter: false)
      end
  • CHANGELOG にエントリを追加

  1. 影響範囲・注意点
  • 影響範囲:
    • Enumerable#in_order_offilter: false 付きで利用しているコード全般。
    • 特に「nil が除外される」ことを前提にワークアラウンドしていた場合、その挙動が変わります。
  • 期待される正しい挙動:
    • filter: false のときは、series に含まれない要素(nil を含む)も結果に残るようになります。
  • 注意点:
    • これまで「in_order_of(..., filter: false) を使うと nil が落ちる」と誤解して利用していた場合、この PR マージ後に nil が残るようになるため、必要なら明示的に compact するなどの対応が必要です。
    • filter: true 側の挙動は変わっていないため、series に存在しないキーに対する「ダミーの nil」は引き続き削除されます。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57553
  • 該当コード: activesupport/lib/active_support/core_ext/enumerable.rb
  • テスト: activesupport/test/core_ext/enumerable_test.rb
  • 関連ドキュメント: Enumerable#in_order_offilter オプションの説明(Rails API ドキュメント / guides)

#57548 Test Range#sole with an endless range

マージ日: 2026/6/3 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    このPRは、Range#sole が「終端なしの範囲(endless range)」に対して SoleItemExpectedError を投げることを確認するテストを追加するものです。既にあった「始端なしの範囲(beginless range)」向けテストに対応する形で、テストカバレッジを補完しており、本番コードの変更はありません。

  2. 変更内容の詳細

  • 対象機能: ActiveSupport に追加されている Range#sole

    • Range#sole は「範囲内に要素がちょうど1つだけ存在する場合にその要素を返し、それ以外の場合は例外を投げる」ユーティリティメソッドです(ActiveSupport::SoleItemExpectedError など)。
  • 既存の挙動:

    • Range#sole には、以下のようなガードがあります(PR説明より):
      ruby
      if self.begin.nil? || self.end.nil?
        raise ActiveSupport::SoleItemExpectedError
      end
      つまり、
      • beginless range: (..1)self.begin.nil? #=> true
      • endless range: (1..)self.end.nil? #=> true のどちらも「無限範囲」とみなして SoleItemExpectedError を投げる設計です。
  • これまでのテスト状況:

    • beginless range のケースはすでにテストされていました:
      ruby
      assert_raises(ActiveSupport::SoleItemExpectedError) { (..1).sole }
    • 一方で、endless range のケース:
      ruby
      (1..).sole
      が例外を投げることは、テストでカバーされていませんでした。
  • このPRでの変更:

    • activesupport/test/core_ext/range_ext_test.rb に4行追加し、
      • (1..).soleActiveSupport::SoleItemExpectedError を投げることを確認するテストを追加。
    • これにより、「self.begin.nil? || self.end.nil? の両側」がテストで担保されるようになっています。

    追加されるテストのイメージ(擬似コード):

    ruby
    def test_sole_with_endless_range_raises
      assert_raises(ActiveSupport::SoleItemExpectedError) do
        (1..).sole
      end
    end
  1. 影響範囲・注意点
  • 影響範囲:

    • 変更はテストコードのみであり、ランタイム挙動や公開APIに変更はありません。
    • すでに実装されている「無限 range に対する Range#sole の例外スロー仕様」をテストで明示的に保証することによって、将来的なリグレッションを防止する効果があります。
  • 注意点:

    • 開発者視点では、Range#sole は以下のように振る舞うことが前提として固まっていると考えてよいです:
      • 有限範囲で要素が1つのみ → その要素を返す
      • 有限範囲で要素0個または2個以上 → SoleItemExpectedError などの例外
      • beginless range ((..x)) および endless range ((x..)): 「無限 range」とみなして常に SoleItemExpectedError
    • 無限 range に対して sole を使ってはいけない、という仕様がテストでより強固に固定されるため、将来この仕様を変えたい場合は、テスト変更を含めて明示的な設計変更が必要になります。
  1. 参考情報 (あれば)
  • 対象メソッド: ActiveSupportRange#sole
    ドキュメント:
    • Rails ガイドでは sole は主に ActiveRecord::Relation#sole として紹介されますが、Enumerable#sole / Range#sole も同様の振る舞いをします。
  • 関連仕様:
    • Ruby 2.6以降で導入された beginless range (..x) と endless range (x..) に対し、Rails は「無限集合」とみなして sole を禁止するポリシーを取っていることが、このPRのテストからも読み取れます。

#57532 Fix grouped calculations by a belongs_to association with a composite primary key model

マージ日: 2026/6/2 | 作成者: @55728

  1. 概要 (1-2文で)
    belongs_to 先が複合主キーを持つモデルの場合に group(:association).count などの集計が ArgumentError で落ちていた問題を修正した PR です。従来の単一カラム主キーと同様に、「関連オブジェクトをキーにしたグループ集計」が複合主キーでも動作するようになります。

  1. 変更内容の詳細

何が問題だったか

次のようなモデル構成を考えます。

ruby
class Order < ApplicationRecord
  self.primary_key = [:shop_id, :id] # 複合主キー
end

class Book < ApplicationRecord
  belongs_to :order, foreign_key: [:shop_id, :order_id]
end

このとき、

ruby
Book.group(:order).count

を実行すると、本来は

ruby
{ #&lt;Order ...> => 3, #&lt;Order ...> => 5, ... }

のように「Order オブジェクトをキーとしたハッシュ」が返ってきてほしいところですが、実際には以下のエラーになっていました。

text
ArgumentError: Expected corresponding value for ["shop_id", "id"] to be an Array

単一主キー版(例:Comment.group(:post).count)は以前から動いていて、複合主キーのときだけ壊れている状態でした。

原因の詳細

ActiveRecord::Relation#calculate 系メソッドで「関連をキーにしたグループ集計」を行う場合、内部では execute_grouped_calculation が呼ばれ、次のような処理が行われます(擬似コード):

ruby
# 集計結果の生データから、グルーピングに使ったキーの値を抜き出す
key_ids = calculated_data.collect { |row| row[group_aliases.first] }

# 取り出したキーをもとに、関連レコードをまとめて取得
key_records = association.klass.base_class.where(
  association.klass.base_class.primary_key => key_ids
)

records_by_id = key_records.index_by(&:id)

単一主キーであれば、

  • primary_key"id" (文字列)
  • group_aliases.first は、id に対応する集計結果のカラム名
  • key_ids[1, 2, 3, ...] のような一次元配列
  • where(id: [1,2,3,...]) となり問題なく動作

という流れで正しく動きます。

しかし複合主キーのときは、

  • primary_key["shop_id", "id"](配列)
  • 実際に GROUP BY されているカラムは、group_fields = Array(association.foreign_key) で、複数列(例:["shop_id", "order_id"]
  • ところが key_ids の取得では group_aliases.first しか使っておらず、1 列分だけ 抜き出してしまう

その結果、内部的にだいたい次のような形の where が組み立てられます。

ruby
where(["shop_id", "id"] => [1, 2, 3, ...])

このとき、Active Record の PredicateBuilder は「複合主キーなら [[shop_id, id], [shop_id, id], ...] のようなタプル配列が来るはず」と期待しているのに、実際には単なるスカラ値の配列([1,2,3,...])が渡されるため、

text
ArgumentError: Expected corresponding value for ["shop_id", "id"] to be an Array

を投げてしまっていました。

修正内容

この PR では「関連先モデルが複合主キーを持っている場合」に限り、次のように動作を変えています。

  • これまで: group_aliases.first だけを使ってキーを 1 列分だけ拾っていた
  • 修正後: 複合主キーの全カラムに対応する GROUP BY 列を 1 行ごとにタプルとして集める

つまり、ざっくり言うと「key_ids[["shop1", 1], ["shop1", 2], ...] のような“複合キーの配列”を入れる」ようにして、where(primary_key => key_ids)

ruby
where(["shop_id", "id"] => [["shop1", 1], ["shop1", 2], ...])

という形になり、PredicateBuilder の期待する形に合うようにしています。

その後の

ruby
key_records.index_by(&:id)

はもともと複合主キーに対しても「id メソッドが複合キーのタプル([shop_id, id])を返す」設計になっており、今回の修正と整合的です。
単一カラム主キーについては既存の分岐ロジックがそのまま維持されており、挙動は変わりません。

テスト

activerecord/test/cases/calculations_test.rb に以下を満たす回帰テストが追加されています。

  • belongs_to 先が複合主キーを持つ
  • それを group(:association).count でグループ化
  • 結果のキーが「関連オブジェクト(複合主キーのモデル)」になっていることを検証

このテストは:

  • 現行 main ブランチでは ArgumentError で落ちる(red)
  • 修正適用後は sqlite3 / postgresql の両方でグリーン

であることが確認されています。


  1. 影響範囲・注意点
  • 影響する機能

    • ActiveRecord::Relation のグループ集計(count, sum, average, minimum, maximum など)で、グループキーに belongs_to 関連を指定したケース
    • かつ、その belongs_to の参照先モデルが 複合主キー を持つ場合
    • 例: Book.group(:order).countBook.group(:order).sum(:price) など
  • 単一主キーのモデルに対しては挙動は変わらず、後方互換性を壊す変更ではありません。

  • 複合主キー関連を使っているアプリでは:

    • これまで group(:association) を避けて自前で JOIN + GROUP BY していたようなケースを、Rails 標準の API に置き換えられる可能性があります。
    • 逆に、アプリ側でこのバグを前提に「例外発生」を利用したワークアラウンドを書いていた場合は、その挙動が変わる点に注意が必要です(一般には少ないはずです)。
  • 複合主キーサポート全体としては、まだ Rails の公式サポートが「どこまでを保証するか」という課題は残っているものの、少なくともこの経路(group(:belongs_to) → 関連オブジェクトキーでの計算結果)は動作するようになります。


  1. 参考情報 (あれば)
  • 変更ファイル

    • activerecord/lib/active_record/relation/calculations.rb
      • execute_grouped_calculation 付近で、複合主キー対応のキー収集ロジックを追加
    • activerecord/test/cases/calculations_test.rb
      • 複合主キー belongs_to を使った group(:association).count の回帰テストを追加
    • activerecord/CHANGELOG.md
      • バグ修正としてエントリ追加
  • 関連する概念

    • Active Record の「複合主キー」は依然として標準機能としては限定的な扱いですが、この PR のように局所的なサポート改善が徐々に進んでいます。
    • index_by(&:id) は複合主キーを持つモデルに対しても「id が複合キーの配列を返す」ことを前提にしており、今回の形状修正とよく噛み合っています。

#57539 Reverse default_order in reverse_order instead of discarding it

マージ日: 2026/6/2 | 作成者: @55728

  1. 概要 (1-2文で)
    Rails の ActiveRecord::Relation#reverse_order が、default_order だけで並び替えられている場合にその並び順を正しく反転せず、主キー降順に差し替えてしまっていたバグを修正する PR です。明示的な order と同様に、default_orderreverse_order で正しく反転されるようになります。

  1. 変更内容の詳細

これまでの挙動

default_order のみが設定されている relation に対して reverse_order を呼ぶと、default_order が無視されて PK(主キー) 降順に置き換えられていました。

ruby
Model.default_order("name ASC").to_sql
# => "... ORDER BY name ASC"

Model.default_order("name ASC").reverse_order.to_sql
# => "... ORDER BY \"models\".\"id\" DESC"   # 本来は name DESC になってほしい

Model.order("name ASC").reverse_order.to_sql
# => "... ORDER BY name DESC"               # 明示的 order の場合は正しく動く

原因は、reverse_order!order_values だけ を見て処理していたことです。

ruby
def reverse_order! # :nodoc:
  orders = order_values.compact_blank
  self.order_values = reverse_sql_order(orders)
  self
end
  • default_order だけが設定されている relation では order_values は空配列 []
  • 空配列を reverse_sql_order([]) に渡すと、「明示的な順序なし」とみなされ、内部的な _reverse_order_columns (PK DESC) が返る
  • それが order_values に設定されるため、その後の build_orderdefault_order_values を一切見なくなる
    → 結果として、「default_order を消して PK 降順にする」誤った挙動になっていた

そもそも default_order 機能を導入したコミットでは、build_orderordered_relation は「order_values が空のときは default_order_values を使う」というフォールバックを持つように変更されていましたが、reverse_order! 側の知識更新が漏れていた、という位置づけです。

修正内容

reverse_order! にも default_order_values の存在を考慮させ、明示的 order がない場合は default_order を反転するようにしました。

ruby
def reverse_order! # :nodoc:
  orders = order_values.compact_blank

  if orders.empty? && (default_orders = default_order_values.compact_blank).any?
    self.default_order_values = reverse_sql_order(default_orders)
  else
    self.order_values = reverse_sql_order(orders)
  end

  self
end

ポイント:

  • order_values に有効な order がある場合
    → これまで通り order_values を反転 (明示的 order 優先の挙動はそのまま)
  • order_values が空で、default_order_values が存在する場合
    default_order_valuesreverse_sql_order に通して default_order_values 自体を反転
    (ここが新挙動)

この実装により、以下が成立します:

ruby
# 1. default_order だけの場合 → その default_order が反転される
Model.default_order("name ASC").reverse_order.to_sql
# => "... ORDER BY name DESC"

# 2. default_order があるが、明示的 order を追加した場合 → 明示的 order を反転
Model.default_order("name ASC").order("id").reverse_order.to_sql
# => "... ORDER BY id DESC"  # default_order はあくまでフォールバック

# 3. default_order も明示的 order もない場合
#    → 今まで通り PK DESC か IrreversibleOrderError の挙動を維持
Model.all.reverse_order.to_sql
# => これまでと同じ PK 降順 or IrreversibleOrderError

テスト

activerecord/test/cases/relations_test.rb に以下のような意図のテストが追加されています:

  • default_order("title ASC").reverse_order の SQL が ORDER BY title DESC になること
  • 明示的な order("title ASC")reverse_order と同じ並び替え指定になること
  • 主キーによるフォールバックが用いられていないこと (PK 名が含まれないこと)

テストは sqlite3/postgresql/mysql2/trilogy でグリーンとのことです。


  1. 影響範囲・注意点
  • 影響するのは default_order を使っているコードで、かつその relation に対して reverse_order を呼んでいるケース に限定されます。
    • 具体例:
      • モデルやスコープで default_order "created_at ASC" を定義し、ビューや API で reverse_order を使って「新しい順」「古い順」を切り替えている場合など
  • これまで:
    • default_order のみ → reverse_order すると PK DESC でソート (事実上バグ・silent wrong result)
  • これから:
    • default_order のみ → その default_order を単純反転
  • 明示的 order を使っているコードの挙動は変わりません (order_values が優先されるロジックはそのまま)

注意点 / マイグレーション的観点:

  • もし既存アプリが「このバグ前提の挙動 (default_order を無視して PK で降順になる)」を暗に利用していた場合、reverse_order の結果が変わる可能性があります。
    • 例: default_order("name ASC") を定義したが、「reverse_order したらなぜか id DESC になる」挙動を仕様として組み込んでいた場合
  • 通常は「default_order を反転してほしい」と考える方が自然なので、ほとんどのアプリにとっては バグ修正による改善 になり、破壊的と見なさないケースが多いと思われますが、挙動が変わる点は留意が必要です。

  1. 参考情報 (あれば)
  • この PR とは別に、同じ default_order 機能周りで以下の既知バグがあると記載されています:
    • has_many default_order: が、関連がロード済みのときに無視される問題 (#57538)
      → こちらは relation/query_methods ではなくアソシエーション層の問題で、今回の修正とは独立しています。
  • 実装の観点では、「build_order / ordered_relation と同様、reverse_order!default_order_values を考慮するようにした」という一貫性の確保が主眼です。

#57546 Test ContentDisposition.format class method

マージ日: 2026/6/2 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActionDispatch::Http::ContentDisposition.format クラスメソッドに対して、これまで存在していなかった直接のテストを追加した PR です。プロダクションコードの変更は一切なく、テストコードのみの追加です。

  2. 変更内容の詳細(あればサンプルコードも含めて)

  • 対象

    • ActionDispatch::Http::ContentDisposition.format というクラスメソッド
      • Content-Disposition ヘッダ文字列を組み立てるための公開ショートカットメソッド
      • データストリーミング処理や ActiveStorage 内部で利用される想定の API
  • これまでの問題点

    • 既存テストは ActionDispatch::Http::ContentDisposition のインスタンスを生成し、#to_s を呼ぶ形でのみ挙動を検証していた
    • そのため、公開 API としての .format 自体がテストされていなかった(未カバー)
  • 今回の変更

    • actionpack/test/dispatch/content_disposition_test.rb.format 用のテストを追加(+10行)
    • .format を以下の2パターンでテスト:
      1. ファイル名なしでの呼び出し
      2. ファイル名ありでの呼び出し

    想定されるテストイメージは以下のような形です(概念的なサンプル):

    ruby
    def test_format_without_filename
      header = ActionDispatch::Http::ContentDisposition.format(disposition: "attachment")
      assert_equal "attachment", header
    end
    
    def test_format_with_filename
      header = ActionDispatch::Http::ContentDisposition.format(
        disposition: "attachment",
        filename: "report.pdf"
      )
      assert_equal 'attachment; filename="report.pdf"; filename*=UTF-8\'\'report.pdf', header
    end

    実際の期待値文字列やオプションは内部仕様に依存しますが、PR では「.format を直接呼び出し、その戻り値(ヘッダ文字列)を検証する」形でカバレッジを追加しています。

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

    • Rails 利用アプリの挙動には変更なし
    • ActionDispatch::Http::ContentDisposition.format を既に使っているコードへの互換性影響もなし
    • 主な影響は「テストカバレッジが正しく .format にも及ぶようになった」ことのみ
  • 注意点

    • .format の挙動自体は変わっていないため、これを契機に API の仕様変更などが行われたわけではない
    • ただし、今後 .format の実装を変更した際にはこのテストが壊れることでリグレッションを検知できるようになったため、**.format の振る舞いが事実上「仕様として固定化されやすくなった」**とも言える
    • .format を直接利用しているライブラリやアプリの開発者にとっては、今後このテストが仕様の参考になる
  1. 参考情報 (あれば)
  • 対象クラス: ActionDispatch::Http::ContentDisposition
  • 主な利用箇所の例(Rails 本体側)
    • ストリーミングレスポンス (send_data, send_file など) のヘッダ生成
    • ActiveStorage のファイルダウンロードレスポンスヘッダ生成
  • この PR: https://github.com/rails/rails/pull/57546

#57544 Test ValidationError exposes model and message

マージ日: 2026/6/2 | 作成者: @hammadxcm

  1. 概要 (1–2文で)
    ActiveModel::ValidationError に対するテストを拡充し、validate! 実行時に発生する例外から #model とエラーメッセージが期待どおり取得できることを検証する変更です。プロダクションコードの変更はなく、テストコードのみの追加です。

  1. 変更内容の詳細

対象ファイル:

  • activemodel/test/cases/validations_test.rb (+12/-0)

主なポイント:

  • ActiveModel::ValidationError はドキュメント上、以下を保証しています:

    • #model メソッドで、バリデーションに失敗したモデルインスタンスを取得できる。
    • 例外メッセージは "Validation failed: ..." という形式で生成される。
  • これまでの validate! のテストは「例外が発生すること」だけを確認しており、

    • どのモデルインスタンスが ValidationError に入ってくるか (error.model)
    • 例外メッセージ (error.message)
      については検証していませんでした。
  • 今回のPRでは、validate! を呼び出して ActiveModel::ValidationError が発生したときに、

    1. error.model が元のモデルインスタンスと同一であること
    2. error.message"Validation failed: ..." 形式(かつ、具体的なエラー内容を含む)になっていること

    を明示的にアサートするテストが追加されています。

おおよそのイメージとして、以下のようなテストが追加されていると考えられます(疑似コード):

ruby
def test_validate_bang_exposes_model_and_message
  user = User.new # 何らかのバリデーション付きモデル
  assert_raises(ActiveModel::ValidationError) do
    user.validate!
  end.tap do |error|
    assert_equal user, error.model
    assert_match "Validation failed:", error.message
    # 必要に応じて特定のエラーメッセージ文字列も確認
  end
end

実際のPRでは上記と同等のアサーションを activemodel/test/cases/validations_test.rb に数行追加しているだけで、テストケースは 1 つ増える程度の小さな差分です。


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

    • テストコードのみの変更のため、Rails を利用するアプリケーションの挙動や公開APIには一切影響しません。
    • CI でのテストカバレッジ向上と、ActiveModel::ValidationError の仕様に対する回帰検知能力が高まります。
  • 注意点:

    • このPRが前提としている仕様(ValidationError#model"Validation failed: ..." 形式のメッセージ)はすでにドキュメント化・実装済みのものです。
      → もし将来的にこの仕様を変更する場合(例: メッセージフォーマットを変える)には、今回追加されたテストが落ちることになります。
    • 独自に ActiveModel::ValidationError をラップ・再生成しているコードがある場合は、Rails 本体と同じインターフェース (#model とメッセージ形式) を維持しているか確認しておくと、今後の互換性維持に役立ちます。

  1. 参考情報 (あれば)
  • 対象クラス:

    • ActiveModel::ValidationError
      • ドキュメント: Active Model のバリデーションエラー用例外クラスで、record.validate!model.validate! でバリデーションに失敗したときに発生します。
      • 典型的な利用例:
        ruby
        begin
          user.validate!
        rescue ActiveModel::ValidationError => e
          # e.model で失敗した user インスタンスにアクセス
          # e.message は "Validation failed: Name can't be blank" など
        end
  • このPRの位置付け:

    • 「ドキュメントされている仕様をテストでカバーする」タイプの変更であり、今後のリファクタリングやメッセージ生成ロジック変更時に、仕様が誤って壊されないようにするための安全網になっています。

#57543 Test ArrayInquirer#any? without candidates

マージ日: 2026/6/2 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActiveSupport::ArrayInquirer#any? を引数なしで呼び出した場合の挙動(配列に要素があるかどうかを返す)を確認するテストが追加された PR です。
    本番コードへの変更はなく、テストカバレッジのみが拡充されています。

  1. 変更内容の詳細
  • 対象クラス: ActiveSupport::ArrayInquirer
  • 対象メソッド: #any?

ArrayInquirer#any? にはもともと以下の3パターンの利用形態があります:

  1. 引数あり:

    ruby
    inquirer.any?(:phone, :tablet)

    → 渡した候補のうち一つでも含まれているか判定。

  2. ブロックあり:

    ruby
    inquirer.any? { |v| v.to_s.start_with?("p") }

    → 内部配列の要素に対してブロック条件を満たすものがあるか判定。

  3. 引数・ブロックなし(今回テストを追加したケース):

    ruby
    variants = ActiveSupport::ArrayInquirer.new([:phone, :tablet])
    variants.any? # => true
    
    empty_variants = ActiveSupport::ArrayInquirer.new([])
    empty_variants.any? # => false

    Array#any? と同様に、「配列が空でないかどうか」を返す。

今回の PR では、この「引数なし」のケースについて、以下のようなテストが activesupport/test/array_inquirer_test.rb に追加されています(イメージ):

ruby
def test_any_without_arguments_for_populated_inquirer
  inquirer = ActiveSupport::ArrayInquirer.new([:phone, :tablet])
  assert_equal true, inquirer.any?
end

def test_any_without_arguments_for_empty_inquirer
  inquirer = ActiveSupport::ArrayInquirer.new([])
  assert_equal false, inquirer.any?
end

実際には 5 行程度のテスト追加のみで、本体コードには一切手が入っていません。


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

    • 実行時の挙動は一切変わりません(既存の ArrayInquirer#any? の仕様をテストで明示しただけ)。
    • すでに variants.any? のような引数なし呼び出しを利用しているコードの挙動はそのままです。
  • 注意点:

    • この PR により、「ArrayInquirer#any? は引数・ブロックなしの場合、Array#any? と同じく『要素が1つでもあれば true』を返す」という挙動がテストで固定化されます。
    • 将来的にこの挙動を変えようとするとテストが落ちるため、仕様としての重みが増したことになります。
    • テストのみの変更なので、パフォーマンスや互換性への実務的なリスクはありません。

  1. 参考情報 (あれば)
  • 対象メソッド: ActiveSupport::ArrayInquirer#any?
    Rails ガイド等で ArrayInquirer が "variants" などのフラグ判定を簡潔に書くためのユーティリティとして紹介されることがありますが、その一部として any?Array#any? と互換の呼び出し形態を持つことが、この PR によりテストで明確化されました。

#57545 Test BigInteger serializing string values

マージ日: 2026/6/2 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActiveModel::Type::BigInteger#serialize が文字列をどう扱うかについて、既存の挙動をテストで明示的にカバーする PR です。プロダクションコードの変更はなく、テストが 7 行追加されただけです。

  1. 変更内容の詳細

対象: activemodel/test/cases/type/big_integer_test.rb

この PR では、ActiveModel::Type::BigInteger#serialize に対する以下の「文字列入力時の挙動」をテストで保証しています。

  • 数値文字列の場合
    • 例: "123"123 (Integer)
  • 先頭が数値だが途中から非数値文字が含まれる場合
    • 例: "123abc"123 に切り詰められる
  • 数値を含まない文字列の場合
    • 例: "abc"nil

テスト側では、概ね次のようなケースが追加されていると考えられます(擬似コード):

ruby
def test_serialize_numeric_string
  type = ActiveModel::Type::BigInteger.new
  assert_equal 123, type.serialize("123")
end

def test_serialize_leading_numeric_string
  type = ActiveModel::Type::BigInteger.new
  assert_equal 123, type.serialize("123abc")
end

def test_serialize_non_numeric_string
  type = ActiveModel::Type::BigInteger.new
  assert_nil type.serialize("abc")
end

これにより、これまで Integer や Integer ライクな値("1_000" を to_i するとか、BigDecimal 等)についてしかテストされていなかった部分に、「文字列入力時の分岐」がきちんと含まれるようになっています。


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

    • テストコードのみの追加であり、本体コード (ActiveModel::Type::BigInteger) の挙動変更はありません。
    • 現在すでに存在している「文字列→整数(or nil)」への変換仕様を明文化・固定化する意味合いがあります。
  • 注意点・仕様として改めて意識すべき点

    • "123abc" のような「先頭が数値の文字列」は、エラーではなく 123 としてシリアライズされる
      → 予想に反してサイレントに切り捨てられる可能性があるため、バリデーションや型チェックで厳密性を求める場合は別途対処が必要。
    • 完全に非数値の文字列は nil になる
      → DB カラムが NOT NULL / 外部キーなどの場合、この nil が後続の処理でエラーを引き起こす可能性がある。
    • 今回のテスト追加により、この挙動は今後のリファクタリング時にも「期待される既存仕様」として守られる可能性が高くなります。

  1. 参考情報 (あれば)
  • 該当クラス: ActiveModel::Type::BigInteger
    • Active Record の bigint カラムや類似の用途で使われる型オブジェクトで、serialize は「Ruby オブジェクト → DB 送信値」への変換を担当します。
  • 変換ロジックの背景:
    • ActiveModel の数値系 Type は、多くが to_i / to_s ベースで文字列を解釈しており、その標準挙動(先頭数値だけ読む、非数値は 0 / nil 扱い等)に依存していることが多いです。
  • 実務上の補足:
    • 「ユーザー入力などで混入しうる不正な文字列を BigInteger にそのまま渡した場合、どうなるか」を把握・テストで保証したいアプリケーションでは、この PR のようなテストを自前のアプリ層で追加しておくと挙動がブレにくくなります。

#57541 Test truncate when omission is longer than truncate_to

マージ日: 2026/6/2 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    String#truncate において、:omissiontruncate_to より長い場合の挙動(省略記号だけが返り、結果文字列が truncate_to を超えるケース)をテストでカバーする PR です。プロダクションコードの変更はなく、テスト追加のみです。

  1. 変更内容の詳細
  • 対象メソッド: ActiveSupport::CoreExtensions::String::Filters#truncateString#truncate

  • 既存仕様(ドキュメントに記載されている挙動):

    • truncate_to で指定した最大長を超えないように文字列を切り詰める
    • ただし「元の文字列 (text) と :omission の両方が truncate_to より長い場合」は例外的に、結果の長さが truncate_to を超えうる、という仕様になっている
    • 内部的には「offset が負になる」パスでこの挙動が発生するが、その分岐がテストされていなかった
  • 本PRでの変更:

    • activesupport/test/core_ext/string_ext_test.rb に、以下の条件を満たすテストケースが追加された:
      • :omission の文字列長 > truncate_to
      • その結果として、戻り値が :omission だけになることを確認する
    • 行数としては +5 行程度の小さなテスト追加のみ
  • サンプルイメージ(擬似コード・概念的な例):

    ruby
    text = "Hello world"
    omission = "[TRUNCATED]" # 例: 長さ 11
    truncate_to = 5
    
    result = text.truncate(truncate_to, omission: omission)
    
    # このケースでは result == "[TRUNCATED]" となり、
    # 結果の長さ 11 > truncate_to 5 となることが仕様として許容される。

    この「truncate_to より長い :omission がそのまま返る」挙動が、既にドキュメントに明記されている仕様であり、それに対するテストが追加された形です。


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

    • 本PRはテストコードのみの変更であり、ランタイム挙動や既存アプリケーションへの影響はありません。
    • ただし、「:omission が長い場合に結果が truncate_to を超える」という現在の仕様がテストで固定化されたため、将来的にこの仕様を変える場合は互換性問題として扱う必要が出てきます。
  • 開発者が意識すべきポイント:

    • String#truncate を利用する際、「結果文字列が常に truncate_to 以下になる」と思い込むとバグにつながります。
    • 特に UI レイアウトや DB カラム幅など、「結果文字列長を厳密に制限したい」場面では、次のような考慮が必要です:
      • omissiontruncate_to 以下の長さに抑える
      • あるいは、truncate の後にさらに mb_chars.limit などで二重に長さチェックを行う
    • 今回のテストにより、この挙動は「仕様として意図的にサポートされている」ことがより明確になります。

  1. 参考情報 (あれば)
  • Rails API ドキュメント(String#truncate)には、以下のような文言があります(要旨):
    • 「結果の全体長は truncate_to を超えません。ただし、text:omission の双方が truncate_to より長い場合はこの限りではありません。」
  • このPRは、そのドキュメント記載の「例外ケース(offset が負になる分岐)」に対するテストカバレッジを補完するものです。
  • PR番号: https://github.com/rails/rails/pull/57541

#57540 Test Array offset accessors when out of bounds

マージ日: 2026/6/2 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    Array#second#forty_two および #second_to_last / #third_to_last が、配列の長さが足りない場合に nil を返す挙動についてテストを追加した PR です。プロダクションコードの変更はなく、テストコードのみの追加です。

  1. 変更内容の詳細
  • 対象: activesupport/test/core_ext/array/access_test.rb
  • 目的: 「範囲外アクセス時に nil を返す」というパスをテストでカバーすること。

これまで存在していたテスト test_specific_accessor は、42 要素の配列を使って #second#forty_two を確認していたため、常に有効なインデックスにアクセスしており、「配列が短い場合に nil を返す」動作は検証されていませんでした。

今回の PR では、長さが不足している配列に対して、以下のメソッドが nil を返すことを確認するテストが追加されています。

  • 先頭側のエイリアス
    • Array#second#forty_two
  • 末尾側のエイリアス
    • Array#second_to_last
    • Array#third_to_last

具体的なイメージとしては、例えば次のようなパターンを確認するテストが加えられています(実際のコードイメージ):

ruby
def test_specific_accessor_out_of_bounds
  ary = [1] # 意図的に短い配列
  
  assert_nil ary.second         # 2番目の要素は存在しない
  assert_nil ary.third          # 3番目も存在しない
  # ...
  assert_nil ary.forty_two      # 42番目ももちろん存在しない

  assert_nil ary.second_to_last # 要素1つでは「後ろから2番目」は存在しない
  assert_nil ary.third_to_last  # 同様に存在しない
end

ポイントは、配列の長さ < 要求された n 番目/後ろから n 番目の位置の場合に、例外ではなく nil を返すことを明示的にテストしている点です。


  1. 影響範囲・注意点
  • 影響範囲:
    • 変更はテストコードのみであり、Array の拡張メソッド (second, third, ..., forty_two, second_to_last, third_to_last) の実装には一切手を入れていません。
    • 既存アプリケーションの挙動には影響ありません。
  • 開発者視点での意義:
    • 以前から「範囲外アクセス時は nil を返す」という仕様で動いてはいたものの、それを保証するテストがなかったため、将来のリファクタリング等でこの挙動が壊れた場合に検知できるようになったといえます。
    • これにより、「配列が短いかもしれないが array.third などをそのまま呼んで nil チェックで扱う」といったコードパターンに対して、仕様保証が強化されます。

注意点として、テストが追加されたことで明示的に仕様が固定されたとも解釈できるため、将来的に「範囲外なら例外を投げたい」といった互換性を壊す変更は、より困難になります(少なくとも大きなBreaking Change扱いになる)。


  1. 参考情報 (あれば)
  • 対象メソッドは Active Support の ArrayInquirer ではなく、ActiveSupport::CoreExtensions::Array::Access 系の拡張 (Array#second など) です。
  • 関連する既存テスト: activesupport/test/core_ext/array/access_test.rb 内の test_specific_accessor
    こちらは「配列が十分に長い場合に、各メソッドが正しい要素を返すこと」を確認しており、今回の PR で「短い配列時は nil」という逆パターンのテストが補完されました。

#57542 Test Mime::Type#html? predicate

マージ日: 2026/6/2 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    Mime::Type#html? メソッドの挙動(:htmlシンボル、"html"を含むMIMEタイプ文字列、それ以外でfalse)を直接検証するテストが追加されたPRです。プロダクションコードの変更はなく、テストカバレッジを補完する目的の修正です。

  1. 変更内容の詳細

Mime::Type#html? は以下のような条件で真偽値を返すメソッドです:

  • Mime[:html] のようにシンボルが :html の場合 → true
  • MIMEタイプ文字列に "html" が含まれている場合
    例: Mime::Type.new("application/xhtml+xml")true
  • 上記いずれにも該当しない場合 → false

これまでこのメソッドに対する「直接の」テストが存在しなかったため、以下を対象とするテストが actionpack/test/dispatch/mime_type_test.rb に追加されています(+7行):

  • シンボルが :html である場合に #html?true を返すこと
  • MIMEタイプ文字列に "html" を含む ("application/xhtml+xml" など) 場合に true を返すこと
  • それ以外のMIMEタイプ (text/plain など) では false になること

※ PR本文から読み取れるテストイメージ(概念的な例):

ruby
def test_html_predicate
  assert Mime[:html].html?

  xhtml = Mime::Type.new("application/xhtml+xml")
  assert xhtml.html?

  plain = Mime::Type.new("text/plain")
  assert_not plain.html?
end

あくまで例示ですが、このような「trueパス2種類+falseパス1種類」を明示的にカバーするテストが追加されています。


  1. 影響範囲・注意点
  • 影響範囲:
    • 影響はテストコードのみで、本番コード・APIの仕様変更はありません。
    • 既に実運用されている Mime::Type#html? の振る舞いを、テストとして「仕様として固定」した形になります。
  • 注意点:
    • 将来的に html? の判定仕様(例: "html" 部分一致をもっと厳しく/緩くする)が変わる場合、このテストが落ちることで、仕様変更が意図的かどうかを確認するきっかけになります。
    • カスタムMIMEタイプや xhtml 系の取り扱いで html? の真偽に依存しているコードがある場合、その現在の挙動がテストによって明示されたと捉えられます(「"html" を含めば true になる」という挙動が正式にテストで保証される)。

  1. 参考情報 (あれば)
  • 対象メソッド: Mime::Type#html?
    • 用途の典型例:
      • コントローラやミドルウェア内で request.format.html? のように利用し、「HTML向けレスポンスかどうか」を判定する場面など。
  • 関連ファイル:
    • actionpack/test/dispatch/mime_type_test.rb
      • 各種 Mime::Type 振る舞いのテストが集約されているファイルで、その一部として #html? が明示的にテスト対象に加わった形です。

#56422 ActionView::TestCase#render resets rendered

マージ日: 2026/6/2 | 作成者: @drjayvee

  1. 概要 (1-2文で)
    ActionView::TestCase の #render が、複数回呼び出したときに rendered の内容を正しくリセットできていなかったバグを修正した PRです。メモ化導入 (#51093) によって壊れていた既存の挙動を復元しつつ、content_class をカスタム実装しているケースにも配慮した実装になっています。

  1. 変更内容の詳細

問題の背景

  • ActionView::TestCase では、render を呼ぶと結果が rendered に溜まっていきます。
  • 以前は render 内で @rendered.clear を呼ぶことで、「テストごとの render 呼び出しごとに内容をリセットする」という挙動になっていました。
  • #51093 でメモ化が導入された際、この「renderrendered をリセットする」という挙動が壊れ、render を複数回呼ぶと出力が意図せず蓄積されてしまう状態になっていました。

今回の修正の要点

説明文から読み取れるポイント:

  • ActionView::TestCase#renderrendered をリセットする振る舞いを復元した。
  • 以前は @rendered.clear を直接呼んでいたが、メモ化の影響でそれが正しく動かなくなっていた。
  • さらに、ユーザーが content_class クラス属性を使って @rendered のクラスを差し替えできる仕様があるため、
    • そのオブジェクトが 必ずしも #clear を実装しているとは限らない(契約上必須なのは #<< だけ)。
    • したがって @rendered.clear を前提とする実装は安全ではない。
  • そこで、「@rendered をクリアする」のではなく、「新しい @rendered インスタンスを毎回作る」というアプローチを採用している。

コードのイメージ(実際のコードを単純化した擬似例):

ruby
# 以前 (概念的な挙動)
def render(*args)
  @rendered ||= content_class.new
  @rendered.clear      # この辺が壊れていた & clear を必須としてしまう
  super
end

# 今回の修正後イメージ
def render(*args)
  # 毎回新しいインスタンスを作ることで、「リセット」を保証する
  @rendered = content_class.new
  super
end

テスト (actionview/test/template/test_case_test.rb) では:

  • render を同じテスト内で複数回呼んだときに、rendered が前回の結果を持ち越さないこと。
  • メモ化導入後に壊れていた挙動が正しく戻っていること。

などを確認するテストが追加/調整されています。

CHANGELOG.md には:

  • ActionView に関するバグフィックスとして、
    • ActionView::TestCase#renderrendered を正しくリセットするようになった旨
    • (必要であれば関連 Issue / PR 番号) が追記されています。

  1. 影響範囲・注意点
  • 対象: ActionView::TestCase を使っている ビューのテストコード
  • 影響内容:
    • render を同一テスト内で複数回呼び出したときの rendered の内容が、前回の結果を引き継がず、毎回リセットされるようになります。
    • これはもともとの想定挙動であり、「壊れていたものが直る」形なので、Rails の従来の仕様に沿った変更です。
  • content_class をカスタム実装している場合:
    • これまで #clear が実装されていなくても、今回の修正により #clear の有無に依存しない 実装になっているため、むしろ安全側に振られています。
    • content_class#initialize が毎回呼ばれる前提になるので、もし重い初期化処理をしている場合は、パフォーマンス上の影響がごくわずかに増える可能性がありますが、通常のテスト用途では問題ないレベルと考えられます。

  1. 参考情報 (あれば)
  • 関連 Issue / PR:
    • #56235: 本件の詳細背景が議論されている Issue(この PR 内で参照されている)
    • #51093: ActionView::TestCase にメモ化を導入し、本バグの原因となった変更
  • カスタム content_class を使っているプロジェクトでは、
    • render を複数回呼んだときの rendered の値(およびそのクラスのライフサイクル)が、今回の仕様と整合しているかを軽く確認しておくと安心です。

#57534 Fix disable_joins through associations grouping by composite key

マージ日: 2026/6/2 | 作成者: @55728

  1. 概要 (1-2文で)
    disable_joins: true を指定した has_many/has_one :through で、途中に order があり、かつ関連先が複合キー(複合 primary key / foreign key)を使っている場合に、結果が黙って空配列になってしまうバグを修正しています。
    複合キーを使う関連の in-memory グルーピング処理で、キーの扱い方を正しく複合キー対応にすることで、取得済みレコードが消えてしまう問題を解消しています。

  1. 変更内容の詳細

問題のパス

disable_joins: true を使うと、AR は JOIN を避けるため、ActiveRecord::Associations::DisableJoinsAssociationScope によって一つの JOIN クエリを複数クエリに分解します。
この際、関連チェーンのどこかに order がついていて、かつ最終スコープ側には order がない場合、その order は Ruby 側での in-memory ソート・グルーピングに回されます。

その処理は ActiveRecord::DisableJoinsAssociationRelation#load にあり、問題のコードは:

ruby
records_by_id = records.group_by do |record|
  record[key]
end

records = ids.flat_map { |id| records_by_id[id] }
records.compact!

ここで keyreflection.join_primary_key で、
単一キーなら "id" のような文字列ですが、複合キーの場合は ["shop_id", "id"] のような「カラム名の配列」になります。

  • belongs_to 先が複合 primary key を持つ場合
    (BelongsToReflection#join_primary_keyassociation_primary_key)
  • has_many/has_one 側が複合 foreign key を持つ場合
    (AssociationReflection#join_primary_keyforeign_key)

バグの核心

複合キーの場合でも record[key] と一発で読もうとしているのが問題です。

  • record[["shop_id", "id"]] は、「["shop_id", "id"] という名前の属性」を読む挙動になる
  • 当然そんな属性は存在しないが、ここでは MissingAttributeError にならず、Null 属性として nil が返る
  • 結果として、全レコードのグルーピングキーが nil になり、1つのグループ records_by_id[nil] に全部まとまる
  • しかし ids[ [1, 2], [1, 3], ... ] のような「複合キーのタプル配列」になっているので、
    ruby
    records = ids.flat_map { |id| records_by_id[id] }
    records_by_id[[1, 2]] 等を探しても何も見つからない
  • 結果として records[] になり、その後の compact! でも変わらず、関連は空配列になってしまう

このため、「SQL ではちゃんとレコードが取れているのに、その後の Ruby 側でドロップされて消える」という非常に気付きづらい不具合が起きていました。

修正内容

キーが複合キー(配列)である場合に、配列の各要素カラムを個別に読み出し、同じ構造(配列)でグルーピングキーを構成するように修正しています。

変更後(擬似コード):

ruby
records_by_id = records.group_by do |record|
  if key.is_a?(Array)
    key.map { |column| record[column] }  # ["shop_id", "id"] -> [record["shop_id"], record["id"]]
  else
    record[key]
  end
end
  • 単一カラムキーのケースは従来通り record[key] のままなので、挙動は変えない
  • 複合キーの場合だけ、["shop_id", "id"][record["shop_id"], record["id"]] という配列キーでグルーピングするようになる
  • これにより、ids に入っている複合 ID タプル(例: [1, 2])と records_by_id のキーが一致し、flat_map で正しくレコードが取り出せる

再現ケースの例

テストで使われている構成は以下のようなものです(要約):

ruby
class Cpk::Author < ActiveRecord::Base          # 単一カラム PK
  has_many :ordered_books, -> { order(id: :desc) }, class_name: "Cpk::Book"
  has_many :orders, through: :ordered_books, source: :order
  has_many :no_joins_orders,
           through: :ordered_books,
           source: :order,
           disable_joins: true
end

class Cpk::Book < ActiveRecord::Base            # PK [:author_id, :id]
  belongs_to :order, foreign_key: [:shop_id, :order_id]   # Cpk::Order PK [:shop_id, :id]
end
  • author.orders.to_a
    通常の JOIN を使うパスでは、期待通り [ #&lt;Order ...>, #&lt;Order ...> ] が返る
  • author.no_joins_orders.to_a
    disable_joins: true のパスでは、main ブランチでは [] が返ってしまう
    この PR の修正後は、JOIN ありの場合と同じ正しいリストが返るようになる

テスト追加

  • has_many_through_disable_joins_associations_test.rb
    test_ordered_disable_joins_through_with_composite_primary_key_source を追加
  • 既存の Cpk::Author / Cpk::Book / Cpk::Order モデルを再利用している
  • main ではこのテストが赤(no_joins_orders[])になり、修正適用後に緑になることを確認
  • sqlite3 / postgresql 両方で red→green を確認している
  • 関連するテスト群 (has_many_through_disable_joins_associations_test, has_one_through_disable_joins_associations_test, has_many_through_associations_test, has_many_associations_test) も両アダプタでグリーン

  1. 影響範囲・注意点

影響を受けるのは、以下すべてを満たす関連のみです。

  1. has_many / has_one :through を利用している
  2. 関連定義に disable_joins: true を付けている
  3. 関連チェーン中のどこかに order(...) が指定されている
    (最終スコープに order がない → in-memory ソート経由になるパス)
  4. 途中または最終的な関連先が複合キーを利用している
    • 複合 primary key を持つ belongs_to
    • 複合 foreign key を使う has_many / has_one など

この条件に当てはまる場合、これまで空配列が返っていたのが、実際には関連するレコードが返るようになる ため、以下に注意するとよいです。

  • 既存コードが「空配列である」ことを前提としたワークアラウンドを組んでいた場合、ロジックが変わる可能性がある
    (例: 期待通り動かないので disable_joins を避けるようにしていたが、いつの間にか残っていた、等)
  • この修正により「取得結果が増える」方向に変わるので、セマンティクスとしては本来の挙動に近づきますが、テストが「空を期待していた」場合は見直しが必要です
  • 単一キーの関連や、disable_joins: false / 未指定の関連への影響はありません
  • in-memory グルーピングのロジック側の変更のみで、SQL 発行やクエリパス自体は変わっていません

  1. 参考情報 (あれば)
  • この問題は、最近修正された他の複合キー関連の不具合(findids= writer で配列キーの扱いを誤っていたもの)と「同じファミリー」のバグとされていますが、メカニズムは別です。
    • 以前の問題: 配列キーを「no-op な型キャスト」として誤用
    • 今回の問題: 配列キーを「属性名」として誤用し、record[["shop_id", "id"]] のようなアクセスをしていた
  • activerecord/CHANGELOG.md にも本件が追記されており、disable_joins と複合キーの組み合わせに関する既知の問題がこれでカバーされる形になります。

#57469 Fix FormBuilder#to_partial_path returning nil for non-Builder subclasses

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    FormBuilder#to_partial_path が、クラス名が *Builder で終わらないサブクラスに対して nil を返してしまうバグを修正した PR です。String#sub!String#sub に変更することで、あらゆる FormBuilder サブクラスで常に有効なパーシャルパス文字列が得られるようになります。

  1. 変更内容の詳細

バグの内容

ActionView::Helpers::FormBuilder には、インスタンスをそのまま render できるように to_partial_path / _to_partial_path が定義されています。

現状の実装(バグあり):

ruby
def self._to_partial_path
  @_to_partial_path ||= name.demodulize.underscore.sub!(/_builder$/, "")
end

ここで問題なのは sub!(破壊的メソッド)を使っている点です。

  • "labelled_form_builder".sub!(/_builder$/, "") # => "labelled_form" (マッチするので OK)
  • "admin_form".sub!(/_builder$/, "") # => nil (マッチしないので nil)

sub!マッチしなかった場合 nil を返す仕様のため、クラス名が *_builder で終わらない場合、_to_partial_pathnil になり、そのままキャッシュされてしまいます。結果として:

ruby
class AdminForm < ActionView::Helpers::FormBuilder; end

AdminForm._to_partial_path        # => nil (本来は "admin_form" を期待)
AdminForm.new(...).to_partial_path # => nil
# => render @admin_form で後段が落ちる

Rails コアのテストにある以下のサブクラスも同様に壊れていました:

ruby
class LabelledFormBuilderSubclass < LabelledFormBuilder; end
LabelledFormBuilderSubclass.new(...).to_partial_path
# => nil(本来は "labelled_form_builder_subclass" を期待)

修正内容

sub! を非破壊版の sub に変更し、マッチしない場合も元文字列がそのまま返るようにしました。

diff
 def self._to_partial_path
-  @_to_partial_path ||= name.demodulize.underscore.sub!(/_builder$/, "")
+  @_to_partial_path ||= name.demodulize.underscore.sub(/_builder$/, "")
 end

String#sub はマッチしなければ元の文字列のコピーを返すため、*_builder で終わらない FormBuilder サブクラスでも、_to_partial_path には常に有効な文字列が入ります。

修正後の挙動:

ruby
ActionView::Helpers::FormBuilder._to_partial_path
# => "form" (従来どおり)

LabelledFormBuilder._to_partial_path
# => "labelled_form" (従来どおり。末尾 _builder が削られる)

LabelledFormBuilderSubclass._to_partial_path
# => "labelled_form_builder_subclass" (nil ではなくなる)

class AdminForm < ActionView::Helpers::FormBuilder; end
AdminForm._to_partial_path
# => "admin_form"

つまり:

  • FormBuilder / *Builder で終わる慣習に沿ったクラス
    → これまでどおり末尾 _builder が取り除かれる
  • *Builder で終わらないクラス
    → クラス名をそのまま underscore した文字列が返る(admin_form など)

テストと CHANGELOG

  • 既存の test_form_for_with_labelled_builder_path は「慣習通りの *Builder 名」のケースだけを見ていた
  • そこに、既に定義済みの LabelledFormBuilderSubclass を使って「*Builder で終わらないサブクラス」の回 regresstion テストを追加
  • actionview/test/template/form_helper_test.rb 全体(343 runs / 431 assertions)はグリーン
  • actionview/CHANGELOG.md に、バグ修正としてエントリを追加

  1. 影響範囲・注意点
  • 影響するのは:
    • ActionView::Helpers::FormBuilder を継承したクラス
    • かつクラス名が *Builder で終わらないもの
    • そして render @form_builder_instance のように、to_partial_path ベースでレンダリングしているケース
  • これまで暗黙に壊れていたケースが正常に動くようになる変更であり、既存の慣習 (*Builder でクラスを命名) に従ったコードの挙動は変わりません。
  • _to_partial_path のキャッシュに今まで nil が入っていたケースでは、今後は「期待されるパーシャルパス文字列」が入るようになるだけなので、後方互換性の問題は実質的にありません(nil を意図的に利用していたコードがない限り)。
  • もし FormBuilder サブクラスを *Builder で終わらない名前で定義しており、かつ render @builder のような使い方をしている場合、Rails 更新後に初めてそこが動くようになる可能性があります。その場合は対応するパーシャル(例: app/views/admin_form/_admin_form.html.erb)が存在するか確認してください。

  1. 参考情報 (あれば)
  • 対象メソッド: ActionView::Helpers::FormBuilder._to_partial_path / #to_partial_path
  • 関連する Ruby 仕様:
    • String#sub!(pattern, replacement): マッチしなければ nil
    • String#sub(pattern, replacement): マッチしなければ元文字列のコピーを返す
  • バグの起点:
    • もともとは 2009 年の self.model_name 実装内で sub! が使われており、その後 _to_partial_path へのリネームを経ても引き継がれていた
  • 影響コンポーネント: actionview のみ(3 ファイルの微小変更)

#57531 Fix collection ids= writers raising RecordNotFound for composite primary key models with string ids

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    複合主キーを持つモデルに対して has_many / has_and_belongs_to_manyxxx_ids= を文字列のID配列で呼び出すと、実在するレコードでも ActiveRecord::RecordNotFound が発生していた問題を修正するPRです。
    フォームやJSONから来る「文字列の複合ID」を正しくキャストして関連付けできるようにしています。

  1. 変更内容の詳細

問題の挙動

対象ケース:

ruby
class Author < ApplicationRecord
  has_many :books              # Book.primary_key = [:author_id, :id]
end

ids = author.books.ids         # => [[1, 10], [1, 20]]

author.book_ids = ids                              # OK (整数)
author.book_ids = ids.map { |t| t.map(&:to_s) }    # ❌ RecordNotFound が発生

フォーム (collection_select / チェックボックス) や params、JSON では ID は通常文字列になるため、

ruby
author.book_ids = params[:book_ids]  # params[:book_ids] が [["1","10"], ["1","20"]] のような文字列の配列

としたときに、本来存在する Book レコードが選択されているにもかかわらず ActiveRecord::RecordNotFound が発生していました。

原因

CollectionAssociation#ids_writer のキャスト処理が、複合主キーを想定していなかったことが原因です。

該当コード(単純化):

ruby
pk_type = klass.type_for_attribute(primary_key)
ids.map! { |id| pk_type.cast(id) }
  • 単一主キー: primary_key"id" のような文字列
    type_for_attribute("id")id カラムの型 (integer など) を返す
    cast("1") # => 1 となり、文字列が期待通りに整数へキャストされる

  • 複合主キー: primary_key / association_primary_key["author_id", "id"] のような「カラム名の配列」
    type_for_attribute(["author_id", "id"]) は意味のある型を解決できない
    → 結果として「汎用の ActiveModel::Type::Value」が返る → その cast はほぼ no-op なので、["1", "10"] はそのまま文字列のまま残る

その後の流れ:

  1. DB 検索自体は where(primary_key => ids) で行われるため、DB 側の型変換によりレコードはちゃんと取得できる。
  2. 取得したレコードを Ruby 側で「主キー値をキー」にハッシュ化する際、主キーは 整数タプル になる例:
    ruby
    # 実体はこういうイメージ
    index = {
      [1, 10] => &lt;Book ...>,
      [1, 20] => &lt;Book ...>,
    }
  3. ところが lookup 側は、キャストされていない ["1", "10"] などの 文字列タプルvalues_at に渡す:
    ruby
    index.values_at(["1", "10"], ["1", "20"]) # => どれも見つからない
  4. その結果、期待数と取得数が一致せず「存在しないIDが指定された」と判断し ActiveRecord::RecordNotFound を投げる。

修正内容

複合主キーの場合に、各キー成分ごとに対応するカラム型でキャストするように CollectionAssociation#ids_writer を修正しています。

イメージとしては次のような処理に分岐します(擬似コード):

ruby
if primary_key.is_a?(Array)
  # ["author_id", "id"] のような配列
  column_types = primary_key.map { |key|
    klass.type_for_attribute(key)
  }

  ids.map! do |tuple|
    # tuple は ["1", "10"] のような配列
    tuple.each_with_index.map { |value, i|
      column_types[i].cast(value)  # => [1, 10] にキャストされる
    }
  end
else
  # 従来の単一主キーの処理
  pk_type = klass.type_for_attribute(primary_key)
  ids.map! { |id| pk_type.cast(id) }
end

これにより:

ruby
author.book_ids = [["1", "10"], ["1", "20"]] # も正しく動作

となり、フォームやAPIから渡された文字列複合IDをそのまま xxx_ids= に渡しても問題なく関連付けできるようになります。

この処理は:

  • has_manyxxx_ids=
  • has_and_belongs_to_manyxxx_ids=

の両方で共有されているため、HABTM も同時に修正対象になっています。

テスト

has_many_associations_test.rb に回帰テストが追加されています:

  • 複合主キーの関連に対して
  • 文字列の複合IDを xxx_ids= に渡す
  • 正しく関連づけされ、例外が発生しないことを検証

このテストは main ブランチでは red(バグ再現)、本修正適用後は green になることが確認されています。
sqlite3 / postgresql の両DBアダプタで has_many / has_and_belongs_to_many 関連テスト一式が通過しています。


  1. 影響範囲・注意点
  • 影響を受けるのは「複合主キーを使っているモデル」の has_many / has_and_belongs_to_manyxxx_ids= のみです。
  • 単一主キーのモデルに対する xxx_ids= の挙動・互換性には変更はありません(既存のキャストロジックをそのまま維持)。
  • 実運用上の影響:
    • フォームのチェックボックスや collection_select で複合主キーの関連を選択して params 経由で更新するケース
    • API / JSON で文字列の複合IDを受け取り、そのまま xxx_ids= に突っ込むケース
      これらが RecordNotFound で落ちていた場合に、正常に動作するようになります。
  • has_and_belongs_to_many でも同じ ids_writer を共有しているため、HABTM で複合主キーを用いている場合も自動的に修正が効きます。
  • アプリ側で xxx_ids= を monkey patch している場合:
    • 内部実装に依存したコードを書いていると、今回の内部仕様変更と競合する可能性があるため確認推奨です。

  1. 参考情報 (あれば)
  • このPRは、以下と同じクラスの不具合(primary_key が配列であることを考慮せず type_for_attribute してしまうケース)の一つです:
    • find_by_token_for の修正: コミット 8617a7c
    • find_signed の修正: PR #57245
    • find に対する兄弟修正: PR #57530
  • 変更ファイル:
    • activerecord/CHANGELOG.md: バグ修正の記録を追加
    • activerecord/lib/active_record/associations/collection_association.rb: ids_writer の複合主キー対応
    • activerecord/test/cases/associations/has_many_associations_test.rb: 回帰テスト追加

#57530 Fix find silently returning [] for composite primary key ids passed as strings

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    Rails の複合主キーを持つモデルに対し、find に「文字列の id タプル」を複数渡した場合にレコードが1件も返らず常に [] になっていた不具合を修正する PR です。各主キー列ごとに適切な型キャストを行うことで、単一主キーと同様にパラメータ由来の文字列 id でも正しくレコードが取得されるようになります。

  1. 変更内容の詳細

問題となっていた挙動

対象は「複合主キー+find に複数 id タプルを渡す」ケースです。

ruby
class Book < ApplicationRecord
  self.primary_key = [:author_id, :id]
end

# これは動く (整数タプル)
Book.find([[1, 10], [1, 20]])         # => [#&lt;Book...>, #&lt;Book...>]

# これがバグっていた (文字列タプル)
Book.find([["1", "10"], ["1", "20"]]) # => []  # 本来は 2 件返ってほしい

単一主キーの場合は以前から Model.find("1") などの文字列 id を自動で型キャストしており問題ありませんでしたが、複合主キーで「2件以上の id タプル」を渡した場合だけ、例外も出さずに空配列を返すという非常に発見しづらい不具合になっていました。

バグの原因

内部的には FinderMethods#find_some_ordered が次の手順で動きます:

  1. where(primary_key => ids) で DB から行を取得

    • ここでは DB 側が文字列 "1" を整数 1 に暗黙キャストするため、行自体は正しくヒットしている。
  2. 取得した結果を result.in_order_of(:id, casted_ids) で指定した id の順序に並び替え・フィルタ

    • casted_ids の生成に次のコードを使っていた:

      ruby
      ids.map { |id| model.type_for_attribute(primary_key).cast(id) }

ここでの問題点は、複合主キーの primary_key が配列 ["author_id", "id"] であることです。

  • model.type_for_attribute(primary_key) に「配列のカラム名」を渡してしまう
    • ActiveRecord は配列名をカラムとして解決できず、フォールバックとして「汎用型 ActiveModel::Type::Value」を返す
    • この型の cast は基本的に 何もしない (no-op) ので、["1", "10"] はそのまま文字列のまま残る

結果として:

  • result 側のレコードは DB の暗黙キャストにより [1, 10] のように 整数タプル
  • 並び替えに使う series 側は ["1", "10"] のように 文字列タプル

Enumerable#in_order_of(:id, series)(record.id == series_item) という形で一致比較を行いますが、[1, 10] == ["1", "10"]false になるため、すべてのレコードがマッチせず除外され、結果が [] になっていたという流れです。

修正内容

find_some_ordered で使う主キーキャスト処理を、複合主キーを正しく扱えるように変更しています。

新たに導入されたメソッドのイメージは以下のようなものです(説明用。実際の定義名は PR を参照):

ruby
def cast_primary_key(id)
  if model.composite_primary_key?
    # 複合主キー: 各列ごとに型キャスト
    primary_key.zip(id).map do |attr, value|
      model.type_for_attribute(attr).cast(value)
    end
  else
    # 従来挙動: 単一主キーはそのまま
    model.type_for_attribute(primary_key).cast(id)
  end
end

ポイント:

  • 複合主キーの場合

    • primary_key[:author_id, :id] のような配列
    • 渡される id["1", "10"] のような配列
    • primary_key.zip(id)[[:author_id, "1"], [:id, "10"]] の形にし、 各カラム名ごとに type_for_attribute(attr).cast(value) を実行
    • 結果として [1, 10] のように 正しい型にキャストされたタプル が得られる
  • 単一主キーの場合

    • 既存コードとバイトレベルで同じ処理を残しており、挙動変更は一切なし

これにより、in_order_of に渡される series 側も [1, 10] のように整数タプルとなり、record.id == series_item が正しく true になって、期待通りのレコード配列が返ってくるようになります。

テスト

  • activerecord/test/cases/finder_test.rb に、文字列の複合 id を find に渡す回帰テストを追加
  • そのテストが:
    • 現行 main では Red (バグ再現)
    • この修正を含むブランチでは Green
      であることを、sqlite3 / PostgreSQL 両方で確認した旨が PR に記載されています。

CHANGELOG にもこの不具合修正が追記されています。


  1. 影響範囲・注意点

影響を受ける条件

このバグの影響を受けるのは、次の条件が揃う場合です:

  1. モデルが 複合主キー (self.primary_key = [:col1, :col2, ...]) を使っている
  2. find2件以上の id タプル を渡す
    • 例: Book.find([["1", "10"], ["1", "20"]])
    • 1件だけだと find_one 経由になり、このパスは元々問題なかった
  3. 関連に 明示的な order を付けていない
    • デフォルトの find_some_ordered 経路を通る場合のみ
    • 逆に order(...).find([...]) のようにしていたケースは、内部で別経路を辿るため元から影響を受けていなかった
  4. 渡す id が DBカラムのネイティブ型と異なり、キャストが必要な値 である
    • 代表例: コントローラの params 由来の文字列 "1", "10"
    • すでに整数タプル [1, 10] にしてから渡していた場合は、以前から問題なく動作していた

実務的な注意点・確認ポイント

  • Rails 7.1 以降で複合主キーを使っている場合は、次のようなコードが潜んでいないか注意するとよいです:

    ruby
    # 典型的な危険パターン(修正前は常に [] が返りうる)
    def index
      books = Book.find(params[:ids])  # params[:ids] が [["1","10"], ["1","20"]] のような形
      # books が [] のまま後段ロジックに渡されると、不具合が表面化しにくい
    end
  • この PR 適用後は、上記のようなコードでも 期待通りレコードが返る ようになります。

  • 逆に、これまで「[] が返ることを前提にロジックを書いてしまっていた」場合は、修正適用後に挙動が変わる可能性があります。

    • 本来それはバグ依存の実装なので望ましくはありませんが、アップグレード時にはテストで挙動の変化がないか確認が必要です。
  • 単一主キーの Model.find("1") の挙動は一切変わりません。

  • すでに整数にキャストしてから find していたコードも挙動は変わりません。


  1. 参考情報 (あれば)
  • この種の「複合主キー+キャストまわりの取りこぼし」は過去にもいくつか修正されています:
    • find_by_token_for まわりの修正: コミット 8617a7c
    • find_signed の修正: PR #57245
  • 今回の問題は in_order_of 導入時 (9cb09411e1, 2021年) に仕込まれており、複合主キー機能(Rails 7.1)リリースによって初めて表に出てきた、という経緯があります。
  • 該当コードは find_by や Predicate Builder を通らず、既に取得済みのレコード配列に対して順序付けだけを行う経路なので、1カラムごとの type_for_attribute(column).cast(value) を素直に使うのが最もシンプルかつ一貫した修正になっています。

#57439 Fix PostgreSQL range column schema dump producing invalid Ruby

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    PostgreSQL の range 型カラム(daterange, tsrange, tstzrange など)にデフォルト値がある場合、db:schema:dump が Ruby としてパース不能な schema.rb を生成していた不具合を修正する PR です。range の両端値をサブタイプ(Date / Time など)の type_cast_for_schema を通して Ruby リテラルとして正しく出力するように変更されています。

  1. 変更内容の詳細

これまでの問題

OID::Range#type_cast_for_schema は、range 型のデフォルト値を schema.rb に書き出す際に、単純に Range#inspect に丸投げしていました:

ruby
def type_cast_for_schema(value)
  value.inspect.gsub("Infinity", "::Float::INFINITY")
end

Range#inspect は両端の値に対して inspect を呼ぶため、以下のような表現になります。

ruby
(Date.new(2024, 1, 1)...Date.new(2025, 1, 1)).inspect
# => "Mon, 01 Jan 2024...Wed, 01 Jan 2025"

(Time.utc(2024, 1, 1)...Time.utc(2025, 1, 1)).inspect
# => "2024-01-01 00:00:00 UTC...2025-01-01 00:00:00 UTC"

これは人間には読みやすいものの、Ruby の式としては無効です(クォートされていない文字列扱いになる)。
結果として、例えば以下のマイグレーション:

ruby
create_table :events do |t|
  t.daterange :period, default: "[2024-01-01,2025-01-01)"
end

から db:schema:dump した schema.rb には

ruby
t.daterange "period", default: Mon, 01 Jan 2024...Wed, 01 Jan 2025

のような行が出力され、db:schema:load / db:setup 時に SyntaxError で落ちていました。

int4range / numrange など数値系の range は整数 / Float の inspect がそのまま Ruby リテラルになるため問題は出ておらず、日付・時刻系サブタイプだけが壊れていた状況です。

新しい実装

range の両端を、サブタイプの type_cast_for_schema を通して Ruby リテラルに変換し、それらを range 演算子(.. / ...)でつなぎ直すように変更されました。

変更後のメインロジック:

ruby
def type_cast_for_schema(value)
  from = bound_for_schema(value.begin)
  to   = bound_for_schema(value.end)
  op   = value.exclude_end? ? "..." : ".."
  "#{from}#{op}#{to}"
end

private
  def bound_for_schema(bound)
    case bound
    when nil
      "nil"
    when ::Float::INFINITY
      "::Float::INFINITY"
    when -::Float::INFINITY
      "-::Float::INFINITY"
    else
      @subtype.type_cast_for_schema(bound)
    end
  end

ポイント:

  • @subtypeOID::Range に紐づくサブタイプ(OID::Integer, OID::Float, OID::Date, OID::Timestamp, OID::TimestampWithTimeZone など)。
  • Date / Time 系の subtype ではもともと type_cast_for_schema
    value.to_fs(:db).inspect のように DB 形式の文字列 + inspect(= クォート付き文字列) を返すため、
    例えば daterange"2024-01-01"..."2025-01-01" のような Ruby 文字列リテラルで表現されるようになる。
  • 範囲の両端が以下の値のときは特別扱い:
    • nil(beginless / endless) → "nil"
    • ::Float::INFINITY"::Float::INFINITY"
    • -::Float::INFINITY"-::Float::INFINITY"

これにより、schema.rb には次のように出力されます。

Range (概念的な値)修正前 (壊れている例含む)修正後 (Ruby として正しい)
1...10 (int4range)1...101...10(変更なし)
1.5..2.5 (numrange)1.5..2.51.5..2.5(変更なし)
(-Float::INFINITY..Float::INFINITY) (numrange)-::Float::INFINITY..::Float::INFINITY-::Float::INFINITY..::Float::INFINITY(変更なし)
Date.new(2024,1,1)...Date.new(2025,1,1) (daterange)Mon, 01 Jan 2024...Wed, 01 Jan 2025"2024-01-01"..."2025-01-01"
Time.utc(2024,1,1)...Time.utc(2025,1,1) (tsrange)2024-01-01 00:00:00 UTC...2025-01-01 00:00:00 UTC"2024-01-01 00:00:00"..."2025-01-01 00:00:00"
nil..10 (beginless)..10(Ruby 的には有効)nil..10(より明示的)
1..nil (endless)1..(Ruby 的には有効)1..nil(より明示的)

beginless / endless の表現は以前も Ruby として有効でしたが、nil を明示する形に変わっています(動作的にはほぼ等価ですが、より自己説明的なコードになります)。

互換性・ラウンドトリップ

schema.rb"2024-01-01"..."2025-01-01" のような文字列 range リテラルは、

  1. Ruby の評価時には Range&lt;String> として解釈される
  2. その後 OID::Range#serializesubtype.castsubtype.serialize を通して、 OID::Date / OID::Timestamp / OID::TimestampWithTimeZone が適切に Date / Time オブジェクトに変換

というフローを通るため、DB デフォルトとのラウンドトリップ(DB → schema dump → Ruby で評価 → serialize → DB)は動作検証済みです。

テストとしては:

  • OID::Range#type_cast_for_schema の各サブタイプに対し、生成されたリテラルが RubyVM::InstructionSequence.compile で正しくコンパイルできることを確認
  • PostgreSQL 17 を使った end-to-end での round-trip 検証(int4range, daterange, tsrange, tstzrange

が追加されています。


  1. 影響範囲・注意点
  • 直接影響を受けるケース
    • PostgreSQL の range 型 (daterange, tsrange, tstzrange) で「デフォルト値」を設定しているプロジェクト。
    • これらのカラムがある状態で db:schema:dumpdb:schema:load / db:setup を実行すると SyntaxError が出ていたケースが解消されます。
  • 影響しない/ほぼ影響しないケース
    • range カラムにデフォルト値を設定していない場合: 今回の不具合自体が発生していないため、挙動は変わりません。
    • int4range / numrange など数値系 range の場合: 以前から Ruby リテラルとして正しい文字列が出ており、今回も等価な出力を維持しています(Infinity 表現も同一)。
  • 微妙な挙動変化
    • beginless / endless range の schema 表現が ..10 / 1.. から nil..10 / 1..nil に変わりますが、Ruby としてはどちらも有効で、range の意味もほぼ同じです(nil 自体はサブタイプでキャストされる段階で無限側として処理される)。
  • 移行時の注意
    • 既存の schema.rb を手で編集して回避していた場合(例: 自前でクォートを追加していたなど)、Rails を更新してから db:schema:dump し直すと、より機械的な "YYYY-MM-DD" 形式に揃えられるため、diff が大きめに出る可能性はあります。
    • schema から再生成された range のデフォルト値が、DB 側に設定されているものと一致しているか、気になる場合は一度 db:schema:load → 実テーブル定義の確認をすると安心です。

  1. 参考情報 (あれば)
  • 対象クラス:
    • ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range
  • 関連ファイル:
    • activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb
    • activerecord/test/cases/adapters/postgresql/range_test.rb
    • activerecord/CHANGELOG.md(今回の挙動変更が記載)
  • 不具合の性質:
    • range 型サブタイプ(特に Date / Time 系)の inspect に依存していたために起きた、「人間可読だが Ruby として無効」な schema 出力が原因で、db:schema:load 時に即座に発覚するタイプのバグです。
    • 今回の修正で、range も他の型(date, timestamp など)と同様に、サブタイプ自身の type_cast_for_schema を尊重する設計に揃えられました。

#57403 Update Active Storage for ImageProcessing 2.0

マージ日: 2026/6/1 | 作成者: @janko

  1. 概要 (1-2文で)
    ImageProcessing 2.0 のリリースに合わせて、Active Storage の依存関係・設定・ドキュメントを更新した PRです。v2.0 での破壊的変更(明示的な gem 依存と libvips による「危険フォーマット」のブロック)に追随しています。

  1. 変更内容の詳細

2-1. ImageProcessing の利用方法の更新

ポイント:
ImageProcessing 2.0 からは、内部で mini_magickruby-vips を自動ロードしなくなったため、アプリケーション側で明示的に Gem を追加する必要があります。

このPRで行っている主な変更:

  • Gemfile / アプリケーションジェネレータの更新
    • rails new 時に生成される Gemfile テンプレート (Gemfile.tt) が更新され、ImageProcessing を使う場合に必要な設定が反映されるようになりました。
    • 実際の Gemfile にも ImageProcessing 2.0 系向けの記述が反映されています。

今後推奨される構成イメージ(例):

ruby
# Gemfile

gem "image_processing", "~> 2.0"

# 利用するバックエンドを明示的に指定 (どちらか/両方)
gem "mini_magick"  # ImageMagick ベース
gem "ruby-vips"    # libvips ベース

Active Storage の ImageProcessing ベースの variant 変換は、上記のようにサポートライブラリを別途入れておく前提で動作するようになります。


2-2. LoadError への対応強化

ImageProcessing 2.0 では、mini_magickruby-vips が Gemfile に無い場合、ロード時に LoadError が発生しやすくなります。

この PR では:

  • Active Storage の ImageProcessing 関連コード(image_processing_transformer など)において、mini_magickruby-vips がロードできない場合の LoadError 分岐を追加
  • これにより、対象の gem が入っていない状態で variant 処理をしようとしたときに、より意図した形で例外ハンドリングができるようになります(Rails 側で明示的に扱える)。

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

ruby
begin
  require "ruby-vips"
rescue LoadError
  # 適切なエラーメッセージや fallback を提供できるような分岐
end

2-3. libvips 8.13+ における「危険フォーマット」ブロックへの対応

背景:

  • libvips 8.13+ では、既定で「untrusted / unfuzzed loader」とされるフォーマット(安全性が十分でないフォーマット)をブロックするようになりました。
  • これには BMP, PSD, ICO などが含まれます。
  • 一方で、Active Storage 側ではこれらを「variable content types(バリアント生成を許可するコンテンツタイプ)」として受け付けています。

この PR での対応:

  • activestorage/lib/active_storage/transformers/vips.rb に変更が入り、libvips 側の設定により特定フォーマットがブロックされる可能性を意識した実装になっています。
  • PR 本文にもある通り、「Active Storage 上では許可されているが libvips 側ではブロックされるフォーマット」をどう扱うのがベストかはまだ検討中のトピックとして明示されています。

開発者としては、BMP/PSD/ICO の変換が vips バックエンドで突然失敗する可能性がある点を意識する必要があります。


2-4. Active Storage エンジン・モデル周りの調整

  • activestorage/lib/active_storage/engine.rb
    • Active Storage エンジンの初期化処理の中で、ImageProcessing / vips / mini_magick のロードとエラーハンドリングロジックが更新されています。
    • Rails 起動時に、利用可能な画像処理バックエンドがどうあるべきかをより明確に扱うようになっています。
  • activestorage/app/models/active_storage/variant.rb
    • ImageProcessing 2.0 を前提とした微調整(呼び出し側のパラメータやデフォルト挙動の整合)が行われています。
  • activestorage/lib/active_storage/transformers/image_processing_transformer.rb
    • ImageProcessing への委譲コードを 2.0 に合わせて修正。

2-5. ドキュメント・CHANGELOG・テストの更新

  • activestorage/CHANGELOG.md
    • ImageProcessing 2.0 対応に関する変更点を明記。
  • guides/source/active_storage_overview.md
    • ImageProcessing のセットアップ方法(Gemfile に ruby-vipsmini_magick を明示的に追加する必要があることなど)をガイドに追記。
  • railties/test/...
    • 新しい Gemfile テンプレート・Active Storage 設定に即したテストへ更新(engine_integration_test.rb, app_generator_test.rb)。

  1. 影響範囲・注意点

影響範囲:

  • Active Storage で ImageProcessing を使って画像バリアントを生成しているすべてのアプリケーション
  • 特に、以下のようなアプリは影響が大きいです:
    • image_processing だけ Gemfile に書いており、mini_magickruby-vips を明示的に追加していない
    • vips ベースで BMP/PSD/ICO などを扱っている

注意点・必要な対応:

  1. Gemfile の見直し

    • 画像処理バックエンドを明示的に追加してください:
      ruby
      gem "image_processing", "~> 2.0"
      
      # 使用するバックエンドに応じて
      gem "mini_magick"  # ImageMagick を利用するなら
      gem "ruby-vips"    # libvips を利用するなら
    • どちらも入れておくことも可能ですが、その場合どちらを使うかは Active Storage 設定やコード側の指定に依存します。
  2. BMP/PSD/ICO 等の扱い

    • libvips 8.13+ 環境で ruby-vips を使っている場合、これらのフォーマットは標準設定だとブロックされる可能性があります。
    • 対策案:
      • そもそもこれらのフォーマットでバリアント(リサイズ・サムネイル)を作らないようにする
      • ImageProcessing のバックエンドに mini_magick を使うように切り替える(ImageMagick 側の設定に依存)
      • libvips の設定を調整して対象フォーマットを許可する(ただしセキュリティリスクを十分検討する必要あり)
  3. 例外ハンドリング

    • LoadError に対して Active Storage 側でハンドリングが追加されたとはいえ、 実運用では「必要な Gem が入っていない」「危険フォーマットで vips が失敗する」ケースに対する アプリ側のエラーメッセージやフォールバック処理を検討するのが望ましいです。

  1. 参考情報 (あれば)

#57004 Reimplement RedisCache store using redis-client

マージ日: 2026/6/1 | 作成者: @byroot

  1. 概要 (1-2文で)
    Redis ベースのキャッシュストア RedisCacheStore を、従来の redis gem ではなく新しい redis-client を使って作り直した PRです。既存の設定・APIとの互換性を確保するため、旧実装は DeprecatedRedisCacheStore として残しつつ、RESP2 プロトコルを使うことで Redis サーバの要件は従来から変えない形になっています。

  1. 変更内容の詳細

全体方針

  • ActiveSupport::Cache::RedisCacheStore の内部実装を redis gem 依存から redis-client 依存に差し替え。
  • redis: オプションなど、これまでの設定インターフェイスを壊さないために、旧実装を ActiveSupport::Cache::DeprecatedRedisCacheStore として分離・温存。
  • redis-client は RESP2 を使うように設定されており、Redis サーバ側のバージョン要件を増やさない設計。

主なコードレベルの変更点

1) 新しい RedisCacheStore の実装切り替え

activesupport/lib/active_support/cache/redis_cache_store.rb が大きく書き換えられています。

ポイント:

  • 依存ライブラリが redisredis-client に変更。
  • シャーディング(従来の Redis::Distributed 相当)について、redis-client 側に RedisClient.ring を実装して対応。
    • これにより、複数 Redis インスタンスへの分散キャッシュも継続してサポート。
  • 基本的なキャッシュ API (read, write, fetch, delete, increment, decrement, マルチキー操作など) の挙動は、可能な限り従来と互換になるよう維持。

使用イメージ(概念的には従来と同じ):

ruby
Rails.application.config.cache_store = :redis_cache_store, {
  url: "redis://localhost:6379/0",
  # 他オプションも基本同じ感覚で利用可能
}

複数ノードへの分散(リング)も、内部では RedisClient.ring によって実現される想定です。

2) 旧実装を DeprecatedRedisCacheStore として退避

activesupport/lib/active_support/cache/deprecated_redis_cache_store.rb が新規追加され、約 500 行強の旧実装が移植されています。

  • 役割:
    • 以前の redis gem ベース実装を名前を変えて保持。
    • redis: オプション等で直接 Redis クライアントインスタンスを渡しているような「高度/裏技的」なユースケースへの移行期間を確保。
  • 実質的には、
    • ActiveSupport::Cache::RedisCacheStore = 現 ActiveSupport::Cache::DeprecatedRedisCacheStore
    • ActiveSupport::Cache::RedisCacheStore = redis-client ベースの再実装 という状態。

3) redis: オプションの扱い

PR 説明にもある通り、当初は :redis コンストラクタ引数を削除しようとしたものの、Redis Cluster クライアントなどを直接渡すユーザーの存在を考慮して、完全な削除は見送られています。

  • つまり、「redis: を使ってクライアントを直渡しするパターン」を完全に壊さないように配慮した設計。
  • 実際のマイグレーションパスとしては、DeprecatedRedisCacheStore を利用する/移行期間を設ける形と思われる(詳細は今後のガイドや CHANGELOG で明示される見込み)。

4) Gemfile の更新

  • Gemfile / Gemfile.lockredis-client が追加され、redis からの移行が反映されています。
  • 依存関係の更新により、Rails 自身が redis-client を公式・標準の Redis クライアントとして扱う流れが明確になった形です。

5) テストの分離と更新

  • activesupport/test/cache/stores/redis_cache_store_test.rbredis-client ベース実装向けに大きく変更。
  • activesupport/test/cache/stores/deprecated_redis_cache_store_test.rb が新規に追加され、旧実装専用のテストとして 600 行超が移植。
  • 振る舞い共通部分のテスト (cache_instrumentation_behavior, failure_raising_behavior など) は、新旧両ストアで動くように微修正。

これにより、

  • 新実装の互換性保証(従来テストが落ちないか)
  • 旧実装の「後方互換」の担保 の両方を CI で継続的に確認できるようになっています。

6) ドキュメント更新

  • activesupport/CHANGELOG.md に今回の変更が追加され、RedisCacheStoreredis-client ベースになったこと、旧実装が DeprecatedRedisCacheStore として残ることなどが明記されているはずです。

  1. 影響範囲・注意点

影響範囲

  1. Rails のデフォルト/推奨 Redis キャッシュクライアントが redisredis-client に変更

    • キャッシュ用途で Redis を使っている Rails アプリ全般に影響。
    • ただし、基本 API やサーバ要件(RESP2)は変えていないため、ほとんどのアプリは「内部実装が変わるだけ」で済む想定。
  2. redis: オプションでクライアントインスタンスを直接渡しているケース

    • 旧来の Redis クライアント(redis gem の Redis オブジェクトや Redis::Cluster など)を直接注入しているコードは要注意。
    • そうしたケースのために DeprecatedRedisCacheStore が用意されているが、
      • 将来的には廃止される可能性が高い
      • どのタイミングで何が deprecated になるかは CHANGELOG / ガイドを要確認。
  3. シャーディング/分散構成 (Redis::Distributed 相当)

    • 複数 Redis インスタンスに分散してキャッシュする構成は、RedisClient.ring で実装される。
    • キー分散アルゴリズムやフェイルオーバー挙動など、Redis::Distributed 依存ロジックを書いていた場合は、細かい差異がないかを確認したほうがよい。

開発者視点での注意点・確認ポイント

  • Rails をアップグレードしたら、まず Redis キャッシュが正常動作しているか確認
    • fetch, write, delete, increment/decrement, マルチキー操作など、アプリが多用している操作を一通り叩いてみる。
  • もし直接 Redis クライアントを触っているコードがあれば要チェック
    • 例: Rails.cache.redis を前提に Redis のメソッドを呼び出すようなコード。
    • RedisCacheStore の内部実装が redis-client に変わったことで、返されるオブジェクトや利用を想定していないメソッド呼び出しが壊れる可能性がある。
  • Redis Cluster / Sentinel / シャーディング構成を利用している場合
    • 接続設定の記述方法・サポート状況が redis-client でどうなっているかを確認(redis-client の README / Rails ガイドを参照)。
    • 特にクラスタリング特有のコマンド (READONLY, ASK, MOVED ハンドリングなど) を期待していた場合は差分に注意。

  1. 参考情報 (あれば)

#57533 Reimplement Action Cable redis adapter with redis-client

マージ日: 2026/6/1 | 作成者: @byroot

  1. 概要 (1-2文で)
    Action Cable の Redis アダプタ実装を、従来の redis gem ベースから redis-client ベースに書き換えた PRです。これにより、Rails が巨大な redis gem へ依存せずに済むようになり、購読(Pub/Sub)まわりの実装もシンプルになっています。

  1. 変更内容の詳細

※PR本文・diff から読み取れる範囲に基づく解説です(ファイルパスと行数規模からの構造的推測も含みます)。

a. Redis アダプタの内部実装を redis-client に差し替え

対象ファイル:

  • actioncable/lib/action_cable/subscription_adapter/redis.rb (+39 / -85)

このファイルは Action Cable の Redis ベースの Subscription Adapter 実装です。ここで、以下のような切り替えが行われています。

  • 以前:

    • require "redis" などで redis gem を直接利用
    • Redis.new, subscribe, psubscribe, unsubscribe など、redis gem 固有の API を利用して Pub/Sub を実装
    • コネクションプール周りも redis gem のオブジェクトを前提とした作り
  • 変更後:

    • redis-client を用いた接続確立・購読・メッセージ受信ロジックに置き換え
    • redis-client の Subscription API(ブロッキングな購読ループやコールバック)に沿った実装に再構成
    • redis gem に依存していた箇所(エラークラスや返り値の型など)を redis-client のインターフェイスに対応するよう修正

redis-client は Redis 7 系を意識した軽量クライアントで、Pub/Sub 用の API が redis gem よりもシンプルかつストレートな設計になっており、Action Cable 側のコードもそれに合わせて大きく簡素化されています(行数が 85 行削除 / 39 行追加という差分になっているのは、その簡素化の結果と考えられます)。

b. テストコードの更新

対象ファイル:

  • actioncable/test/subscription_adapter/redis_test.rb (+11 / -9)

Action Cable の Redis アダプタ向けテストが、redis gem 前提の挙動から redis-client 前提に変更されています。

具体的には、例えば:

  • 接続確立時のオブジェクト型やエラークラスの変更
  • Pub/Sub の購読開始・停止、再接続動作などのテストケースが新しい API を使うように微調整
  • redis-client 由来のタイミング/スレッド挙動の差異を考慮したアサーションの調整

が入っていると考えられます。テスト全体としては大きく増減しておらず (+11 / -9)、主にインターフェイス差分の吸収といったレベルの修正です。

c. #57004 との組み合わせによる依存関係変更

PR 本文で、以下のように言及されています:

Combined with https://github.com/rails/rails/pull/57004 this would allow to not longer depend on the much larger redis gem

つまり、この PR 単体ではなく、#57004 とセットで見ると:

  • Rails 全体として redis gem を Gem dependency から外せる(または optional にできる)
  • Redis 関連の機能はすべて redis-client でまかなえるようになる

という整理が進んでいることがわかります。
#57004 側では、おそらく他の Redis 利用箇所(キャッシュストアや Action Cable 以外のアダプタ等)が redis-client に置き換えられているか、あるいは共通接続ロジックが導入されているはずです。


  1. 影響範囲・注意点

a. Rails ユーザ側(アプリケーション開発者)の影響

1) Gem 依存の変化

  • Action Cable で Redis を使う場合、今後は redis-client が前提になります。
  • これにより:
    • redis gem をわざわざ Gemfile に入れなくてもよくなる(将来的には削除推奨)
    • 逆に、redis-client が必要(Rails が依存として引き込む形になる想定)

アプリ側が Action Cable 用に redis gem のカスタム設定などをしていた場合、それらは無効になり、redis-client の設定方法へ移行する必要があります。

2) 設定値・接続オプションの見直し

redis gem と redis-client では、細かい接続オプション名や挙動が違う場合があります。たとえば:

  • タイムアウト系オプション名
  • コネクションプールの扱い
  • reconnect/retry 動作のデフォルト

など。Action Cable の Redis アダプタ設定(config/cable.ymlurl, channel_prefix, redis オプションなど)で、redis 特有のパラメータを渡している場合は、そのままでは効かなくなる可能性があります。

ただし、この PR は主に内部実装の差し替えなので、高レベルな設定インターフェイス(urlchannel_prefix など)はできるだけ後方互換になっていると想定されます。特殊な設定をしていない典型的なアプリでは、そのまま動く可能性が高いです。

3) 例外クラス・ログ出力などの変更

  • Redis 関連の例外クラスが Redis::BaseError などから RedisClient::Error 的なものに変わる可能性があります。
    • 例外クラス名で rescue している場合は影響が出ます。
  • 接続/購読のログメッセージ、警告メッセージが変わる可能性があります(監視やログ解析している場合は要確認)。

b. ライブラリ作者・メンテナの影響

Action Cable の Redis アダプタをラップしたり、拡張している gem・ライブラリは、redis gem に強く結合している部分があれば修正が必要になります。

  • 具体例:
    • ActionCable::SubscriptionAdapter::Redis のインスタンスに対して instance_variable_get などで内部の Redis インスタンスを参照している
    • Redis の接続オブジェクトを前提とした monkey patch を当てている
    • Redis アダプタの初期化時に Redis.current などの仕組みと連携させている

このような場合は、redis-client のクラスや API にあわせて実装を見直す必要があります。

c. 運用面の注意

  • redis-client は比較的新しめのクライアントなので、運用中の Redis バージョンとの互換性を事前に確認することが推奨されます(特に古い Redis との組み合わせ)。
  • 接続数・サブスクライバ数が多い大規模環境では、redis gem と redis-client のパフォーマンス・メモリ使用量・再接続挙動の違いが出る可能性があります。
    • 本番切り替え前にステージング環境で負荷テスト・フェイルオーバーテストをしておくと安全です。

  1. 参考情報 (あれば)

Action Cable の Redis アダプタまわりをカスタマイズしている場合や、Redis 連携を独自にラップしている場合は、上記 PR と redis-client の README をあわせて確認しつつ、移行方針を検討するのが良いです。


#57528 Document ports can be added to hosts in `config.hosts' [ci-skip]

マージ日: 2026/6/1 | 作成者: @p8

  1. 概要 (1-2文で)
    このPRは、Rails の config.hosts にホスト名と一緒に「ポート番号も指定できる」ことをガイドに追記した、ドキュメント専用の変更です。コードの挙動自体は変わらず、既存機能の説明を補完するものです。

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

  • 変更ファイルは guides/source/configuring.md のみで、Rails アプリケーション設定ガイドの「config.hosts の説明」に数行の記述が追加されています。

  • 追加された内容はだいたい以下のような趣旨です(擬似的な抜粋・イメージ):

    md
    ### config.hosts
    
    # 中略: もともとの説明(許可ホストの設定など)
    
    ホスト名だけでなく、ポート番号を含めて指定することもできます:
    
    ```ruby
    # 例: 特定ポートのみ許可する
    config.hosts << "example.com:3000"
    config.hosts << "localhost:3001"

    これは、同じホスト名でもポートごとにアクセス可否を分けたい場合に有用です。

  • もともと Rails 本体は "example.com:3000" のような文字列をホストとして扱うことができており(ActionDispatch::HostAuthorization のマッチ時にホストヘッダ全体を評価)、このPRでは「その事実が公式ドキュメントに明記された」という位置付けです。

  • [ci-skip] がタイトルに付いている通り、テストを伴わない単なるドキュメント変更として扱われています。

  1. 影響範囲・注意点
  • 影響範囲:
    • ランタイムの挙動変更や API 変更はありません。config.hosts の既存仕様のうち、「ポートを含められる」という点がドキュメントで明文化されたのみです。
    • これにより、「ホスト名だけしか指定できない」と誤解していた開発者が、ポート単位でのホワイトリスト制御を正しく設定できるようになります。
  • 注意点:
    • 実際の Host ヘッダの値(および Rack/サーバがアプリに渡す request.host_with_port など)と、config.hosts に書いた値の形式を合わせる必要があります。
      • 例: 開発で http://localhost:3000 にアクセスするなら、config.hosts << "localhost:3000" のようにポート付きで書く。
    • 逆に、ポートを省いた "example.com" を設定した場合は、「任意のポートでの example.com へのアクセスを許可する」という扱いになる実装/バージョンもあるため、厳密にポートを絞りたい場合は必ずポート込みで指定することが推奨されます。
    • 一部のプロキシやコンテナ環境では、アプリケーション側から見える Host ヘッダのポートが、クライアントからのポートと異なることがあるため、実環境でどの値が来るかをログ等で確認した上で config.hosts を設定するのが安全です。
  1. 参考情報 (あれば)
  • 対応する機能: ActionDispatch::HostAuthorization ミドルウェア (config.hosts 設定に基づいてリクエストを許可/拒否する機能)
  • 関連ドキュメント(英語版・最新版を参照するとよい部分):
    • Rails Guides: Configuring Rails Applications – 「config.hosts」セクション
  • 実際の活用例:
    • docker-compose などで複数コンテナを異なるポートでローカル公開しており、特定のポートに対してのみ Rails アプリがリクエストを受け付けるようにしたい場合に、config.hosts << "myapp.local:8080" のように設定できることが、今回のドキュメント変更で分かりやすくなります。

#57503 Reject malformed hosts with extra ports

マージ日: 2026/6/1 | 作成者: @afurm

  1. 概要 (1-2文で)
    HostAuthorization ミドルウェアが「許可ホストにポートを含めて設定した場合」にも誤って「追加の任意ポート」を許容してしまう問題(例: www.example.com:80:80 が通ってしまう)を修正する PR です。ポートを含む許可ホストに対しては、余分なポートを受け入れないよう正規表現生成ロジックを変更し、対応するテストも追加しています。

  1. 変更内容の詳細

問題の背景

config.hostsHostAuthorization ミドルウェアにおいて、許可ホストにポート番号を含めた値(例: "www.example.com:80")を指定したとき、内部ではそのホスト名から許可判定用の正規表現が生成されます。

従来の実装では:

  • 「ポートなし」のホスト(例: "www.example.com")→ :80 など任意のポートを許可するため、正規表現の末尾に「任意ポートのオプション部分」が付与される((?:\:\d+)? のようなイメージ)。
  • ところが、「ポートあり」のホスト(例: "www.example.com:80")に対しても、同様に「もう一個オプションのポート」を付けてしまっていた。

その結果:

  • 許可リスト: "www.example.com:80"
  • 実際の Host ヘッダ: "www.example.com:80:80"

という不正な値でも、生成された正規表現にマッチしてしまい、HostAuthorization を通過 → その後段でリダイレクト処理などが「正しい Host 値が来る前提」で動いてしまう、というバグが発生していました。

この PR の修正内容

修正のポイントは「許可ホストにポートが含まれているかどうかで、生成する正規表現を分ける」ことです。

  1. ホストが「ポートなし」の場合

    • 従来どおり
      • example.com^example\.com(?::\d+)?$ のように、末尾に「任意ポートのオプション」を付けて、:3000 など任意のポート付き Host を許可する。
  2. ホストが「ポートあり」の場合(今回の修正箇所)

    • 例: example.com:80
    • 既にポートが明示されているので、その後ろに「さらにもう一つ任意ポート」を追加しない。
    • つまり example.com:80 なら、^example\.com:80$ のように「ピッタリ一致」だけを許可する正規表現を生成するように変更。

コード的には、ActionDispatch::HostAuthorization 内で許可ホストを正規表現に変換しているメソッド(compile_hosts かその周辺)において:

  • host:(ポート指定)が含まれているかどうかを判定
  • 含まれている場合は (?::\d+)? のような「オプションのポート部分」を付けない
  • 含まれていない場合は従来どおり「オプションのポート部分」を付ける

という条件分岐が入った形になります。

追加されたテスト

actionpack/test/dispatch/host_authorization_test.rb に以下のような観点のテストが追加されています(内容のイメージ):

  • 「明示的なポート付きホスト」が許可されること

    • 設定: config.hosts << "www.example.com:80"
    • Host: "www.example.com:80" → 許可される(ミドルウェアでブロックされない)。
  • 「余分なポートを含むホスト」が拒否されること

    • 同じ設定のもとで、Host: "www.example.com:80:80" → 許可されない(ミドルウェアで 403 などが返る)。
  • 既存挙動(ポートなしホストに対して任意ポートを許容する挙動)が壊れていないこと

    • 設定: config.hosts << "www.example.com"
    • Host: "www.example.com:3000" → 従来通り許可される。

テストコマンド例として PR に記載されているもの:

bash
cd actionpack && bin/test test/dispatch/host_authorization_test.rb -i "/extra ports/"
cd actionpack && bin/test test/dispatch/host_authorization_test.rb
cd actionpack && bundle exec rubocop lib/action_dispatch/middleware/host_authorization.rb test/dispatch/host_authorization_test.rb
git diff --check

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

    • ActionDispatch::HostAuthorization ミドルウェアを利用している Rails アプリ(Rails 6 以降など)で、config.hosts にポート付きのホストを列挙しているケース
    • 例:
      ruby
      # config/environments/production.rb
      config.hosts << "example.com:443"
      config.hosts << "internal.example.com:3000"
  • この変更により起こる挙動の変化:

    • これまでは(バグにより)"example.com:443:443" のような不正な Host 値が通っていた可能性があるが、今後は 確実にブロック される。
    • 正しい Host 値(example.com:443)は従来同様に許可されるため、通常の正しいクライアントには影響しない
  • 注意点:

    • 万が一、CDN / リバースプロキシ / 独自ミドルウェアなどが誤って Host ヘッダを二重ポートのような形で付与していた場合、この変更適用後に 403 などでリクエストが落ちる可能性がある。
      • ただし、Host: example.com:80:80 といった形自体が RFC 的にもおかしい値なので、基本的には「バグを表面化させる」方向の改善と言える。
    • 「ポートなしホストに対して、任意ポートを許可する挙動」は維持されているため、config.hosts << "example.com" のような設定に関しては意図した挙動はそのままです。

  1. 参考情報 (あれば)
  • 関連 issue: #37956
  • 変更ファイル:
    • actionpack/lib/action_dispatch/middleware/host_authorization.rb
      • 許可ホストを正規表現に変換するロジックの条件分岐追加(ポート有無で「オプションポート」の扱いを変える)
    • actionpack/test/dispatch/host_authorization_test.rb
      • 明示ポートホスト・余分なポートを持つホストの挙動を検証するテストを追加

この変更により、Host ヘッダの検証がより厳密かつ安全になり、下流のリダイレクトや URL 生成ロジックが「正しい Host が来る」という前提で動きやすくなります。


#57526 Revert adding ractor helpers in Kernel

マージ日: 2026/6/1 | 作成者: @byroot

  1. 概要 (1-2文で)
    このPRは、Ractor 関連のヘルパーメソッドを Kernel に追加して Rails のパブリック API にする方針を撤回し、代わりに内部モジュール (ActiveSupport::Ractors) として提供するように変更しています。目的は、Ruby の古いバージョン互換のためだけに必要なヘルパーを「永久に維持すべきパブリック API」にしないようにすることです。

  1. 変更内容の詳細

背景

  • 元の PR (#57467) では、Ractor 対応のために以下のようなヘルパーを Kernel に追加し、Rails から利用しやすくしていました(例: ractor_shareable?make_ractor_shareable のようなもの)。
  • しかし Kernel にメソッドを追加すると、「Rails のパブリック API」とみなされ、将来的に不要になっても簡単には削除できません。
  • これらのヘルパーは「古い Ruby で Ractor 関係の API が不足しているのを埋める」ための穴埋めであり、Ruby のサポートバージョンが上がれば不要になる見込みです。

主な変更点

1) Kernel 拡張の削除

削除されたファイル:

  • activesupport/lib/active_support/core_ext/kernel/ractor_shareability.rb
    Kernel に Ractor 関連のヘルパーを生やしていた拡張を丸ごと削除しています。

修正されたファイル:

  • activesupport/lib/active_support/core_ext/kernel.rb
    → 上記ファイルを require していた行を削除。
  • guides/source/active_support_core_extensions.md
    → Kernel 拡張としての Ractor ヘルパーに関するガイド記述を削除。

これにより、Kernel#xxx として Ractor ヘルパーを呼ぶ API は廃止されます(そもそもまだリリースされていない段階で戻した形)。

2) 内部モジュール ActiveSupport::Ractors の追加

新規ファイル:

  • activesupport/lib/active_support/ractors.rb (+69行)

ここに、元々 Kernel に定義していた Ractor ヘルパー群が「内部 util モジュール」として再定義されています。
Rails 内部や gem から使う場合は、以下のような形になることが想定されます:

ruby
# 内部的な利用イメージ(実際のメソッド名は元PR依存ですが概ねこんな感じ)
require "active_support/ractors"

if defined?(Ractor)
  ActiveSupport::Ractors.shareable?(obj)
  ActiveSupport::Ractors.make_shareable(obj)
end

byroot のコメントにもある通り、このモジュールは

  • 単にモジュール関数(module_function)として定義するか
  • singleton メソッドとして使うか

といった設計も含め、「内部 API をどのような形で提供するか」を議論可能な範囲に閉じ込める意図があります。

3) ActiveSupport 本体からの読み込み

  • activesupport/lib/active_support.rb
    require "active_support/ractors" が追加されました。

これにより、require "active_support" した環境から ActiveSupport::Ractors が利用できるようになります(あくまで内部 API の位置づけ)。

4) Changelog とドキュメントの整理

  • activesupport/CHANGELOG.md (-16行)
    → 前の PR (#57467) の内容として記載されていた、「Kernel に Ractor ヘルパーを追加した」というエントリが削除。
  • guides/source/active_support_core_extensions.md (-40行)
    → Kernel 拡張としての Ractor 関連記述を削除。

つまり、「ユーザー向けの新機能として Kernel 拡張を追加した」という扱いそのものを取り消しています。

5) テストの更新

  • activesupport/test/ractors_test.rb (+12/-13)
    → これまで Kernel に生えたメソッドを前提にしていたテストを、ActiveSupport::Ractors を使う形に修正しています。
    テスト対象が「パブリック Kernel API」から「内部 ActiveSupport モジュール」に変わったイメージです。

  1. 影響範囲・注意点

  2. Rails アプリ/gem からの直接利用は想定していない

    • そもそも Kernel 拡張がまだリリース前に revert された形なので、「既存コードが壊れる」という互換性問題は基本的に発生しない想定です。
    • 新たに Ractor 向けヘルパーを使いたい場合も、Rails の「公認パブリック API」とは位置付けられていない ActiveSupport::Ractors に依存することになるため、将来的な変更・削除のリスクがあります。
  3. Kernel にメソッドは増えない

    • この PR の結果として、Kernel に Ractor 関連のメソッドは追加されません。
    • Ractor 周りで Ruby 本体と Rails が混ざった API になることを避け、「Rails 起因でグローバル名前空間を汚染しない」方針が保たれています。
  4. 将来的な削除を見越した設計

    • 元コメントにある通り、Ruby のサポートバージョンが上がり、Ractor 関連の機能が標準で十分に提供されるようになれば、ActiveSupport::Ractors 自体が不要になり、内部実装・互換レイヤーとして削除できる余地が残されます。
    • パブリック API にしてしまうと「古い Ruby のためだけのラッパー」を半永久的に維持しなければならないため、それを避けています。
  5. Ractor を積極利用するライブラリ作者への注意

    • Rails の Ractor サポート状況を調べる際、「Kernel に Ractor 関連ヘルパーがある」という情報は誤りになったので注意が必要です。
    • もし Rails 内部と同じヘルパーを使いたい場合は ActiveSupport::Ractors を読む形になりますが、それは互換保証のない内部 API です。
      長期的に安定した API を求めるなら、Ruby 本体の Ractor API を直接使用する方が安全です。

  1. 参考情報 (あれば)

設計上のポイント:

  • 「互換性確保のための一時的な補助を、Kernel やパブリック API に乗せない」
  • 「内部モジュール化して、Ruby バージョン戦略に応じて将来的に削除しやすくする」

という Rails の API スタビリティポリシーがよく表れている変更です。


#57529 Fix syntax for Rack response in Rack guide (#57527) [ci-skip]

マージ日: 2026/6/1 | 作成者: @p8

  1. 概要 (1-2文で)
    Rails ガイド「Rails on Rack」に記載されている Rack レスポンスのサンプルコードの構文ミスを、正しい Rack 仕様に沿う形で修正したドキュメント修正PRです。コードや挙動には一切変更がなく、ガイドの記述のみが更新されています。

  2. 変更内容の詳細

  • 対象ファイル: guides/source/rails_on_rack.md
  • 変更内容は 1 行のみで、Rack アプリケーションの返り値(Rack レスポンス)の書き方を正しい構文に修正しています。

Rack のアプリケーションは以下の形式の 3 要素配列を返す必要があります:

ruby
# [ステータスコード, ヘッダ(Hash), ボディ(各要素が文字列の Enumerable)]
[status, headers, body]

ガイド内のサンプルでは、この 3 要素目(body)か、もしくは全体の書式が Rack 仕様に即していない形になっていたため、例えば次のような形に修正されていると考えられます:

修正前(誤った例・イメージ):

ruby
# body を単なる文字列で返している / または配列ではない、など
[200, { "Content-Type" => "text/html" }, "Hello, world!"]

修正後(正しい Rack レスポンスの例):

ruby
[200, { "Content-Type" => "text/html" }, ["Hello, world!"]]

あるいは単純なシンタックスエラー(カンマやカッコの抜け、シンボルの書き方など)があれば、それが修正されています。
いずれにせよ、修正内容は 「Rack アプリの返り値サンプルを、Rack が要求する [status, headers, body] 形式として正しく書き直した」 ものです。

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

    • Rails 本体・Rack インテグレーションの実装・挙動には影響しません。
    • ドキュメント(Rails ガイド)を見て Rack アプリケーションを書く開発者が、誤ったレスポンス形式を参考にしてしまうリスクが減ります。
  • 注意点:

    • 既に誤ったサンプルをもとにアプリを書いていた場合、Rack のインターフェース仕様(body は each できる文字列の配列などである必要がある)を改めて確認するのがよいです。
    • PR タイトルに [ci-skip] が付いている通り、コード変更がないため CI はスキップされています。
  1. 参考情報 (あれば)
  • Rack SPEC(Rack アプリの返り値仕様)
  • Rails ガイド: Rails on Rack
    • 最新版の rails_on_rack.md を参照すると、今回修正された正しいサンプルコードを確認できます。

#57527 Fix syntax for Rack response in Rack guide

マージ日: 2026/6/1 | 作成者: @ayushn21

  1. 概要 (1-2文で)
    Railsガイド「Rails on Rack」で、Rackレスポンスの書き方に誤りがあったため、正しい Rack::Response オブジェクトを使う形に修正したドキュメント更新のPRです。コードの挙動ではなく、ガイドのサンプルコードの記述ミスを直しています。

  1. 変更内容の詳細 (サンプルコード例)
  • 対象: guides/source/rails_on_rack.md の Rack レスポンスに関するサンプルコード
  • 内容: Rack アプリケーションの戻り値を「配列で返す例」から、「Rack::Response オブジェクトを使う例」に修正

Rails が Rack アプリを扱う際に、ガイド上で本来は以下のような「オブジェクトの戻り値」を示したかったところが、誤って単純な Rack の「配列レスポンス」のように書かれていた、という趣旨です。

イメージとしては、誤った例(※実際のPRとは文言が異なる可能性がありますがニュアンス):

ruby
# 誤: Rack レスポンスを単純な配列として記述していたケース
app = Proc.new do |env|
  [200, { "Content-Type" => "text/html" }, ["Hello Rack!"]]
end

を、Rails ガイドで意図している正しい形へ:

ruby
# 正: Rails が要求する形に即した Rack::Response オブジェクトの使用例
app = Proc.new do |env|
  response = Rack::Response.new
  response['Content-Type'] = 'text/html'
  response.write 'Hello Rack!'
  response.finish
end

あるいは、シンプルな Rack::Response オブジェクトを直接返す形など、
「Rails がサポート・想定している Rack::Response ベースの書き方」に統一する修正です。

PR本文では「Rails が要求する形として Rack::Response オブジェクトを使うべきであり、配列ではない」という点が明示されています。


  1. 影響範囲・注意点
  • 実コードへの影響

    • 変更はガイド(ドキュメント)だけで、Rails 本体のコードには一切変更がありません。
    • そのため、既存アプリケーションの挙動に影響はありません。
  • 誤解解消の観点

    • 以前のガイドの記述に従って、Rails の Rack エンドポイントやミドルウェアを「配列レスポンス前提」で書いていた場合、ガイドと実際の期待仕様とのズレが生じていた可能性があります。
    • これを読んだ新規読者が「Rails 上の Rack アプリでは、Rack::Response を使うほうが正しい/推奨される」という点を理解しやすくなります。
  • 実装時の注意

    • Rails 上で Rack アプリやミドルウェアを書く場合:
      • Rack::Response を利用すると、ヘッダ操作・Cookie 設定・レスポンス組み立てが明示的かつ安全に行いやすいです。
      • response.finish[status, headers, body] に変換されるため、Rack のインターフェイス要件も満たします。

  1. 参考情報 (あれば)
  • Rack 公式仕様(README)
    • Rack アプリケーションのインターフェイス: call(env) -> [status, headers, body]
    • Rack::Response はこの標準インターフェイスをラップするヘルパークラスで、finish で配列を返します。
  • Rails ガイド: Rails on Rack
    • Rails を Rack アプリケーション/ミドルウェアとして扱う方法や、config.ru での設定などを説明しているドキュメントで、そのサンプルコードの一部が今回修正対象となっています。

#57516 Fix Duration#in_* truncating sub-second precision

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveSupport::Durationin_minutes, in_hours, in_days, in_weeks, in_months, in_years が、内部で秒数を整数に切り捨ててから計算していたため、サブ秒精度が失われていたバグを修正する PR です。in_seconds / to_i の挙動はそのままに、in_* 系メソッドだけが小数を正しく扱うようになります。

  1. 変更内容の詳細

問題点

元の実装では、各 in_* メソッドが in_seconds(= to_i のエイリアス)を用いており、そこで小数部分が切り捨てられていました。

ruby
def to_i
  @value.to_i
end
alias :in_seconds :to_i

def in_minutes
  in_seconds / SECONDS_PER_MINUTE.to_f
end

このため、@value にサブ秒(小数)が含まれていても、to_i のタイミングで整数へ丸められ、以降の計算結果が不正確になっていました。

例:

ruby
90.5.seconds.in_minutes   # 1.5 (実際は 1.5083... が正しい)
0.5.seconds.in_days       # 0.0 (実際は ~5.78e-06)
3661.5.seconds.in_hours   # 1.0169444... (実際は 1.0170833...)

in_* のドキュメントは「float を返す」となっているため、サブ秒精度を落とす現在の挙動は仕様ではなくバグと判断されています。

修正内容

in_* 系メソッドで、整数に丸められた in_seconds ではなく、元の value(サブ秒を含む実数値)を用いて計算するように変更されています。

ざっくりいうと、次のようなイメージです:

ruby
# 変更前 (イメージ)
def in_minutes
  in_seconds / 60.0   # in_seconds は to_i 済み
end

# 変更後 (イメージ)
def in_minutes
  value / 60.0        # value はサブ秒を含む実数
end

実際のパッチでは、SECONDS_PER_* 定数を使って、同様の修正が in_minutes, in_hours, in_days, in_weeks, in_months, in_years 全てに適用されています。

テスト

activesupport/test/core_ext/duration_test.rb に以下のようなテストが追加されています(趣旨だけ抜粋):

ruby
def test_in_units_preserve_sub_second_precision
  assert_in_delta 1.5083333333, 90.5.seconds.in_minutes
  assert_in_delta 5.78e-06,     0.5.seconds.in_days
  assert_in_delta 1.0170833333, 3661.5.seconds.in_hours
end
  • 追加テストは main ブランチでは red(失敗)、本修正適用後に green(成功)になることを確認済み。
  • 既存の duration_test.rb の全テストもグリーンで通過。

  1. 影響範囲・注意点
  • 影響を受けるのは、サブ秒(小数秒)を含む ActiveSupport::Durationin_minutes, in_hours, in_days, in_weeks, in_months, in_years で変換しているコードです。
    • これまで: 小数秒が切り捨てられ、実際より小さい値(あるいは 0)になる場合があった。
    • 今後: サブ秒を含めたより正確な float が返ってきます。
  • 整数秒のみを扱っているコードへの影響はありません。
    • Integer / 60.0Integer.to_i / 60.0 は同じなので、既存の挙動は変わりません。
  • in_seconds / to_i はこれまで通り整数への切り捨てです。
    • 整数変換の挙動を前提にしたコード(例: ログ出力、インデックス計算など)は影響を受けません。
  • 精度が向上することで、「いままで暗黙に切り捨てられていたサブ秒分」が計算に含まれるようになるため、
    • 厳密な数値比較テスト(== 比較)をしている場合、テストが落ちる可能性があります。
    • 特に「期待値を古い不正確な値で固定している」テストは見直しが必要になります。
  • 過去約 5.7 年にわたり未テストの経路だったため、逆に言えば「この不正確な挙動に依存したコード」はあまり多くないことが期待されますが、ライブラリや長寿命のアプリでは一応の確認が必要です。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/57516
  • 該当実装ファイル: activesupport/lib/active_support/duration.rb
  • 該当テスト: activesupport/test/core_ext/duration_test.rb
  • バグの導入コミット:
    • 7bd9603778c (2020-03-11) で初登場
    • a2535d9fe9 (2020-08-26) で in_* にリネーム

#57514 Hash PostgreSQL cable channel identifiers by byte size

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    Action Cable の PostgreSQL サブスクリプションアダプタで、チャネル名の長さ判定を「文字数」ではなく「バイト数」で行うように変更し、マルチバイト文字を含むチャネル名が PostgreSQL の識別子上限(63バイト)でサイレントに切り詰められる問題を修正した PR です。
    これにより、長い日本語名などを使ったチャネルで発生し得た「メッセージのサイレントロス」および「別チャネルへの誤配送」が解消されます。

  1. 変更内容の詳細

何を直したか

Action Cable の PostgreSQL アダプタでは、PostgreSQL の識別子長上限(NAMEDATALEN - 1 = 63 バイト)を超えるチャネル名は SHA1 でハッシュ化していましたが、その判定に String#size文字数)を使っていました。

ruby
# 旧実装
def channel_identifier(channel)
  channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel
end

しかし、PostgreSQL の上限は「文字数」ではなく「バイト数」です。UTF-8 の日本語(3バイト/文字)などの場合、

  • 21文字の「あ" * 21` → 63バイト
  • 30文字 + 1文字 ("あ" * 30 + "X") → 91バイト

といったように、63文字未満でも63バイトを超えることがあり得ます。
size <= 63 だとハッシュ化されずそのまま LISTEN/NOTIFY に使われ、PostgreSQL 側でサイレントに 63バイトへ切り詰められます。

この結果、Action Cable 内部で使っている「チャネル識別子」と、PostgreSQL 側から wait_for_notify で返ってくる識別子とで不整合が発生していました。

具体的な不具合

Action Cable の PostgreSQL アダプタはざっくり次のような構造を持っています。

  • 購読時: channel_identifier(channel) をキーとした Ruby のハッシュに subscriber を登録
  • 受信時: PG#wait_for_notify が返すチャネル名をキーに subscriber を引いて dispatch

ここで、以下のような状況を考えます(DB は UTF-8):

ruby
S = "あ" * 21          # 21文字 / 63バイト → size=21 <= 63 なのでハッシュされない
L = ("あ" * 30) + "X"  # 31文字 / 91バイト → size=31 <= 63 なのでハッシュされない
                       # ただし PostgreSQL は 63バイトに切り詰め → "あ" * 21 == S
  • Ruby 側登録キー
    • channel_identifier(S)"あ"*21(63バイト)
    • channel_identifier(L)"あ"*30 + "X"(91バイト、そのまま
  • PostgreSQL 側から wait_for_notify で返るキー
    • NOTIFY L"あ"*2191バイト → 63バイトにトリムされて S と同一

このため、2パターンの不具合が生じます:

  1. サイレントメッセージロス
    長いチャネル L へ broadcast しても、wait_for_notify が返すキーは "あ"*21(S相当)になるが、登録時のキーは "あ"*30 + "X"(91バイト)で一致しないため、L の subscriber には一切メッセージが届かない。

  2. 別ストリームへの誤配送
    もし "あ"*21(S)にも別の subscriber が存在している場合、
    wait_for_notify のキー "あ"*21 でハッシュを引くと S 側の subscriber がヒットするため、
    本来 L 向けのメッセージが S に誤配送され、L は何も受け取れない。

この問題は、マルチバイトで63バイトを超えたチャネル名を使い、かつその63バイトへの切り詰め結果に一致する別のチャネルが存在する場合に顕在化します。
日本語などの多バイト言語をチャネル名に使うと、実務上発生し得るパターンです。

修正内容

判定を「文字数」から「バイト数」に変更しました。

ruby
# 新実装
def channel_identifier(channel)
  channel.bytesize > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel
end
  • bytesize > 63 の場合のみ SHA1 ハッシュ(40バイト文字列)に変換
  • SHA1 は常に 40文字(40バイト)なので、PostgreSQL の 63バイト制限を超えない
  • 結果として、
    • PostgreSQL に渡す識別子
    • PostgreSQL から返ってくる識別子
    • Ruby 側の登録時キー
      が再び一致するようになり、上記のロス・誤配送が解消されます。
  • ASCII だけのチャネル名は size == bytesize のため挙動は従来と変わりません。

テスト追加

actioncable/test/subscription_adapter/postgresql_test.rb に以下の end-to-end テストを追加しています。

  • テスト名: test_long_multibyte_identifiers
  • 実際の LISTEN/NOTIFY を使う harness(subscribe_as_queue / @rx_adapter / @tx_adapter)で動作確認
  • シナリオ:
    1. long(91バイト、日本語 + 英字)と short("あ"*21, 63バイト)両方に subscribe
    2. long に対して broadcast
    3. 2つの assertion:
      • long 側 subscriber が payload を受け取ること(旧実装ではロス)
      • short 側 subscriber は何も受け取らないこと(旧実装では誤配送)
  • 受信は Queue#pop(timeout: ...) でタイムアウト付きにして、旧実装時もテストがハングせずにすぐ失敗するよう工夫されています。
  • String#size 版では両 assertion が赤、String#bytesize に変更すると緑になることを確認済み。

  1. 影響範囲・注意点
  • 対象:
    • Action Cable を PostgreSQL アダプタ(ActionCable::SubscriptionAdapter::PostgreSQL)で利用しているアプリケーション
    • チャネル名(stream 名)に UTF-8 のマルチバイト文字を使い、合計バイト数が 63バイトを超えるケース
  • 影響:
    • 従来: 63文字以内であれば、バイト数が 63を超えていても「ハッシュされず」そのまま識別子として使われていた
    • 今回: 63バイトを超えた時点で必ず SHA1 ハッシュに変換される
  • 互換性上の注意:
    • チャネル名 → PostgreSQL 上の identifier へのマッピングが、一部ケースで「素の名前」から「SHA1 ハッシュ」へと変わるため、
      • 旧バージョンの Rails / adapter と新バージョンの adapter を混在運用している場合、
      • あるいは Action Cable のチャネル名を直接参照して PostgreSQL 側の LISTEN/NOTIFY を行っている自前コード
        などがあると、識別子の不一致が発生する可能性があります。
    • 一般的な Rails アプリ(Action Cable も Rails 側 API 経由のみ利用)では、多くの場合は透過的な変更となります。
  • セキュリティ・安定性:
    • SHA1 の利用はあくまで識別子の短縮用途であり、暗号学的安全性が問題になるわけではありません(元々そういう用途として導入されたもの (#28751))。
    • 修正により、意図しない cross-delivery / silent drop が解消され、安定性・予測可能性は向上します。

  1. 参考情報 (あれば)
  • PostgreSQL の識別子長制限:
    • NAMEDATALEN は通常 64
    • 実際の識別子長上限は NAMEDATALEN - 1 = 63 バイト
    • これを超える識別子はサイレントに切り詰められ、NOTICE が出るのみ
  • 関連する過去の変更:
    • もともとのハッシュ化ロジックは #28751(commit 2bce7777b7)で導入されており、今回の変更は「長さ判定を PostgreSQL の仕様(バイト数)に合わせて是正した」ものです。
  • 実務的な示唆:
    • WebSocket / PubSub 系で DB をバックエンドに使う場合、識別子長の単位(文字数ではなくバイト数)に注意が必要
    • マルチバイトを多用するシステムでは、UI 上の「文字数制限」だけでは不十分なことがあるため、バックエンド側のバイト数制限も意識する必要があります。

#57505 Parse all HTTP-date formats in If-Modified-Since

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    If-Modified-Since ヘッダの日時パースを Time.rfc2822 から Time.httpdate に変更し、RFC 9110 で必須とされる3種類すべての HTTP-date 形式(IMF-fixdate / RFC 850 / asctime)を正しく解釈できるようにしたPRです。これにより、これまで一部フォーマットで 304 Not Modified が返せずフルレスポンスになっていた挙動が修正されます。

  1. 変更内容の詳細

背景

  • If-Modified-SinceHTTP-date を持つヘッダで、RFC 9110 §5.6.7 では次の3フォーマットを定義し、かつ「すべて受理しなければならない(MUST)」としています:

    1. IMF-fixdate (例: Sun, 06 Nov 1994 08:49:37 GMT)
    2. RFC 850 (例: Sunday, 06-Nov-94 08:49:37 GMT)
    3. asctime (例: Sun Nov 6 08:49:37 1994)
  • 既存実装では ActionDispatch::Http::Cache::Request#if_modified_sinceTime.rfc2822 でパースしていました:

    ruby
    def if_modified_since
      if since = get_header(HTTP_IF_MODIFIED_SINCE)
        Time.rfc2822(since) rescue nil
      end
    end
  • Time.rfc2822 は IMF-fixdate 相当の形式しか受け付けないため、RFC 850 / asctime 形式で送られた If-Modified-Since はパースに失敗し、rescue nil により「ヘッダが無いかのように」扱われていました。
    not_modified? が常に false になり、本来なら 304 Not Modified を返せるケースでもフルレスポンスが返されていた。

  • 一方で、同じファイル内のレスポンス側(Last-ModifiedDate の読み取り)は既に Time.httpdate を使っており、リクエスト側だけが不整合な状態でした。

修正内容

if_modified_since のパーサーを Time.httpdate に変更:

ruby
def if_modified_since
  if since = get_header(HTTP_IF_MODIFIED_SINCE)
    Time.httpdate(since) rescue nil
  end
end
  • Time.httpdate は HTTP-date の3フォーマットすべてをサポートするため、RFC 9110 が要求する仕様に沿った挙動になります。
  • ブラウザや Rails が自分で生成する日付(Time#httpdate)は IMF-fixdate なので、一般的なケースの挙動は変わりません。

再現例と修正後の挙動

ruby
req  = ->(ims) { ActionDispatch::Request.new(Rack::MockRequest.env_for("/", "HTTP_IF_MODIFIED_SINCE" => ims)) }
mtime = Time.utc(1994, 11, 6, 8, 49, 37)

# 1. IMF-fixdate: 以前から OK
req.("Sun, 06 Nov 1994 08:49:37 GMT").not_modified?(mtime)
# => true

# 2. RFC 850: 修正前は nil(ヘッダ無しと同等)→ 修正後は true
req.("Sunday, 06-Nov-94 08:49:37 GMT").not_modified?(mtime)
# => 修正前: nil  /  修正後: true

# 3. asctime: 同様に修正前 nil → 修正後 true
req.("Sun Nov  6 08:49:37 1994").not_modified?(mtime)
# => 修正前: nil  /  修正後: true

テスト

actionpack/test/dispatch/request_test.rbRequestIfModifiedSince テストケースを追加:

  • 3種類の HTTP-date 形式が if_modified_since で正しくパースされ、not_modified? が期待どおり true になること。
  • パースできない日付文字列が渡された場合は nil を返すこと。
  • ヘッダが存在しない場合も nil を返すこと。

既存コードでは RFC 850 / asctime に対するテストが失敗し、この修正を適用するとパスすることが確認されています。


  1. 影響範囲・注意点

影響範囲

  • 対象: If-Modified-Since による条件付き GET (not_modified?) を利用するすべての Rails アプリ。
  • 変更点:
    • RFC 850 / asctime 形式を利用するクライアント・プロキシに対し、これまでより 積極的に 304 を返す ようになります。
    • これまでは「If-Modified-Since が無いもの」と見なされていたケース(= 常にフルレスポンス)で、正しく条件付きレスポンスが機能するようになります。
  • 性質:
    • キャッシュ・帯域最適化に関する改善であり、誤ったコンテンツを返すリスクはありません(最悪でもフルレスポンスが返るだけ)。

Time.httpdate の厳格さによる差異

  • Time.httpdate はタイムゾーンに GMT を要求します。
  • 例えば Sun, 06 Nov 1994 08:49:37 +0000 のような「RFC 2822 的には妥当だが HTTP-date としては非準拠」な値は、Time.httpdate ではパースエラーになります。
    • 以前: Time.rfc2822 がパースして Time オブジェクトを返す。
    • 以後: Time.httpdate が例外を投げ、rescue nil によって nil となる → ヘッダ無し相当の扱い → 304 を返し損ねてフルレスポンスになる。
  • ただし、これらは HTTP-date としてそもそも不正なフォーマット なので、仕様準拠を優先した形です。
  • 挙動の変化も「本来 304 を返すべきではないヘッダに対して 304 を返さない」「結果的にフルレスポンスになる」だけであり、安全側に倒れていると言えます。

互換性の総括

  • 仕様通りの HTTP-date (GMT、3フォーマットのいずれか) を送るクライアントは完全に後方互換であり、挙動は変わらないか、RFC 850 / asctime の場合に改善されます。
  • 非準拠クライアント(+0000 など)に対しては、304 を返さずフルレスポンスになる方向への変化のみが発生し、誤った 304 が返るような形にはなりません。
  • サーバ側とクライアント側での HTTP-date 取り扱いが Time.httpdate で統一されたことで、ActionDispatch 内の一貫性も向上しています。

  1. 参考情報 (あれば)
  • RFC 9110 §5.6.7: HTTP-date の定義(IMF-fixdate / RFC 850 / asctime、および GMT 必須)
    https://www.rfc-editor.org/rfc/rfc9110#section-5.6.7
  • Rails 内の関連箇所:
    • ActionDispatch::Http::Cache::Request#if_modified_since(本PRで修正)
    • 同ファイル内での Last-Modified / Date の読み取りロジック(既に Time.httpdate を使用)

#57519 Fix store accessor *_change and saved_change_to_* reporting unchanged keys as changed

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    ActiveRecord::Store で生成される *_change / saved_change_to_* が、実際には値が変わっていないキーに対しても [value, value] を返してしまう不具合を修正した PR です。
    これにより、通常の ActiveModel の dirty トラッキング API と同様に、値が変わっていないキーに対しては nil を返すようになります。

  1. 変更内容の詳細

何が問題だったか

ActiveRecord::Store では、store / store_accessor で定義したキーごとに、次のような dirty メソッドが自動生成されます。

  • <key>_changed?
  • <key>_change
  • saved_change_to_<key>?
  • saved_change_to_<key>

このうち *_change / saved_change_to_* の実装が、カラム単位の変更有無attribute_changed?(store_attribute) / saved_change_to_attribute?(store_attribute))しか見ておらず、キー単位で値が変わったかどうかを見ていなかったのが問題でした。

元の挙動は以下のようになります。

ruby
user = User.create!(color: "black", size: "L").reload
user.color = "white"          # color だけ変更

user.size_changed?            # => false          (キー単位のpredicateは正しい)
user.size_change              # => ["L", "L"]     (バグ、本来は nil であるべき)

user.save!
user.saved_change_to_size?    # => false          (正しい)
user.saved_change_to_size     # => ["L", "L"]     (バグ、本来は nil)

つまり、

  • ストアカラム(例: settings)のどれか1キーでも変更があると
  • そのカラム内の 他のキー についても
    • <key>_changed? / saved_change_to_<key>?false(正しい)
    • なのに <key>_change / saved_change_to_<key>[value, value] を返す(間違い)

という不整合な状態になっていました。

ActiveModel の通常の dirty API (attribute_change, saved_change_to_attribute) では「値が変わっていない場合は nil を返す」のが契約であり、これに反していたことになります。

どう修正されたか

activerecord/lib/active_record/store.rb*_change / saved_change_to_* の実装において、キー単位での値比較を行い、変化がなければ nil を返すように修正されています。

疑似コードレベルでいうと、これまで:

ruby
define_method("#{accessor_key}_change") do
  return unless attribute_changed?(store_attribute)
  prev_store, new_store = changes[store_attribute]
  accessor = store_accessor_for(store_attribute)
  [accessor.get(prev_store, key), accessor.get(new_store, key)]  # 変わってなくても常に返していた
end

だったものが、以下のように変更されています:

ruby
define_method("#{accessor_key}_change") do
  return unless attribute_changed?(store_attribute)
  prev_store, new_store = changes[store_attribute]
  accessor = store_accessor_for(store_attribute)

  prev_value = accessor.get(prev_store, key)
  new_value  = accessor.get(new_store, key)

  [prev_value, new_value] unless prev_value == new_value
end

saved_change_to_<key> についても同様に、前後の値を比較して同値であれば nil を返すようになっています。

変更されていない挙動

以下のメソッドは今回の修正対象外で、従来どおりの挙動を保っています。

  • <key>_was
  • <key>_before_last_save

これらは通常の attr_was / attribute_before_last_save と同様、「キーが変更されていない場合は現在値を返す」という契約であり、nil にはなりません。この契約は dirty pair (*_change) の契約とは別物なので、そのまま維持されています。

テスト

activerecord/test/cases/store_test.rb にリグレッションテストが追加されています。

  • ストアに複数のキー(例: color, homepage)を定義

  • homepage に実データ(URL)を入れた状態で

  • color だけ変更したときに

    • homepage_changed? / saved_change_to_homepage?false であることに加えて
    • homepage_change / saved_change_to_homepagenil を返すこと

を検証しています。
PR内で main ブランチに対してテストが「red → fix 適用後 green」になることが確認されています。


  1. 影響範囲・注意点

影響を受けるコード

影響を受けるのは、store accessor の *_change / saved_change_to_* の戻り値を直接利用しているコードです。

たとえば、次のようなコードを書いている場合:

ruby
before_save do
  prev, cur = settings_change_for_size = [size_change].flatten
  # もしくは
  prev, cur = size_change if size_change
  # あるいは
  prev, cur = saved_change_to_size
end

以前は「サイズは変わっていないが、同じストア内の他のキーだけが変わった」という場合に ["L", "L"] のようなペアが返ってきていましたが、これが nil に変わります

よくあるパターン:

ruby
prev, cur = size_change
if prev != cur
  # ... 何か処理 ...
end

こうしたコードは、これまでは prev != curfalse になって何も起きない、という「一応安全だが無駄な比較」状態でしたが、今後は size_changenil になり、prev, cur = size_changeTypeError になる可能性があります。
とはいえ、dirty pair の契約に従うコードであれば、本来は以下のように nil チェックを行うべきです:

ruby
if (change = size_change)
  prev, cur = change
  # ... 何か処理 ...
end

あるいは:

ruby
if size_changed?
  prev, cur = size_change
  # ... 何か処理 ...
end

そのため、契約どおりの使い方をしている限りは問題になりにくく、挙動はより一貫性のあるものになります

後方互換性の観点

  • ActiveModel の標準的な dirty トラッキング (attribute_change, saved_change_to_attribute) と同じ挙動になるよう修正されているため、API 契約としてはむしろ「正しい」方向への変更です。
  • ただし、過去の「間違った挙動」に暗黙的に依存していたコード(「変わっていない場合も [value, value] が返る」前提)は壊れる可能性があります。
  • *_was / *_before_last_save はこれまでどおり「変わっていない場合も現在値を返す」ので、こちらに依存しているコードは影響を受けません。

影響レベル

PR説明上は「Medium」とされており:

  • セキュリティインパクトはなし
  • *_changed? / saved_change_to_*? はもともと正しかったため、これらの predicate を利用するコードは基本的に影響なし
  • 直接ペアを読んでいるコードのみが対象

という整理です。


  1. 参考情報 (あれば)
  • 対象コード: activerecord/lib/active_record/store.rb
    • store_accessor 周りの dirty メソッド (<key>_change, saved_change_to_<key>) の実装
  • テスト: activerecord/test/cases/store_test.rb
    • 兄弟キー(同じ store カラム内の別キー)が変化したときの *_change / saved_change_to_* の戻り値の検証
  • バグの由来:
    • store の dirty メソッドが追加された 2019-03-25 (61a39ffcc6) から存在
    • 2025年の 97df37b898 で値の取得方法を digaccessor.get に変えたが、ガードロジック自体はそのまま残っていたためバグは解消されていなかった

store accessor の dirty 情報を直接扱うコードを書いている場合は、この PR の変更(「変化していないキーでは *_change / saved_change_to_*nil を返す」)を前提に、nil ハンドリングが適切かを一度確認しておくと安全です。


#57515 Fix PostgreSQL range bounds parser corrupting comma-containing bounds

マージ日: 2026/6/1 | 作成者: @55728

  1. 概要 (1-2文で)
    PostgreSQL の range 型を ActiveRecord が文字列からパースする際、境界値にカンマが含まれていると誤って分割してしまい、両端の値が壊れる不具合を修正した PR です。特に moneyrange では $1,000.00 のような通常の値であっても 0.0..0.0 に化けるという、重大なサイレントデータ破壊を防ぐ修正です。

  1. 変更内容の詳細

問題のあったコードとバグの内容

対象は PostgreSQL::OID::Range#extract_boundsactiverecord/lib/active_record/connection_adapters/postgresql/oid/range.rb)で、range のテキスト表現から下限・上限を取り出す処理です。

以前は、外側の括弧・ブラケットを落とした後、

ruby
value[1..-2].split(",", 2)

先頭のカンマで単純に split していました。

PostgreSQL の range 表現の仕様として、

  • 境界値にカンマやスペース、クオート等が含まれる場合は "..."ダブルクォートしてエスケープ する
  • 例: ["a,b","c,d"), ["$1,000.00","$2,000.50"]

という挙動がありますが、split(",", 2) だと ダブルクォートを考慮せず 生の文字列を分割してしまうため、以下のようなバグが発生していました。

例: ["a,b","c,d") をパースする場合

text
value[1..-2] # => " "a,b","c,d" "
split(",", 2) # => ["\"a", "b\",\"c,d\""]

結果として、ActiveRecord から見える range は

ruby
# 本来
range.string_range # => "a,b"..."c,d"

# 実際 (before)
range.string_range # => "\"a"..."b\",\"c,d\""

のように両端とも壊れていました。

特に危険な moneyrange

money 型のテキスト表現はロケールによっては 3 桁ごとにカンマ区切り を含みます(例: "$1,000.00")。そのため moneyrange で次のようなデータを持っていると:

sql
'["$1,000.00","$2,000.50"]'::moneyrange

ActiveRecord 側では分割後に money 型としてキャストされる際、壊れた "\"$1 などの断片が 0 に解釈されてしまい、結果として 0.0..0.0 という完全な誤値 になります。これは異常値にも見えず、検知が困難なため深刻です。

修正内容

1. カンマ分割を「クオート対応」なものに変更

split(",", 2) を、ダブルクォートを理解した split_bounds メソッドに置き換えました。

ruby
BOUNDS = /\A((?:"(?:[^"\\]|""|\\.)*"|[^,"])*),(.*)\z/m # :nodoc:

def split_bounds(value)
  # 高速パス: ダブルクォートを含まなければ従来通り split(",", 2)
  return value.split(",", 2) unless value.include?('"')

  (m = BOUNDS.match(value)) ? [m[1], m[2]] : [value, nil]
end

ポイント:

  • value.include?('"')なければ従来どおり String#split(",", 2) を使用
    → int4range/numrange/tsrange/daterange など、通常の組み込み range 型はパフォーマンス影響がほぼない
  • ダブルクォートが含まれる場合のみ、BOUNDS 正規表現で
    • 「ダブルクォートで囲まれたセグメント」をスキップしつつ
    • 「境界を分ける 1 個のカンマ」を検出して 下限・上限に綺麗に分割する

正規表現の構造:

regex
\A(
  (?:
    "(?:[^"\\]|""|\\.)*"  # "..." の中。 ""(二重引用)と \x のエスケープも許容
    |                     # または
    [^,"]                 # カンマ・ダブルクォート以外の任意文字(= 非クオート領域)
  )*
),                         # ↑で取ったものの直後のカンマが境界
(.*)\z                     # 残りが上限側
/m
  • [^,"] にしているのは、
    「クオートされた部分」と「クオートされていない部分」が正規表現上で競合しないようにするため(曖昧性排除とパフォーマンスのため)
  • /m フラグにより、上限側(2 番目のキャプチャ)が改行をまたいでも取得できるようにしている

2. unquote の nil 耐性向上

元コードでは、split(",", 2) が失敗すると to == nil になり、後続の unquote(to) 内で nil.start_with? により NoMethodError が発生する可能性がありました。

今回の変更で split_bounds はマッチ失敗時に [value, nil] を返すようになり、unquote 側も nil をそのまま返すようにして、不正入力時はクラッシュではなく「無変換の値 or nil」として処理されるようになっています。
(有効な PostgreSQL 出力では必ずカンマが含まれるため、このフォールバックパスは基本的に壊れた入力への備えです。)

3. テスト追加

activerecord/test/cases/adapters/postgresql/range_test.rb に、以下の新規テストが追加されています(いずれも PostgreSQL 実機に対してレッド → 修正後グリーンを確認済み):

  • カンマを含むクオート境界の基本ケース
    • ["a,b","c,d")"a,b"..."c,d" として読み戻せること
  • クオート+カンマ+ダブルクォート+バックスラッシュ混在
    • ["a,""b","c\\,d") のような複雑ケースのパース検証
  • 片側のみクオートされている場合
    • ["x,y",z)
  • 片側無限境界(beginless / endless)+カンマ
    • ["a,b",)[,"c,d")
  • 境界に改行を含むケース
    • /m フラグを外すと壊れるケースのドキュメント兼テスト
  • 空文字列 "" を境界に含むケース
    • ["","z") が、空境界("")と無限境界(nil)を区別して扱えていること

これにより、クオート・エスケープ・カンマ・改行・空文字といった典型的な落とし穴を網羅的にカバーしています。

4. パフォーマンス評価

extract_bounds は deserialization のホットパスであるため、速度劣化がないようベンチマークも行われています。Ruby 4.0.2 + benchmark-ips での計測結果:

  • ダブルクォートを含まないケース(通常の int/date 等)
    • 旧: split(",", 2) 単体
    • 新: include?('"')split(",", 2) のハイブリッド
      → ほぼ 1.2 倍程度のオーバーヘッドに収まる(数百 ns レベル)
  • ダブルクォートを含むケース(今回バグっていた text/varchar/money など)
    • split は速いが 結果がバグっている
    • regexp ベースの split_bounds は ~300ns 程度
      → deserialization 全体(gsub, subtype.deserialize, Range オブジェクト生成など)に比べれば十分小さい

また、「手書き 1 パスの Ruby スキャナ」も試されたものの、必ずしも正規表現より速くならなかったため採用されていません。


  1. 影響範囲・注意点

影響を受ける可能性があるケース

  • PostgreSQL 側で以下のような range 型を使用しており、ActiveRecord で読み書きしているアプリケーション:
    • text / varchar サブタイプのカスタム range 型
      • 例: CREATE TYPE textrange AS RANGE (subtype = text); など
    • money サブタイプの range 型(特に危険)
      • 例: CREATE TYPE moneyrange AS RANGE (subtype = money);

PostgreSQL では pg_range カタログに登録されたすべての range 型 (typtype = 'r') が、ActiveRecord 起動時に PostgreSQL::OID::Range.new(subtype, typname) として 自動登録 されるため、「カスタム range 型だから非サポート」という扱いではありません。全てサポート対象であり、そのうち「クオートされた境界にカンマが入りうるもの」は影響を受けます。

一方で、次のような標準 range 型はそもそもカンマを含まないテキスト表現のため影響なし(ただし include?('"') の 1 パス分だけは増える)です。

  • int4range / int8range
  • numrange
  • tsrange / tstzrange
  • daterange
    (これらはカンマもクオートも通常出力では使われません)

実運用上のリスク

  • すでに本バグを含んだ Rails でアプリを運用していた場合、
    • moneyrangetextrange 等から ActiveRecord 経由で値を読んだ瞬間に「壊れた値」がアプリロジックの中で利用されていた可能性があります。
    • ただし「DB の中身(ディスク上の値)」自体は壊れておらず、パース時に壊れていただけ なので、修正後に再読み込みすれば正しい値が取得されます。
  • ログや監査、集計値などに影響が波及している可能性があるため、moneyrange を使っている場合は特に挙動確認を推奨します。

マイグレーション / 互換性上の注意

  • range のテキストフォーマットや PostgreSQL 側の動作を変えているわけではなく、あくまで Rails 側のパーサの修正のみです。
  • 有効な PostgreSQL の range 文字列表現に対しては後方互換であり、「以前は通っていたけど今は例外になる」というような変更は意図的には入っていません(むしろ逆に、不正入力に対しては例外から「穏当な失敗」へと挙動が緩和されています)。
  • したがって、ほとんどのアプリケーションは「アップデートすると正しくなる」だけで、追加でのコード変更は不要です。

  1. 参考情報 (あれば)
  • 該当コード:
    activerecord/lib/active_record/connection_adapters/postgresql/oid/range.rb
    • extract_bounds 内部で split_bounds が利用される形に変更
  • range 型の自動登録:
    activerecord/lib/active_record/connection_adapters/postgresql/type_map_initializer.rb
    • pg_range カタログを走査し、OID::Range.new(subtype, typname) として type map に登録
  • バグの由来:
    • 2019-11-06: quoted bound 対応 (unquote) を追加したが、分割ロジックはカンマを無条件 split のままだった (45d7dad578)
    • 2020-05-29: split(",")split(",", 2) に変更したのみ (c65864cdca)
      → 5.5 年ほど潜伏していた不具合

この PR により、PostgreSQL の range 表現仕様(ダブルクォート・エスケープ・カンマ・改行)に正しく追従したパースが行えるようになり、特に moneyrange のような実害の大きいケースでのサイレントデータ破壊が解消されます。


#57476 Rescue RedisClient::Error in RedisCacheStore failsafe

マージ日: 2026/6/1 | 作成者: @darrunategui

  1. 概要 (1-2文で)
    ActiveSupport::Cache::RedisCacheStore の「Redis障害時でも例外を投げずにフェイルセーフに動作する」という契約が、Redis Sentinel など一部構成で破れていた問題を修正するPRです。RedisClient::Error 系の例外もフェイルセーフで握りつぶすようにし、テストとCHANGELOGを追加しています。

  1. 変更内容の詳細

2-1. 問題の背景

RedisCacheStore はドキュメント上、以下のような動作が期待されています:

  • Redis サーバが落ちている・一時的に接続できない場合
    • 例外はアプリ側に伝播しない
    • read は常にミス扱い (nil 等)
    • writedelete などは黙って失敗(エラーを投げない)

これまでの実装は、Redis::Client#translate_error! が投げる Redis::BaseError など「Redis::* 階層の例外」だけを rescue していました。
しかし、以下のような構成時に問題がありました:

  • Redis.new(sentinels: ...) で Sentinel を使うと、内部的に client_implementation: ::RedisClient が強制される
  • このとき、プールされている接続は Redis::Client ではなく素の ::RedisClient になる
  • そのため Redis::Client#translate_error! を経由せず、RedisClient::ConnectionError, RedisClient::ReadTimeoutError など 生の RedisClient::Error サブクラスが直接飛ぶ
  • RedisCacheStore のフェイルセーフは Redis::* 系だけを rescue していたため、これら RedisClient::Error はすり抜けてアプリケーションエラーとなってしまう

これは、過去にあった ConnectionPool::TimeoutError がフェイルセーフをすり抜けていたバグ (#54432) と同種の問題です。

2-2. コード上の主な変更点

2-2-1. フェイルセーフで捕捉する例外の拡張

RedisCacheStore#failsafe(内部で使用されている例外捕捉ラッパー)で rescue する対象に ::RedisClient::Error を追加しました。

変更点のポイント:

  • フェイルセーフが rescue する例外クラスのリストを、プライベート定数 FAILSAFE_ERRORS として切り出し
  • その中に ::RedisClient::Error を条件付きで追加

イメージとしては次のような形になっています(実際のコードから簡略化):

ruby
# activesupport/lib/active_support/cache/redis_cache_store.rb

FAILSAFE_ERRORS = [
  ::Redis::BaseError,
  # 既存のConnectionPool::Errorなど...
].tap do |errors|
  # redis 5.x 以降でトップレベルに RedisClient がある場合のみ追加
  errors << ::RedisClient::Error if defined?(::RedisClient::Error)
end.freeze

def failsafe(return_value = nil)
  yield
rescue *FAILSAFE_ERRORS => error
  handle_exception(error) # ログ等
  return_value
end

これにより、

  • redis-rb 5.x 系 (RedisClient がトップレベルに存在する)
    • RedisClient::Error も確実に rescue される
  • redis-rb 4.x 系 (RedisClient がトップレベルに存在しない)
    • defined?(::RedisClient::Error) が false になるため、互換性を壊さない

という挙動になります。

2-2-2. テストの追加

新しいテスト FailureSafetyFromRedisClientErrorTestredis_cache_store_test.rb に追加されています。
目的は「RedisClient::Error が発生した場合でも、RedisCacheStore の全公開メソッドがフェイルセーフに動作すること」を保証することです。

テストの構成:

  • RedisClientErrorRedisClient というテスト用ダミーのクライアントクラスを定義
    • ensure_connected で強制的に RedisClient::Error を raise する
    • self.translate_error! を「何もしないで再送出する」no-op 実装にオーバーライド
      • redis-rb 現行実装では Redis::Client#call_vRedis#send_command の2段階で RedisClient::ErrorRedis::BaseError などに変換する
      • 両方とも Client.translate_error! を「定義位置の Client」として参照する(lexical lookup)
      • テスト内で Redis.const_set(:Client, …) のように差し替えているため、見ている translate_error! はこのダミークラスになる
      • そこで no-op にすることで、「RedisClient::Error が最後まで変換されずに飛んでくる状況=Sentinel 経由の実際のパス」を忠実に再現している
  • その上で FailureSafetyBehavior という既存のテストスイートを再利用し、以下のような各メソッドについて
    • read, write, fetch, delete, increment, decrement, clear など
  • ドキュメント通りの「フェイルセーフな戻り値」(例: readnilincrementnil / 既定値、など) を返すことを検証

テストのパターン自体は、既存の

  • FailureSafetyFromUnavailableClientTest
  • FailureSafetyFromMaxClientsReachedErrorTest

と同じ構造になっており、それの RedisClient::Error 版が追加された形です。

2-2-3. CHANGELOG の更新

activesupport の CHANGELOG に、RedisCacheStore のフェイルセーフが RedisClient::Error を拾うようになったことが追記されています。
バグフィックスとしての振る舞い変更があるため、ライブラリアップデート時に利用者が把握しやすくなっています。


  1. 影響範囲・注意点

3-1. 影響を受ける構成

特に影響が大きいのは次のような構成です:

  • ActiveSupport::Cache::RedisCacheStore を使用している
  • Redis.new(sentinels: ...) / Sentinel 構成、または client_implementation: RedisClient を使うような設定
  • 一時的な Redis 障害時に、Rails.cache.read / Rails.cache.write などの呼び出しで RedisClient::ConnectionError, RedisClient::ReadTimeoutError 等がアプリケーション側に伝播していた

このPR適用後は、これらの例外はフェイルセーフにより握りつぶされ、従来のドキュメント通り:

  • 例外を投げず
  • 「キャッシュミス」または「書き込み失敗だが無視」という扱い

に統一されます。

3-2. 例外ハンドリング・監視の観点での注意

  • これまで、RedisClient::Error をアプリ側で rescue していた / エラートラッキングに出ていた場合
    • 今後は Rails.cache 呼び出しからその例外は飛んでこなくなります
    • Rails 側のログ (handle_exception 相当) や Redis 側のメトリクスで監視する必要があります
  • 「Redis エラーをあえてアプリケーションエラーとして扱いたい」という設計には合致しません
    • RedisCacheStore はあくまで「キャッシュは落ちてもアプリを落とさない」設計であり、その契約に仕様を合わせた変更です
    • もし Redis 障害を致命的とみなしたい場合は、RedisCacheStore ではなくアプリ側で直接 Redis クライアントを扱う等、別設計が必要です

3-3. 互換性

  • redis-rb 4.x:
    • RedisClient 定数が存在しないため、この変更は実質無影響です(FAILSAFE_ERRORS に追加されない)
  • redis-rb 5.x 以降:
    • これまで例外として表に出ていた RedisClient::Error 系がフェイルセーフで抑制されるため、「バグ修正としての挙動変更」が発生しますが、ドキュメントされた期待仕様に揃う形の変更です

  1. 参考情報 (あれば)
  • このPRが扱う問題と関連する既存Issue/PR:
    • #54432: ConnectionPool::TimeoutError がフェイルセーフをすり抜けていた問題
    • #54440 / #54460: 上記問題への対応で、rescue 範囲を広げた変更
  • RedisCacheStore の設計思想:
    • 「キャッシュは落ちてもアプリケーションを落とさない」というポリシーで、Redis 障害時には例外を投げず、ミス扱い / サイレントな書き込み失敗とすることが明示されています
    • 本PRは、そのポリシーが Sentinel + RedisClient パスでも一貫して守られるようにするためのものです

#57502 Handle malformed signed cache payloads gracefully

マージ日: 2026/6/1 | 作成者: @fallintoplace

  1. 概要 (1-2文で)
    Rails の ActiveSupport::Cache::Coder#load が「署名付きキャッシュフレームの形式が壊れている場合」に例外を投げてしまう問題を修正し、他の破損ペイロードと同様に「キャッシュミス(nil)」として扱うようにした PR です。これにより、壊れた署名付きキャッシュがアプリケーションエラーとして表面化するのを防ぎます。

  1. 変更内容の詳細

問題のパス: 署名付きキャッシュフレーム

ActiveSupport::Cache::Coder は、キャッシュ値を以下のような形で扱います。

  • シリアライザ(例: Marshal
  • 圧縮器(例: Zlib
  • 署名付きフレーム形式(ActiveSupport が内部で使う独自フォーマット)

Coder#load は、ペイロードが署名付きキャッシュフレームのプレフィックスを持っているとき、ヘッダ部分を String#unpack1 で解析していました。しかし、ヘッダを展開する前後が例外保護されておらず、バイト列が短すぎる/形式が崩れていると次のような例外が外に漏れていました。

ruby
coder = ActiveSupport::Cache::Coder.new(Marshal, Zlib)
coder.load("\x00\x11".b)
# => ArgumentError: @ outside of string

本来であれば「壊れたキャッシュ → 読み込めない → nil を返す(キャッシュミス扱い)」という動作に統一されるべきところが、署名付きパスだけが例外をそのまま投げてしまう状態でした。

何をしたか

  1. 署名付きヘッダのパース処理を例外から保護

    • 署名付きフレームのヘッダ(unpack1)を扱うコードを、他のデシリアライズエラー処理と同じように例外ハンドリングの中に入れています。
    • ヘッダが読み取れない・短すぎる・不正なバイト列などで ArgumentError 等が発生しても、内部で捕捉して nil を返すようにした、という挙動に変更されています。
    • つまり、「署名はついているがフレームが壊れていて LazyEntry すら作れない」ケースでも、キャッシュミスと同じ扱いに統一されます。
  2. テストの追加・回帰テスト

    • 既存の「破損したペイロード」のテストケースに、「署名付きだがあまりにも短い(ヘッダが読めない)」ケースを追加しています。
    • これにより、今後のリファクタで再び同様の例外が外に漏れるような変更が入った場合に検知できるようになっています。

テストコマンド:

sh
BUNDLE_WITHOUT=db bundle exec ruby -w -Itest test/cache/cache_coder_test.rb

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

    • ActiveSupport::Cache を利用しているすべての箇所に潜在的な影響がありますが、挙動の方向性としては「過去に例外を投げていたケースを、キャッシュミスとして扱う」方向の緩和的変更です。
    • 特に、以下のような環境・ケースで影響を受ける可能性があります:
      • 署名付きキャッシュ(Rails 6 以降のデフォルトキャッシュ形式など)を使っている
      • キャッシュストアに何らかの理由で「壊れた/部分的な」バイト列が保存される可能性がある(バージョンアップ、キャッシュ共有、データ破損など)
      • これまで ArgumentError: @ outside of string のような例外が時々発生していた
  • 動作上の変化

    • 従来: 壊れた署名付きフレーム → Coder#load 内で例外 → アプリ側に例外が伝播(キャッシュ読み込み時の例外として表面化)
    • 変更後: 壊れた署名付きフレーム → 例外を内部で捕捉 → nil を返す → キャッシュミスと同じ挙動
    • そのため、「壊れたキャッシュを検知したい」「あえて例外を発生させて監視している」といった特殊な運用をしていない限り、アプリケーションのロジックに悪影響はなく、むしろ安定性が向上します。
  • 注意点

    • これまでは例外により気付きやすかった「キャッシュデータの破損」が、静かにキャッシュミスとして扱われるようになります
    • キャッシュ破損を監視したい場合は、ActiveSupport::Cache::Store のラッパーを作成してログを仕込むなど、別途アプリ側で監視を実装する必要があります。
    • 本変更は ActiveSupport::Cache::Coder の内部挙動レベルの変更であり、通常の API (Rails.cache.read 等) のインターフェースは変わりません。

  1. 参考情報 (あれば)
  • 対象クラス: ActiveSupport::Cache::Coder
    • 署名付きフレーム形式や LazyEntry は、Rails 内部のキャッシュ最適化に関わる仕組みです。
  • 類似挙動:
    • 既に「未署名/レガシー形式のペイロード」については、デシリアライズエラーをキャッシュミスとして扱う処理があり、今回の変更はそのポリシーに「署名付きフレームパス」を揃えたものです。
  • 実際に問題を踏んでいるか確認するには:
    • アプリケーションログやエラートラッカーで ActiveSupport::Cache::Coder#load 起点の ArgumentError: @ outside of string などを検索し、本 PR 適用後にそれが収まるかを確認できます。

#57511 Test to_sentence with an empty array

マージ日: 2026/6/1 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActionView の to_sentence が「空配列に対しては空の html_safe な文字列を返す」ことを確認するテストが追加された PR です。プロダクションコードの変更は一切なく、テストコードのみの追加です。

  2. 変更内容の詳細

  • 対象メソッド

    • ActionView::Helpers::TextHelper#to_sentence(正確には、Array#to_sentence を ActionView が拡張したもの)
    • 既に以下のケースはテスト済みだった:
      • 要素数 1 の配列
      • 要素数 2 の配列
      • 要素数 3 以上の配列
    • 今回、新たに「要素数 0(空配列)」のケースがテストに追加された。
  • 追加されたテストのポイント
    厳密なコードはPR本文には載っていませんが、意図としては以下のようなテストが actionview/test/template/output_safety_helper_test.rb に 6 行ほど追加されています。

    想定されるテストイメージ(擬似コード):

    ruby
    def test_to_sentence_with_empty_array_returns_empty_html_safe_string
      array = [].to_sentence
      assert_equal "", array
      assert_predicate array, :html_safe?
    end

    もしくは既存の output_safety 系テストに寄せた書き方として:

    ruby
    def test_to_sentence_with_empty_array_is_html_safe
      result = [].to_sentence
      assert result.html_safe?
      assert_equal "", result
    end

    いずれにせよ、以下の2点を確認するテストが追加されています。

    • 空配列に to_sentence を呼ぶと、空文字列 ("") が返る
    • その返り値は html_safe 扱いになる(ActiveSupport::SafeBuffer
  1. 影響範囲・注意点
  • 実行時の挙動への影響:

    • 本PRはテスト追加のみで、ライブラリ本体のコードは変更されていません。
    • そのため、既存のアプリケーションの挙動は一切変わりません。
  • 既存仕様の明文化:

    • 空配列に対する to_sentence は空の html_safe な文字列を返す、という仕様がテストによって保証されました。
    • これにより、将来の変更でうっかり nil を返したり、非 html_safeString を返すようなリグレッションが発生しにくくなります。
  • 開発者視点での注意点:

    • ビューで [].to_sentence を使った場合、単に何も表示されない(空文字)だけでなく、XSSサニタイズ済みの安全なバッファとして扱われることが保証されます。
    • rawhtml_safe を二重に呼ぶ必要はありません([].to_sentence.html_safe は不要)。
    • この仕様に依存しているコード(例: content_tag にそのまま渡す、safe_join と組み合わせる等)は、今後も同じ挙動が継続することがテストで担保されます。
  1. 参考情報 (あれば)

#57524 [ci skip] Update Ruby version requirement to 3.4 or newer in the Getting started guide

マージ日: 2026/6/1 | 作成者: @paul-louyot

  1. 概要 (1-2文で)
    Rails公式ガイド「Getting Started」で推奨するRubyバージョンが「3.4以上」に引き上げられました。これにより、チュートリアル内でRuby 3.4以降で追加されたit構文を前提としたコード例を維持できるようにすることが目的です。

  1. 変更内容の詳細
  • 対象ファイル: guides/source/getting_started.md
  • 変更内容は1行のみで、ガイド内の「使用するRubyの推奨バージョン」の記述を更新しています。

例(イメージ):

diff
- You will need Ruby 3.2 or newer...
+ You will need Ruby 3.4 or newer...

PRの説明によると、この更新は以下を意図しています。

  • Getting Started ガイド内では、新しいRubyのブロック内it構文(例:パターンマッチなどの新構文)を使ったサンプルが含まれている。
  • Rubyのバージョン要件が古いままだと、読者がそのままサンプルを実行した際にエラーになる可能性がある。
  • チュートリアルのサンプルコードと、公式に案内するRubyバージョンを整合させるため、「Ruby 3.4以上」を必須(あるいは強く推奨)とする。

it syntax inside blocks」という表現から、Ruby 3.4で導入された新構文(ブロック内のよりモダンな書き方)を前提としたチュートリアルコードを維持するための変更であると読み取れます。


  1. 影響範囲・注意点
  • 対象はドキュメントのみで、Rails本体のコードや挙動には変更はありません。
  • Getting Started ガイドに従って新規に環境構築するユーザー:
    • Ruby 3.4未満だと、ガイドのコード例がそのままでは動作しない可能性があります。
    • ガイドに沿う場合は、少なくともRuby 3.4へのアップグレードが推奨されます。
  • 既存プロジェクト:
    • 既にRuby 3.2や3.3などでRailsを使っているプロジェクトに対する強制的な変更ではありません。
    • ただし、ガイドにある最新の構文を利用したい場合は、Ruby本体のバージョンアップを検討する必要があります。
  • Railsのサポートポリシー:
    • ガイドのRuby要件が3.4以上となることで、「Rails入門」を目的としたユーザーは、比較的新しいRubyを使うことが事実上の前提になります。
    • CI設定などでRuby 3.4をまだ追加していない場合は、教育用途のサンプルや社内向けチュートリアルを更新する際にバージョン整合を取る必要があります。

  1. 参考情報 (あれば)
  • 元Issue: Fixes #57513
    → ガイドと実際の推奨Rubyバージョン・構文の不整合を解消するためのIssueと思われます。
  • 変更種別: ドキュメントのみ ([ci skip] がタイトルに付与されており、CI不要の軽微変更であることを示唆)
  • この変更は、Railsで新規に学習・開発を始めるユーザーが、Ruby 3.4の新構文を前提とした最新チュートリアルに沿いやすくするための調整と位置づけられます。

#57460 Fix Mysql2Adapter#discard! corrupting parent connection after fork

マージ日: 2026/6/1 | 作成者: @yahonda

  1. 概要 (1-2文で)
    MySQL 用アダプタ Mysql2Adapter#discard! が、fork 後の子プロセス終了時に親プロセス側の MySQL コネクションを壊してしまう不具合を修正する PRです。MYSQL_PREPARED_STATEMENTS=true かつ mysql2 アダプタ使用時に、子プロセス終了時の finalize が親のソケットに書き込めないよう、子側のソケット FD を /dev/null に差し替えるようにしました。

  1. 変更内容の詳細

問題の背景

  • MYSQL_PREPARED_STATEMENTS=true の場合、Mysql2Adapter は SQL → Mysql2::Statement のキャッシュ(@statements)を持つ。

  • fork すると、

    • MySQL クライアントのソケット FD(Mysql2::Client が内部で使うソケット)
    • そのクライアントに紐づく Ruby オブジェクト(Mysql2::Statement 等) が子プロセスに「コピー」される(FD は同じ番号を指す共有リソース)。
  • 既存コードでは、子プロセス側で Mysql2Adapter#discard! を呼ぶときに

    • client.automatic_close = false だけ行い、親とソケットを共有している Mysql2::Client を「自動クローズしない」ようにする対応のみ行っていた。
  • しかし、@statements にぶら下がっている Mysql2::Statement オブジェクトは、そのまま子プロセスのヒープ上に残る。

    • 子プロセスが exit すると、その Statement オブジェクトの finalizer が走り、COM_STMT_CLOSE をソケット FD に書き込む。
    • そのソケット FD は親プロセスと共有しているため、その書き込みはサーバ側から見ると「まだ接続を使っているはずの親プロセスから COM_STMT_CLOSE が飛んできた」ことになり、サーバがコネクションを閉じる。
    • 結果として、親プロセス側が次のクエリを投げると Lost connection to MySQL server during query で落ちる。

テスト上では test_forked_child_doesnt_mangle_parent_connectionMYSQL_PREPARED_STATEMENTS=true + mysql2 の構成で失敗し始めていた、というのが直接の発端です。

修正内容

Mysql2Adapter#discard! を、PostgreSQL アダプタの discard! と同じ方針に合わせて修正し、子プロセス側のソケット FD を /dev/null に付け替えるようにしました。

イメージとしては PostgreSQL 側でやっているこれと同じことを、mysql2 でも行います:

ruby
@raw_connection&.socket_io&.reopen(IO::NULL) rescue nil

mysql2 の場合も同様に、子プロセスで discard! が呼ばれた段階でソケット IO を IO::NULLreopen することで、

  • 子プロセス内の Mysql2::Statement オブジェクトの finalizer が走って COM_STMT_CLOSE を「送ろうとしても」、
  • その書き込み先は /dev/null になっているため、実際の MySQL サーバには届かない

という状態になります。
これにより、親プロセスが保持している「本物の」コネクションは安全に維持されます。

PR 説明にある再現コードの意味

PR に記載の再現スクリプトは、Rails を介さずに問題そのものを説明しています。

重要なポイントだけ抜き出すと:

ruby
client = Mysql2::Client.new(...)

# prepared statement を作ってキャッシュに溜めるイメージ
statements = {}
statements["sql1"] = client.prepare("SELECT 1")
statements["sql1"].execute.to_a
statements["sql2"] = client.prepare("SELECT 2")
statements["sql2"].execute.to_a

pid = fork do
  # 子では元の client を自動クローズしないようにする
  client.automatic_close = false

  # 子は新しく別 client を作ってクエリして exit
  Mysql2::Client.new(...).query("SELECT 1").to_a
  exit
end
Process.waitpid pid

# 親で再度クエリしようとすると接続ロスト
client.query("SELECT 1")
# => Mysql2::Error::ConnectionError: Lost connection to MySQL server during query

子プロセス終了時に、statements で保持している Mysql2::Statement の finalizer が動き、その COM_STMT_CLOSE が親と共有している FD に書かれることで、親の接続が壊れる、という構造になっています。
ここで子側で FD を /dev/nullreopen しておけば、最後の client.query("SELECT 1") が成功するようになる、というのが修正の妥当性の確認です。


  1. 影響範囲・注意点
  • 対象となるのは:

    • mysql2 アダプタ使用時
    • MYSQL_PREPARED_STATEMENTS=true の構成
    • fork を使うアプリケーション(例: prefork 型のアプリサーバ、バックグラウンドワーカーなど)
  • 上記条件下で、

    • 既存のテスト test_forked_child_doesnt_mangle_parent_connection が通るようになり、
    • 子プロセスが discard! を呼んだ後に exit しても、親プロセスの接続が壊れないことが保証されます。
  • 挙動としての変更点:

    • 子プロセス側で discard! が実行された後は、そのプロセスからは元の MySQL ソケットへは一切書き込みが行われなくなります(書き込み先は /dev/null)。
    • これは「子は親のコネクションを使わない」という設計と一致しており、Rails 的には期待される動作です。
  • 既存テスト:

    • mysql2 アダプタに関する connection-adapter テスト一式 (178 tests)
    • forkdiscard! 関連の mysql2 テスト (6 tests) がすべてグリーンであることが確認されています。

アプリケーション開発者視点では、「fork する環境で mysql2 + prepared statements を有効にしていても、親コネクションが子終了時に壊れることはなくなった」と理解しておけば十分です。特別な設定変更は不要です。


  1. 参考情報 (あれば)

#57509 Test ImmutableString custom boolean options and serialize

マージ日: 2026/6/1 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    ActiveModel::Type::ImmutableString に関して、ドキュメントされている挙動(boolean 用の true: / false: オプションや、boolean / 数値 / symbol の文字列化)が実際に保証されるよう、テストを追加した PR です。プロダクションコードの変更はなく、テストコードのみの追加です。

  1. 変更内容の詳細

対象ファイル:

  • activemodel/test/cases/type/immutable_string_test.rb (+20/-0)

この PR で追加されたテストは、主に以下の点をカバーします。

(1) true: / false: オプション付きコンストラクタのテスト

ActiveModel::Type::ImmutableString は boolean 値のキャスト・シリアライズ時に、コンストラクタのオプションで文字列表現をカスタマイズできますが、その挙動がテストされていませんでした。

例(イメージコード):

ruby
type = ActiveModel::Type::ImmutableString.new(true: "YES", false: "NO")

type.cast(true)   # => "YES"
type.cast(false)  # => "NO"

type.serialize(true)   # => "YES"
type.serialize(false)  # => "NO"

この PR では、上記のような true: / false: オプションを指定した場合に:

  • cast(true) / cast(false)
  • serialize(true) / serialize(false)

の両方が指定した文字列に変換されることをテストで検証しています。

(2) デフォルトの boolean 文字列表現 "t" / "f" のテスト

オプションを指定しない場合のデフォルト挙動として:

ruby
type = ActiveModel::Type::ImmutableString.new

type.serialize(true)   # => "t"
type.serialize(false)  # => "f"

となることがドキュメントされています。この PR は、このデフォルトの "t" / "f" という表現が確実に保証されるようテストを追加しています。

(3) 数値 / symbol の serialize のテスト

ImmutableStringserialize は、boolean 以外にも数値や symbol を受け取った場合に文字列化する分岐を持っていますが、その部分もこれまでテストされていませんでした。

テスト対象となる典型的な挙動のイメージ:

ruby
type = ActiveModel::Type::ImmutableString.new

type.serialize(123)      # => "123"
type.serialize(3.14)     # => "3.14"
type.serialize(:status)  # => "status"

これらのパスについてもテストを追加し、数値や symbol が期待どおり String に変換されることを確認しています。


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

    • プロダクションコードには一切変更がないため、挙動の変更や既存アプリへの影響はありません。
    • ActiveModel::Type::ImmutableString の既存仕様(特に boolean, 数値, symbol の取り扱い)がテストでカバーされるようになり、将来的なリファクタリングや仕様変更時に意図しない互換性破壊が検出されやすくなりました。
  • 注意点:

    • 既にドキュメントに書かれている仕様をテストで「固定」している形になるため、今後 "t" / "f" 以外のデフォルト表現に変えたい、あるいは boolean / 数値 / symbol の扱いを変えたい場合は、テストの更新と互換性への配慮が必要になります。
    • カスタムの true: / false: オプションに依存しているコードを書いている場合、その仕様が今後も守られることがテストで担保されるようになったと理解しておくとよいです。

  1. 参考情報 (あれば)
  • 関連クラス:

    • ActiveModel::Type::ImmutableString
      • ActiveModel の型システムの一部で、String を freeze(不変化)して扱うための型クラス。
      • boolean, 数値, symbol を受け取った際にどのように文字列へ変換するかが、今回テストで明示的に押さえられました。
  • 想定ユースケース:

    • Postgres の text カラムを immutable に扱いたい場合や、ActiveRecord の attribute API で attribute :foo, :immutable_string のように定義して boolean / 数値 / symbol を一貫した文字列表現にマッピングしたいケースなどで、この仕様とテストの存在が意味を持ちます。

#57510 Test Float casting of Infinity and NaN strings

マージ日: 2026/6/1 | 作成者: @hammadxcm

  1. 概要 (1-2文で)
    Rails の ActiveModel::Type::Float"Infinity", "-Infinity", "NaN" を正しく Float::INFINITY, -Float::INFINITY, Float::NAN にキャストできることを確認するテストが追加されました。
    本PRはテストコードのみの変更で、本番コードの挙動変更はありません。

  1. 変更内容の詳細

対象ファイル:

  • activemodel/test/cases/type/float_test.rb (+7行)

すでにドキュメント上は以下の仕様が記載されていました:

  • "Infinity"Float::INFINITY
  • "-Infinity"-Float::INFINITY
  • "NaN"Float::NAN

しかし、この挙動を直接検証するテストが存在していなかったため、今回それらを明示的に検証するテストが追加されました。

おおまかには、以下のような内容のテストが追記されています(イメージコード):

ruby
def test_casts_special_float_strings
  type = ActiveModel::Type::Float.new

  assert_equal Float::INFINITY, type.cast("Infinity")
  assert_equal(-Float::INFINITY, type.cast("-Infinity"))
  assert_predicate type.cast("NaN"), :nan?
end

ポイント:

  • "Infinity""-Infinity" は通常の == 比較で検証。
  • NaNNaN == NaNfalse になるため、nan? メソッドなどで検証する形になっているはずです。

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

    • 追加されたのはテストのみであり、既存のキャスティングロジック(ActiveModel::Type::Float#cast)自体には変更がありません。
    • 既に "Infinity", "-Infinity", "NaN" を前提にコードを書いていたアプリケーションに対して挙動変更はありません。
  • 注意点:

    • これらの文字列を DB カラム(float/decimal)に対する入力値として使う場合、ORM レイヤーでは Float になるものの、DB ドライバや DB 側が InfinityNaN を受け付けるかは別問題です(DBごとの仕様に依存)。
    • テストが入ったことで、将来的なリファクタリングで "Infinity", "-Infinity", "NaN" のサポートが壊れた場合には速やかに検知されるようになりました。
      → これらの文字列を利用している場合、挙動が暗黙に変わるリスクが減ります。

  1. 参考情報 (あれば)
  • 対象クラス: ActiveModel::Type::Float
    • ActiveModel の型キャスティング機能で、フォーム入力やパラメータ等を Ruby の Float に変換する役割を持つクラスです。
  • Ruby の Float 特殊値:
    • Float::INFINITY / -Float::INFINITY
    • Float::NANnan? で判定)

#57523 Fix typos and grammar in Form Helpers docs

マージ日: 2026/6/1 | 作成者: @VladNegara

  1. 概要 (1-2文で)
    Action View の Form Helpers ガイド (guides/source/form_helpers.md) に含まれていた英語のタイポや文法ミスを修正するドキュメント専用のPRです。コードや挙動には一切手を加えておらず、ガイドの読みやすさ・正確さを向上させる内容です。

  2. 変更内容の詳細(あればサンプルコードも含めて)

  • 対象: guides/source/form_helpers.md のみ(+5/-5 行)
  • 内容の種類:
    • 単純なスペルミスの修正
      • 例: “recieve” → “receive”、“extenstion” → “extension” のような誤記修正
    • 文法の調整
      • 主語と動詞の不一致、冠詞 (a/the) の抜けや過多、前置詞の誤りなどを自然な英語に修正
      • 長すぎて読みにくい文を適切に区切る、あるいは語順を入れ替えて読みやすくする
    • 用語・言い回しの統一
      • 既存の Rails ガイド全体の用語スタイルに合わせた表現へ微調整(例: “form helper” の単数/複数の揺れを統一、同じ概念に対する別表現を統一 など)

サンプルコードのロジックや API の説明内容自体は変えておらず、「文中の英語」だけが対象です。そのため、form_with, form_for, fields_for などの使い方やサンプルコードは従来どおりで、説明の語句がより正確・自然になっている、というタイプの修正です。

  1. 影響範囲・注意点
  • 影響範囲:
    • 実装コードには一切変更がないため、アプリケーションや既存コードへの影響はありません。
    • 影響するのは、英語版の Rails ガイドを読む開発者のみです。
  • 注意点:
    • 「挙動の変更」や「推奨パターンの変更」は含まれていないため、これを理由にアプリケーションの修正を行う必要はありません。
    • 実装や API の仕様が変わったわけではないので、Changelog というより「ドキュメントの品質向上」として理解しておけば十分です。
  1. 参考情報 (あれば)
  • 対象 PR: https://github.com/rails/rails/pull/57523
  • 修正対象のガイド:
    • Action View Form Helpers ガイド (guides/source/form_helpers.md)
  • CI は [ci skip] が指定されているため走っておらず、完全にドキュメント専用の変更であることが明示されています。

#57525 JSONGemCoderEncoder: serialize non-String keys with to_s instead of as_json

マージ日: 2026/6/1 | 作成者: @byroot

  1. 概要 (1-2文で)
    Rails の ActiveSupport における JSONGemCoderEncoder が、ハッシュの非文字列キーを as_json ではなく to_s でシリアライズするように戻されました。これにより、従来の JSON エンコーダと同じ振る舞いになり、#57520 で導入された挙動変更による非互換を解消します。

  1. 変更内容の詳細

何が変わったか

対象: ActiveSupport::JSON::Encoding::JSONGemCoderEncoderActiveSupport::JSON.encode などで使われるエンコーダ)

以前 (問題のあった状態)

  • ハッシュのキーが String 以外(シンボル、数値、オブジェクトなど)の場合、キーに対して as_json が呼ばれていました。

  • 例:

    ruby
    h = { foo: 1, 42 => 2 }
    # キー :foo / 42 に対して as_json が使われる

今回の PR での修正

  • 非 String キーに対して as_json ではなく to_s を呼ぶように変更。
  • これは「旧来のエンコーダが行っていた動作」に合わせたものです。

擬似コードイメージとしては、キーの処理が:

ruby
# 変更前(問題のあるイメージ)
hash.each_with_object({}) do |(k, v), result|
  encoded_key = k.as_json # または JSON エンコードの途中で as_json が使われる
  result[encoded_key] = v
end

から:

ruby
# 変更後(この PR のイメージ)
hash.each_with_object({}) do |(k, v), result|
  encoded_key = k.is_a?(String) ? k : k.to_s
  result[encoded_key] = v
end

のようなロジックに戻されています。

テストの追加

activesupport/test/json/encoding_test.rb に、以下のような振る舞いを検証するテストが追加されています(内容の要旨):

  • ハッシュに非 String キー(例: シンボル、数値、独自オブジェクト)を含めて JSON エンコードしたとき、
    • キーが to_s で文字列化されること
    • as_json による予期せぬ変換を行わないこと

  1. 影響範囲・注意点

影響範囲

  • JSON エンコード時のハッシュのキーが対象です。
  • 特に以下のようなコードを使っている場合に関係します:
    • ActiveSupport::JSON.encode(hash)
    • hash.to_json(内部で同じエンコーダを使うケース)
  • Rails の JSON レスポンスなどで、ハッシュをそのまま返している場合も間接的に影響します。

実務的な影響

  1. 互換性の回復 (後方互換)

    • 旧来のエンコーダは非 String キーを to_s していたため、それと同じ挙動に「戻る」変更です。
    • 直前の PR #57520 によって挙動が変わり、as_json が呼ばれるようになったことで:
      • キーの型に依存した複雑な as_json 実装を持つアプリで、予期しないキー変換・エラーが発生していた可能性があります。
    • 今回の PR により、その不意の互換性破壊が解消されるため、多くのアプリにとっては「バグ修正」であり、望ましい変更です。
  2. as_json に依存したキー変換をしていた場合

    • もし一時的に(あるいは意図的に)「キー側の as_json が呼ばれる」ことを前提に実装していた場合、
      • この PR により、その前提は再び無効になります。
    • 具体的には:
      • 「キー側の as_json をオーバーライドして JSON のキー表現を制御する」ようなトリッキーな実装は動かなくなります。
    • 一般的には推奨されない使い方なので、多くのアプリには無関係ですが、該当する場合は注意が必要です。
  3. JSON のキーは文字列になる前提が明確に

    • JSON 仕様上もオブジェクトのキーは文字列であるため、
    • 「非 String キーは to_s して文字列キーにする」という挙動は自然で、ライブラリ作者・クライアント実装側ともに扱いやすい挙動です。

  1. 参考情報 (あれば)
  • 該当 PR:
    • #57525: JSONGemCoderEncoder: serialize non-String keys with to_s instead of as_json
  • 修正対象の直前の PR:
    • #57520: 挙動を変更し、非 String キーに as_json を使うようにしてしまった PR(今回の修正でその部分が元に戻された)
  • 関連する観点:
    • JSON のオブジェクトキーは文字列が前提であり、Ruby 側でシンボル・数値・オブジェクトなどをキーにしている場合も、クライアントからは常に文字列キーとして見えることを前提に設計すべきです。

#57521 Fix assert_part / assert_no_part for body parts nested under an attachment

マージ日: 2026/5/31 | 作成者: @55728

  1. 概要 (1-2文で)
    ActionMailer のテスト用アサーション assert_part / assert_no_part が、添付ファイル付きメールで本文パートを正しく検出できない問題を修正した PR です。Mail#parts ではなく Mail#all_parts を使うことで、multipart/mixed 配下にネストされた multipart/alternative 内の text/html パートも検査対象に含めるよう変更されています。

  1. 変更内容の詳細

問題の背景

ActionMailer::TestCase#assert_part / #assert_no_part(まだ未リリースの新機能)は、メールの MIME パートを検査するためのテスト用ヘルパーです。
しかし、元実装では以下のようなコードでパートを検索していました:

ruby
part = [*mail.parts, mail].find { |part| mime_type.match?(part.mime_type) }

ここで使っている Mail#parts は「直下の子パートのみ」を返します。

一方、Action Mailer で「text と html の両方の本文 + 添付ファイル」があるメールを送ると、構造は次のようになります:

multipart/mixed        ← 最上位
├── multipart/alternative
│   ├── text/plain     ← 本文の text パート
│   └── text/html      ← 本文の html パート
└── image/png          ← 添付ファイル

mail.parts で見えるのは multipart/alternativeimage/png だけで、その1段下にぶら下がっている text/plaintext/html は見えません。

そのため:

  • assert_part :html / assert_part :text
    → 実際には存在するのに検出できず、テストが失敗(false negative)。
  • assert_no_part :html / assert_no_part :text
    → 実際にはパートが存在するのに「見えていない」ため、テストが通ってしまう(false positive)。
    特にこちらは「html を出さないはず」という保証テストが意味をなさなくなり、危険。

修正内容

Mail#parts ではなく、ネストされたパートも含めてフラットに列挙する Mail#all_parts を使うように修正されています。これは既に ActionMailer::InlinePreviewInterceptor でも使われている手法です。

修正後のコード(概念的には以下のような変更):

ruby
# 変更前
part = [*mail.parts, mail].find { |part| mime_type.match?(part.mime_type) }

# 変更後
part = [*mail.all_parts, mail].find { |part| mime_type.match?(part.mime_type) }

この変更が assert_partassert_no_part 両方に適用されています。
これにより、multipart/alternative の下にネストされた本文パートも検索対象に含まれます。

再現用テスト

actionmailer/test/assert_select_email_test.rb に、新しいテストケース AssertMultipartWithAttachmentEmailTest が追加されています。

テスト用メールは以下のように組み立てられます:

ruby
attachments["example.png"] = { mime_type: "image/png", content: "PNGDATA" }
mail(...) do |format|
  format.text { render plain: options[:text] } if options.key?(:text)
  format.html { render plain: options[:html] } if options.key?(:html)
end

追加されたテスト:

  1. test_assert_part_finds_body_parts_nested_under_attachment
    • assert_part :textassert_part :html で、添付ファイルの存在によってネストされた本文パートが正しく見つかることを確認。
  2. test_assert_no_part_detects_body_parts_nested_under_attachment
    • 対応するパートが存在する状態で assert_no_part を呼び出したとき、必ず失敗(例外を送出)することを確認。

修正前の main では:

  • assert_no_part が例外を投げずに通ってしまう(false positive)
  • assert_part がパートを見つけられない(false negative)

という想定通りの「赤」を再現し、修正後はテストファイル全体がグリーンになることが確認されています。


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

    • ActionMailer::TestCase#assert_part / #assert_no_part の挙動のみ(いずれも未リリース API)。
    • 実際のメール送信や本番環境でのメール構造には影響しません。
    • Action Mailer で MIME パートを扱う既存コードのうち、InlinePreviewInterceptor は既に all_parts を使っており、本 PR と整合的です。
  • 注意点:

    • もともとの実装では「トップレベルのパートだけを対象にする」という仕様にも読み取れるが、添付ファイルを追加しただけで本文パートが検査できなくなるのは、テスト API として直感的でなく危険な挙動です。この PR により、開発者の期待に近い「メール全体の MIME パートを対象に検査する」動きに揃えられます。
    • API は 8.2.0.alpha 時点で未リリースのため、既存アプリの互換性リスクはほぼゼロです。リリース前に false positive/negative を潰せた形になります。

  1. 参考情報 (あれば)
  • 対象メソッド:
    • ActionMailer::TestCase#assert_part
    • ActionMailer::TestCase#assert_no_part
  • 関連 PR:
    • 元機能追加: #55348 (assert_part / assert_no_part 追加)
  • 類似の all_parts 利用箇所:
    • ActionMailer::InlinePreviewInterceptor (inline_preview_interceptor.rb:56)

#55555 Rate limiting calls cache_key on by: if the object responds to it

マージ日: 2026/5/31 | 作成者: @daniel-sabourin

  1. 概要 (1-2文で)
    Rails の ActionController::RateLimiting において、rate_limitby: オプションに任意オブジェクトを渡せるようにし、そのオブジェクトが cache_key を実装していればそれを自動的に使ってレート制限用のキャッシュキーを生成できるようにした PR です。これにより、IP アドレスではなく「ユーザーごと」など、より柔軟な単位でのレート制限が行いやすくなります。

  1. 変更内容の詳細

背景・モチベーション

  • これまでの Rails のレートリミットは、デフォルト設定では remote_ip に基づくケースが多く、「同じネットワーク(同じ IP)からの複数ユーザー」がいる環境では不都合が出ることがある。
  • 認証済みユーザーがいる場合、IP 単位ではなく「ユーザー単位」でレートリミットをかけたいが、その際に毎回 cache_key を明示的に呼ぶのは煩雑。
  • Active Record オブジェクトなど「cache_key を持つオブジェクト」をそのまま by: に渡せれば、コントローラ側の記述をシンプルにできる。

実際の仕様変更

変更前(従来の書き方の例)

「ユーザーごとのレートリミット」をしたい場合は、自分で cache_key を呼び出す必要がありました。

ruby
class CommentsController < ActionController::API
  # キャッシュキー: "rate-limit:comments:user/1" (current_user の cache_key )
  rate_limit to: 2, within: 2.seconds, by: { current_user.cache_key }

  private

  def current_user
    User.find(1)
  end
end

変更後(この PR で可能になる書き方)

by: に任意のオブジェクトを渡せるようになり、そのオブジェクトが cache_key に応答する場合は内部で cache_key が呼ばれます。

ruby
class CommentsController < ActionController::API
  # キャッシュキー: "rate-limit:comments:user/1" (current_user.cache_key が内部的に呼ばれる)
  rate_limit to: 2, within: 2.seconds, by: { current_user }

  private

  def current_user
    User.find(1)
  end
end

ポイント:

  • by: に渡されたオブジェクトが respond_to?(:cache_key) の場合、その cache_key を使ってストア上のキャッシュキーが組み立てられるように rate_limiting.rb が修正されています。
  • cache_key を持たないオブジェクトや、文字列・数値などは従来どおりの取り扱い(=そのままキーに使われるか、既存のロジックに従う)と推測されます。
  • テスト (rate_limiting_test.rb) が追加・更新され、cache_key 対応オブジェクトを by: に渡したときの挙動がカバーされています。
  • actionpack/CHANGELOG.md にこの挙動変更が追記されています。

  1. 影響範囲・注意点
  • 既存コードへの互換性

    • 既存で by: { current_user.cache_key } のように書いているコードは、そのまま動作します(挙動は変わりません)。
    • by: にオブジェクトを渡していて、かつそのオブジェクトがすでに cache_key を持っていた場合は、今回の変更により「cache_key が自動で使われる」ようになります。
      ただし、これまでにそういった使い方をしていたケースは少ないと考えられ、互換性リスクは比較的小さいと思われます。
  • キャッシュキーの安定性

    • cache_key は通常 Active Record モデルでは model_name/id-更新時刻 形式になり、更新のたびに変わることがあります。
    • レートリミットの粒度として「ユーザーごと」で安定したキーを持ちたい場合、User モデルの cache_key が更新タイミングで変わることを理解しておく必要があります(通常はそれでも「同じユーザー」であればキーは論理的には同じドメインに属するので問題になりづらいですが、モデル定義によっては注意が必要)。
    • 完全に安定したキー(例: user:<id> 固定)を使いたい場合は、ユーザー側で cache_key を上書きするか、従来どおり by: { "user:#{current_user.id}" } といった文字列を明示する選択肢もあります。
  • パフォーマンス

    • cache_key を呼ぶコストが追加されますが、多くのアプリでは無視できる程度です。大量リクエストでレートリミットが多用されるケースでは、cache_key の実装が高コストでないかを確認しておくと安心です。
  • 設計上のメリット

    • 認証ユーザー単位や、テナント単位など、cache_key を実装したドメインオブジェクトごとにレートリミットを簡潔に適用できるようになり、「IP 単位レートリミットからの脱却」がしやすくなります。
    • プロジェクト全体で統一した書き方(by: { current_user })ができ、cache_key 呼び出しを各所にバラバラに散らさなくてよくなります。

  1. 参考情報 (あれば)
  • 変更ファイル:

    • actionpack/lib/action_controller/metal/rate_limiting.rb
      • by: で渡されたオブジェクトに対し、respond_to?(:cache_key) チェックを行い、true の場合は cache_key を用いて内部キャッシュキーを生成するロジックが追加。
    • actionpack/test/controller/rate_limiting_test.rb
      • cache_key を持つオブジェクトを by: に渡した場合のレート制限動作を確認するテストが追加。
    • actionpack/CHANGELOG.md
      • rate_limitby: にオブジェクトを渡した際の新挙動が記載。
  • レートリミットをユーザー単位にしたい場合の典型的な形:

    ruby
    class ApplicationController < ActionController::API
      # 例: 各ユーザー 1 分あたり 60 回まで
      rate_limit to: 60, within: 1.minute, by: { current_user }
    
      def current_user
        # Devise 等で取得
        super
      end
    end

#55388 Respect schema_search_path on rails dbconsole for Postgres

マージ日: 2026/5/31 | 作成者: @sobrinho

  1. 概要 (1-2文で)
    PostgreSQL を使う際に、rails dbconsoleconfig/database.ymlschema_search_path 設定をこれまで無視していた問題を修正し、アプリケーションと同じ search_path で psql が起動するようにした PR です。Rails の ActiveRecord 接続と手動で使う rails dbconsole の挙動を揃える変更です。

  1. 変更内容の詳細

何をしたか

  • rails dbconsole(アダプタ: PostgreSQL)が psql を起動する際に、config/database.ymlschema_search_pathPGOPTIONS に反映するようにしました。
  • 具体的には、ActiveRecord::ConnectionAdapters::PostgreSQLAdapter の dbconsole 用ロジックで、PGOPTIONS="--search_path=..." を環境変数として付与して psql を呼び出すようになっています。
  • これにより、Rails アプリ内の接続で使われている search_path と、rails dbconsole で開かれる psql セッションの search_path が一致します。

イメージ・サンプル

config/database.yml で以下のように search path をカスタムしているケースを想定します:

yaml
production:
  adapter: postgresql
  database: myapp_production
  username: myuser
  password: secret
  schema_search_path: myschema,public

従来:

  • Rails アプリの DB 接続: search_path = myschema, public
  • rails dbconsolepsql:
    • search_path デフォルト: $user,public
    • 手動で SET search_path TO myschema,public; する必要があった

変更後:

  • rails dbconsole 実行時、内部的に:
    • PGOPTIONS="--search_path=myschema,public" psql ...
  • その結果、psql セッションも自動的に myschema,public が search_path に設定された状態で開く

コード上のポイント

※PR本文から読み取れる範囲の挙動:

  • postgresql_adapter.rb で dbconsole 実行用のパラメータを構築する箇所に、schema_search_path が存在する場合に PGOPTIONS を組み立てる処理が追加されています。
  • 追加されたテスト (activerecord/test/cases/adapters/postgresql/dbconsole_test.rb) では、
    • schema_search_path を設定した接続設定を用意
    • dbconsole 用のコマンド・環境変数を生成
    • PGOPTIONS"--search_path=<schema_search_path>" が含まれていること を検証しています。
  • activerecord/CHANGELOG.md にもこの挙動変更(機能追加)が追記されています。

  1. 影響範囲・注意点

主な影響範囲

  • 対象: PostgreSQL を使っていて、かつ schema_search_pathconfig/database.yml で指定している Rails アプリrails dbconsole 実行時の挙動。
  • 今後は rails dbconsole で開く psql セッションが、Rails アプリと同じ search_path を自動的に利用します。

開発者視点でのポイント

  • これまで rails dbconsole 後に毎回 SET search_path TO ... を手で打っていた場合、その手順が不要になります。
  • 逆に「dbconsole は always デフォルト search_path で開きたい」といった前提のスクリプトや運用がある場合、挙動が変わる点に注意が必要です(例: ログイン直後の search_path を前提にした診断スクリプトなど)。
  • schema_search_path が設定されていないプロジェクトには影響しません(従来どおりデフォルトの $user,public)。

環境変数の扱い

  • すでに PGOPTIONS を独自に使っている場合、この PR の実装次第では:
    • 既存の PGOPTIONS--search_path=... を追加する
    • あるいは schema_search_path があるときに PGOPTIONS を上書きする いずれかになります。
      実装上は「上書き」か「追記」かが重要ですが、PR説明文からは主に「search_pathPGOPTIONS にセットする」ということだけが明示されているため、既存の PGOPTIONS 活用をしている場合は、マージ後の挙動を一度確認した方が安全です。

  1. 参考情報 (あれば)
  • PR: https://github.com/rails/rails/pull/55388
  • 関連する設定項目:
    • config/database.yml
      • schema_search_path: ActiveRecord が PostgreSQL に接続するときの search_path を制御
  • PostgreSQL 公式ドキュメント(PGOPTIONS / search_path):
    • PGOPTIONS 環境変数を使うと、psql 等クライアント起動時にサーバへのオプション(--search_path=... など)を渡せる
    • search_path はスキーマ解決順序を制御し、テーブル名などの解決対象スキーマを切り替えるために使われる