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

週刊Railsウォッチ: solid_queueとmission_control-jobsが正式にRailsのgemに、Rubyの"チルド"文字列ほか(20240402)

こんにちは、hachi8833です。xzの脆弱性対策をお忘れなく。

週刊Railsウォッチについて

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

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

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

🔗 Active Storageでプリプロセス不要なファイルをエンキューしないよう修正

#51030のフォローアップとして、「バリアントを一切指定していない」または「TransformJobでプリプロセスが必要なバリアントを含まない」プレビュー可能なattachedファイルの振る舞いを変更する。従来は、プリプロセスが必要なバリアントがない場合、Attachment#transform_variants_laterは何もしなかった(named_variantsが以下のように空になる)。

def transform_variants_later
  named_variants.each do |_name, named_variant|
    blob.preprocessed(named_variant.transformations) if named_variant.preprocessed?(record)
  end
end

#51030以後は、attachedファイルのblobと空配列を持つActiveStorage::PreviewImageJobがエンキューされるようになり、以下のようにblob.create_preview_image_later(preprocessed_variations)の呼び出しでpreprocessed_variationsが空になっていた。

def transform_variants_later
  preprocessed_variations = named_variants.filter_map { |_name, named_variant|
    if named_variant.preprocessed?(record)
      named_variant.transformations
    end
  }

  if blob.preview_image_needed_before_processing_variants?
    blob.create_preview_image_later(preprocessed_variations)
  else
    preprocessed_variations.each do |transformations|
      blob.preprocessed(transformations)
    end
  end
end

自分たちの場合、予想していなかった新しいジョブが大量に流れ込み、さらに受信メール処理中に作成されたblobとattachedファイルがデッドロックしたためにジョブが大量に失敗し、非常に驚かされた。
そのため、インラインプレビューを作成するタイミングは、これらのblobが取り込まれてAction Textのリッチテキストレコードに埋め込まれるときではなく、プレビューが要求された時点で意図的に行うようにした。
blobを取り込んでからInboundEmail::RoutingJobの処理と並行してプレビューを作成すると、2つのジョブで同時にtouchがトリガーされて階層を駆け上がり、デッドロックが発生する可能性が非常に高くなる。
同PRより


つっつきボイス:「Active Storageではattachedファイルのバリアント(サイズ違いの画像)生成をジョブキューに送って生成できるけど、バリアントの生成が不要なファイルまでジョブが生成されていたのか」「そのせいで大量にジョブが発生してデッドロックでびっくりしたとありますね」「修正は&& preprocessed_variations.any?を追加することで行ったのね↓: 何も処理しないとわかっているジョブは、正常に終了できるとしてもエンキューすべきでないと自分も思います👍」

# activestorage/app/models/active_storage/attachment.rb#L134
    def transform_variants_later
      preprocessed_variations = named_variants.filter_map { |_name, named_variant|
        if named_variant.preprocessed?(record)
          named_variant.transformations
        end
      }

-     if blob.preview_image_needed_before_processing_variants?
+     if blob.preview_image_needed_before_processing_variants? && preprocessed_variations.any?
        blob.create_preview_image_later(preprocessed_variations)
      else
        preprocessed_variations.each do |transformations|
          blob.preprocessed(transformations)
        end
      end
    end

参考: Active Storage の概要 - Railsガイド

🔗 Active Record内に残っているlease_connectionを排除

Ref: #51349

通常のActive Record APIが使われるときに恒久的なリースを取得できないようにする。排除した恒久的リースのいくつかは理想的な形で削除できなかったので、いくつかTODOやFIXMEを追加した。将来改善されると願っているが、これらを修正してconfig.active_record.permanent_connection_checkoutを7.2で実験できるようにしたかった。
同PRより


つっつきボイス:「byrootさん(casperisfineは別名)がこの間から取り組んでいるActiveRecord::Base.with_connection周りの改修の続きのようですね(ウォッチ20240312)(ウォッチ20240228)」「あのときBase.connectionは非推奨化処理はされずに今後も使えることになりましたけど、このプルリクを見た感じでは、その後#51349がマージされてconfig.active_record.permanent_connection_checkoutというコンフィグが7.2で追加されることになって、非推奨警告を出したければ出せるようにしたんですね: このコンフィグの動作を確かめる準備として、残っていたlease_connectionをまず削除したという流れのようですね」「なるほど」

# activerecord/lib/active_record/associations/join_dependency/join_association.rb#L92
        private
-         def append_constraints(join, constraints)
+         def append_constraints(connection, join, constraints)
            if join.is_a?(Arel::Nodes::StringJoin)
              join_string = Arel::Nodes::And.new(constraints.unshift join.left)
