ActiveStorageでアップロードしたファイルをsidekiqで遅延少なく処理する(sidekiqのコードリーディング)

## やりたいこと(背景)
フロントエンドからファイルをActiveStorageでアップロードし、そのファイルを利用した処理を行う必要がある。
該当のファイルへの処理は数秒かかるためweb apiはすぐに返し、該当の処理を非同期実行することにした。
該当処理のOK / NGの結果に応じてユーザーに再度アップロード要求する必要があり、ユーザーには解析中という表示で待たせている。(処理に時間がかかりすぎた時の対応は別途検討してますが今回は省略)
サーバー処理が終わったらActionCableを利用してフロントに判定結果を返す。


![ファイルアップロード.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280595/bfe20204-3c54-c8f5-0ee2-7b85a44b58a0.png)

![解析job.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280595/5e11f891-f19b-8de0-33d7-e3c46b8c320a.png)

※ 簡素化しているため一部違和感があるかもしれませんが全体の流れの把握が目的のためご容赦ください。



## 発生した事象
### step1: ステージング環境で `ActiveStorage::FileNotFoundError` が発生
##### 起きた事象
解析用のworkerを `perform_async` で呼び出した結果、最初の1回目に高確率で `ActiveStorage::FileNotFoundError` が発生。
ただし、2回目のリトライ時には当該エラーが発生せず、正常終了していた。

##### 原因
ActiveStorageのファイルアップロードジョブは非同期で行われており、「ActiveStorageのファイルアップロードジョブ」と「ファイル解析用のworker」のレースコンディションが起きていた。

##### 対応
解析用のworkerの呼び出しを `perform_in` に変更した上でファイルアップロード後に実行されやすいようにした。
=> これにより `ActiveStorage::FileNotFoundError` の発生が落ち着いた(可能性が0ではないがそれはsidekiqのretryに流すことにしました)

```rb
SampleWorker.perform_in(0.2.second, ~~_id)
```

### step2: 想定したパフォーマンスが出ない
##### 起きた事象
解析用のworker自体は2-3s程度で終了する見込みであったが、フロントの体験的に遅延が起こるケースが多かった。
=> 正確には予想していた2-3sで終わることもあれば10s以上かかるケースもあった

##### 原因(ここの詳細がメインテーマなので後述します。)
sidekiqを `perform_in` で実行すると `Sidekiq::Sucheduled` の機構に乗ります。
その場合、workerの実行までにperform_inで指定した秒数以上のpending時間がかかる。

##### 対応
`perform_async` に戻し、worker側で `ActiveStorage::FileNotFoundError` をハンドリングするようにしました。
(別のいい対処法があればご教授願いたいです)

```rb
class SampleWorker
  include Sidekiq::Worker

  def perform(~~_id)
    # ファイル解析処理
  rescue ActiveStorage::FileNotFoundError
    sleep(~) # ここの秒数は現在も調整中
    SampleWorker.perform_async(~~_id)
  end
end
```

実際の処理ではActiveStorageをattach指定るActiveRecordのcreated_atを見て直近にアップロードしたものでない時にはraiseするようにしました。
(ActiveStorageのファイルが別の理由で削除されていた時に無限にリトライされないように)

## なぜ `perform_in` では指定した時間以上の時間がかかってしまったのか
わからないなりにコードを読んでみた。(全てを読んでるわけではないです)
参考: [sidekiqコードリーディング
](https://blog.freedom-man.com/sidekiq-codereading)

### クラスについて確認
sidekiqのクラス関係(ざっくり)はこちら。

![sidekiq概論.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280595/2e4c293b-c0c2-2a34-75e3-6350007630b9.png)

#### Jobをenqueueする

* [Sidekiq::Worker](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/worker.rb)
    * Railsにmoduleとして提供している。perform_inやperform_asyncを呼んだ時にSidekiq::Clientを介してredisにデータをpushする
    * perform_inで指定された時間を計算してatオプションに変換する([参考](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/worker.rb#L272-L283))
* [Sidekiq::Client](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/client.rb)
    * redisへのコネクションの管理とpushを行う    * atオプションが指定されている時には "schedule" に対してatの時間と共にzaddされる

雑感:
WorkerとClientの責務分担がちょっと把握しきれなかったです。
* 何秒後に実施と指定した時にatオプションに変換するロジックがWorkerとClient両方にある。
* 元々Client側で実装していて、moduleをincludeするだけで簡単に使えるようにWorkerを追加したような気がしました。

#### Jobをdequeueする
* [Sidekiq::CLI](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/cli.rb)
    * sidekiqコマンドを解釈するための層。[Launcherの実行](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/cli.rb#L116)を行う。
* [Sidekiq::Launcher](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/launcher.rb)
    * sidekiqで必要な機能を起動する
    * 以下の二つを起動
        * [Sidekiq::Manager](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/manager.rb)
        * [Sidekiq::Scheduled::Poller](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/scheduled.rb)
* [Sidekiq::Manager](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/manager.rb)
    * [Processorを必要数用意する](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/manager.rb#L37-L39)
        * 必要数のデフォルトは10で `concurrency` で外部から変更可能
    * [Processorの起動](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/manager.rb#L43-L47)
* [Sidekiq::Processor](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/processor.rb)
  * [終了ロジックが呼ばれるまでひたすらにjobがないか、あれば実行を繰り返す](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/processor.rb#L68)
* [Sidekiq::Scheduled::Poller](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/scheduled.rb)
    * 定期的に `Sidekiq::Sucheduled::Enq` を呼び出す
    * 複数のsidekiqで実行されるため、 `Sidekiq::Sucheduled::Enq` を何度も呼び出さないように調整している
    * [random_poll_interval: Sidekiq::Sucheduled::Enqを呼び出す間隔を計算(プロセス数が増えてもいい感じになるように計算している)](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/scheduled.rb#L101-L129)
        * `poll_interval_average` : それぞれのsidekiqプロセスでpollingする間隔。
        * `average_scheduled_poll_interval` : 複数のsidekiqプロセスでどれくらいのインターバルになることを期待するか(デフォルト 5s)
    * sidekiqプロセスが10以内の時にはプロセスごとのインターバル時間の0.5-1.5倍の幅で実行される
        * デフォルトの5sで考えると2.5-7.5s感覚でポーリングされる(プロセスが複数の時のワーストケースでは実行感覚がもっと広くなる)
* [Sidekiq::Scheduled::Enq](https://github.com/mperham/sidekiq/blob/4a0432622fbd2f02fe96f12c3afd1b6ac3fab876/lib/sidekiq/scheduled.rb)
    * リトライ対象のものと "schedule" タグのものを取得して対象時間が過ぎているを取得してjobとしてenqueueする

#### perform_inしたらなぜ遅くなったのか
perform_inで実行した結果"schedule"タグでenqueueされる。
その結果 `Sidekiq::Scheduled::Poller` のポーリング時間が課題となっていました。

対応方針の検討で以下のパターンがありましたが、今回は後者を選択しました。

* average_scheduled_poll_intervalの感覚を短くする
* perform_inを使わずperform_asyncにする

上で行った時の懸念として、プロセス数が増えたときに同時実行のリスクが高くなり、予期せぬバグや負荷を招く可能性があると思いました。


まぁsidekiqに流したのにパフォーマンス気にするなよというごもっともなツッコミはあるのですが、、
誰かの参考になれば、そしてこうした方がいいぞというコメントいただけるとありがたいです

## 参考
[sidekiqコードリーディング
](https://blog.freedom-man.com/sidekiq-codereading)