こんにちは、hachi8833です。
🔗Rails: 先週の改修(Rails公式ニュースより)
- 公式更新情報: Ruby on Rails — This Week in Rails - July 28, 2023(後半)
- 公式更新情報: Ruby on Rails — Omit webdrivers gem from new apps Gemfile, support for filtering tests by line range and more!
🔗 キャッシュ関連
🔗 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
参考: Home — JRuby.org
参考: TruffleRuby | Oracleヘルプセンター
参考: GraalVMとは | オラクル | Oracle 日本
なお、昔のRailsではquoted_column_names
やquoted_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
オプションと同様に、シリアライザはdump
とload
に応答する必要がある。
ただし、シリアライザの責務はキャッシュされた値をシリアライズすることだけであるが、コーダーの責務は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という名前のデータ圧縮アルゴリズムもあった↓」
🔗 デバッグログが有効な場合にのみ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.rb
のmissing
メソッドを変更する。ActiveRecordの
missing
クエリメソッドに別の条件チェックを追加する。この追加チェックにより、:joins
内の値が:left_outer_joins
内の値と異なるようにする。それらが存在する場合、おそらくenumを利用している可能性がある。
自分は、最初に:joins
と:left_outer_joins
の値の存在をチェックする。次に、2つの配列の交差(共通集合)のサイズと:joins
配列のサイズを比較して両者が異なっていれば、enumエイリアスを除外したスコープメソッドが実装される。このパフォーマンスを維持するために
size
メソッドを利用した。これによって、Active Recordのすべてのチェック(47940や45015など)がパスするようになる。同PRより
つっつきボイス:「元の#47940はRails 7.0.6↓のリリースノートに含まれていました」「enumを使うモデルとエイリアスを使うモデルがある場合のwhere.missing
の挙動を修正したということなのか」
「missing
はRails 6.1で入った、関連先がないレコードを取り出すメソッドでしたね↓」「そうそう、孤立レコードを見つけるのに使えるヤツ」
「ちょっとわかりにくいけど、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
🔗 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
を変更して、テストが失敗した場合にスクリーンショットのパスをテストのメタデータに保存するようにする。追加情報
minitest
を5.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
っていう機能が入ってましたね」「新機能が使えるのはとりあえずありがたい👍」
一意性制約とインデックスが 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_variables
のgsub
が変更されてますね↓」
# 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)
- 20230803後編 Railsフラグメントキャッシュ経由の情報漏洩に注意ほか
- 20230802前編 Active Storageバリアントの事前変換、Linkヘッダープリロードのオプトアウトほか
- 20230727後編 Rubyにdefp導入の提案、IRB 1.7.3リリースほか
- 20230725前編 config.autoload_libとconfig.autoload_lib_onceが追加ほか
- 20230721後編 Kaigi on Rails 2023プロポーザル募集、rubocop-magic_numbersほか
- 20230719前編 複合主キー関連の実装進む、Action TextでHTML5サニタイザほか
- 20230705後編 AWS LambdaでRailsをRackで動かすLambyほか
- 20230704前編 productionのforce_ssl=trueがデフォルトで有効に、rakeタスクをthorで書くほか
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)