-             join.left = Arel.sql(base_klass.lease_connection.visitor.compile(join_string))
+             join.left = Arel.sql(connection.visitor.compile(join_string))
            else
              right = join.right
              right.expr = Arel::Nodes::And.new(constraints.unshift right.expr)
            end
          end

🔗 Railsエンジンが多くなると起動が遅くなる問題を修正

#47347を部分的にやり直した。

現在、すべてのRails::Engineを初期化すると、エンジンがパスを先頭に追加するときに新しいViewウォッチャーが作成される。

ActiveSupport.on_load(:action_controller) { prepend_view_path(views) if respond_to?(:prepend_view_path) } 

これは、遅延読み込みされたアプリケーションで最初のコールドリクエストを実行するのに要する時間に影響する。

prepend_view_path -> _build_view_paths -> cast_file_system_resolvers -> file_system_resolver_hooks.each(&:call) -> rebuild_watcher

この変更により、最初のupdated?チェックが実行されるまでViewウォッチャーの初期化が遅延され、ウォッチャーの初期化が1回だけで済むようになる。

ベンチマーク

このベンチマークは完全ではないいものの、不要なウォッチャーのリビルドの振る舞いや影響を確実に示していると思う。
(ベンチマークコードは省略)

  • mainブランチ: Time: 17.98 ms
  • このブランチ: Time: 2.93 ms

自分たちのモジュール式モノリスでは、ビューパスを冒頭に追加するエンジンが100個ほどあり、ウォッチャーをリビルドするディレクトリリストが増大していた。この変更により、コールドリクエストが最大16秒から最大8秒まで短縮される。

同PRより


つっつきボイス:「Viewウォッチャーって何だろうと思ったら、Action Viewの内部で使われているViewReloaderクラスのウォッチャー機能のことみたいですね」「Rails::Engineを初期化するたびにウォッチャーが作成されるせいで、Railsエンジンが多いと起動が遅くなっていたので、初期化を遅延評価することで修正したということか」「エンジンが100個ほどあるRailsアプリってすごいですね」「想像だけどマイクロサービス的にエンジンに分けて実装しているのかもしれませんね」

参考: Rails エンジン入門 - Railsガイド

🔗 複合主キー関連の修正: 関連付けのprimary_keyオプションに配列を渡せるようになった

修正: #50850
#49671のコメントも少し関連している。

関連付けのprimary_keyは、関連付けられるクラスのprimary_keyquery_constraintsから導出された場合は複合可能だが、Railsが既にサポート可能であっても、明示的に設定することは許されていない。

このコミットによって、関連付けのprimary_keyオプションに配列を渡せるようになる。

以下の例では、primary_keyが既に配列にできるようになっていることが前提。

 if custom_primary_key.is_a?(Array)

同PRより


つっつきボイス:「お、複合主キー関連の改修は落ち着いたかと思ったらまだあった: モデルのbelongs_toで、このテストコード↓のprimary_key: [:shop_id, :id]のようにprimary_keyオプションに配列を渡せるようになったことで複合主キーが使えるようになったんですね👍」

# activerecord/test/models/cpk/book.rb#L3
module Cpk
  class Book < ActiveRecord::Base
    attr_accessor :fail_destroy

    self.table_name = :cpk_books
    belongs_to :order, autosave: true, query_constraints: [:shop_id, :order_id], counter_cache: true
_   belongs_to :order_explicit_fk_pk, class_name: "Cpk::Order", query_constraints: [:shop_id, :order_id], primary_key: [:shop_id, :id]
    belongs_to :author, class_name: "Cpk::Author"

    has_many :chapters, query_constraints: [:author_id, :book_id]
    before_destroy :prevent_destroy_if_set
    private
      def prevent_destroy_if_set
        throw(:abort) if fail_destroy
      end
  end
# activerecord/lib/active_record/reflection.rb#L871
      def association_primary_key(klass = nil)
        if primary_key = options[:primary_key]
-         @association_primary_key ||= -primary_key.to_s
+         @association_primary_key ||= if primary_key.is_a?(Array)
+           primary_key.map { |pk| pk.to_s.freeze }.freeze
+         else
+           -primary_key.to_s
+         end
        elsif (klass || self.klass).has_query_constraints? || options[:query_constraints]
          (klass || self.klass).composite_query_constraints_list
        elsif (klass || self.klass).composite_primary_key?
          # If klass has composite primary key of shape [:<tenant_key>, :id], infer primary_key as :id
          primary_key = (klass || self.klass).primary_key
          primary_key.include?("id") ? "id" : primary_key
        else
          primary_key(klass || self.klass)
        end
      end

🔗Rails

