Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

週刊Railsウォッチ: 書籍『Rails Scales!』、RubyにNamespaceがマージほか(20250515)

こんにちは、hachi8833です。

週刊Railsウォッチについて

  • 各記事冒頭には🔗でパーマリンクを置いてあります: 社内やX.comでの議論などにどうぞ
  • 「つっつきボイス」はRailsウォッチ公開前ドラフトを(鍋のように)社内有志でつっついたときの会話の再構成です👄
  • お気づきの点がありましたら@hachi8833までメンションをいただければ確認・対応いたします🙏

TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)

🔗Rails: 最近の改修(Rails公式ニュースより)

🔗 allocateでアロケーションされたActive Recordオブジェクトで関連付けを探索可能にした

従来は、アロケーションされたレコードオブジェクトに関連付けキャッシュが設定されていなかったため、関連付けを探索するとクラッシュしていた。mochaなどのテストフレームワークでは、stub可能なインスタンスメソッドに対してallocateを使っている。

Gannon McGibbon
同Changelogより

従来は、allocateでアロケーションされたActive Recordオブジェクトに関連付けキャッシュが設定されていなかったため、関連付けを探索するとクラッシュしていた。mochaなどのテストフレームワークでは、stub可能なインスタンスメソッドに対してallocateを使っているが、これは関連付けの探索をトリガーする可能性がある(Railsの場合の)。

動機/背景

このプルリクを作成した理由は、allocateでアロケーションされたActive Recordオブジェクトが関連付けを探索できなかったため。これは正しくないと思う。

詳細

このプルリクは、関連付けのキャッシュをlazyに初期化することで探索を行えるようにする。

追加情報

もしかすると、allocateでアロケーションされたオブジェクトではinit_internalsを使う方がよいのかもしれないが、その場合はsendを使うかメソッドをpublicにする必要がある。他のインスタンス変数もそこに存在すべき理由がなければ、この方が理にかなっていると思われる。
同PRより


つっつきボイス:「ここで言うallocateとは?」「RubyにあるClass#allocateは"自身のインスタンスを生成して返す"とあるので↓、クラスのインスタンスは生成するけどnewの場合と異なりinitializeを実行しないということらしい」「あ、そういうことなのか!」「初期化が何も行われていないインスタンスで関連付けキャッシュにいきなりアクセスするとクラッシュするので、||= {}を挟んで修正したということなのね↓: allocateは使ったことないけど、stub化するような状況では使われることがあるらしい」「これはニッチな修正ですね」「この問題を踏んだら原因を特定するのが大変そう」

# activerecord/lib/active_record/associations.rb#L81
it exists, +nil+ otherwise.
      def association_instance_get(name)
-       @association_cache[name]
+       (@association_cache ||= {})[name]
      end

参考: Class#allocate (Ruby 3.4 リファレンスマニュアル)

「プルリクで言及されているRailsのinit_internalsはprivateメソッドなので、いつものRails APIでは出てこなくて、APIdockの方にありました↓」「privateメソッドに無理やりsendでアクセスしたりするのは避けたいというのはわかる」

参考: init_internals (ActiveRecord::Core) - APIdock

🔗 署名済みIDのベリファイアをRails.application.message_verifiersで設定可能になった

この変更以前は、署名済みID検証を設定する主な方法は、以下のように個別のモデルクラスでsigned_id_verifierを設定する方法だった。

Post.signed_id_verifier = ActiveSupport::MessageVerifier.new(...)
Comment.signed_id_verifier = ActiveSupport::MessageVerifier.new(...)

開発者がsigned_id_verifierを設定していなかった場合、ベリファイアはsecret_key_baseから派生したsecretと以下のオプションを用いてインスタンス化されることになる。

{ digest: "SHA256", serializer: JSON, url_safe: true }

このため、すべてのベリファイアでコンフィグをローテーションするのが面倒だった。

この変更では、Railsにconfig.active_record.use_legacy_signed_id_verifierという新しいコンフィグを定義する。

