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

週刊Railsウォッチ: Rails 7.0.7に含まれているRails 7.0.6のバグ修正ほか(20230823前編)

こんにちは、hachi8833です。

週刊Railsウォッチについて

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

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

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

🔗 キャッシュ関連

🔗 JRuby/TruffleRubyでActive Recordの"quoted name"キャッシュをスレッドセーフにした

動機/背景
TruffleRubyでRailsアプリケーションを実行中に、Active Recordのいわゆる"quoted name"キャッシュのスレッドセーフ問題に遭遇した。これらの共有キャッシュはSQLクエリをビルドすると更新されるが、2つのスレッドが同時にクエリをビルドすると、両方のスレッドがキャッシュを更新しようとする可能性がある。

さらに、キャッシュのアロケーションがlazyに行われていたため、両方のスレッドが参照するキャッシュストアが異なっている可能性がある。キャッシュエントリの比較はオブジェクトの同一性を元にすることはないので、これは(少なくとも自分の知る限りでは)さほど問題にならないかもしれないが、複数の有効なキャッシュが異なるスレッド間に存在するとメモリを余分に消費し、デバッグ時に混乱する可能性がある。

このプルリクは、"quoted name"キャッシュのアロケーションや更新の競合状態を修正するために作成された。

詳細

このプルリクは、Active Recordアダプタの"quoted name"キャッシュをHashからConcurrent::Mapに変更し、更新がすべてのスレッドでアトミックに反映されるようにする。さらに、"quoted name"キャッシュが複数のスレッドで遅延解決を試みるときに問題が発生しないようにするため、アロケーションをeagerに行うようになった。これによりキャッシュオブジェクトが複数作成されることになるが、一部のスレッドから見えなくなることがある問題も回避される。

追加情報

その気になれば引き続きキャッシュをlazyに解決することは可能だが、複数のスレッドが同時にキャッシュを解決しようとすると複数のキャッシュオブジェクトが作成される可能性はある。また、キャッシュへの同時書き込みの問題については、既に依存関係として存在しているconcurrent-ruby gemを使うのがベストだと思う。別の方法としてロックを利用する方法も考えられるが、これといったメリットはないと思う。

同PRより


つっつきボイス:「quoted nameは、SQLをビルドするときにカラム名やテーブル名をくくったものなんですね」「MySQL/PostgreSQL/SQLite3のquoted nameキャッシュが今まで{}になっていたのか: RubyのHashを複数スレッドで共有したときに問題になるというのはありがち」「それをconcurrent-rubyのConcurrent::Mapに置き換えることでスレッドセーフにしたんですね」「JRubyはJava VM上で動作するRubyで、TruffleRubyはOracleのJVMであるGraalVMベースで動作するRuby」

# activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L95
-     def self.quoted_column_names # :nodoc:
-       @quoted_column_names ||= {}
-     end
-
-     def self.quoted_table_names # :nodoc:
-       @quoted_table_names ||= {}
-     end
# activerecord/lib/active_record/connection_adapters/mysql/quoting.rb#L6
  module ConnectionAdapters
    module MySQL
      module Quoting # :nodoc:
+       QUOTED_COLUMN_NAMES = Concurrent::Map.new # :nodoc:
+       QUOTED_TABLE_NAMES = Concurrent::Map.new # :nodoc:
...

        def quote_column_name(name)
-         self.class.quoted_column_names[name] ||= "`#{super.gsub('`', '``')}`"
+         QUOTED_COLUMN_NAMES[name] ||= "`#{super.gsub('`', '``')}`"
        end

        def quote_table_name(name)
-         self.class.quoted_table_names[name] ||= super.gsub(".", "`.`").freeze
+         QUOTED_TABLE_NAMES[name] ||= super.gsub(".", "`.`").freeze
        end

ruby-concurrency/concurrent-ruby - GitHub

参考: Home — JRuby.org
参考: TruffleRuby | Oracleヘルプセンター
参考: GraalVMとは | オラクル | Oracle 日本

なお、昔のRailsではquoted_column_namesquoted_table_nameはpublicメソッドになっていました→。

参考: quoted_column_names (ActiveRecord::Base) - APIdock -- Rails 2.3.8まではpublic
参考: quoted_table_name (ActiveRecord::Base) - APIdock -- Rails 3.1.0まではpublic

