こんにちは、YOUTRUST のやまでぃ(YOUTRUST/X)です。
最近のわたくしごとですが
行きつけの温泉施設である南多摩の季乃彩にはリラクゼーションスペースがあり、整った身体で漫画を読みながらゆっくりと自分の時間を過ごすことができます。
どうやら3ヶ月に1回、在庫漫画のラインナップが一新される運用のようで、先日訪れた際に名探偵コナンの漫画が並んでいるのを発見しました。
3ヶ月で100巻…92日で100巻…1日1巻+…
今Q中に全巻読破を目標に通い続けようと思います。
今日はなんの話?
先日執筆した以下の記事の解答編です。まだ未読の方は先に是非読んでみてください。
上記記事内の擬似コードも再掲しておきます。
class User < ActiveRecord::Base has_one :hoge end class Hoge < ActiveRecord::Base belongs_to :user end
class HogeController < ApplicationController before_action :authenticate_user! # 認証処理 def create # `current_user` → 認証された User インスタンス result = HogeQuery.run(operation_user: current_user) return head :ok if result use_case = CreateHogeUseCase.run(operation_user: current_user) if use_case.success? head :ok else head :bad_request end end end
class HogeQuery include QueryRunnable # .run → .new.run みたいなクラスメソッドを定義している def initialize(operation_user:) @operation_user = operation_user end def run Hoge.where(user: @operation_user).exists? end end
class CreateHogeUseCase include UseCase::Base # .run → .new.tap(&:run) みたいなクラスメソッドを定義している attr_reader :hoge def initialize(operation_user:) @operation_user = operation_user end def run # 排他制御のためのロック @operation_user.with_lock do command = CreateHogeCommand.run(user: @operation_user) if command.success? @hoge = command.hoge else errors.add(:base, 'failed') raise ActiveRecord::Rollback end end end end
class CreateHogeCommand include Command::Base attr_reader :hoge validate :validate_user def initialize(user:) @user = user end def run @hoge = Hoge.create!(user: @user) end private def validate_user if Hoge.where(user: @user).exists? errors.add(:base, 'invalid') end end end
module Command::Base extend ActiveSupport::Concern include ActiveModel::Model module ClassMethods def run(**args) new(**args).tap { |command| command.valid? && command.run } end end def success? errors.none? end end
不具合の原因
不具合内容は「(意図せず)あるUserに対して複数のHogeレコードが作成されてしまう」ことでした。
ずばりこの原因は、Hogeに対するクエリーキャッシュが、Commandクラスのバリデーション内から参照されてしまい、バリデーションをすり抜けてしまったから、です。
class HogeQuery include QueryRunnable def initialize(operation_user:) @operation_user = operation_user end def run Hoge.where(user: @operation_user).exists? # ここでキャッシュされた値が、、、 end end
class CreateHogeCommand include Command::Base attr_reader :hoge validate :validate_user def initialize(user:) @user = user end def run @hoge = Hoge.create!(user: @user) end private def validate_user if Hoge.where(user: @user).exists? # ここで参照されてしまった。 errors.add(:base, 'invalid') end end end
「ん?どういうこと?Controller で true がキャッシュされても、そのまま 200 を返すだけだし、 false がキャッシュされても Command のバリデーションが通って正常にレコードが作成されるだけでは?複数レコードなんてできっこないよ!」
と思われたかもしれません。
それに対する回答は「本番環境では複数のスレッドがAPIリクエストを並列に処理しているので、複数できうる」になります。
以下に詳細に説明します。
ほぼ同タイミングで本アクションに対する複数のリクエスト(A、Bとします)がRailsサーバーに到達した状況を考えます。(ボタンの disabled 対応が漏れていた場合など実際にダダッと連続したリクエストが発生しちゃいそうですね)
- [A] Controller→Queryを呼び出し、false が返る。
- Request A内で false がキャッシュされる
- [B] Controller→Queryを呼び出し、false が返る。
- Request B内で false がキャッシュされる
- [B] Controller→UseCaseで排他ロック
- [A] Controller→UseCaseで排他ロック
- Bがロックを解放(≒COMMIT)するまで待機
- [B] UseCase→Commandを呼び出し、バリデーション内のHogeがキャッシュされた false を返す。
- DBへクエリは発行していない。
- [B] Commandによりレコードが作成される。(1個目)
- [B] UseCaseでCOMMIT&排他ロック解放。
- [A] UseCase→Commandを呼び出し、バリデーション内のHogeがキャッシュされた false を返す(!)
- この時点でDBの中に既にレコードが存在するが、キャッシュされた値を参照するのでバリデーションが通ってしまう。
- [A] Commandによりレコードが作成される。(2個目!)
…という流れで意図せず複数のレコードが存在するようになってしまっていたという訳でした。
※排他ロックに関してはこちらの記事を参照ください。
再発防止
以下のように ActiveRecord::QueryCache.uncached を用いるようにしました。
class ApplicationRecord < ActiveRecord::Base def self.transaction(**options, &block) ApplicationRecord.uncached do super end end end
YOUTRUSTでは更新系の処理を実行する際には、必ずActiveRecord::Transactions.transaction or ActiveRecord::Locking::Pessimistic#with_lock のブロックを利用するコーディングルールにしているので、この対応で本件のような不具合を防ぐことができます。
仲間募集中です!
今回の解答編、いかがだったでしょうか。
引き続きYOUTRUSTでは一緒に切磋琢磨していけるエンジニア仲間を募集しております!是非下記の募集一覧をチェックしてみてください!