デフォルト値である:generate_and_verifyは、従来の振る舞いを維持する。
ただし値を:verifyに設定すると、署名済みID検証はRails.application.message_verifiers(具体的にはRails.application.message_verifiers["active_record/signed_id"])のコンフィグを用いて署名済みIDを生成・検証するが、古い設定を用いた署名済みIDの検証も行う。

話を複雑にしないため、新しい振る舞いはsigned_id_verifier_secretがモデルクラス(またはその先祖クラス)で設定されて「いない」場合にのみ適用される。
また、従来のsigned_id_verifier_secretは非推奨化される。現在モデルクラスでsigned_id_verifier_secretを設定している場合は、以下のようにsigned_id_verifierを代わりに設定できる。

# 改修前
Post.signed_id_verifier_secret = "my secret"

# 改修後
Post.signed_id_verifier = ActiveSupport::MessageVerifier.new("my secret", digest: "SHA256", serializer: JSON, url_safe: true)

移行を容易にするため、signed_id_verifierclass_attributeとして振る舞う(つまり継承可能)に変更されたが、ただしこれはsigned_id_verifier_secretが設定されて「いない」場合に限られる。

# 改修前
ActiveRecord::Base.signed_id_verifier = ActiveSupport::MessageVerifier.new(...)
Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => false

# 改修後
ActiveRecord::Base.signed_id_verifier = ActiveSupport::MessageVerifier.new(...)
Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => true

Post.signed_id_verifier_secret = "my secret" # => 非推奨警告
Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => false

ただし、最終的にはモデル固有のベリファイアを、Rails.application.message_verifiersで管理される統一コンフィグに移行することが推奨される。
以下のようにActiveSupport::MessageVerifier#rotateを使うことで移行しやすくなる。

# 移行前
# 署名済みのPost IDの生成と検証をPostモデル固有のコンフィグで行っている
Post.signed_id_verifier = ActiveSupport::MessageVerifier.new("post secret", ...)

# A移行後
# 署名済みのPost IDの生成と検証を統一コンフィグで行っている
Post.signed_id_verifier = Post.signed_id_verifier.dup

# 署名済みIDを検証する場合にPost固有のコンフィグにフォールバックする
Post.signed_id_verifier.rotate("post secret", ...)

Ali Sepehri, Jonathan Hefner
同Changelogより


つっつきボイス:「今までだと署名済みIDをモデル単位で設定していたので、ベリファイア設定を差し替えようとするとモデルの数だけ変更が必要で面倒だったので、コンフィグで一元的にやれるようにした↓」「デフォルトの振る舞いは変わらないのでリファクタリングなんですね」

# 署名済みID用のベリファイアをローテートできる
Rails.application.message_verifiers.rotate do |salt|
  next nil if salt != 'active_record/signed_id'

  secret_generator = ->(_) { 'old_secret' }
  { secret_generator: secret_generator, digest: "SHA256", serializer: JSON, url_safe: true }
end

# ローリングデプロイ用に`transitional`モードを有効にする
Rails.application.message_verifiers.transitional = true

# 古いベリファイアの利用状況をログ出力するための`on_rotation`コールバック
Rails.application.message_verifiers.on_rotation do
  puts "Old verifier used for verifying! Still in use!"
end

「この署名済みIDを、たとえばActive Storageの添付ファイルの一般アクセス可能なURLに使えば、IDが改ざんされた場合に検出できますね↓」「ユーザーがURLをコピーしたり、推測して改ざんしたりしてもアクセスできないようにするヤツですね」

参考: 3.4 添付ファイルの置き換えvs追加 -- Active Storage の概要 - Railsガイド

<!-- Active Storage の概要より -->
<% @message.images.each do |image| %>
  <%= form.hidden_field :images, multiple: true, value: image.signed_id %>
<% end %>

<%= form.file_field :images, multiple: true %>

🔗 ネストしたトランザクション内のcreate_or_find_byをロールバック可能になるよう修正

動機/背景