🔗 すべてのキャッシュストアが#deleteメソッドでブーリアン値を返すようになった

従来のRedisCacheStore#deleteメソッドは、エントリが存在する場合には1を、存在しない場合には0を返していた。
修正後は、他のストアと同様に、エントリが存在する場合にはtrueを、存在しない場合にはfalseを返すようになった。

また、FileStoreも以前はエントリが存在しない場合にnilを返していたのが、falseを返すようになった。

Petrik de Heus

同Changelogより


Rails.cache.delete('key')は、エントリが存在する場合にはtrueを返すようになっている(存在しない場合は果たしてfalseなのか?)。ほとんどのストアはこのように振る舞っている。

ただしRedisCacheStoreは、存在するエントリを削除するときには1を返し、存在しない場合には0を返す。
しかし0はtruthyなので、これは期待通りの振る舞いではない。

このプルリクによって、RedisCacheStoreはエントリが存在する場合にtrueを返し、存在しない場合にfalseを返すようになり、他のキャッシュストアとの整合性が取れるようになった。

FileCacheStoreも同様に、エントリが存在しない場合に以前のnilではなくfalseを返すようになった。

この振る舞いがすべてのストアに適用されることを確認するテストも追加した。

deleteのドキュメントも、この振る舞いを明確にする形で更新した。

同PRより


つっつきボイス:「他のキャッシュストアはエントリが存在しない場合にfalseを返すのに、RedisCacheStoreのdeleteメソッドは0を返して、FileCacheStoreはnilを返していたのか」「こういう微妙な不揃いは嬉しくないですね」「breaking changeではあるけど修正する方がよさそう: でも後から別のキャッシュストアに差し替えることはありうるので、そういうときは振る舞いが変わる可能性に注意しておかないといけないかも」「そうなったら厄介ですね...」

# activesupport/lib/active_support/cache/redis_cache_store.rb#L371
        def delete_entry(key, **options)
          failsafe :delete_entry, returning: false do
-           redis.then { |c| c.del key }
+           redis.then { |c| c.del(key) == 1 }
          end
        end
# activesupport/lib/active_support/cache/file_store.rb#L120
        def delete_entry(key, **options)
          if File.exist?(key)
            begin
              File.delete(key)
              delete_empty_directories(File.dirname(key))
              true
            rescue
              # Just in case the error was caused by another process deleting the file first.
              raise if File.exist?(key)
              false
            end
+         else
+           false
          end
        end

「これはまさに型チェックがあれば未然に防げたヤツですね」「そうそう」

🔗 キャッシュエントリの値をデシリアライズせずに期限切れ/不一致を検出可能になった

キャッシュフォーマットがバージョン7.1以上の場合や、カスタムシリアライザを利用する場合に、期限切れまたはバージョン不一致のキャッシュエントリを、値をデシリアライズせずに検出できるようになった。

Jonathan Hefner

同Changelogより


つっつきボイス:「デシリアライズすると処理が増えることになるので、キャッシュエントリをlazyにすることで、デシリアライズしなくてもエントリが無効になったことを調べられるようにした、なるほど」

🔗 キャッシュストアの圧縮やシリアライザをカスタマイズ可能になった

  • Active Supportのキャッシュストアで、:compressorオプションを用いてデフォルトのコンプレッサーを置き換え可能になった。

指定されたcompressorは、以下のようにdeflateおよびinflateに応答する必要がある。

module MyCompressor
  def self.deflate(string)
    # 圧縮ロジックをここに書く
  end

  def self.inflate(compressed)
    # 解凍ロジックをここに書く
  end
end

config.cache_store = :redis_cache_store, { compressor: MyCompressor }

Jonathan Hefner

  • Active Supportのキャッシュストアで:serializerオプションを新たにサポート。

:coderオプションと同様に、シリアライザはdumploadに応答する必要がある。
ただし、シリアライザの責務はキャッシュされた値をシリアライズすることだけであるが、コーダーの責務はActiveSupport::Cache::Entryインスタンス全体のシリアライズである。

さらに、シリアライザからの出力は自動的に圧縮可能だが、コーダーは自分自身の圧縮に責任を持つ。

