こんにちは、hachi8833です。RubyKaigi 2024の発表スケジュールが公開されて、LT募集が始まっていますね。
RubyKaigi 2024 schedule is online (actually, since last week). https://t.co/wsXWruci5F
Check out all the incredible lineup of tech-talks that you'll see in Okinawa! #rubykaigi— RubyKaigi (@rubykaigi) April 9, 2024
And, RubyKaigi 2024 is calling for Lightning Talk speakers now!
Submit your proposal and get a 5min talk slot at the 2nd day of RubyKaigi! https://t.co/80MUIKRLpx #rubykaigi— RubyKaigi (@rubykaigi) April 9, 2024
🔗Rails: 先週の改修(Rails公式ニュースより)
🔗 ジョブのエンキューをトランザクション完了時まで自動先延ばしするようになった
ActiveRecord.after_all_transactions_commit
コールバックが追加された。トランザクションの内部か外部のどちらかで実行される可能性のあるコードで、ステートの変更が適切に永続化されてから処理を実行しなければならない場合に有用。
def publish_article(article) article.update(published: true) ActiveRecord.after_all_transactions_commit do PublishNotificationMailer.with(article: article).deliver_later end end
同公式情報より
Railsで割とよくあるミスとして、ジョブをトランザクション内からエンキューするときにレコードを引数として渡してしまうというのがある。これをやると、ジョブがキューで拾われたときにRecordNotFoundエラーが発生する。
これは、
solid_queue
やdelayed_job
やgood_job
といったDBベースのジョブランナーにおける論点の1つにもなっているが、私見ではActive Jobの抽象化に漏れが生じるので望ましくないと思っている。将来別のバックエンドに移行する必要が生じたときもそうだし、キューを単に別のデータベースに移動する必要が生じた場合にも、この種の競合が多数発生する可能性がある。しかしより一般的な話をすると、Active Recordには、処理を現在のトランザクションが完了した後に先延ばし(defer)する機能がない。現時点でこれを行うにはモデルのコールバックから実行する方法しかないので、本来なら他の場所で実行する方が望ましい処理をActive recordモデル内に移動しなければならなくなることもある。「Service Object懐疑論者」を自称する私ですら、この機能が欲しくなることが過去10年間に何度もあったし、この機能を欲しがっている人が多いことも確信している。
また、サードパーティにもモンキーパッチでこの機能を実現するgemがいくつかある。その機能をRailsに取り入れる理由というわけではないが、需要が存在することの証拠にはなる。
実装についてだが、この概念実証プルリクは、マルチDBでネステッドトランザクションをサポートしていても、実装がそこまで難しくはないことを示している。
after_rollback
のコールバックも公開すべきか(かつどちらの状態の後でも利用可能にするか)どうかという点には議論の余地がある。紙の上でならわかるが、自分にはよいユースケースがどうも思い当たらない。
同PRより
つっつきボイス:「上のプルリクメッセージと説明が少しかぶっていますが、公式情報にこんな感じでまとめられていました↓」
Active Jobでよくあるミスのひとつ: ジョブをトランザクション内からエンキューしてしまい、そのジョブが拾われて別のプロセスで実行された時点でトランザクションがまだコミットしていないと、さまざまなエラーが発生する。
Topic.transaction do topic = Topic.create NewTopicNotificationJob.perform_later(topic) end
Active Jobの改修によって、トランザクションがコミットした後になるまでジョブのエンキューを自動的に先延ばしするようになった。トランザクションがロールバックした場合はジョブを削除する。
この振る舞いは、さまざまなキュー実装で選択的に無効化できる。ユーザーはこの振る舞いを無効にすることも、ジョブごとに強制することも可能。
class NewTopicNotificationJob < ApplicationJob self.enqueue_after_transaction_commit = :never # or :always or :default end
同公式更新情報より
「お〜、トランザクションがコミットし終わるまでジョブのエンキューを自動的に先延ばしして、トランザクションがロールバックしたら自動的にジョブを削除するのか: こういうのを自動でやってくれるのはありがたい🎉」「enqueue_after_transaction_commit
で振る舞いを変えられるようにもなったんですね」
「ジョブで使うデータが、ジョブが実行される時点で実はコミットされていなかったというミスはあるあるですね」「コミットされていれば動いちゃうので、エラーが起きたり起きなかったりするところが厄介ですね」
「Active Jobでは基本的に、ジョブを実行するワーカープロセスがトランザクションのプロセスとは別物だということを常に意識すべきだし、レコードやメモリ上のオブジェクトのようなものをジョブに渡すのもよくなくて、イミュータブルなデータか、さもなければデシリアライズ可能なデータを可能な限り渡すべき」「そうですね」「よくないものをActive Jobに渡したら、検出してエラーにするぐらいのことはやってもいいと思います」
「ところで、ジョブのエンキューがデフォルトで自動先延ばしされるようになったということは、これまで意図的にそういう処理を自力でやったりgemでやったりしていた人たちは注意が必要でしょうね」「あ、たしかに」
🔗 トランザクションブロックの中で当該トランザクションのコールバックを登録可能になった
ActiveRecord::Base.transaction
でActiveRecord::Transaction
オブジェクトをyield
するようになった。このオブジェクトにはコールバックを登録できる。Article.transaction do |transaction| article.update(published: true) transaction.after_commit do PublishNotificationMailer.with(article: article).deliver_later end end
ActiveRecord::Base.current_transaction
が追加された。これにもコールバックを登録できる。Article.current_transaction.after_commit do PublishNotificationMailer.with(article: article).deliver_later end
同公式情報より
つっつきボイス:「上のプルリクに関連して、以下のように書くとtransaction
がyieldされるようになって、そこにafter_commit
などのコールバックを安全に書けるようになったんですね: こんなふうに明快に書けるのはいい👍」
Article.transaction do |transaction|
article.update(published: true)
transaction.after_commit do
PublishNotificationMailer.with(article: article).deliver_later
end
end
「2つ目はcurrent_transaction
が追加されて同じようなことができるようになった↓: current_transaction
にコールバックを書いても大丈夫かどうか一瞬気になってしまうので、1つ目の書き方の方が納得しやすいかな」
Article.current_transaction.after_commit do
PublishNotificationMailer.with(article: article).deliver_later
end
🔗 クエリ数とクエリキャッシュヒット数をログに表示するようになった
現在のアクションでSQLクエリが何件生成されたかを手っ取り早く知りたいことがよくある(例: N+1問題が解決したかどうかをチェックする、キャッシュが効いているかどうかをチェックする、クエリ件数が削減されたかどうかをチェックする)。ログを自力で調べてクエリの件数をカウントすれば一応可能だが、クエリを何十件も含む大規模なアクションを実行すると数千件にのぼるSQLクエリが発生するような場合は簡単ではない。
同公式更新情報より
# 変更前 Completed 200 OK in 3804ms (Views: 41.0ms | ActiveRecord: 33.5ms | Allocations: 112788) # 変更後 Completed 200 OK in 3804ms (Views: 41.0ms | ActiveRecord: 33.5ms (2 queries, 1 cached) | Allocations: 112788)
同Changelogより抜粋
つっつきボイス:「ビューのレンダリングが完了したときのログに、そのリクエストで実行したクエリ数とクエリキャッシュヒット数も(2 queries, 1 cached)
のように表示するようにしたんですね: 地味だけど便利👍」「Rails実行中のコンソールやlog/development.logなどに出力されるヤツですね」
🔗 カウンタキャッシュからの読み出しをcounter_cache: { active: false }
で制御できるようになった
既存の巨大なテーブルでカウンタキャッシュを使い始めると厄介になることがある。カラムの追加とは別に、
:counter_cache
を使う前にはカラムの値をバックフィル(埋め戻し)しておかなければならない(テーブルが長時間ロックされないようにするために必要)。さもないと、内部カウンタキャッシュを利用しているsize
やany?
などのメソッドが誤った結果を生成する可能性がある。関連付けにカウンタキャッシュ構成を導入する前には、バックフィル中に子関連付けでデータベーストリガーまたはコールバックを使うことが多い。改修の結果、子レコードの追加や削除のときに次のように書いておくことで、カラムを更新済みのままカラムを安全にバックフィルできるようになった。
class Comment < ApplicationRecord belongs_to :post, counter_cache: { active: false } end
カウンタキャッシュが"active"でない場合、
size
やany?
などのメソッドはカウンタキャッシュを使わなくなり、データベースから直接結果を取得する。カウンタキャッシュのカラムでバックフィルが完了した後は、カウンタキャッシュの定義から{ active: false }
を削除するだけで、size
やany?
などのメソッドでカウンタキャッシュが使われるようになる。
同PRより
つっつきボイス:「counter_cache: { active: false }
と書くことで、カウンタキャッシュを使っているメソッドがキャッシュにではなくデータベースにアクセスするようになるので、その間にカラムを安全にバックフィルできるようになり、終わったら{ active: false }
の部分を消せば通常の振る舞いに戻る、というふうに運用するんですね: あくまで手動で行う必要があるもので、自動でやるものではない」「カウンタキャッシュがらみのマイグレーションをやりやすくする改修なんですね」「従来だとマイグレーションの後にrakeタスクを実行するような形で運用することが多いんですが、途中でカウンタキャッシュにアクセスしてしまう可能性もあるので、この機能を知っていたら現場で喜ばれそう👍」
🔗 テスト実行中にActionableError
をリトライするようになった
テスト実行中に発生した
ActionableError
(対応可能なエラー)をリトライ可能になった。Migrations are pending. To resolve this issue, run: bin/rails db:migrate You have 1 pending migration: db/migrate/20240201213806_add_a_to_b.rb Run pending migrations? [Yn] Y == 20240201213806 AddAToB: migrating ========================================= == 20240201213806 AddAToB: migrated (0.0000s) ================================ Running 7 tests in a single process (parallelization threshold is 50) Run options: --seed 22200 # Running: ....... Finished in 0.243394s, 28.7600 runs/s, 45.1942 assertions/s. 7 runs, 11 assertions, 0 failures, 0 errors, 0 skips
この機能はターミナルで対話的にのみ実行可能。
Andrew Novoselac & Gannon McGibbon
同Changelogより
つっつきボイス:「テスト中にRun pending migrations? [Yn] Y
のようなプロンプトがターミナルで表示されたときに操作可能にするという改修ですね」「そういうプロンプトを出すのがActionableErrorなんですね」
「"ターミナルで対話的にのみ実行可能"ということになってはいますけど、こういうふうにテスト中にプロンプトを出して止まるような機能がクラウド上のCIとかで何かのはずみで動き出したりすると、知らないうちにCIが一時停止したままになって、課金が爆発するとかクォータを使い切ってしまうみたいなことになりかねないのが気になるんですよ」「それもそうか...」「昔似たような目に遭ったことがあるので」
「そういう場合の被害の大きさを考えると、この機能を後づけして得られるメリットにあまり見合っていない気がする: デフォルトではオフで、指定した場合にのみ使えるようにするとかの方がいいんじゃないかな」「たしかに」「今後そういうケースを運悪く踏む人がいたら、この機能が取り消される可能性もあるかもしれませんね」
🔗 AbstractMysqlAdapter
で適切なエラーがraiseされるよう修正
MySQLデータベースが返すバージョン文字列が無効な場合は
ActiveRecord::ActiveRecordError
をraiseするよう修正。Kevin McPhillips
同Changelogより
問題
AbstractMysqlAdapter
では、(抽象でない)具体的な実装クラスでget_full_version
が呼び出され、その結果は以下で解析される。full_version_string.match(/^(?:5\.5\.5-)?(\d+\.\d+\.\d+)/)[1]
ここでは、数値のバージョン文字列を正確な形で抽出して返せることが前提になっている。出来なかった場合
match
呼び出しはnil
を返し、その後[1]
をraiseする。自分たちが遭遇したのは、プロキシを利用中に予想外の値を返す場合があるという問題だった。プロキシの問題であることは明らかだが、フレームワークの奥深くに隠れている
[] for nil
エラーが不親切で、無効な値を表示してくれなかったためデバッグに手こずった。解決方法
正規表現のマッチをガード文で保護し、マッチしない場合は、期待通りでないバージョン文字列を含む説明的なメッセージを表示する
ActiveRecord::ActiveRecordError
をraiseするようにした。こうなっていたらデバッグでとても貴重な情報となっただろう。
同PRより
つっつきボイス:「MySQLのバージョン文字列が無効だった場合にエラーをraiseするまではよかったけど、その無効な文字列が何なのかがわからなくて困ったので、それを表示するよう改修したという流れですね」「nil
参照系のエラーしか情報がないのは悲しい」
前編は以上です。
バックナンバー(2024年度第2四半期)
- 20240409前編 Rails公式の"rails-new"ツールでRailsプロジェクトをセットアップほか
- 20240402 solid_queueとmission_control-jobsが正式にRailsのgemに、Rubyの"チルド"文字列ほか
ソースの表記されていない項目は独自ルート(TwitterやはてブやRSSやruby-jp SlackやRedditなど)です。
週刊Railsウォッチについて
TechRachoではRubyやRailsなどの最新情報記事を平日に公開しています。TechRacho記事をいち早くお読みになりたい方はTwitterにて@techrachoのフォローをお願いします。また、タグやカテゴリごとにRSSフィードを購読することもできます(例:週刊Railsウォッチタグ)