🧵

arproxy を puma と使う場合の注意点

2024/12/23に公開

ActiveRecord でクエリ実行時に任意の処理を挟むことができる arproxy について、puma で...というかマルチスレッドで使う場合に注意すべき点があった。

問題がある実装

class FooController < ApplicationController
  before_action { Arproxy.enable! }
  after_action { Arproxy.disable! }
end

puma ではデフォルトでマルチスレッドでリクエストを処理するので、複数スレッドで Arproxy.enable! Arproxy.disable! が呼ばれる可能性がある。

しかし、これらのメソッドは Arproxy のインスタンス変数 @proxy_chain を変更したり、DB アダプターのメソッドを書き換えたりするため複数スレッドで実行すると問題が発生する。

回避策

マルチスレッドで実行されうる箇所では Arproxy.enable! Arproxy.disable! は呼ばない。

リクエストごとに実行を切り替えたい場合は、スレッドローカル変数を使い proxy の実装で切り替えるなどする。

class AllQueryLogger < Arproxy::Base
  def execute(sql, name=nil)
    # スレッドローカル変数を参照して実行切り替え
    return super unless Thread.current[:arproxy_all_query_logger_enabled]

    warn sql
    super(sql, name)
  end
end

# デフォルトで有効にする
# Executor によりスレッド生成されたときに実行される
Rails.application.executor.to_run do
  Thread.current[:arproxy_all_query_logger_enabled] = true
end
# メインスレッドでも有効に
Thread.current[:arproxy_all_query_logger_enabled] = true

より構造化した例 ↓

require "arproxy"

module Arproxy
  module MultiThread
    def self.included(klass)
      klass.extend ClassMethods
    end

    module ClassMethods
      def enable_proxy!(klass)
        proxy_enabled[klass] = true
      end

      def disable_proxy!(klass)
        proxy_enabled[klass] = false
      end

      def enable_proxy?(klass)
        proxy_enabled[klass]
      end

      private
      def thread_local
        Thread.current[:arproxy] ||= {}
      end

      def proxy_enabled
        thread_local[:proxy_enabled] ||= Hash.new {|h, k| h[k] = true }
      end
    end
  end

  class Base
    module MultiThread
      def self.included(klass)
        klass.extend ClassMethods
        klass.prepend InstanceMethods
      end

      module ClassMethods
        def enable!
          Arproxy.enable_proxy!(self)
        end

        def disable!
          Arproxy.disable_proxy!(self)
        end

        def enable?
          Arproxy.enable_proxy?(self)
        end
      end

      module InstanceMethods
        # 次の proxy を呼ぶ前に enable? をチェックしてスキップする処理を追加
        def execute(sql, name = nil, **_kwargs)
          n = next_proxy
          loop do
            if n.class.enable?
              break n.execute sql, name
            else
              n = n.next_proxy
            end
          end
        end
      end
    end
  end
end

Arproxy.include Arproxy::MultiThread
Arproxy::Base.include Arproxy::Base::MultiThread

# 最初になにもしない (次の proxy の enable? をチェックして実行する) クラスを設定
# これがないと最初の proxy の enable? がチェックできない
class ChainHead < Arproxy::Base
end

class AllQueryLogger < Arproxy::Base
  def execute(sql, name=nil)
    warn sql
    super(sql, name)
  end
end

Arproxy.configure do |config|
  config.adapter = "mysql2"
  config.use ChainHead
  config.use AllQueryLogger
end

Rails.application.executor.to_run do
  AllQueryLogger.enable!
end
AllQueryLogger.enable!

Arproxy.enable!

# スレッド中で AllQueryLogger.(enable!|disable!) は安全に呼ぶことができる

Discussion