サービスドメインに組込むためのジョブスケジューラ crono_trigger を作った

ある時間になったらジョブを起動するスケジューラを実装したgemは、世の中に色々とあります。

- [javan/whenever: Cron jobs in Ruby](https://github.com/javan/whenever)
- [adamwiggins/clockwork: A scheduler process to replace cron](https://github.com/adamwiggins/clockwork)
- [moove-it/sidekiq-scheduler: Lightweight job scheduler extension for Sidekiq](https://github.com/moove-it/sidekiq-scheduler)

しかし、いまいち自分の用途に合致するものがなかったので自作しました。
基本的に上記のgemは、事前に設定ファイルを書いてあるジョブクラスをトリガーするスケジュールを登録するスタイルです。
システムの運用に関わるバッチのトリガーとかならそれでも便利なんですが、サービスの機能としてユーザーにスケジューラを提供したい場合に困ります。
例えば、ユーザーが毎日12時に広告メールを配信したい、とかそういう機能をサービスとして提供したい場合です。

この場合、ActiveRecordで扱っているモデルそのものがスケジュールの定義になって欲しい。
[collectiveidea/delayed_job](https://github.com/collectiveidea/delayed_job) は割とそれに近いし、そういう使い方もできる。
ただ、あれはテーブルが基本的に一つであくまでスケジュールの定義だけを扱うことになる。
というわけで、微妙に用途が噛み合わない。

そこで自作したのが [joker1007/crono_trigger](https://github.com/joker1007/crono_trigger) です。
次に実行する時間を保存するカラムと、実行しているレコードをロックするカラムが最低要件で、それらのカラムがあれば任意のActiveRecordモデルをスケジュール定義として利用できます。
その他にオプションでカラムを追加することで、cron形式で繰り返しの実行時間を定義したり、スケジュール自体の有効期間を設定することができます。

典型的なモデルは以下の様な定義になります。

```ruby
class CreateNotifications < ActiveRecord::Migration[5.0]
  def change
    create_table :notifications do |t|
      t.string :name

      # used by crono_trigger
      t.string :cron
      t.datetime :next_execute_at
      t.datetime :last_executed_at
      t.string   :timezone
      t.integer  :execute_lock, limit: 8, default: 0, null: false
      t.datetime :started_at, null: false
      t.datetime :finished_at
      t.string :last_error_name
      t.string :last_error_reason
      t.datetime :last_error_time
      t.integer :retry_count, default: 0, null: false

      t.timestamps null: false
    end
    add_index :notifications, [:next_execute_at, :execute_lock, :started_at, :finished_at], name: "crono_trigger_index_on_notifications"
  end
end
```

```ruby
class Notification < ApplicationRecord
  include CronoTrigger::Schedulable

  self.crono_trigger_options = {
    retry_limit: 5,
    retry_interval: 10,
    exponential_backoff: true,
    execute_lock_timeout: 300,
  }

  # `execute` callback is defined
  # can use `before_execute`, `after_execute`, `around_execute`

  # If execute method raise Exception, worker retry task until reach `retry_limit`
  # If `retry_count` reaches `retry_limit`, task schedule is reset.
  # 
  # If record has cron value, reset process set next execution time by cron definition
  # If record has no cron value, reset process clear next execution time
  def execute
    send_mail

    throw :retry # break execution and retry task
    throw :abort # break execution and raise AbortExecution. AbortExecution is not retried
    throw :ok    # break execution and handle task as success
    throw :ok_without_reset    # break execution and handle task as success but without schedule reseting and unlocking
  end
end
```

この様にモジュールをincludeしてexecuteメソッドを定義します。
execute_lockだけちょっと特殊なので解説しておくと、これはあるレコードを実行しようとした時のunixtimeが入ります。
0だと誰も実行していないと判断してスケジューリングの対象に含まれます。もし0でなくて、現在時刻がロックしてから指定したタイムアウト時間を過ぎてない場合はスケジュール対象から除外します。

ちなみに、用途的に噛み合わない感じがしたのでActiveJobのインターフェースは実装していません。

実際にジョブを実行するためにはワーカープロセスが必要です。
ワーカープロセスは、serverengineとconcurrent-rubyを利用しています。

```
$ crono_trigger MailNotification
```

```
$ crono_trigger --help
Usage: crono_trigger [options] MODEL [MODEL..]
    -f, --config-file=CONFIG         Config file (ex. ./crono_trigger.rb)
    -e, --envornment=ENV             Set environment name (ex. development, production)
    -p, --polling-thread=SIZE        Polling thread size (Default: 1)
    -i, --polling-interval=SECOND    Polling interval seconds (Default: 5)
    -c, --concurrency=SIZE           Execute thread size (Default: 25)
    -l, --log=LOGFILE                Set log output destination (Default: STDOUT or ./crono_trigger.log if daemonize is true)
        --log-level=LEVEL            Set log level (Default: info)
    -d, --daemonize                  Daemon mode
        --pid=PIDFILE                Set pid file
    -h, --help                       Prints this help
```

こんな感じで、引数にスケジュール定義として利用したいモデルを指定します。
コンフィグファイルを書いてそこで指定することもできます。

```ruby
CronoTrigger.configure do |c|
  c.executor_thread = 10
  c.model_names = ["MailNotification", "OtherNotification"]
end
```

まだ、0.1.0って感じで、自分でも使いこめていないので、バグが残ってるかもしれませんが、用途があったら触ってみてください。