Ruby
Rails

レースコンディションによりRecordInvalidを抜けてRecordNotUniqueを引いてしまうときの対処

More than 3 years have passed since last update.

凄くシンプルに以下のようなコントローラとモデルがあるとする。

users_controller.rb
class UsersController < ApplicationController
  def create
    render json: User.create!(user_params).to_json, status: 201
  end

  def user_params
    params.permit(:name)
  end
end
user.rb
class User < ActiveRecord::Base
  validates name,
    presence: true,
    uniqueness: { :case_sensitive => false }
end

/usersへ同じnameパラメータでかつ同時POSTされたりすると、タイミングによってはuniquenessバリデーションを抜けられることがある。
DBのテーブル定義でnameカラムにuniqueインデックスを張っていれば、ActiveRecord::RecordNotUniqueがraiseされる。

エラーハンドラーがActiveRecord::RecordInvalidを補足していて、モデルの#errors.messagesなどをパラメータエラーのヒントとして返すようにしていても台無しになってしまう。
このケースでは再度バリデーションを通してActiveRecord::RecordInvalidをraiseさせたい。

コントローラの処理をリトライする

application_controller.rb
class ApplicationController < ActionController::Base
  # counter: リトライする回数
  # exception: リトライする例外
  def retry_action(counter = 1, exception = StandardError)
    yield
  rescue exception => e
    retry if (counter -= 1) >= 0
    raise e
  end

  def retry_from_record_not_unique
    retry_action(1, ActiveRecord::RecordNotUnique) do
      yield
    end
  end
end
users_controller.rb
class UsersController < ApplicationController
  around_action :retry_from_record_not_unique, only: :create

  def create
    render json: User.create!(user_params).to_json, status: 201
  end

  def user_params
    params.permit(:name)
  end
end

リトライ回数と補足する例外を抽象化したretry_actionを定義し、リトライさせたい例外のコールバック用メソッドretry_from_record_not_uniqueを作り、around_actionにより対象のアクションをブロックで渡す。

こうするとレースコンディションにより、バリデーションを抜けてActiveRecord::RecordNotUniqueがraiseされても、再処理によってuniquenessでバリデーションエラーが起こりActiveRecord::RecordInvalidがraiseされる。

参考: http://stackoverflow.com/questions/9020648/handle-timeouterror-with-retry

アクションにrescueはやすほうが、シンプルでわかりやすい気もする。。

users_controller.rb
class UsersController < ApplicationController
  def create
    render json: User.create!(user_params).to_json, status: 201
  rescue ActiveRecord::RecordNotUnique => e
    counter ||= 1
    retry if (counter -= 1) >= 0
    raise e
  end

  def user_params
    params.permit(:name)
  end
end

こういうのデバッグするのが難しくて、いい方法があったら知りたい。
ちなみに今回はUserモデルのバリデーション後にディレイを入れてデバッグしてた。。

user.rb
class User < ActiveRecord::Base
  validates name,
    presence: true,
    uniqueness: { :case_sensitive => false }

  after_validation { sleep 3 }
end