EJBタイマーサービスを使う

定番のスケジュール実行としてはcronがあるが、Java EEにおいても同様の機能が用意されている。Java EE 5以前では『x秒後に実行する』ような機能しかなかったが、Java EE 6(WLS12c/JBossEAP6など)以降ではcron風にカレンダー形式で設定ができる。

使い方

Java EE 6から導入されたアノテーションによるタイマー設定と、従来からあるAPIによるタイマー設定の両方を紹介する。

@Scheduleによるタイマー設定

昔のcron.dailyのように、毎日4時2分に動かしたい場合。
@Scheduleを付与したメソッドが、対象の時間になるとコールされる。EJBなので、timeout()メソッドはトランザクション・コンテキスト内として実行される。

import javax.ejb.Schedule;
import javax.ejb.Singleton;

@Singleton
public class ScheduleTimer {
    @Schedule(hour="4", minute="2")
    public void timeout() {
        System.out.println("timeout occur.");
    }
}

毎分、0秒と30秒に呼び出したい場合。*/30で30秒ごとを示す。例えばsecond="*/10"とした場合は、毎分の10秒/20秒/30秒…のように10秒ごとに呼び出すことも可能。

@Schedule(hour="*", minute="*", second="*/30")

毎週土曜日の04時02分。
0=日曜日とした数値による指定もできるが、文字列の方がわかりやすい。Sun, Mon, Tue, Wed, Thu, Fri, Satのいずれかで指定できる。

@Schedule(dayOfWeek="Sat", hour="4", minute="2")

毎分10秒と30秒に呼び出したい場合。
カンマを使って複数の条件を記載する。

@Schedule(hour="*", minute="*", second="10,30")

他の例はJava EE6のチュートリアルにも詳しく解説されている。

APIによるタイマー設定

x秒後に1回だけ実行

Java EE 5の頃からあるcreateTimerメソッドによる設定。10000ミリ秒後(10秒後)に1回だけ、@Timeoutで定義したタイムアウト処理を実行する。タイマーにはSerializable実装のインスタンスをパラメータとして渡すこともでき、この例ではStringの"hello world"を渡している。

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import javax.ejb.Singleton;
import javax.ejb.Startup;
import javax.ejb.Timeout;
import javax.ejb.Timer;
import javax.ejb.TimerService;

@Singleton
@Startup
public class HelloTimer {
    @Resource
    private TimerService timerService;

    @PostConstruct
    public void setTimer() {
        // 10秒後に1回だけタイムアウト処理をする
        timerService.createTimer(10000, "hello world");
    }

    @Timeout
    public void handleTimeout(Timer timer) {
        String msg = (String) timer.getInfo();
        System.out.println("Timeout: " + msg);
    }
}

x秒間隔で繰り返し実行

Java EE6では、APIによるタイマ設定も強化されていて、インターバル設定によるタイマ定義もできる。以下の例では、0秒後をカウントスタートにして、10秒に1回タイムアウト処理を呼び出す。createTimerメソッドと異なり、タイムアウト処理に渡したい情報はjavax.ejb.TimerConfigクラスにラップして渡す。

TimerConfig timerConfig = new TimerConfig();
timerConfig.setInfo("hello world");
timerService.createIntervalTimer(0, 10000, timerConfig);

スケジュール実行をAPIで指定する

@Scheduleで定義できるスケジュール実行は、APIでも定義することができる。
以下の例では毎分10秒に@Timeoutで定義されたタイムアウトメソッドが呼び出される。

TimerConfig timerConfig = new TimerConfig();
timerConfig.setInfo("hello world");
ScheduleExpression schedule = new ScheduleExpression();
schedule.hour("*");
schedule.minute("*");
schedule.second("*/10");
timerService.createCalendarTimer(schedule, timerConfig);

他にもいろいろあるが、TimerServiceインタフェースのJavaDocに解説がある。

はまりどころ

Java EEチュートリアルからは読み取れない、実際に触ってみて思ったいくつかのはまりどころを以下にまとめる。

はまりどころ1 『リトライ』

EJB3.2(Java EE7)仕様では、タイマー呼び出し先のメソッドで例外が投げられても、少なくとも1回はリトライしてくださいと定義してある。

13.4.3 Timer Expiration and Timeout Callback Method
If the transaction fails or is rolled back, the container must retry the timeout at least once.

http://download.oracle.com/otndocs/jcp/ejb-3_2-fr-eval-spec/index.html

これはEJB3.1(Java EE6)でも同様の記載がある。WildFly8.0.0.Finalで試すと、タイムアウトメソッドからランタイム例外を投げると以下のようなログが出力され、1回のみリトライされる。