#54830を修正するため(このissueは間接的に修正される)。

問題

create_or_find_byメソッドはトランザクションのロールバックが許されていないが、この振る舞いはcreateとは異なっている。その理由は、これが二重トランザクションのエッジケースであり、外側のトランザクションがロールバックを認識しないため。

詳細

class User < ApplicationRecord
  after_save do
    raise ActiveRecord::Rollback
  end
end

User.create(name: "John") #=> 正常にロールバックする

User.find_or_create_by(name: "John") #=> Rails 7.0までは正常にロールバックしていたが、コミット023a3eb3c046091a5d52027393a6d29d0576da01からロールバックしなくなった

User.create_or_find_by(name: "John") #=> ロールバックしない

解決方法

内側のトランザクションが成功したかどうかを知る必要があるが、.createメソッドは(トランザクションのステータスではなく)常にレコードを返すため、これは不可能。

# https://github.com/rails/rails/blob/1eac7f27748ffa43c5ce91c036c928fc22d4c597/activerecord/lib/active_record/persistence.rb#L39
  object

newに続けてsaveを実行することはcreateと同等だが、saveを行うことでトランザクションのreturnステータスを取得できるようになり、外側のトランザクションが正常にロールバック可能になる。
同PRより

参考: Rails API create_or_find_by -- ActiveRecord::Relation
参考: Rails API find_or_create_by -- ActiveRecord::Relation


つっつきボイス:「createの動作をnewsaveに分けて、saveがバリデーションエラーなどで失敗したら明示的にロールバックするように修正したんですね↓」

# activerecord/lib/active_record/relation.rb#L273
    def create_or_find_by(attributes, &block)
      with_connection do |connection|
-       transaction(requires_new: true) { create(attributes, &block) }
-       record = nil
+       transaction(requires_new: true) do
+         record = new(attributes, &block)
+         record.save || raise(ActiveRecord::Rollback)
+       end
+       record
      rescue ActiveRecord::RecordNotUnique
        if connection.transaction_open?
          where(attributes).lock.find_by!(attributes)
        else
          find_by!(attributes)
        end
      end
    end

「今さらですが、Active Recordにはcreate_or_find_byfind_or_create_byが両方あるんですね」「ちょうどこの記事にあるんですが↓、create_or_find_byはまずcreateを投機的に実行してからfind_byで取り出すことで、同じ処理が同時に行われていても確実にidを生成するメソッドですね(トランザクション分離レベルにもよると思いますが)」「お〜、なるほど!」「データベースを勉強すると更新はこのような投機的な方法で説明されていることが多いですね」

参考: create_or_find_by はユニーク制約が定義されたテーブルで効いてくる #Rails - Qiita

「逆にfind_or_create_byfind_byで行ロックしてからcreateするメソッドですが、存在しない行はロックできないので、Qiita記事にもあるように、同じ処理が同時に行われているとタイミングによっては同じidが2つ生成されてしまう可能性がある(データベース側でunique制約が設定されていれば回避できます)」

🔗 /rails/info/notesに表示される情報をアノテーションでフィルタできるようになった

動機/背景
/rails/info/notesで表示されるnotesをタグでフィルタできるようにする。

詳細

FIXMEやTODOやOPTIMIZEなどのアノテーションをドロップダウンボックスで選択することでフィルタする。

同PRより


つっつきボイス:「ローカルのRailsで/rails/info/notesを開いたときの情報表示を改善したそうです(動画の右上隅)」「CLIでbin/rails notesで表示されるのと同じ情報ですね: 自分ならIDEで検索すると思いますが、GUIでTODOやFIXMEみたいなnotesをフィルタしたい人にはよいと思います👍」

参考: 2.10 bin/rails notes -- コマンドラインツール - Railsガイド

🔗 コレクションが無限の場合にEnumerable#soleがハングする問題を修正

