YOUTRUST で本当に起こった不具合の話 〜解答編〜 - YOUTRUST Tech Blog

YOUTRUST で本当に起こった不具合の話 〜解答編〜

こんにちは、YOUTRUST のやまでぃ(YOUTRUST/X)です。

最近のわたくしごとですが

行きつけの温泉施設である南多摩の季乃彩にはリラクゼーションスペースがあり、整った身体で漫画を読みながらゆっくりと自分の時間を過ごすことができます。

どうやら3ヶ月に1回、在庫漫画のラインナップが一新される運用のようで、先日訪れた際に名探偵コナンの漫画が並んでいるのを発見しました。

3ヶ月で100巻…92日で100巻…1日1巻+…

今Q中に全巻読破を目標に通い続けようと思います。

www.tokinoirodori.com

今日はなんの話?

先日執筆した以下の記事の解答編です。まだ未読の方は先に是非読んでみてください。

tech.youtrust.co.jp

上記記事内の擬似コードも再掲しておきます。

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 対応が漏れていた場合など実際にダダッと連続したリクエストが発生しちゃいそうですね)

  1. [A] Controller→Queryを呼び出し、false が返る。
    • Request A内で false がキャッシュされる
  2. [B] Controller→Queryを呼び出し、false が返る。
    • Request B内で false がキャッシュされる
  3. [B] Controller→UseCaseで排他ロック
  4. [A] Controller→UseCaseで排他ロック
    • Bがロックを解放(≒COMMIT)するまで待機
  5. [B] UseCase→Commandを呼び出し、バリデーション内のHogeがキャッシュされた false を返す。
    • DBへクエリは発行していない。
  6. [B] Commandによりレコードが作成される。(1個目)
  7. [B] UseCaseでCOMMIT&排他ロック解放。
  8. [A] UseCase→Commandを呼び出し、バリデーション内のHogeがキャッシュされた false を返す(!)
    • この時点でDBの中に既にレコードが存在するが、キャッシュされた値を参照するのでバリデーションが通ってしまう。
  9. [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では一緒に切磋琢磨していけるエンジニア仲間を募集しております!是非下記の募集一覧をチェックしてみてください!

herp.careers