14:59:27,461 INFO  [org.jboss.as.ejb3] (EJB default - 1) JBAS014121: Timer: [id=353403aa-c950-4aae-b6ba-656076e2bcc1 timedObjectId=Timer-1.0-SNAPSHOT.Timer-1.0-SNAPSHOT.HelloTimer auto-timer?:false persistent?:true timerService=org.jboss.as.ejb3.timerservice.TimerServiceImpl@39f8f1b0 initialExpiration=Sun Apr 27 14:59:27 JST 2014 intervalDuration(in milli sec)=0 nextExpiration=null timerState=IN_TIMEOUT info=hello world will be retried
14:59:27,462 INFO  [org.jboss.as.ejb3] (EJB default - 1) JBAS014123: Retrying timeout for timer: [id=353403aa-c950-4aae-b6ba-656076e2bcc1 timedObjectId=Timer-1.0-SNAPSHOT.Timer-1.0-SNAPSHOT.HelloTimer auto-timer?:false persistent?:true timerService=org.jboss.as.ejb3.timerservice.TimerServiceImpl@39f8f1b0 initialExpiration=Sun Apr 27 14:59:27 JST 2014 intervalDuration(in milli sec)=0 nextExpiration=null timerState=IN_TIMEOUT info=hello world

タイムアウト処理はトランザクション内のため、DBMSへのアクセスは例外時にロールバックされるが、ファイルシステムにアクセスしてCSVファイルを編集したり、ディレクトリを作ったり消したりする場合は自分で例外処理を書かないと戻らないので注意が必要。

はまりどころ2 『タイマーの永続化』

各タイマーはデフォルトで永続化される。WildFly8.0.0.Finalではデフォルトで${WILDFRY_HOME}/standalone/data/timer-service-dataにファイル形式で永続化されている。ドキュメントは見当たらなかったが、DBMSを永続化先に設定することもできるようだ。

永続化されたタイマーは、APサーバを一旦停止して、再起動したあとにも引き継がれる。例えば、30秒後にタイマーを設定し、30秒経つ前にAPサーバを停止、しばらくしてから再起動すると、再起動時点でタイマー設定時より30秒以上経過していた場合、すぐにタイムアウト処理が実行される。

f:id:n_agetsuma:20140427202650p:plain

タイマー永続化は明示的に再実行しなくても良い反面、少し怖い面もある。

例えば、夜に動いて欲しいバッチ処理をデフォルト通り永続化されたタイマーで設定していた場合、トラブル等で夜間に一時的に停止していて、日中になってから再起動した時、意図せず日中にバッチ処理が動き始めてしまうことも考えられる。

@Scheduleを使っている場合は、以下のようにpersistent="false"を設定することで、永続化が無効となる。

@Schedule(hour="4", minute="2", persistent="false")

APIでも、TimerConfigに永続化有無を設定することができる。

// コンストラクタの第2引数で永続化有無を設定。falseは無効。デフォルトtrue。
TimerConfig timerConfig = ("hello world", false);
timerService.createIntervalTimer(0, 10000, timerConfig);
はまりどころ3『EJB実行スレッドが足りない』

APサーバの実装にもよるが、少なくともWildFly8およびJBossEAP6は、タイムアウト処理はEJB処理用のスレッドプールからスレッドを払い出して処理を行う。デフォルト値は10であるため、10を超える並列数でタイムアウト処理を走らせようとすると、スレッド空き待ちになる可能性がある。

並行で動かしたいタイムアウト処理が多くなる場合はチューニングした方が良いと思う。

EJBスレッド数は管理コンソールでは以下のような画面から確認できる。

[Profile] -> [Subsytems] -> [Container] -> [EJB 3] -> [THREAD POOLS]
f:id:n_agetsuma:20140427203554p:plain

管理コンソールでそのまま変更しても良いし、以下のようにCLIから変更することもできる。

[standalone@localhost:9990 /] /subsystem=ejb3/thread-pool=default:write-attribute(name=max-threads,value=50)
はまりどころ4 『EJBプール数が足りない』

タイムアウト処理が大量に並行動作させるケースでは、スレッドだけでなくStatelessSessionBeanのプール数にも注意する必要がある。
EJB仕様ではEJB2.xの頃より、StatelessSesionBeanのインスタンスに対する並行アクセスを許可していない。代わりにEJBをプールしつつで1スレッドごとに1つのインスタンスを割り当てる実装としている。

4.3.13 Serializing Session Bean Methods
The container serializes calls to each stateful and stateless session bean instance. Most containers will support many instances of a session bean executing concurrently; however, each instance sees only a serialized sequence of method calls.

http://download.oracle.com/otndocs/jcp/ejb-3_2-fr-eval-spec/index.html

WildFly8の場合、デフォルトでは要求に応じてその都度EJBインスタンスを生成するが、JBossEAP6.2の場合はEJBプール数に上限があり、デフォルトは20である。多くの場合、普通のオンライン処理でも並行数20では足りないので、100ぐらいまで広げてみても良いと思う。

EJBスレッド数は管理コンソールでは以下のような画面から確認できる。

[Profile] -> [Subsytems] -> [Container] -> [EJB 3] -> [BEAN POOLS]
f:id:n_agetsuma:20140427211747p:plain

管理コンソールでそのまま変更しても良いし、以下のようにCLIから変更することもできる。

[standalone@localhost:9999 /] /subsystem=ejb3/strict-max-bean-instance-pool=slsb-strict-max-pool:write-attribute(name=max-pool-size,value=100)

まとめ

自分の身近な範囲ではcronが定番でJava EEのタイマー機能はあまり使われていないが、この機能を使うとデプロイがwar1つにまとめられるので、cronの設定し忘れやパーミッション設定誤りなどのミスを防ぐことが期待される。以下のはまりどころさえ意識すれば、とても便利な機能だと思う。

  • タイムアウト処理から例外が投げられた場合、少なくとも1回はリトライされる
  • 再起動時のタイマ実行を防ぐために、必要に応じてタイマーの永続化はオフにする
  • 並行して動作するタイムアウト処理が多い場合は、EJBスレッドプールをチューニングする
  • 同じく並行数が多い場合は、EJBインスタンスプールをチューニングする