バッチ#
書く#
public class PostCleanupBatch extends AbstractBatch {
@Override
public String batchName () { return "記事の掃除"; }
@Override
public String cron () {
// 毎日 3:15
return "15 3 * * *";
}
@Override
public void execute (BatchArgs args) {
int days = settings().getInt("days");
while (!isCancelOrder()) {
// 長い処理は中断指示を見る(要件 F-B-06)
}
}
}
batchName()は管理画面に出る名前ですcron()は5フィールドの cron 式です。書けばスケジューラが拾います(isEnableScheduler()が既定でtrue)isScheduler()はtrueにしません。「スケジューラそのものか」を聞くメソッドで、trueにするとマスタに載らず、cron で回りませんsettings()は DB に入れた設定(defaultBatchSettings()が初期値)です
登録する#
走査はしないので、自分で登録します。
BatchRegistry.add(PostCleanupBatch::new);
// DB の一覧を、いま登録されているものに合わせる
BatchRegistry.sync(DBUtil.getMainDB());
sync は、コードから消えたバッチの行を status = nothing にします(行は残すので履歴からたどれます)。
[!WARNING]
syncは登録を済ませてから呼んでください。 「1つも登録されていない」は「全部消えた」として扱うので、 登録し忘れたまま呼ぶと全部の行がnothingになります(スケジューラが何も回さなくなります)。 バッチを持たないアプリは呼ばないでください。
batch_masterを別のアプリと共有しないでください。 消えた判定はクラス名で行うので、同じテーブルを見るアプリが2つあると お互いの行をnothingにし合います。
中断できるようにする#
while (!isCancelOrder()) {
// 1件ずつ処理する
}
管理画面から中断を押すと isCancelOrder() が true になります。
長く回る処理では必ず見てください。 見ていないバッチは止められません。
AbstractBatch を継承していないところ(呼び出した先の Domain 層など)からは、
コンテキスト越しに同じものが見られます。
BatchContext context = Context.current(BatchContext.class);
for (Data row : rows) {
if (context.isCancelOrdered()) { break; }
// ...
}
**どちらも同じ指示を見ています。**DB を引くのは
batch.cancel_check に1回だけなので、ループの中で毎回呼んで構いません。
落とし穴
0.6.0 まで BatchContext.isCancelOrdered() は常に false でした。
中断の判定は AbstractBatch の側にあり、コンテキストには何も繋がっていませんでした。
例外も警告も出ないので、本番で中断ボタンを押すまで分かりません(1.0 で繋ぎました)。
一定件数ずつ読んでまとめて書く#
100万件を1つのトランザクションで抱えたくないときは AbstractChunkBatch を継承します。
読む → 加工する → まとめて書くを繰り返し、chunkSize() 件ごとに確定します。
public class RequestArchiveBatch extends AbstractChunkBatch<Data> {
@Override public String batchName () { return "古い申請の書庫入れ"; }
@Override public String cron () { return "0 4 * * *"; }
@Override public int chunkSize () { return 500; }
@Override
protected Iterator<Data> reader (BatchArgs args, DB db) {
// キー順に少しずつ読む
return KeyPagingReader.of("id", 0L, chunkSize(), (lastKey, limit) -> db.selectList("""
SELECT id, amount FROM request
WHERE status = ? AND id > ?
ORDER BY id ASC
LIMIT ?
""", "approved", lastKey, limit));
}
@Override
protected Data process (Data item) {
// 書かないものは null を返す(既定は素通し)
return item.getLong("amount") == 0 ? null : item;
}
@Override
protected void write (List<Data> items, DB db) {
for (Data item : items) {
db.update("UPDATE request SET status = 'archived' WHERE id = ?", item.getLong("id"));
}
}
}
登録の仕方も、管理画面での見え方も、ふつうのバッチと同じです。
- 1回の
write()が1トランザクションです。抜けた時点で確定します - 中断はかたまりの切れ目で見ます。
isCancelOrder()を自分で呼ぶ必要はありません execute()はfinalです。手を入れるのはreader()/process()/write()の3つだけです
途中で落ちたとき#
**そのかたまりだけ戻して、バッチ全体を失敗にします。**黙って次へ進むことはしません。
どこまで確定したかは batch_history.execute_info に残ります。
| キー | 中身 |
|---|---|
chunk_read |
読んだ件数 |
chunk_filtered |
process() が null を返してよけた件数 |
chunk_written |
確定した件数 |
chunk_committed |
確定したかたまりの数 |
chunk_failed_at |
何かたまり目で落ちたか |
chunk_canceled |
中断で抜けたか |
chunk_written は batch.progress(既定5秒)ごとに書き換わるので、
走っている最中でも管理画面から「いま何件目か」が見えます。
読む DB と書く DB は別です#
reader() に渡る db と write() に渡る db は別のインスタンスです。
渡されたものをそのまま使ってください。
かたまりのトランザクション(Tx)が終わるとコネクションがプールへ返るので、
同じ DB で読んでいると最初のかたまりを確定した時点で読みかけが死にます。
こつ
カーソル(selectListWithFetcher)ではなく KeyPagingReader を勧めます。
カーソルは開いているあいだ、ずっとコネクションを1本押さえます。
数時間かかるバッチではそれが効いてきます。
ページングはページとページの間はコネクションを持ちません。
KeyPagingReader に渡すキーは、一意で、ORDER BY と揃っていて、走っているあいだ書き換わらない列にしてください。
キーが進まないまま満杯のページが返ってきたときは例外になります(黙って無限に回るより落ちたほうがよいためです)。
[!WARNING] **
write()の中の SQL が失敗するとSqlExecuteExceptionが飛びます。**そのかたまりは巻き戻り、バッチは失敗になります(上の「途中で落ちたとき」)。 1文ごとに結果を確かめる必要はありません。 **失敗をcatchして続けないでください。**そのかたまりの確定がTransactionException(DB_004)で断られ、全部巻き戻ります。
二重起動#
同じバッチが同時に走る本数は allowConcurrentExecutionCount() で決まります。
既定は1本です。前の実行がまだ終わっていなければ、次はスキップされます。
サーバーが複数台でも、cron の1回で走るのは同時実行数までです(2.5.8 から)。
| 同時実行数 | cron の1回で走る数(3台で動かしたとき) |
|---|---|
| 1(既定) | 1(どれか1台) |
| 2 | 2 |
| 0(上限なし) | 3(台数分) |
どの台にも同じ回の時刻が来ますが、batch_master の「最後に走らせた回」と「その回で走らせた数」を進められた台だけが走らせます。
2.5.7 までは「いま走っている数」だけで止めていたので、ほかの台より早く終わるバッチは、時刻が少し遅れて来た台でもう一度走ることがありました(確かめる間隔は1秒、それに台どうしの時計のずれが加わります)。
単体で走らせる#
バッチの入口は自分で書きます。起動の順番がそのファイルに全部出ます。
public class BlogBatch {
public static void main (String[] args) {
DBUtil.load(Conf.conf().config(), BlogBatch.class); // 1. DB
BatchTables.install(DBUtil.getMainDB()); // 2. バッチのテーブル
BatchRegistry.add(PostCleanupBatch::new); // 3. 登録
BatchRegistry.sync(DBUtil.getMainDB());
BatchResult result = BatchExecutor.start(args); // 4. 実行
DBUtil.stop();
System.exit(result == BatchResult.completed ? 0 : 1);
}
}
java -Djimble.env=prod -cp app.jar blog.BlogBatch class=blog.batch.PostCleanupBatch days=30
key=value の形で渡したものが args.cliArgs に入ります。
環境は -Djimble.env で決めます(env= を渡しても環境は変わらず、いまの環境と違えば止まります)。
終了コードは completed のときだけ 0 にします。isExecuted() は「走ったか」で、失敗(error)や中断(canceled)でも true です。
cron で回すなら DbScheduler を入口にした常駐プロセスを別に立てます。
管理画面#
jimble-batch-manager を入れると、Web からバッチの一覧・実行・中断・履歴が見られます。
implementation("io.jimble:jimble-batch-manager:<版>")
install(BatchManagerController::new);
Basic 認証が組み込みで付きます。設定で有効にし、ユーザー名とパスワードを渡します。
batch_manager {
enabled = true # 既定は false
username = "admin"
password = ${?JIMBLE_BATCH_MANAGER_PASSWORD}
}
ユーザー名かパスワードが空だと、画面は組み込まれません(エラーのログが出るだけです)。認証の無いまま公開されることはありません。