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

週刊Railsウォッチ: Pumaのデフォルトスレッド数変更、Rails 1.0をRuby 3.3で動かすほか(20240206前編)

こんにちは、hachi8833です。Railsガイドを7.1.3向けに更新しました。

週刊Railsウォッチについて

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

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

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

🔗 Pumaコンフィグのデフォルトスレッド数を5から3に変更

修正: #50450

主な変更点は、#50450で説明されているように、デフォルトのスレッド数が5から3に減らしたこと。

将来「Railsチューニングガイド」が作成される可能性もあるため、これに関連するトレードオフの要点をコメントに含めておいた。
同PRより

ここ数日間、#50450でPumaスレッド数の新しいデフォルト値の設定について広範な議論が行われた(このissueに目を通せば、Pumaコンフィグのスレッド数に関連するレイテンシvsスループットのトレードオフの詳細についてコミュニティのさまざまなメンバーが共有している知見を得られる)。この議論に基づいて、Pumaコンフィグのデフォルトスレッド数が5から3に更新された。
同公式情報より


つっつきボイス:「お〜、Pumaのデフォルトスレッド数を変更したんですね: いろんな状況が考えられるからどんな値をデフォルトに決めるかは割りと難しい問題」「issue #50450の議論が長いですね」

puma/puma - GitHub

「最近はDockerコンテナで動かすことが多いのと、コンテナでは昔ほど大量のメモリを割り当てることはあまりなさそうな気はするので、スレッド数5だと多いのかも」「なるほど」

「あとこの手の値は、開発者がリクエストをすぐ返却するようにコードをきちんと書くことが前提になっているものなので、そのあたりをさぼって長々とブロッキングするようなコードがあるなら値を増やすことになるのかな(さすがに今どきそういう書き方はあまり望ましくありませんが)」

# (コメントを一部省略)
# railties/lib/rails/generators/rails/app/templates/config/puma.rb.tt#L5
-max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
-min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
-threads min_threads_count, max_threads_count

-if ENV["RAILS_ENV"] == "production"
-  require "concurrent-ruby"
-  worker_count = Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
-  workers worker_count if worker_count > 1
-end
+rails_env = ENV.fetch("RAILS_ENV", "development")

+threads_count = ENV.fetch("RAILS_MAX_THREADS") { 3 }
+if rails_env == "production"
+  processors_count = Integer(ENV.fetch("WEB_CONCURRENCY") { 1 })
+  if processors_count > 1
+    workers worker_count
+  else
+    preload_app!
+  end
+end
# Specifies the `environment` that Puma will run in.
-environment ENV.fetch("RAILS_ENV") { "development" }
-
-# Specifies the `pidfile` that Puma will use.
-if ENV["PIDFILE"]
-  pidfile ENV["PIDFILE"]
-else
-  pidfile "tmp/pids/server.pid" if ENV.fetch("RAILS_ENV", "development") == "development"
-end
+environment rails_env

# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
+
+pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
+
+if rails_env == "development"
+  worker_timeout 3600
+end

↑development環境ではデバッグの邪魔にならないようworker_timeoutが長めに設定されています。

🔗 Object#withに渡すブロックのインスタンスをyieldするようになった

以下のようにObject#withに渡したブロックのインスタンスをyieldするようになった。

client.with(timeout: 5_000) do |c|
  c.get("/commits")
end

Sean Doyle
同Changelogより

Object#withにブロック引数を導入するということは、このメソッドが以下のようにSymbol#to_procをブロック引数として受け取れるということ。

client.with(timeout: 5_000, &:ping)

同PRより


つっつきボイス:「Object#withは昨年Active Supportに追加されたメソッドでしたね(ウォッチ20230328)」「yieldyield selfに変更されているんですね↓」「Object#withはまだ使いこなせていないけど、汎用性が高まるのはよさそう👍」

