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

週刊Railsウォッチ: シャーディング用メソッドを追加、localsマジックコメント修正ほか(20240709)

こんにちは、hachi8833です。Rails 7.2のRCはまだリリースされていません。

週刊Railsウォッチについて

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

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

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

🔗 シャーディング用メソッド.shard_keys.sharded?.connected_to_all_shardsが追加

動機/背景

現在は、モデルが単一のデータベースに接続しているか複数のシャードに接続しているかを知る(簡単な)方法がない。しかも、モデルのコネクションをループで回さない限り、モデルが接続可能なシャードのリストを返す簡単な方法はないと思う。

詳細

このコミットは、@shard_keysインスタンス変数を追加する。このインスタンス変数は.connects_toが呼び出されるたびに必ず設定され、shards.keysの結果に設定する。
.connects_toshardsは、デフォルトでは空のハッシュに設定されるため、connects_to database: {...}を呼び出すと、@shard_keysは空の配列に設定される。

@shard_keysは、以下の行よりも前に設定される。

if shards.empty?
  shards[:default] = database
end

この条件は、唯一のシャード(:default)を、.connects_toに渡すdatabaseの値に設定する。これにより、以下のようにデータベースだけに接続するよう設定されたモデルでもconnected_to(shard: :default)を呼び出せるようになる。

class UnshardedBase < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: { writing: :primary }
end

class UnshardedModel < UnshardedBase
end

UnshardedBase.connected_to(shard: :default) { UnshardedBase.connection_pool.db_config.name } => primary

このモデルは結局「非シャード」のままなので、@shard_keysはこの条件より前のタイミングで設定される。

新しい@shard_keysインスタンス変数では、Active Recordの抽象モデルの子孫が同じ値を返す方法が必要になる。そのため、既存の.connection_class_for_selfメソッドを活用する。このメソッドは、.connects_toが呼び出されたモデルの先祖を返すか、コネクションクラスの場合はselfを返す。

class UnshardedBase < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: { writing: :primary }
end

class UnshardedModel < UnshardedBase
end

ActiveRecord::Base.connection_class_for_self => ActiveRecord::Base

UnshardedBase.connection_class_for_self => UnshardedBase(abstract)

UnshardedModel.connection_class_for_self => UnshardedBase(abstract)

新しい.shard_keysメソッドは、コネクションクラスから@shard_keysの値か空配列を返すゲッターである。この空配列は、connects_toが呼び出されなかった場合に必要。

最後に、.connected_to_all_shardsメソッドを追加した。これは.connected_toのすべての引数(ただしshardは除く)を受け取り、すべてのシャードキーをループで回してから、それ以外のすべてを.connected_toに委譲する。各ブロックの結果をコレクションできるようにするため、.eachではなく.mapを使った。
同PRより


つっつきボイス:「Active Recordのモデルオブジェクトがあったときに、シャーディングされているのかどうかを知るメソッドなどを追加したんですね」「もちろんクエリを投げればできますけど、クエリを投げずにモデルのオブジェクトでできるのはよさそう👍」「シャードのキー(shard_one:shard_two:)を指定できるのがいいですね↓」

  • シャード用に.shard_keys.sharded?.connected_to_all_shardsメソッドを追加
class ShardedBase < ActiveRecord::Base
    self.abstract_class = true

    connects_to shards: {
      shard_one: { writing: :shard_one },
      shard_two: { writing: :shard_two }
    }
end

class ShardedModel < ShardedBase
end

ShardedModel.shard_keys => [:shard_one, :shard_two]
ShardedModel.sharded? => true
ShardedBase.connected_to_all_shards { ShardedModel.current_shard } => [:shard_one, :shard_two]

Nony Dutton
同Changelogより

参考: 7 自動シャード切り替えを有効にする -- Active Record の複数データベース対応 - Railsガイド

「なお、ここで扱っているシャーディングとは複数DBに分割してデータを保存する方式を指していて、1テーブル内で複数テーブルに分割するパーティショニングとは異なります: そのためシャードの単位はDB接続先の単位となります」