🔗 solid_queueとmission_control-jobsがRailsのリポジトリに追加された(Rails公式ニュースより)

rails/mission_control-jobs - GitHub


つっつきボイス「あれ、mission_control-jobsはActive Job用に37signalsが公開したジョブダッシュボードだったけど、この間取り上げていましたよね?(ウォッチ20240227)」「あ、たしかに」「よく見たら、以前mission_control-jobsを取り上げたときは37signalsのGitHubリポジトリ(リポジトリ名は旧社名のbasecamp)に置かれていたのか: mission_control-jobsがRailsのリポジトリに正式に取り入れられたことで今回Rails公式ニュースでアナウンスされたんですね」「なるほど、そういう流れだったんですね」

「そしてSolid Queueも最初に取り上げたとき(ウォッチ20240117)は37signalsのリポジトリにあったけど、これも今見るとRailsのリポジトリにリダイレクトされました」

Rails: Solid Queueで重要なUPDATE SKIP LOCKEDを理解する(翻訳)

🔗 論理削除でカオスと化した日(Ruby Weeklyより)


つっつきボイス:「記事を書いた人は論理削除で痛い目に遭ったようです」「論理削除やソフトデリートと言われているものは、Railsとかがなかったような昔から一般的なDB設計でいつも問題になるヤツですね: まさに歴史は繰り返す」

参考: 論理削除(ソフトデリート)とは - 意味をわかりやすく - IT用語辞典 e-Words

「ちなみに記事に登場しているacts_as_paranoidというgemは論理削除で使われていたものですが、今は古いのでparanoiaというgemに移行しています↓」「そういえばacts_as_で始まるgemは古いものが多いという話がありましたね(ウォッチ20220221)」「なるほど」「論理削除gemは他にもいくつかあった覚えがあります」

rubysherpas/paranoia - GitHub

ActsAsParanoid/acts_as_paranoid - GitHub

🔗 props_template: JbuilderとAPIが近い代替gem(Ruby Weeklyより)

thoughtbot/props_template - GitHub


つっつきボイス:「RailsでビューをJSONで出力するJbuilderの代替gemはいろいろありますが、これはthoughtbotが作ったものだそうです」「Rails標準のJbuilderは遅いので有名ですね」

参考: 3.3 Jbuilder -- Action View の概要 - Railsガイド

rails/jbuilder - GitHub


同リポジトリより

「props_templateのREADMEにもベンチマークのグラフが掲載されていますね↑」「それよりも、APIがJbuilderと非常に近いと書かれているのがポイントでしょうね、実際似ていますし↓」

# 同リポジトリより
json.flash flash.to_h

json.menu do
  json.currentUser do
    json.email current_user.email
    json.avatar current_user.avatar
    json.inbox current_user.messages.count
  end
end

json.dashboard(defer: :auto) do
  sleep 5
  json.complexPostMetric 500
end

json.posts do
  page_num = params[:page_num]
  paged_posts = @posts.page(page_num).per(20)

  json.list do
    json.array! paged_posts, key: :id do |post|
      json.id post.id
      json.description post.description
      json.commentsCount post.comments.count
      json.editPath edit_post_path(post)
    end
  end

  json.paginationPath posts_path
  json.current pagedPosts.current_page
  json.total @posts.count
end

json.footer partial: 'shared/footer' do
end

「jbuilderのようなJSON/XML出力定義用の代替gemは昔からいろいろありますけど、既にJbuilderを広範囲に利用しているRailsアプリでJbuilderを代替gemに差し替える場合、実装方法が異なっていると単純な検索置換ができなくて移行作業量が増えてしまうんですよ」「コードを読んで置き換えてテストしてとなるとつらそうですね」「まあJbuilderは遅いのがわかっているので最初から使わないようにしていますけどね」「そういえば自分のアプリでもrails newの時点でJbuilderを無効にしました」

「props_templateは、"Jbuilder-like DSL"と書かれているので完全に同じではないのかもしれないけど、Jbuilderと書き方が非常に近いというのは乗り換え候補として検討する価値が上がるでしょうね👍」

🔗Ruby

🔗 "チルド"文字列がRubyに入る(Ruby Weeklyより)


つっつきボイス:「Ruby WeeklyでRubyに"チルド"文字列というものがマージされたというニュースを見かけたので取り上げました」「frozenではないけどfrozenみたいな文字列だからチルドと呼んでいるのかな」「今後はチルド文字列を書き換えようとすると警告が表示されるみたいですね」

このコミットでは、将来frozen_string_literal: trueをデフォルトでを有効にするためのパスとして「chilled string(以下、チルド文字列)」を導入する。チルド文字列は、ユーザーの視点から見ると文字列はfrozenのふりをするが、最初に文字列を変更しようとするとfrozenステータスを失い、FrozenErrorを発生せずに警告を発する。

