Sidekiq v7.3に導入されたSidekiq::IterableJobについて

## はじめに

Sidekiq v7.3に `Sidekiq::IterableJob` と呼ばれる機能が導入されました。長時間実行されるジョブが安全にデプロイできるようになったとのことです。

* [sidekiq/Changes.md 7.3.0 at main · sidekiq/sidekiq](https://github.com/sidekiq/sidekiq/blob/main/Changes.md#730)

本記事では `Sidekiq::IterableJob` に関連するWiki/Mike Perhams氏の記事/実装を参考に調査を行い、分かったことをまとめます。

## 参考資料

* [Iteration · sidekiq/sidekiq Wiki](https://github.com/sidekiq/sidekiq/wiki/Iteration)
* [Iteration and Sidekiq 7.3.0 | Mike Perham](https://www.mikeperham.com/2024/07/03/iteration-and-sidekiq-7.3.0/)

## Sidekiq::IterableJob が導入された経緯

元々は2022年11月から[fatkodima/sidekiq-iteration: Make your long-running sidekiq jobs interruptible and resumable.](https://github.com/fatkodima/sidekiq-iteration) というGemが存在していました。

2024/4/26にSidekiq 開発者・メンテナーのMike Perham氏より顧客のデプロイを迅速かつ安全に提供するためにSidekiq本体に取り込まないかという提案があり、そこからIterationとしてSidekiq本体に取り込まれたようです。

https://github.com/fatkodima/sidekiq-iteration/issues/6

https://github.com/sidekiq/sidekiq/pull/6286

## 何が変わるのか

Sidekiq 開発者・メンテナーのMike Perham氏の 記事 [Iteration and Sidekiq 7.3.0 | Mike Perham](https://www.mikeperham.com/2024/07/03/iteration-and-sidekiq-7.3.0/) には以下の説明がありました。

> A common cause of long-running jobs is processing a large amount of data in a loop. For example, below we iterate through each each Product in our database. If there are one million Products, this might take a while!

> Note a major flaw in the code above: if an error occurs or a deploy is triggered, the job will restart Product processing from the very beginning.

確かにこれは問題です。ジョブが最初から最初から実行されることでジョブが完了するまで時間がかかったり、またはジョブが実行中の場合はデプロイができないなどの障壁が生まれます。

```ruby
class ProductImageChecker
  include Sidekiq::Job

  def perform(*args)
    Product.all.each do |product|
      # do something with product
      product.check_image
    end
  end
end
```

> With Sidekiq::IterableJob, we break the loop into discrete chunks which Sidekiq knows about, allowing Sidekiq to break the processing at any point in the loop. Notice we don’t provide a perform method but rather two methods which control the work loop:

```ruby
class ProductImageChecker
  include Sidekiq::IterableJob

  def build_enumerator(*args, cursor:)
    active_record_records_enumerator(Product.all, cursor: cursor)
  end

  def each_iteration(item, *args)
    item.check_image
  end

  def on_complete
    logger.info { "Finished checking product images!" }
  end
end
```

* `#perform` メソッドは実装しない
* `#build_enumerator`, `each_iteration` の２つを実装する

必要があるようです。

※ コードの引用元: [Iteration and Sidekiq 7.3.0 | Mike Perham](https://www.mikeperham.com/2024/07/03/iteration-and-sidekiq-7.3.0/)

## API 
`#perform`の代わりに実装必須のメソッドはこちら

### build_enumerator
* ジョブの繰り返し処理を設定する

### each_iteration
* ループの各反復で実行される作業を定義

## ヘルパーメソッド
配列、ActiveRecord::Relation、CSVなどに応じたヘルパーメソッドが用意されているため、ループさせたいデータの種類に応じて使い分けるようです。

参考: [lib/sidekiq/job/iterable/enumerators.rb](https://github.com/sidekiq/sidekiq/blob/main/lib/sidekiq/job/iterable/enumerators.rb)


## コールバック
便利なコールバックメソッドが用意されていました。

* `on_start` : ジョブが初めて開始されたとき
* `on_resume` : ジョブが中断後、再開されたとき
* `on_stop` : 処理が終了したとき（完了または中断が原因である可能性があります）
* `on_complete` : Enumerator が終了したとき

## :warning:  注意点

> This feature should be considered BETA until the next minor release.

[Sidekiq Changes 7.3.0](https://github.com/sidekiq/sidekiq/blob/main/Changes.md#730)にある「次のマイナーリリースまでベータ版として見なされる」の記載がありました。ですので、まだ本番環境なので利用するには控えた方が良さそうです。

このBETAに関する記載が ChangeLog にしか書かれておらず、ほとんどの人は見落としそうな予感...。

## 疑問

調査の過程で気になったことがいくつかありました

### Sidekiq::IterableJob は何をトリガーとしてジョブを中断させるのか

[How it works](https://github.com/sidekiq/sidekiq/wiki/Iteration#how-it-works) に以下の説明がありました。

> After every iteration, Sidekiq calls Sidekiq::Job#interrupted?. This is a new method available to all Sidekiq::Jobs which returns true if the Sidekiq process is in the process of shutting down.

[Sidekiq::Job#interruputed?](https://github.com/sidekiq/sidekiq/blob/0226e311d02de8ea77563c17c96a3d6527d0ec7f/lib/sidekiq/job.rb#L176-L178) は以下の実装になっていました。

```ruby
    def interrupted?
      @_context&.stopping?
    end
```

この`@_context` で呼ばれる `.stopping?` が

* `Sidekiq::Processor#stopping?`
* `Sidekiq::Process#stopping?`
* `Sidekiq::Launcher#stopping?`

のどれを指すかは分かりませんが、上記３メソッドを確認すると Sidekiq プロセスを「quiet」状態または「shutting down」状態かどうかを判定していました。

つまり「quiet」状態または「shutting down」状態をトリガーに中断をするようです。

### 中断を検知すると何をするのか

[How it works](https://github.com/sidekiq/sidekiq/wiki/Iteration#how-it-works) に以下の説明がありました。

> If interrupted? is true, the IterableJob will flush its current state to Redis and raise Sidekiq::Job::Interrupted, so it can be re-enqueued and restarted with the latest cursor. The retry subsystem ignores this exception but other subsystems (like Batches) will treat it as a failure.

IterableJobは現在の状態をRedisに保存、 `Sidekiq::Job::Interrupted` 例外を発生させることで、再エンキューとSidekiqの再起動時に最新のカーソルからジョブが再開できるようにしているようです。

## まとめ

`Sidekiq::IterableJob` で提供されているAPI, ヘルパーメソッド, 中断・再開に関する仕組みについてまとめました。率直な感想としてはデプロイ時におけるジョブの安全な中断と再開、ジョブの実行時間の最小化という点で `Sidekiq::IterableJob` 機能はとても有効と感じました。但しまだBETA扱いとのことでしたので、Iterationの利用は次のマイナーリリースまで待つべきと思います。今後に期待です。