コーダーの代わりにシリアライザを指定することで、キャッシュ形式バージョン7.1で導入される生の文字列の最適化を含むパフォーマンスの最適化も可能になる。

:serializer:coderオプションはいずれか一方しか指定できない(両方を指定するとArgumentErrorが発生する)。

Jonathan Hefner

同Changelogより


つっつきボイス:「Changelogにエントリが2つありますね」「config.cache_storeってそういえばあったかも」「キャッシュの圧縮率を上げたいとか圧縮を高速化したいときに、キャッシュストアのデコレータ的なものを書いてこのコンフィグオプションに渡すことで、コンプレッサー機能のカスタマイズやシリアライザ機能のカスタマイズができるんですね: どんなデータを圧縮/解凍するかにもよりますが、全般に大きいデータではカスタムのコンプレッサーが有効だと思います」「なるほど」

参考: § 3.2.8 config.cache_store -- Rails アプリケーションを設定する - Railsガイド

「そういえば英語のdeflateは"空気を抜く"でinflateは"空気を吹き込む"ですね」「deflate/inflateという言葉は随分昔からzgipなどのAPIで使われていますね: そういえばDeflateという名前のデータ圧縮アルゴリズムもあった↓」

参考: Deflate - Wikipedia

🔗 デバッグログが有効な場合にのみAction Cableのtransmitでデバッグログを出力するよう修正

動機/背景
現在は、ActionCable::Channel::Base#transmitが呼び出されるたびに、提供されたdataオブジェクトをinspectするデバッグログメッセージが生成されるが、このメッセージはロガーのレベルがWARNより上でも生成される。このパッチは、ログ出力が有効な場合にのみメッセージを生成する。

詳細

ActionCable::Server::Broadcasting::Broadcaster#broadcastデバッグメソッドでも、これと非常に似たシナリオではブロックが使われる。TaggedLoggerProxyは、ActionCable::Channel::Base#transmitメソッドで同じ呼び出しを利用するために、ブロックをラップされたロガーに渡さなければならない。

同PRより


つっつきボイス:「これはバグ修正ですね: デバッグモードでないときにはinspectで重要なデータがログに出さないようにする」「ログに出しすぎないの大事」

# actioncable/lib/action_cable/channel/base.rb#L214
-       def transmit(data, via: nil) # :doc:
-         status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}"
-         status += " (via #{via})" if via
-         logger.debug(status)
+         logger.debug do
+           status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}"
+           status += " (via #{via})" if via
+           status
+         end

          payload = { channel_class: self.class.name, data: data, via: via }
          ActiveSupport::Notifications.instrument("transmit.action_cable", payload) do
            connection.transmit identifier: @identifier, message: data
          end
        end

参考: Rails API transmit -- ActionCable::Channel::Base

🔗 Rails 7.0.6の#47940によるバグを修正(Rails 7.0.7に反映済み)

動機/背景

このプルリクを作成した理由は、issue #48651のバグが発生したため。

Rails 7.0.6で以下の2つのモデルがあり、1つはenumを、もう1つはエイリアスを使っているとする。

class Publisher < ApplicationRecord
  belongs_to :listing

  enum name: {
    foo: 0,
    bar: 1
  }
end

class Listing < ApplicationRecord
  has_many :publishers, dependent: :destroy
  has_one :foo_listing, -> { foo }, class_name: Publisher.name
  has_one :bar_listing, -> { bar }, class_name: Publisher.name
end

Listingインスタンスがfooパブリッシャーを持つがbarパブリッシャーを持たない場合に、以下のように呼び出すとする。

Listing.joins(:foo_listing).where.missing(:bar_listing)

作成した:foo_listingだけを持つリストが返されることが期待されるが、実際には何も返されない。

詳しくは上記のバグ修正を参照。

詳細

このプルリクは、active_record/relation/query_methods.rbmissingメソッドを変更する。

ActiveRecordのmissingクエリメソッドに別の条件チェックを追加する。この追加チェックにより、:joins内の値が:left_outer_joins内の値と異なるようにする。

それらが存在する場合、おそらくenumを利用している可能性がある。
自分は、最初に:joins:left_outer_joinsの値の存在をチェックする。次に、2つの配列の交差(共通集合)のサイズと:joins配列のサイズを比較して両者が異なっていれば、enumエイリアスを除外したスコープメソッドが実装される。