最近、Active SupportのEnumerator#soleメソッドを使っているうちに、実装に関して残念な点があることに気付いた。

  def sole
    case count
    when 1   then return first # rubocop:disable Style/RedundantReturn
    when 0   then raise SoleItemExpectedError, "no item found"
    when 2.. then raise SoleItemExpectedError, "multiple items found"
    end
  end

countを呼び出すと、特定のオブジェクトにもっと効率的な実装がない限りコレクション全体がenumされてしまう。アイテムが1つしかない場合は、firstを呼び出したときに再びenumされる。コレクションがメモリ上にある場合は、ほとんど大した問題にはならない。

ただし、ものすごく馬鹿げたコードを書くと影響が顕著になる。

puts Benchmark.measure {  [].to_enum.sole rescue nil }.utime
# => 5.800000000277805e-05

a = Array.new(1_000_000) # 非常にバカバカしい
puts Benchmark.measure {  a.to_enum.sole rescue nil }.utime
# => 0.02890700000000379

しかし真の問題は、APIエンドポイントから動的にリソースを取得するコレクションでこれを使った場合に発生したこと。これにはいくつかの理由がある。

  • リクエストが1件ではなく2件発生する。
  • 2つ目のenumの結果セットが異なる可能性がある。

背後でcountで使った場合のもう1つの残念な副作用は、無限シーケンス(例: [1].cycle.sole)を渡すとsoleが永久にハングしてしまうこと。

この点を徹底的に検討したわけではないが、現在の実装と内容的に同等である以下の実装は、コレクションから最大2つの項目しか取得しない。

    def sole
      result = nil
      found = false

      self.each_entry do |entry|
        if found
          raise SoleItemExpectedError, "multiple items found"
        end

        result = entry
        found = true
      end

      return result if found

      raise SoleItemExpectedError, "no item found"
    end

#54335より


つっつきボイス:「soleは、該当するオブジェクトが対象セットの中に1個ある場合にだけそれを返して、それ以外の場合はraiseするメソッドでしたね」「要素100万個の配列をto_enum.soleするとraiseしないでハングする、なるほど」

参考: Rails API Enumerable#sole

「見た感じ、個数が多いenumerableをcountした場合に問題になるということか↓」「修正後は個数にかかわらずすぐ結果を返すようになっているみたいですね」「考えてみれば、enumerableをcountするときは愚直に全部カウントするしかないはずなので、1..みたいなendレスのenumerableなどをcountで数えるのがよくないという話か」「solecountで実装していたことでこんなことが問題になるとは...」「Enumerableをincludeしているクラス側で個別にオーバーライドしている場合はそちらのより効率的なメソッドが呼ばれますが、個別実装が無い場合はEnumerableの実装が使われて、Enumerableの中身を1個ずつ数え上げる方式になってしまうようですね」

# activesupport/lib/active_support/core_ext/enumerable.rb#L211
  def sole
-   case count
-   when 1   then return first # rubocop:disable Style/RedundantReturn
-   when 0   then raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "no item found"
-   when 2.. then raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "multiple items found"
+   result = nil
+   found = false
+
+   each do |element|
+     if found
+       raise SoleItemExpectedError, "multiple items found"
+     end
+
+     result = element
+     found = true
+   end
+
+   if found
+     result
+   else
+     raise SoleItemExpectedError, "no item found"
+   end
  end

参考: Enumerable#count (Ruby 3.4 リファレンスマニュアル)
参考: 後者関数 - Wikipedia

「それにしても[1].cycle.soleが無限ループになるとは面白い」「Railsコンソールで[1].cycle.soleを実行すると本当にハングしましたけど、このcycleは?」「Rubyのcycleは指定の回数だけenumerateするメソッドですね: これはEnumerableのインスタンスを返すメソッドなので、cycleを通すことでRailsのEnumerable#soleが呼ばれる」「なるほど、(1..).to_enum.soleと同じ結果になるんですね」

参考: Enumerable#cycle (Ruby 3.4 リファレンスマニュアル)

🔗 distance_of_time_in_wordsにDoS攻撃の可能性があったのを修正

動機/背景