# activesupport/lib/active_support/core_ext/object/with.rb#L4
  # Set and restore public attributes around a block.
  #
  #   client.timeout # => 5
- #   client.with(timeout: 1) do
- #     client.timeout # => 1
+ #   client.with(timeout: 1) do |c|
+ #     c.timeout # => 1
  #   end
  #   client.timeout # => 5
  #
+ # The receiver is yielded to the provided block.
+ #
  # (略)
  def with(**attributes)
    old_values = {}
    begin
      attributes.each do |key, value|
        old_values[key] = public_send(key)
        public_send("#{key}=", value)
      end
-     yield
+     yield self
    ensure
      old_values.each do |key, old_value|
        public_send("#{key}=", old_value)
      end
    end
  end

参考: Rails API Object#with

DHHは以下のように、Ruby 3.4で入る予定のitが使えるのもよいと賛成していますね。

client.with(timeout: 5_000) { it.ping }

🔗 lastpluckcountなどでもexplainを使えるようになった

動機/背景

リレーションでexplainを呼び出すことで、データベースがクエリを実行する方法を調べられる。
現在のexplainは、lastpluckcount(これらはリレーションではなく実際の結果を返す)を使うクエリでは利用できないため、これらのクエリの最適化がやりにくい。

explainで代わりにプロキシを返すようにすることで、これらのメソッドをサポート可能にする。

詳細

User.all.explain.count
# => "EXPLAIN SELECT COUNT(*) FROM `users`"

User.all.explain.maximum(:id)
# => "EXPLAIN SELECT MAX(`users`.`id`) FROM `users`"

これによって既存の動作が破壊され、以下のようにexplainに続けてinspectを呼び出す必要が生じる。

User.all.explain.inspect
# => "EXPLAIN SELECT `users`.* FROM `users`"

ただしexplainはほとんどの場合コマンドラインで使われるのと、IRBでは自動的にinspectが呼ばれるので、これは問題にはならない。

追加情報

これは、#50424でプロキシとメソッド呼び出しの代わりに引数を使う方法のバリエーションである。

代わりにブロックを渡すバリエーションも考えられる。これは既存の振る舞いを壊さない代わりに、やや冗長になる。

User.all.explain do |relation|
   relation.count
end

&:によるシンボル->プロック記法を使えばもう少し簡潔に書ける。

User.all.explain(&:count)

この記法は、プロキシによるソリューションと異なり、to_sqlでも応用可能。

User.all.to_sql(&:count)

またはメソッド名のバリエーションを使う方法も考えられるが、explain(:analyze, :buffers)のようにexplainに渡せる引数が壊れてしまうだろう。

User.all.explain_count
User.all.explain_maximum(:id)

同PRより


つっつきボイス:「inspectaveragecountfirst/lastmaximum/minimumplucksumで使えるようになったんですね↓」「クエリログからSQLをコピペしてEXPLAINを付けて実行しなくてもできるのは便利👍」「軽くbreaking changeになっていて今後はexplainに続けてinspectを呼ばないといけないけど、IRBでinspectが自動で呼ばれるから大丈夫だろうと言ってますね」「たしかにexplainはIRBでクエリを調べるときに使うのがほとんどですね」

# activerecord/lib/active_record/relation.rb#L3
module ActiveRecord
  # = Active Record \Relation
  class Relation
+   class ExplainProxy  # :nodoc:
+     def initialize(relation, options)
+       @relation = relation
+       @options  = options
+     end
+
+     def inspect
+       exec_explain { @relation.send(:exec_queries) }
+     end
+
+     def average(column_name)
+       exec_explain { @relation.average(column_name) }
+     end
+
+     def count(column_name = nil)
+       exec_explain { @relation.count(column_name) }
+     end
+
+     def first(limit = nil)
+       exec_explain { @relation.first(limit) }
+     end
+
+     def last(limit = nil)
+       exec_explain { @relation.last(limit) }
+     end
+
+     def maximum(column_name)
+       exec_explain { @relation.maximum(column_name) }
+     end
+
+     def minimum(column_name)
+       exec_explain { @relation.minimum(column_name) }
+     end
+
+     def pluck(*column_names)
+       exec_explain { @relation.pluck(*column_names) }
+     end
+
+     def sum(identity_or_column = nil)
+       exec_explain { @relation.sum(identity_or_column) }
+     end
+
+     private
+       def exec_explain(&block)
+         @relation.exec_explain(@relation.collecting_queries_for_explain { block.call }, @options)
+       end
+   end