「シャーディングされているかどうかを途中で知りたいときってあるんでしょうか?」「シャーディングのアーキテクチャ次第ですね: シンプルなシステムならシャーディングのノード数が増減すると再構築が必要になるけど、オーバーレイネットワークのシステムにはシャーディングのノード数が増減しても再構築してくれるものも普通にあるし、AWS Auroraで使っているファイルシステムなんかもそういう感じになっていますね」「なるほど」

「そういえばソシャゲ全盛だった頃はシャーディングがソシャゲのデータベース設計の基礎みたいに言われてたりしましたけど、今のソシャゲは有名どころの顔ぶればかりで、低予算のソシャゲで一発当てるみたいな開発はほとんど見かけなくなった感ありますね」「そうかも」「昨今の覇権取れるレベルのユーザー数を前提とするようなソシャゲの場合、昔ながらの単純なシャーディングでは力不足で、GCPのSpannerとかTiDBみたいなもっと大規模なトラフィックに耐えられるものを金の弾丸で導入する、というような状況になっていると思います」

参考: TiDB: The Advanced Distributed SQL Database

🔗 Active Storageプロキシコントローラのエラーハンドリングを修正

修正: #51284

Active Storageに2つあるプロキシコントローラは、どちらもストリーミングより前のタイミングでヘッダーのキャッシュを設定する。

