active_job / delayed_job を高速化するハック

**TL;DR**

- active_job で `perform_later` に大きな配列を渡すと、ジョブ登録にとても時間がかかる
- delayed_job はジョブの引数をYAMLで保存するが `.to_yaml` は大きな配列ではとても遅い 
- `serialize_arguments` をオーバーライドして、高速化できる

# 実際に起きた問題

*「CSVファイルの中身をDBに投入する画面」で、CSVファイルの送信に時間がかかりすぎる* という問題が起きました。

イメージ的には、こんなコントローラーで実装できる画面です。DBへのレコード追加（`User.create!`）がやや重い処理なので、ActiveJobのジョブに分けています。

```ruby
class CsvFormController < ApplicationController
  # 「名前・年齢・メールアドレス」のCSVファイルをアップロードして、Userを作れる画面
  #
  #   太郎,32,taro@example.com
  #   二郎,30,jiro@example.com
  #   三郎,27,sabu@example.com
  def upload
    rows = CSV.parse(params[:file].read)
    UserUploadJob.perform_later(rows: rows)
  end
end

class UserUploadJob < ApplicationJob
  def perform(rows:)
    User.transaction do
      rows.each do |row|
        User.create!(name: row[0], age: row[1], email: row[2])
      end
    end
    # 処理が終わったら、メールやSlackで通知
  end
end
```

これで、CSVファイルのアップロードは数秒で完了。重い処理は、バックグラウンドで実行してくれる・・・はず。

# ジョブ登録に2分かかる

CSVファイルが1,000行程度のうちは問題なく動きました。

しかし、100万行程度になると **アップロード画面の処理に2分以上かかる**ようになってしまいました。これでは使い物になりません。また、CPUやメモリも大量に消費しているようでした。

[stackprof](https://github.com/tmm1/stackprof) を使い調査したところ、**delayed_job の中で呼び出している `to_yaml` が遅い**ことがわかりました。

delayed_job は、ジョブをキューに登録するとき、引数をYAMLにエンコードして保存します。CSVファイルの行数が大きくなると、エンコードするべき引数の配列も大きくなっていたのです。

https://github.com/collectiveidea/delayed_job/blob/v4.1.5/lib/delayed/backend/base.rb#L68

```ruby
module Delayed
  module Backend
    module Base
      ...

      def payload_object=(object)
        @payload_object = object
        self.handler = object.to_yaml
      end

      ...
    end
  end
end
```

# `.to_yaml` は遅い

`.to_yaml` の遅さは、こんな簡単なスクリプトでもわかります。

```ruby
require 'benchmark'
require 'json'
require 'yaml'

large_array = (1..1_000_000).map { |x| [x, x, x] }

puts '.to_json'
puts Benchmark::CAPTION
puts Benchmark.measure { large_array.to_json }
puts
puts '.to_yaml'
puts Benchmark::CAPTION
puts Benchmark.measure { large_array.to_yaml }
```

実行結果:

```
.to_json
      user     system      total        real
  0.180000   0.030000   0.210000 (  0.218959)

.to_yaml
      user     system      total        real
 30.350000   0.890000  31.240000 ( 33.154655)
```

なんと！！ **`.to_yaml` は `.to_json` の 150倍以上も遅い！！**

標準ライブラリの実装が悪いのでは？そもそもYAMLを使う必要がないのでは？等々、問題意識はありますがともあれ、`.to_yaml` は遅いです。

# まっとうな解決策

`.to_yaml` が遅いせいで、ジョブの引数に巨大な配列を渡そうとすると、ジョブの登録に時間がかかります。

しかし、そもそも**ジョブに巨大な引数を渡すのがいけません**。CSVファイルは /tmp なり S3 なりに一時保存するようにして、ジョブにはパスだけ与えるようにすればいいのです。

delayed_job はジョブ登録時にジョブの内容をログに出力します。そのため、巨大な引数をジョブに与えると**ログに巨大な出力されて、fluentd が詰まる**という二次災害も発生します。

# ハック

とはいえ、再設計する時間は無いので、応急処置を探します。

色々試したところ、`.to_yaml` は*巨大な配列の処理は遅いが、巨大な文字列の処理はそれほど遅くない*ようでした。そこで、`.to_yaml` を呼び出す前に配列を文字列に変換する処理を追加します（逆の処理をYAMLをデコードした後にも追加します）。

ジョブの `serialize_arguments` と `deserialize_arguments` をオーバーライドすればよいようです。

```ruby
class UserUploadJob
  def serialize_arguments(args)
    params = args.first.clone
   
    # rows（配列の配列）を、TSV形式の文字列に変換
    params[:rows] = params[:rows].map { |r| r.join("\t") }.join("\n")
    super([params])
  end

  def deserialize_arguments(serialized_arguments)
    args = super(serialized_arguments)

    # TSV形式の文字列を配列の配列に変換
    args.first[:entries] = args.first[:entries].lines.map { |l| l.strip.split("\t") }
    args
  end
```

# 補足＆感想

このハックを使うとコントローラー側のコードを変えずに、引数のシリアライズ方式を変えることができます。責務を上手く分離できているので、イケてる感じですね。

しかし、`serialize_arguments` と `deserialize_arguments`は
[private メソッドなので](https://github.com/rails/rails/blob/b2eb1d1c55a59fee1e6c4cba7030d8ceb524267c/activejob/lib/active_job/core.rb) 、将来変更があるかもしれず、できれば使わない方がよさそうです。