🔗 production環境ではserver.pidファイルを生成しないようになった

動機/背景

#50628の続き。

RailsアプリケーションをDockerで実行している場合、(メモリ不足などで) コンテナがクラッシュすると、/rails/tmp/pids/server.pidファイルがすでに存在しているために再起動が失敗することがある。

このような障害が発生したコンテナを再起動して実行できるようにするには、手動による介入(障害が発生したコンテナの削除など)が必要になる。コンテナを再起動可能にするには、コンテナ起動時にserver.pidファイルを削除する必要がある。
docker-entrypoint内のファイルを削除するのではなく、#50628で提案されているように、まず新規Railsアプリケーションがproductionではデフォルトでpidを作成しないようにする必要がある。

このプルリクを作成した理由は、production環境でpidファイルを作成しないようにするため。

詳細

このプルリクは、デフォルトのpuma.ttテンプレートでpidfileインストラクションを条件付きで生成する。ダミーアプリとテストアプリのpumaファイルも一貫性を保つために更新した。サーバーコマンド内のpidfileのデフォルトパスも条件付きに変更した。

追加情報

自分がpidfileを削除するのではなくpidfileを条件付きで生成する方法を選んだ理由は、development環境で既に使われているから(例: Dockerをデフォルトで使わずに実行する場合)。この条件付きの振る舞いが混乱につながる可能性がもしかするとあるかもしれないが、心当たりはあるだろうか?
同PRより


つっつきボイス:「production環境ではserver.pidを生成しないようにしたそうです」「言われてみれば現代ではpidファイルが必要になることが減ってきたのかも」

「server.pidのように実行中のプロセスのpid(プロセスID)を保存するファイルを生成するのは、Railsに限らず昔からUnix系で広く行われているんですよ: 元々は、たとえばRailsサーバーが複数ある状態で特定のRailsサーバーを停止しようとすると、プロセスがいくつもあってどれを止めたらいいのかわからなくなるので、現在起動中のプロセスのpidをファイルに保存するようにして、スクリプトからそれを参照して止めるのに使ったりしますね」「なるほど、server.pidというと、Railsがクラッシュしたときにtmpの下に残っていて、知らずにRailsを再起動しようとすると"pidが既にあります"って邪魔されるときしか意識したことがなかったけど、そういう目的だったんですね」

「同じ名前のプロセスが複数あると、psコマンドなどではどのプロセスを止めればいいかを確実に特定するのは不可能なので、Unix系アプリケーションではサーバープロセス実行時のpidファイルとして生成することが昔から行われているんですよ」「そうそう、昔からシェルスクリプトから起動を制御するときはこうやってましたね」「pidを消し忘れて起動できなくなるのも昔からよくありますし、消し忘れのときはstartではなくrestartすることでpidファイルを削除するという実装も昔ながらのinit.dスクリプトなどでよく使われている方法ですね」

参考: ps (UNIX) - Wikipedia

「で、現代のようにアプリケーションをDockerコンテナ内で実行するようになってくると、pidを調べなくてもコンテナを停止すれば済むので、以前ほどserver.pidの必要性はなくなってきているとも言えそう」

🔗 after_commitコールバックやafter_rollbackコールバックでon: :updateなどを指定可能になった

ActiveRecord::Transactions::ClassMethods#set_callbackを導入。

