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

週刊Railsウォッチ: ジョブのエンキューをトランザクション完了時まで自動先延ばしほか(20240416前編)

こんにちは、hachi8833です。RubyKaigi 2024の発表スケジュールが公開されて、LT募集が始まっていますね。

週刊Railsウォッチについて

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

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

🔗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

同公式情報より

参考: #26103
参考: #51426

Railsで割とよくあるミスとして、ジョブをトランザクション内からエンキューするときにレコードを引数として渡してしまうというのがある。これをやると、ジョブがキューで拾われたときにRecordNotFoundエラーが発生する。

これは、solid_queuedelayed_jobgood_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.transactionActiveRecord::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を使う前にはカラムの値をバックフィル(埋め戻し)しておかなければならない(テーブルが長時間ロックされないようにするために必要)。さもないと、内部カウンタキャッシュを利用しているsizeany?などのメソッドが誤った結果を生成する可能性がある。関連付けにカウンタキャッシュ構成を導入する前には、バックフィル中に子関連付けでデータベーストリガーまたはコールバックを使うことが多い。

改修の結果、子レコードの追加や削除のときに次のように書いておくことで、カラムを更新済みのままカラムを安全にバックフィルできるようになった。

class Comment < ApplicationRecord
  belongs_to :post, counter_cache: { active: false }
end

カウンタキャッシュが"active"でない場合、sizeany?などのメソッドはカウンタキャッシュを使わなくなり、データベースから直接結果を取得する。カウンタキャッシュのカラムでバックフィルが完了した後は、カウンタキャッシュの定義から{ active: false }を削除するだけで、sizeany?などのメソッドでカウンタキャッシュが使われるようになる。
同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四半期)

週刊Railsウォッチ: SeleniumでRubyの全クラスとモジュールにRBSが追加ほか(20240410後編)

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

Rails公式ニュース

Ruby Weekly


CONTACT

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