GeekFactory

int128.hatenablog.com

Task Queue用のジョブスケジューラを設計する

App Engineで日次バッチを動かす場合はCronかTask Queueを選択することになります。重複起動したくない、ジョブ失敗時はリトライしたい*1という要件があるならTask Queueを使います。Cronの場合は WEB-INF/cron.xml を編集するだけですが、Task Queueの場合はジョブスケジューラを自分で書かなければなりません。

追記: Cronの場合はスケジュール指定でsynchronizedキーワードを付けると重複起動が許容されます。@bluerabbit777jpさん、ありがとうございます。

ここでは、App Engineに限らず汎用的に使えるジョブスケジューラを書いてみます。日次ジョブスケジューラのアルゴリズムを考えてみましょう。例えば、午前3時にジョブを実行したい場合、

  1. 現在日時をTとする。Tは年月日時分秒ミリ秒から成る。
  2. Tの時、分、秒、ミリ秒を0に変更する。
  3. Tの時を3に変更する。
  4. Tが現在時刻より古い場合はTに1日を加える。
  5. 次のジョブ実行日時はTである。

となります。

条件が複数ある場合は、現在日時に最も近い値をジョブ実行日時とします。もし午前3時と午後3時の両方にジョブを実行したい場合は、先ほどの手順に3と15を適用して直近の日時を得ます。

日次ジョブスケジューラのアルゴリズムを実装すると以下のようになります。

/**
 * Scheduler for {@link DailyJob}.
 */
public class DailyJobScheduler implements Scheduler<DailyJob> {
  @Override
  public long getNextTime(DailyJob dailyJob, long baseTime) {
    if (dailyJob == null) {
      throw new NullPointerException("dailyJob is null");
    }

    TimeZone timeZone;
    if (dailyJob.timezone().length() == 0) {
      timeZone = TimeZone.getDefault();
    }
    else {
      timeZone = TimeZone.getTimeZone(dailyJob.timezone());
    }

    long earliest = Long.MAX_VALUE;
    for (Time time : dailyJob.triggers()) {
      Calendar calendar = Calendar.getInstance(timeZone);
      calendar.setTimeInMillis(baseTime);
      calendar.set(Calendar.HOUR_OF_DAY, 0);
      calendar.set(Calendar.MINUTE, 0);
      calendar.set(Calendar.SECOND, 0);
      calendar.set(Calendar.MILLISECOND, 0);
      calendar.add(Calendar.HOUR_OF_DAY, time.hour());
      calendar.add(Calendar.MINUTE, time.minute());
      calendar.add(Calendar.SECOND, time.second());
      if (calendar.getTimeInMillis() < baseTime) {
        calendar.add(Calendar.DAY_OF_MONTH, 1);
      }

      long candidate = calendar.getTimeInMillis();
      if (candidate < earliest) {
        earliest = candidate;
      }
    }

    return earliest;
  }

  @Override
  public Class<DailyJob> getTargetAnnotation() {
    return DailyJob.class;
  }
}

ジョブクラスには日次ジョブであることを示すアノテーションを付加します。

@DailyJob(triggers = @Time(hour = 3), timezone = "Asia/Tokyo")
public class DeleteOldAlbumsController extends Controller {
  @Override
  public Navigation run() throws Exception {
    // バッチ処理...

    // 次のバッチをスケジュールする。
    JobScheduler jobScheduler = new JobScheduler();
    TaskHandle handle = jobScheduler.nextJob(getClass(), request.getRequestURI());
    logger.info("Next job has been scheduled at "
        + DateUtil.toString(new Date(handle.getEtaMillis()), DateUtil.ISO_DATE_TIME_PATTERN));
    return null;
  }
}

ここではSlim3のコントローラクラスを例に出していますが何でも構いません。JobScheduler#nextJob() に渡すクラスにアノテーションが付いていればOKです。

JobScheduler#nextJob() にクラスを渡すと、アノテーションの内容に応じて実行日時が計算され、新しいタスクがスケジュールされます。実行日時の計算は SchedulerService クラスの役割です。

詳しくはgithubを参照してください。

バッチの世界は地味ですが、毎日正しく動いているとうれしいですね。というか動かないと困りますね。日次ジョブが必要になったので作ってみましたが、今後、毎時ジョブが必要になったらスケジューラを追加したいと思います。

*1:Cronもリトライしてくれる旨をどこかで読んだ気がしますが、検証した限りではリトライしてくれないようです。