distance_of_time_in_words関数は、from_time引数とto_time引数の差が大きすぎる場合にDoS攻撃を引き起こす可能性がある。問題のコードを定数時間に収まる計算に置き換えた。

詳細

distance_of_time_in_words関数は、Rubyのrangeとcount関数を使ってうるう年をカウントする。

  def distance_of_time_in_words(from_time, to_time = 0, options = {})
    # ...
    leap_years = (from_year > to_year) ? 0 : (from_year..to_year).count { |x| Date.leap?(x) }
    # ...

このコードは、from_yearto_yearが大きく離れているとDoS攻撃を引き起こす可能性がある(2つの引数の差から生成されたrangeが大量にイテレーションされると実質的に実行をブロックする)。このコードを、うるう年を定数時間内で計算する以下のコードに置き換えた。

    leap_years_up_to = ->(year) { (year / 4) - (year / 100) + (year / 400) }
    leap_years = (from_year > to_year) ? 0 : leap_years_up_to.call(to_year) - leap_years_up_to.call(from_year - 1)

このケースは一見DoS攻撃とは無縁に思えるかもしれないが、私は自分のプロジェクトでこの問題を発見した。タイムスタンプを設定できるユーザーは、どこかにあるdistance_of_time_in_words関数にタイムスタンプが渡されると、簡単にこの問題を引き起こせる可能性がある。
同PRより


つっつきボイス:「うるう年の処理パフォーマンスを改善したそうで、さっきのEnumerable#countの問題に続いてDoS攻撃になりそうな問題ですね」「from_yearto_yearはいかにもユーザーが入力しそうな値なので、悪意がなかったとしてもうっかり極端な値が入って処理がブロックされないようにするのは大事👍」

参考: Rails API distance_of_time_in_words -- ActionView::Helpers::DateHelper

🔗 キャッシュキーの最大サイズをmax_key_sizeオプションで設定可能になった

キーが設定の上限を超えると(デフォルトでは250バイト)、キーは切り捨てられ、残りのキーのダイジェストが追加される。

なお、従来のActiveSupport::Cache::RedisCacheStoreでは、切り捨て前のキャッシュキーが最大1KBまで許可されていたが、現在は250バイトに削減されている。
同Changelogより

プロジェクトのキャッシュストア(Redisを利用)の使用状況を分析したところ、キャッシュ全体の30%がキーのみで占められていることに気づいた。計算したところ、64バイトの制限を強制すると、キャッシュ全体のサイズが15%小さくなる。中には数百バイト単位のかなり大きなキーもある。すべての場所でキーを修正するようユーザーに依頼するのは面倒なので、このプルリクのようなモンキーパッチを導入するとよさそう。

ここで採用したアプローチは、外部キーの命名スキームに似ている(キーを途中で切り捨てて、残りをダイジェストで埋める)。

幸い、私たちのコードベースでは、すべてのキーに一意の識別子がプレフィックスとして付与されているので、コードベース内でキーが設定されている場所を見つけるのに使える。そのため、このアプローチは問題なく機能する

利用例:

# config/application.rb
config.cache_store = :redis_cache_store, { max_key_size: 64 }

同PRより


つっつきボイス:「Active Supportキャッシュの改修だそうです」「 Redisのキャッシュキーとかでキーの最大サイズを指定したいというのは普通にありえますね: ところでキーを途中からダイジェストで埋める実装になっているのが面白い↓」

# activesupport/lib/active_support/cache.rb#L980
+       def truncate_key(key)
+         if key && @max_key_size && key.bytesize > @max_key_size
+           suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}"
+           truncate_at = @max_key_size - suffix.bytesize
+           key = key.byteslice(0, truncate_at)
+           key.scrub!("")
+           "#{key}#{suffix}"
+         else
+           key
+         end
+       end

参考: Rails のキャッシュ機構 - Railsガイド

🔗Rails

🔗 Rails World 2025で「Rails at Scale」イベントも併設(Rails公式ニュースより)