このパフォーマンスを維持するためにsizeメソッドを利用した。これによって、Active Recordのすべてのチェック(4794045015など)がパスするようになる。

同PRより


つっつきボイス:「元の#47940はRails 7.0.6↓のリリースノートに含まれていました」「enumを使うモデルとエイリアスを使うモデルがある場合のwhere.missingの挙動を修正したということなのか」

Rails 7.0.6がリリースされました

missingはRails 6.1で入った、関連先がないレコードを取り出すメソッドでしたね↓」「そうそう、孤立レコードを見つけるのに使えるヤツ」

Railsの技: 関連先レコードがないデータをwhere.missingで検索する(翻訳)

「ちょっとわかりにくいけど、issue #48651を見るとListing.joins(:foo_listing).where.missing(:bar_listing)のSQLクエリがRails 7.0.5.1(左)とRails-7.0.6(右)で違ってますね↓」「bar_listingが右だとbar_listingsになってるのか」


後で調べると、この#48738は、その後さらに以下の#48861の修正も加えたうえで、Rails 7.0.7でリリース済みです。

参考: Fixes #47909 when using order or unscope by paulreece · Pull Request #48861 · rails/rails

Rails 7.0.7がリリースされました

🔗 config.dom_testing_default_html_versionが追加

  • config.dom_testing_default_html_versionは、ActionView::TestCase#document_root_elementで利用するHTMLパーサーを制御する。

このパーサーは、Rails::Dom::Testingのアサーションで使うDOMを作成する。
Rails 7.1のデフォルト設定では、ブラウザのユーザーエージェントでDOMの表示をよりよい形で表現するためにhtml5パーサーを使う(サポートされている場合)。従来、このテストヘルパーでは常にNokogiriのhtml4パーサーが使われていた。

Mike Dalessio

同Changelogより


つっつきボイス:「割りと変更量が多いですね」「これは最近続いているHTML5(正式にはHTML Living Standard)移行の一環ですね(ウォッチ20230621ウォッチ20230719): たしかにRails::Dom::TestingでHTML5非互換のアサーションを知らずに書いてしまう可能性はないわけではなさそう」

「追加ドキュメントを見ると7.1からはhtml5がデフォルトになる↓」「HTML5パーサーへの移行は一応breaking changeになるので、新しいコンフィグで戻せるようにしてあるんですね」

# guides/source/configuring.md#321
| Starting with version | The default value is |
| --------------------- | -------------------- |
| (original)            | `:html4`             |
| 7.1                   | `:html5` (see NOTE)  |

🔗 テスト関連

🔗 minitestの行範囲を指定して実行可能になった

  • テストの行を範囲指定してフィルタできるようになった

新しい構文では、行の範囲でテストをフィルタリング可能になる。たとえば、次のコマンドは10行から20行までのテストを実行する。

$ rails test test/models/user_test.rb:10-20

Shouichi Kamiya, Seonggi Yang, oljfte, Ryohei UEDA

同Changelogより


つっつきボイス:「RSpecは以前から行範囲を指定して実行可能でしたよね」「同じことをRailsのminitestでもできるようになったということか」

参考: RSpecで複数の指定行を実行する方法 - どこでも見れるメモ帳

🔗 システムテスト失敗時のスクリーンショットのパスをメタデータに保存するようになった

動機/背景

UI内で失敗したときのスクリーンショットを表示する機能は、多くのCIツールでサポートされている(例:GitLab)。これらのツールでスクリーンショットを取得・表示できるように、スクリーンショットのパスを永続化したい。

詳細

このプルリクは、ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperを変更して、テストが失敗した場合にスクリーンショットのパスをテストのメタデータに保存するようにする。

追加情報

minitest5.19.0に更新する必要がある(このバージョンでメタデータの変更が追加されたため)。

https://github.com/minitest/minitest/blob/master/History.rdoc#5190--2023-07-26

同PRより


つっつきボイス:「take_failed_screenshotは前からあったけど、metadata[:failure_screenshot_path]でメタデータのハッシュにスクショのパスを設定するようになったんですね↓: この値を他で活用できそう」

# actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb#L44
        def take_failed_screenshot
-         take_screenshot if failed? && supports_screenshot? && Capybara::Session.instance_created?
-         return unless failed? && supports_screenshot? && Capybara::Session.instance_created?
+
+         take_screenshot
+         metadata[:failure_screenshot_path] = absolute_image_path if Minitest::Runnable.method_defined?(:metadata)
        end

参考: Rails API take_failed_screenshot -- ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelper

🔗 Active Record関連

🔗 PostgreSQL 15のNULLS NOT DISTINCTをサポート

動機/背景

PostgreSQL 15でNULLS NOT DISTINCTが導入された。これは、NULLを等しいものとして扱いたい場合にuniqueインデックスをサポートする。それ以外の場合、およびデフォルトでは、uniqueインデックスはnullカラム値を持つ任意の行を許可する。

このプルリクを作成した理由は、行にnull値を1個だけ持つuniqueインデックスを作成することが有用であり、かつPostgreSQLの新機能に追従するのはよい考えだと思えるため。

詳細

このプルリクは、PostgreSQLのコネクションアダプタ、スキーマ検出、およびスキーマ生成を変更する。
(以前の作業は#46659で行われたが、不完全だった: schema.rbから作成されたDBにNULLS NOT DISTINCTが設定されなかった)。

さらに、#44803 でインデックスのスキーマ正規表現にINCLUDEが追加されたが、PostgreSQLドキュメントによるとNULLS [NOT] DISTINCTよりも前に配置する必要がある。

追加情報

PostgreSQLのドキュメントはこちらで確認可能(特にSQLパーツの正しい順序を確認するため)。自分が意図していたのは、Rubyの引数順序で正規表現の検出をINCLUDEの直後に配置すること。

同PRより


つっつきボイス:「そういえばPostgreSQLにNULLS NOT DISTINCTっていう機能が入ってましたね」「新機能が使えるのはとりあえずありがたい👍」

参考: PostgreSQL 15 に関する技術情報

一意性制約とインデックスが NULL値を区別できないもの(not district)として処理できるようになりました。 (Peter Eisentraut) (15)
以前は、NULL項目は常に個別の値として扱われていましたが、「UNIQUE NULLS NOT DISTINCT」を使用して制約とインデックスを作成することで、これを変更できるようになりました。
PostgreSQL 15 に関する技術情報より

🔗 ActionRecord::Sanitization#replace_named_bind_variablesでコロン:をエスケープできるよう修正

(このプルリクは、長年放置されていた#37797を置き換える)

修正: #37779

このプルリクは、名前付きバインドparamsを用いてSQLでリテラルのコロン:をエスケープする機能を追加する。詳しい説明については、上記のissueを参照。

同PRより


つっつきボイス:「SQLのエスケープ?」「replace_named_bind_variablesgsubが変更されてますね↓」

# activerecord/lib/active_record/sanitization.rb#L213
        def replace_named_bind_variables(statement, bind_vars)
-         statement.gsub(/(:?):([a-zA-Z]\w*)/) do |match|
+         statement.gsub(/([:\\]?):([a-zA-Z]\w*)/) do |match|
            if $1 == ":" # skip postgresql casts
              match # return the whole match
+           elsif $1 == "\\" # escaped literal colon
+             match[1..-1] # return match with escaping backlash char removed
            elsif bind_vars.include?(match = $2.to_sym)
              replace_bind_variable(bind_vars[match])
            else
              raise PreparedStatementInvalid, "missing value for :#{match} in #{statement}"
            end
          end
        end

「issue #37779を見ると、SQLのタイムスタンプ部分で:date_strみたいな形式の名前付き変数展開を使うと↓、変数に接しているコロン:のあたりがエスケープされていなかったためにエラーになっていたのか」「なるほど」

raw_sql = "SELECT TO_TIMESTAMP(:date_str, 'YYYY/MM/DD HH12:MI:SS');"
ActiveRecord::Base.sanitize_sql([raw_sql, date_str: '2017/08/02 10:59:00'])

前編は以上です。

バックナンバー(2023年度第3四半期)

週刊Railsウォッチ: Rails 7.0.5のcreate_association挙動変更取り消し、YJITの性能を最大限引き出す方法ほか(20230809)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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