このクラスメソッドは、after_commitコールバックやafter_rollbackコールバックで:onオプションをサポートする他は、ActiveSupport::Callbacks::ClassMethods#set_callbackと同一。

Joshua Young
同Changelogより

動機/背景

#50260の修正を試みるため。

詳細
ActiveRecord::Transactions::ClassMethods#set_callbackを導入する。
これは、#after_commitコールバックや#after_rollbackコールバックで:onオプションを利用可能にするActiveSupport::Callbacks::ClassMethods#set_callbackと振る舞いが似ている。
同PRより


つっつきボイス:「従来からあるafter_update_commitafter_rollbackと同じことをafter_commiton: :updateオプションでもできるようになったらしい↓」

# activerecord/test/cases/transaction_callbacks_test.rb#L1049
  class TopicWithCallbacksOnUpdate < TopicWithHistory
    after_commit :after_commit_on_update_1, on: :update
    after_update_commit :after_commit_on_update_2

    private
      def after_commit_on_update_1
        self.class.history << :after_commit_on_update_1
      end

      def after_commit_on_update_2
        self.class.history << :after_commit_on_update_2
      end
  end

参考: Rails API after_update_commit -- ActiveRecord::Transactions::ClassMethods
参考: Rails API after_rollback -- ActiveRecord::Transactions::ClassMethods

Rails APIドキュメント: Active Recordのトランザクション(翻訳)

🔗 ActiveSupport::CurrentAttributes.attributedefault:オプションをサポート

ActiveSupport::CurrentAttributes.attributedefault:オプションをサポート。

class Current < ActiveSupport::CurrentAttributes
  attribute :counter, default: 0
end

Sean Doyle
同Changelogより


つっつきボイス:「お〜、CurrentAttributesの属性にデフォルト値を指定できるようになったんですね」

「ところでCurrentAttributesというと以下の記事を思い出しますけど、昨年のKaigi on Rails 2023でEvil MartiansのSampoさんがCurrentAttributesには使いどころがあるよという発表もしていましたね」

Railsの`CurrentAttributes`は有害である(翻訳)

「スライドにもあるようにコードがわかりにくくなったりテストしにくくなったりすることが多いので、CurrentAttributesのようなグローバルコンテキストを保持するものは基本的にはあまりよくないんですが、これを使うと圧倒的に簡単に書ける機能もあるので、注意して扱うなら別に使ってもよいと思います」「なるほど」

「たとえばロガーを参照するとかリクエストオブジェクトを取りにいく機能なんかを書くときはグローバルコンテキストが取れるととっても楽ですし、Rails.loggerは実質グローバルコンテキストから取り出していたりしますね」

「昔使ってたPHPのSymfonyというMVCフレームワークではsfContextというグローバルコンテキストがあちこちで引き渡されるようになっていて、loggerやrequestなどを任意の場所から参照しにいけるようになっていた記憶があります(今のバージョンはわかりませんが): そのぐらいロガーはどこからでもアクセスしたいものなんですよ」

参考: symfony の内側 (symfony 1.4 legacy version)

🔗 bunを使うアプリがGitHub CIでbunをセットアップするよう修正

bunを有効にして生成したアプリが、GitHub CIでoven-sh/setup-bunを使うよう修正。

TangRufus
同Changelogより

動機/背景
bunを有効にしてアプリを生成するとGitHub CIテストがCommand install failed, ensure bun is installedで失敗する。

詳細

このプルリクは、bunを有効にして生成したアプリのci.ymloven-sh/setup-bunを追加する。
同PRより


つっつきボイス:「bunは最近Railsで選択可能になった新しいJavaScriptバンドラーですね(ウォッチ20230926)」「JSバンドラーが多いとサポートが大変そう」「bunとかesbuildとかいろいろありますよね」

oven-sh/bun - GitHub

参考: §2 npmパッケージをJavaScriptバンドラーで追加する -- Rails で JavaScript を利用する - Railsガイド