つっつきボイス:「9/4にアムステルダムで開催されるRails World 2025はもうチケット売り切れか」「前日の9/3にRails at Scaleというイベントも併設されるそうです」「会場の写真が教会とか大学の講堂っぽいのがヨーロッパらしいですね↓」


同サイトより

🔗 書籍『Rails Scales!』


つっつきボイス:「上のRails at Scaleに続いて、Rails Scales!という書籍のβ版が公開されました: ZenDeskの中の人がZenDeskでのスケーリングの実例を詳しく書いているそうです」「お、ZenDeskは相当規模が大きいはずなのでスケーリングの参考になりそう👍」

参考: Zendesk - Wikipedia

「ZenDeskではRails 8.0へのアップグレードを一晩で完了させたという発表も行っていたそうです↓」

参考: From Legacy to Latest: How Zendesk Upgraded a Monolith to Rails 8.0

🔗 Kaigi on Rails 2025の公式サイトがオープン


つっつきボイス:「Kaigi on Rails 2025のサイトがオープンされましたね🎉」「こちらは9/26〜27の開催ですね」「JP TOWER Hallってどこかなと思ったら東京駅前か」「旧東京中央郵便局の跡地にできたあのビルですね」

🔗Ruby

🔗 Rubyのインラインコメントに関する議論


つっつきボイス:「ruby-jp Slackで議論が盛り上がっていたので取り上げてみました」「一部の言語にはコードの途中にコメントが書けるので、やってみたい気持ちもわかる」「言われてみれば、途中コメントできる言語ってぱっと思いつかないかも」「C言語は/* */で途中コメント書けますよ」「そうでした😅」

# #20405より抜粋
(| This is a comment (| and nested one |) /:|) (:|) #=> :|

(: This is comment :)

#= This is a comment #= and nested one =# =# :| # => :|

(= ^..^ =)

参考: インラインコメントの使い方 | MattyLogs -- C言語

「Rubyの%wにコメントを書きたい気持ちもわかる↓」

%w(
  foo bar #{= comment =}
  baz     #{= comment =}
)

🔗 Ruby PlaygroundでNamespaceを試せるようになった


つっつきボイス:「お〜、ブラウザでNamespaceを試せるようになったんですね👍」「tagomorisさんが昨年ぐらいからNamespaceの実装を進めていてRubyKaigiでも経過が発表されていますが、Namespaceの話がまだよくわかってなくて😅」「大まかには、以下のNamespace.newのようにNamespaceを定義して、そこでライブラリを読み込むことで、同じライブラリの異なるバージョンをNamespaceに閉じ込める形で共存させられるようにする機能ということでよかったと思います」「そういうことなんですね」

CONST = 1
def foo = :main

ns1 = Namespace.new
ns1.require './a'

#--- a.rb

p CONST

つっつき後に、RubyKaigi 2024〜2025でも発表されたNamespaceがmasterにマージされたことを以下の記事の追記で知りました(#13226)🎉。

参考: RubyKaigi 2025 行ってきた&しゃべってきた、その後 - たごもりすメモ

また、tagomorisさんがNamespaceを実装する動機について書いていた記事を見つけました↓。

参考: ご意見募集: Rubyに名前空間サポート的なものが欲しいという話 - たごもりすメモ

🔗DB

🔗 書籍『SQLアンチパターン』の第2版


つっつきボイス:「t_wadaさん監訳の『SQLアンチパターン』が2025/7/11発売で予約受付中だそうです🎉「紹介文に"第2版では内容を大幅に改訂し、新規書き下ろしの章と15のミニ・アンチパターンが加わりました"とある: 旧版の頃からデータベース周りもそれなりに移り変わっているので、そろそろ改定が必要になってくるのもわかる👍」


今回は以上です。

バックナンバー(2025年度第1四半期)

週刊Railsウォッチ: RuboCop実行結果のキャッシュ機能が追加、ZJIT登場ほか(20250430)

ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。

Rails公式ニュース

Ruby Weekly


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。
OSZAR »