rb_compile_option_struct.frozen_string_literalは、事実上もはやブール値ではなくなり、「enabled(有効)/disabled(無効)/unset(未設定)」の3つの状態を持つようになる。

明示的にenableにもdisabledにも設定していないfrozen string literalを用いるコードがコンパイルされると、その文字列リテラルは新しいputchilledstringインストラクションを用いてコンパイルされる。このインストラクションは、文字列でSTR_CHILLED (FL_USER3) フラグとFL_FREEZEフラグをオンにすることを除いてputstringと同じである。

チルド文字列にはFL_FREEZEフラグがあり、コードベース全体でチルド文字列をチェックする必要性を最小限に抑え、C拡張機能との互換性を向上させる。

メモ:

  • String#freeze: チルドフラグをクリアする
  • String#-@: 文字列はミュータブル(変更可能)であるかのように振る舞う
  • String#+@: 文字列はミュータブル(変更可能)であるかのように振る舞う
  • String#clone: チルドフラグをコピーする

共著者: Jean Boussier byroot@ruby-lang.org
同コミットより

参考: String#-@ (Ruby 3.3 リファレンスマニュアル)
参考: String#+@ (Ruby 3.3 リファレンスマニュアル)

「Rubyがfrozen_string_literal: trueを導入して以来、いつかこれをデフォルトにすると言い続けていたけど、Rubyの場合は文字列のミューテーションが基本操作として広範囲で使われていることもあって、なかなか実現していないんですよ」「そういえばPythonは昔から文字列がイミュータブル(改変不可)ですけど、Rubyでそれを後から実現するのは大変そうですね」「frozen_string_literal: trueをいきなりデフォルトにするのは影響が大きいので、段階的に進める施策のひとつとしてこのチルド文字列を導入したんじゃないかなと思います」

なぜ Python の文字列はイミュータブルなのですか?
これにはいくつかの利点があります。
一つはパフォーマンスです。文字列がイミュータブルなら、生成時に領域を割り当てることができるので、必要な記憶域は固定されて、変更されません。これはタプルとリストを区別する理由の一つでもあります。
他の利点は、Python の文字列は数と同じくらい "基本的" なものと考えられることです。8 という値を他の何かに変える手段が無いように、文字列 "eight" を他の何かに変える手段も無いのです。
デザインと歴史 FAQ — Python 3.6.15 ドキュメントより


以下は直接関係ありませんが、つっつき後に見つけたjnchitoさんの昔のツイートです。

🔗 TruffleRuby 24.0.0はRuby 3.2/3.3構文を完全サポート(Ruby Weeklyより)


つっつきボイス:「お、TruffleRubyが新しくリリースされて、しかも例のPrismパーサーの採用でRuby 3.2/3.3構文に完全対応した🎉」「TruffleRubyのチームもPrism開発に協力していた甲斐あって、Prismが本領発揮してきた感じですね」

Rubyパーサーを一新するprism(旧YARP)プロジェクトの全容と将来(翻訳)

🔗 Time#utcは破壊的メソッド(Ruby Weeklyより)


つっつきボイス:「これは知らなかった: Time#utcはそのTimeオブジェクトをUTCに変換した新しいオブジェクトを返すように見えて、実はそのTimeオブジェクト自体を破壊的にUTCに変える」「言われてみればメソッド名というより属性の読み出しっぽく見えそうですね」「このメソッド名何とかしたい」

参考: Time#gmtime (Ruby 3.3 リファレンスマニュアル) -- Time#utcはエイリアス

🔗言語/ツール/OS/CPU

🔗 GitHub、Copilot使いこなしのコツをGitHubが公式に公開


つっつきボイス:「Copilotを使うときは関連ファイルをできるだけ開いておくと効果的という話は以前もおっしゃっていましたね」「既に知られているコツもいくつか含まれていますし、意味のある命名はCopilotがなくても重要ですけど、そういう情報を公式にGitHubが出してくれたのがいいですね👍」「サンプルコード、やっぱり効くんですね」

🔗 RFCを公開しているサイト


つっつきボイス:「RFCを引用するときの望ましい方法なども調べてあって今回もいい記事👍」

「言われてみればRFCを検索して出てくるサイトはいくつもありますね」「最近になって増えているとは知りませんでした」「どれを参照するのが望ましいかをIETFのdiscussionで聞いてみたのすごい」


今回は以上です。

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

週刊Railsウォッチ: Rails用のHTTP/2プロキシThrusterが登場、Rails Guidesが内容とスタイルを一新へほか(20240328後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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