場合によっては(#51284を参照)、(ストレージサービスからActive Storageへの)ファイルダウンロードが、クライアントに最初のバイトを送信する前に(レスポンスがまだコミットされていない)ファイルのダウンロードが失敗する可能性がある。

この変更によって、そのような場合はキャッシュが無効になるので、より適切なレスポンスステータスを返してからストリームを閉じるようになる。
同PRより


つっつきボイス:「ダウンロードエラー時にFileNotFoundErrorになったときはexpires_nowで強制的にサーバー側のヘッダーキャッシュを失効させるようにしたんですね↓」

# activestorage/app/controllers/concerns/active_storage/streaming.rb#L56
    def send_blob_stream(blob, disposition: nil) # :doc:
      send_stream(
          filename: blob.filename.sanitized,
          disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION,
          type: blob.content_type_for_serving) do |stream|
        blob.download do |chunk|
          stream.write chunk
        end
+     rescue ActiveStorage::FileNotFoundError
+       expires_now
+       head :not_found
+     rescue
+       # Status and caching headers are already set, but not commited.
+       # Change the status to 500 manually.
+       expires_now
+       head :internal_server_error
+       raise
+     end
    end

参考: Rails API expires_now -- ActionController::ConditionalGet

🔗 minitestのアサーション失敗メッセージを遅延実行して高速化

関連: #52036

生成するオブジェクトが非常に大きい場合や、procのAST(抽象構文木)にアクセスするときに、生成コストが高くなる場合が生じている。

minitestはこのメッセージを呼び出し可能オブジェクトとして渡すことをサポートしているので、これらすべての計算を遅延実行できる。
同PRより


つっつきボイス:「やってることはアサーションのメッセージをlambda渡しで遅延生成するように変えるというシンプルなもの↓」「lambdaをメモ化するようにしたのね」「地味だけど、たしかに実行時評価にする方が速くなりますね👍」

# activesupport/lib/active_support/testing/assertions.rb#L21
      def assert_not(object, message = nil)
-       message ||= "Expected #{mu_pp(object)} to be nil or false"
+       message ||= -> { "Expected #{mu_pp(object)} to be nil or false" }
        assert !object, message
      end

🔗 RuboCop対応2件

フィールドが存在しない場合:

  • t.timestampsより前のマイグレーションに空行が入らないようにする
  • コントローラのcreateupdateやAPI機能テストで、空ハッシュ内の前後にスペースが入らないようにする

修正: #52158

この変更は7-2-stableにバックポートすべき。
同PRより

# activerecord/lib/rails/generators/active_record/migration/templates/create_table_migration.rb.tt#L15
-<% if options[:timestamps] %>
+<% unless attributes.empty? -%>
+
+<% end -%>
+<% if options[:timestamps] -%>
      t.timestamps
<% end -%>
    end
# railties/lib/rails/generators/test_unit/scaffold/templates/api_functional_test.rb.tt#L18
  test "should create <%= singular_table_name %>" do
    assert_difference("<%= class_name %>.count") do
-     post <%= index_helper %>_url, params: { <%= "#{singular_table_name}: { #{attributes_string} }" %> }, as: :json
+     post <%= index_helper %>_url, params: { <%= "#{singular_table_name}: #{attributes_string}" %> }, as: :json
    end
  • 配列要素の前後の余分なスペースを修正
  • コードの新しいブロック間にのみ空行を置くよう修正
    同PRより

つっつきボイス:「どちらもscaffoldで生成したコードがRuboCopに怒られたので修正」「あるあるですね😆」「こういう地味な修正の積み重ねが使い勝手に影響してきますよね」

# railties/lib/rails/generators/test_unit/mailer/templates/functional_test.rb.tt#L4
<% module_namespacing do -%>
class <%= class_name %>MailerTest < ActionMailer::TestCase
-<% actions.each do |action| -%>
+<% actions.each_with_index do |action, index| -%>
+<% if index != 0 -%>
+
+<% end -%>
  test "<%= action %>" do
    mail = <%= class_name %>Mailer.<%= action %>
    assert_equal <%= action.to_s.humanize.inspect %>, mail.subject
-   assert_equal ["to@example.org"], mail.to
-   assert_equal ["from@example.com"], mail.from
+   assert_equal [ "to@example.org" ], mail.to
+   assert_equal [ "from@example.com" ], mail.from
    assert_match "Hi", mail.body.encoded
  end

<% end -%>

🔗 親クラスで定義されたメソッドをalias_attributeがオーバーライドしないよう修正

修正: #52144

通常の属性を定義する場合、継承したメソッドはオーバーライドされないが、エイリアス属性を定義する場合は、継承されたメソッドのことが考慮されていなかった。

この振る舞いは議論の余地があるものの、#52118より前の振る舞いであるため、復元することにする。
同PRより


つっつきボイス:「alias_attributeの場合に継承で親に既にある同名のメソッドがオーバーライドされていたのでActive Modelの方でoverride: falseを追加して修正したのね↓」

# activemodel/lib/active_model/attribute_methods.rb#L315
-     def define_attribute_method_pattern(pattern, attr_name, owner:, as:) # :nodoc:
+     def define_attribute_method_pattern(pattern, attr_name, owner:, as:, override: false) # :nodoc:
        canonical_method_name = pattern.method_name(attr_name)
        public_method_name = pattern.method_name(as)

「テストコードを見ると、親クラスで属性ではなくメソッドとして定義されている場合に問題が起きていたということみたい↓」「あ、親クラスがメソッドで子クラスがalias_attributeの場合なんですね」「属性とメソッドは、呼び出し側だけを見ていても属性かメソッドか区別できないことがあるけど、内部的には別モノというのがややこしい」

  test "#alias_attribute override methods defined in parent models" do
    parent_model = Class.new(ActiveRecord::Base) do
      self.abstract_class = true

      def subject
        "Abstract Subject"
      end
    end

    subclass = Class.new(parent_model) do
      self.table_name = "topics"
      alias_attribute :subject, :title
    end

    obj = subclass.new
    obj.title = "hey"
    assert_equal("hey", obj.subject)
  end

🔗 キャッシュコントロールのimmutableをサポート

  • Cache-Controlでimmutableディレクティブをサポート
expires_in 1.minute, public: true, immutable: true
# Cache-Control: public, max-age=60, immutable

heka1024
同Changelogより


つっつきボイス:「immutableっていうのがあるんですね」

参考: immutable -- Cache-Control - HTTP | MDN

Cache-Control は HTTP のヘッダーフィールドで、 キャッシュをブラウザーや共有キャッシュ(プロキシーや CDN など)において制御するためのディレクティブ (指示) を、リクエストとレスポンスの両方で保持します。
(中略)
immutable
レスポンスディレクティブの immutable は、レスポンスが新鮮な間は更新されないことを示します。

Cache-Control: public, max-age=604800, immutable

immutable -- Cache-Control - HTTP | MDNより

「ところで"最近のベストプラクティス"↓って公式ドキュメントらしくない書き方でちょっと面白い😆」「最近とは何ぞや😆」「それはともかく、ここでcache-bustingと呼んでいるパターンは実際よく使うので、Railsで使えるのはいい👍」

静的なリソースに対する最近のベストプラクティスは、バージョン/ハッシュをURLに含める一方で、リソースには決して手を加えず必要なときに、新しいバージョン番号/ハッシュを持つ新しいバージョンでリソースを更新し、URLも異なるものにすることです。これをcache-bustingパターンと呼びます。
immutable -- Cache-Control - HTTP | MDNより

🔗 locals:マジックコメントを使うとlocal_assignsが未定義になる場合があったのを修正

クローズ: #52162

ローカル変数の1つがキーワードと衝突する場合(classが典型)。

(デフォルト値が定義されている場合はlocal_assignsに反映されないのが紛らわしそう)

cc: @bensheldon @rafaelfranca
同PRより


つっつきボイス:「この修正は、少し前に入った、テンプレートに渡していいローカル変数をマジックコメントで指定できる機能↓に関連しているようです(ウォッチ20220822)」「あぁ、Action Viewのlocals:マジックコメントですね、これは手軽に使えるいい機能👍」

<%# issues/_card.html.erb %>
<%# locals: (title: "Default title", comment_count: 0) %>
<h2><%= title %></h2>
<span class="comment-count"><%= comment_count %></span>

「そのlocals:マジックコメントでclassのようなキーワードと同じものを指定したときにlocal_assignsが未定義になっていたのが修正されたのね↓」「割とレアケースですね」

  def test_rails_local_assigns_and_strict_locals
    @template = new_template("<%# locals: (class: ) -%>\n<%= local_assigns[:class] %>")
    assert_equal "some-class", render(class: "some-class", implicit_locals: %i[message])
  end

上の修正に伴ってドキュメントも更新されました↓。

local_assignsメソッドには、locals:マジックコメントで指定されたデフォルト値は含まれません。
classifのようにRubyの予約語と同じ名前のデフォルト値を持つローカル変数にアクセスするには、以下のようにbinding.local_variable_getで値にアクセスできます。

<%# locals: (class: "message") %>
<div class="<%= binding.local_variable_get(:class) %>">...</div>

Document undefined local_assigns when using Strict Locals with defaults by bensheldon · Pull Request #52209 · rails/railsより

🔗 devcontainer生成を修正

動機/背景

現在のdevcontainerコマンドはアダプタ名を設定するが、DevcontainerGeneratorでデータベース名が必要。

rails/railties/lib/rails/generators/rails/devcontainer/devcontainer_generator.rb

 class_option :database, enum: Database::DATABASES, type: :string, default: "sqlite3", 

rails/railties/lib/rails/generators/database.rb

DATABASES = %w( mysql trilogy postgresql sqlite3 ) 

そのせいでMySQL用の設定が生成されていなかった。この修正によって正しい設定が生成されるようになる。
同PRより


つっつきボイス:「devcontainerがMySQLに対応していなかったのをシンプルに修正したものですね↓」

# railties/lib/rails/commands/devcontainer/devcontainer_command.rb#L22
        def devcontainer_options
          @devcontainer_options ||= {
            app_name: Rails.application.railtie_name.chomp("_application"),
-           database: !!defined?(ActiveRecord) && ActiveRecord::Base.connection_db_config.adapter,
+           database: !!defined?(ActiveRecord) && database,
            active_storage: !!defined?(ActiveStorage),
            redis: !!(defined?(ActionCable) || defined?(ActiveJob)),
            system_test: File.exist?("test/application_system_test_case.rb"),
            node: File.exist?(".node-version"),
          }
        end

+       def database
+         adapter = ActiveRecord::Base.connection_db_config.adapter
+         adapter == "mysql2" ? "mysql" : adapter
+       end
    end

🔗 RDoc生成でincludeが遅くなっていたのを修正

CIでは8時間かかった。自分のPCでは3時間経過したところでキャンセルした。

rdocのこのコメントが関連しているのではないかと疑っている(ここで詰まっていたので)。includeされるものをいくつか削除したところ、やっとまともな時間で終わるようになった。なお、どれを削除するかは関係なさそう。

この問題のきっかけになったのは#52185だった。
@vinistockへ: ドキュメントのこのコメントによってruby-lspがこれを無視するようになり、最初にそちらのプルリクで行ったことが基本的に取り消されるかどうか自分にはわからない(これがどう動くかについてこれまで考えたことがなかった)。この修正でよければ知らせて欲しい。

これはおそらくrdocのどこかにあるバグではないか。しかし単にinclude件数が多いせいだとは思えないし、比較的起きやすいように思える。
同PRより


つっつきボイス:「ruby-lspによるメソッド名補完が効くようにするために#52185でモジュールを明示的にincludeするようにしたら、どうやらそのせいでincludeでRDocが生成されるようになってめちゃくちゃ遅くなってしまったのか」

#52185(クリックで展開)

動機/背景
コントローラのアクション内でメソッド補完が効かない(正確な提案を表示できるはずの状況であっても)という報告をRuby LSPのユーザーたちからいくつか受け取った。

補完、定義ジャンプ、シグネチャのヘルプなどの機能が使えない理由は、すべてのモジュールがActionController::Baseに動的にincludeされており、静的分析で先祖をキャプチャできなくなるため。Base自体は実際には何も定義されていないので、静的分析で先祖を分析できなければ、その中で定義されているメソッド、定義、インスタンス変数を識別できなくなる。

@rafaelfrancaとのチャットで、「ここで動的includeを利用している理由は、MODULESの除外可能なリストが常に同期されるためだが、他の方法で保証できるなら明示的なincludeに変更してもよい」と説明を受けた。

このプルリクでは、includeされたモジュールのリストが、MODULESの除外可能なリストと常に一致することを確認するための、簡単な単体テストを追加することを提案している。それと引き換えに、小さな不便が生じる。つまり、ActionController::Baseに新しいモジュールをincludeする場合は、MODULESのリストと明示的なincludeの両方に追加しなければならなくなる。

ただし、このクラスにincludeの項目を毎日のように追加する可能性は低いので、このトレードオフは許容可能だと考えている。メリットは、Ruby LSPのユーザー(および他の静的解析ツールのユーザー)がコントローラ内の機能を正確に取得可能になること(現在はそうなっていない)。

詳細

MODULESの配列をループで回して項目を動的にincludeする代わりに、個別のinclude項目を明示的なものに変更した。

自分が追加したテストは、base.rbファイルを読み込んで、すべてのinclude項目を正規表現で探索し、MODULES配列で期待されるものと一致することを確認する形で同期がずれないようにする。

メモ

先祖チェックを追加しなかった理由:

単体テストを追加する方法の代わりに、モジュールの追加前と追加後で先祖がどう変わるかをチェックする形で実現を試みた。しかし、MODULESにあるモジュールには、このリストでincludeされていない独自の先祖があるため、一致しない。
以下は自分が調べてみたアイデア:

class Base
  # ...

  original_ancestors = ancestors
  include AbstractController::Rendering
  # ...

  # ここにincludeされるのは、現在MODULESリストに存在する項目だけではなく、
  # 推移的な先祖(リスト内のモジュールによってincludeされるモジュール)もincludeされる
  # このリストを動的に生成する適切な方法を思いつかなかった
  MODULES = ancestors - original_ancestors
end

同PRより

「修正そのものは:stopdoc::startdoc:でRDoc生成を抑制するというシンプルなものですね↓」「こんなニッチな原因を特定したのがすごい」

# actionpack/lib/action_controller/base.rb#L269
+   # Note: Documenting these severely degrates the performance of rdoc
+   # :stopdoc:
    include AbstractController::Rendering
    include AbstractController::Translation
    include AbstractController::AssetPaths
    include Helpers
    include UrlFor
...
    # Params wrapper should come before instrumentation so they are properly showed
    # in logs
    include ParamsWrapper
+   # :startdoc:
    setup_renderer!

参考: library rdoc (Ruby 3.3 リファレンスマニュアル)

🔗 ActiveStorage::Service::MirrorServiceのパフォーマンスを改善

動機/背景

このプルリクを作成した理由は、ActiveStorage::Service::MirrorServiceFIXMEコメントを削除するため。

詳細

このプルリクは、ActiveStorage::Service::MirrorServicedeletedelete_prefixedをパラレル化するためにスレッドプールを利用する。

追加情報

この修正で複雑になるだけでパフォーマンスが向上しないのであれば、単にコメントを削除してはどうだろう。
同PRより


つっつきボイス:「concurrent-rubyのThreadPoolExecutorを使うようにしたんですね↓」「単にeach_service.collectで回すよりも、こうする方が特にダウンロードに時間がかかる場合のパフォーマンスがよくなるでしょうね👍」「なるほど」「特にこういうミラーリングサービスで別リージョンにバックアップすると通常より遅くなりがちなのでパラレル化が効きそう」

# activestorage/lib/active_storage/service/mirror_service.rb#L31
    def initialize(primary:, mirrors:)
      @primary, @mirrors = primary, mirrors
+     @executor = Concurrent::ThreadPoolExecutor.new(
+       min_threads: 1,
+       max_threads: mirrors.size,
+       max_queue: 0,
+       fallback_policy: :caller_runs,
+       idle_time: 60
+     )
    end
...
      def perform_across_services(method, *args)
-       # FIXME: Convert to be threaded
-       each_service.collect do |service|
-         service.public_send method, *args
+       tasks = each_service.collect do |service|
+         Concurrent::Promise.execute(executor: @executor) do
+           service.public_send method, *args
+         end
        end
+       tasks.each(&:value!)
      end
  end

ruby-concurrency/concurrent-ruby - GitHub

🔗 inspectで余分な情報が漏出しないようカスタム定義

動機/背景

修正前は、ConnectionPoolや個別のコネクション(Adapters)でinspectを呼び出したときにエラーになると、production環境でデータベースのパスワードが誤って漏洩してしまいやすかった。これは、PoolsAdapters#inspectがデフォルトで出力するものが不必要に大きく、現在はそこにパスワードが含まれているため(PoolDatabaseConfigAdapterの内部コンフィグ経由で)。

詳細

このコミットは、ConnectionPoolAbstractAdapterDatabaseConfig#inspectをカスタム定義することで問題に対処する。スリムになった#inspectには有用なフィールドが数個しか含まれなくなり、すべての内部フィールドを含まなくなったので、巨大な出力やパスワードが含まれなくなった。
同PRより


つっつきボイス:「コネクションプールやアダプタにはいろんな情報がぎっしり詰まっているから、inspectをオーバーライドして出力をスリム化したんですね」「たしかにinspectは思わぬものが出力される可能性もあるので、こういう修正はありがたい👍」

🔗 Rakeタスクの一部をThorに移行

現在のbin/railsではThorとRakeが両方使われているが、最終的にはすべての組み込みタスクをThorコマンドに昇格させたいと考えている。これにより、statsタスクがThorに移行する。
同PRより


つっつきボイス:「Railsのrakeタスクを長期的にthorに置き換えようとしているみたいですね」「たしかにrakeは実行が遅いし」「ヘルプ表示も使いにくいし」「rakeが特に使いにくいのは、Railsとかなり違うコンテキストで動作すること: rakeで書くよりRailsランナーとして書く方がずっと使いやすいですね」「そうそう」

rails/thor - GitHub

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


前編は以上です。

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

週刊Railsウォッチ: Railsのシステムテストを単体テストに置き換えるほか(20240627後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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