🔗 オートロードパスにないファイルの再読み込みをdevelopment環境で防止するよう修正

developmentモードでアプリケーションを不必要に再読み込みしないようになった。

従来は、オートロードパスの外にある一部のファイルによって不要な再読み込みがトリガーされていた。この修正によって、アプリケーションがRails.autoloaders.main.dirsに沿って再読み込みされるようになり、不要な再読み込みを防止する。

Takumasa Ochi
同Changelogより


つっつきボイス:「developmentでオートロードパスの外にあるファイルが読み込まれてたんですね」「読み込まなくて済むならしない方がいいでしょうね」

# railties/lib/rails/application.rb#L412
    def watchable_args # :nodoc:
      files, dirs = config.watchable_files.dup, config.watchable_dirs.dup

-     ActiveSupport::Dependencies.autoload_paths.each do |path|
-       File.file?(path) ? files << path.to_s : dirs[path.to_s] = [:rb]
+     Rails.autoloaders.main.dirs.each do |path|
+       dirs[path.to_s] = [:rb]
      end

      [files, dirs]
    end

参考: § 17 Rails.autoloaders -- Rails の自動読み込みと再読み込み - Railsガイド

🔗Rails

🔗 Rails 8でIRBプロンプトを改良


つっつきボイス:「Rails 8のIRBでdev:001>のように環境をプロンプトで短く表示するようになるそうです」「st0012さんの改修なんですね」「実行環境に合わせてプロンプトを変えたいのはわかる👍」

🔗 Railsルーティングで高度な制約を使う(Ruby Weeklyより)


つっつきボイス:「こういうふうにmatches?で制約を定義したクラスをconstraintsに渡す形でルーティングを拡張するのは↓、Railsを長くやっていれば一度は書いたことがあるはず」「ここではIPの許可リストで制約をかけているんですね」

# 同記事より
# app/constraints/ip_constraint.rb

class IpConstraint
  def self.matches?(request)
    allow_list = Rails.application.config.x.ips.allow_list

    request.ip.in? allow_list
  end
end

# config/routes.rb

authenticated :user, -> { _1.admin? } do
  constraints(IpConstraint) do
    namespace :admin do
      resources :users
    end
  end
end

🔗 comma: モデルをCSVに出力するgem(Ruby Weeklyより)

comma-csv/comma - GitHub


つっつきボイス:「★500個超えてますね」「wikiに使い方が書かれていた」「ApplicationRecordにこういうのを書くとCSVを出力できるようになるみたいですね↓」「CSVからの取り込みもできたらいいんだけど」

# 同リポジトリより
class Book < ApplicationRecord
  # ================
  # = Associations =
  # ================
  has_many   :pages
  has_one    :isbn
  belongs_to :publisher

  # ===============
  # = CSV support =
  # ===============
  comma do
    name
    description

    pages :size => 'Pages'
    publisher :name
    isbn number_10: 'ISBN-10', number_13: 'ISBN-13'
    blurb 'Summary'
  end
end

🔗 Rails 1.0をRuby 3.3で動かした(Ruby Weeklyより)


つっつきボイス:「昔のRails 1.0を最新のRuby 3.3で動かしてみた記事です」「なにゆえに😆」「謎です😆」「エンコーディングの違いやらいろいろ対応して本当に動かしたのね」「懐かしい1.0起動画面」

# 同記事より: Gemfile
source "https://rubygems.org"
gem "rails", "1.0.0"


同記事より

🔗 その他Rails


つっつきボイス:「igaigaさんの"Ruby練習帳"にコンテンツが追加されてる」「ActiveRecord::QueryLogsはRails 7で入った機能でしたね(ウォッチ20210906)」

参考: §3 SQLクエリコメント -- Rails アプリケーションのデバッグ - Railsガイド


前編は以上です。

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

週刊Railsウォッチ: RailsコントローラのparamsはHashではない、ruby-enumほか(20240125後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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