タスクスケジューリングガイド
Guren では、定期実行するタスクをアプリケーションの中で Fluent API を使って定義します。cron のエントリをいくつも管理しなくても、タスクのスケジュールをすべてコードに書けます。
タスクスケジューリングガイド
Guren では、定期実行するタスクをアプリケーションの中で Fluent API を使って定義します。cron のエントリをいくつも管理しなくても、タスクのスケジュールをすべてコードに書けます。
おすすめの書き方は、@guren/core から scheduling API を import し、スケジュールの登録を 1 か所にまとめることです。各機能のコードには、スケジュールから実行するジョブやコマンドの実装だけを書きます。
コアコンセプト
- Scheduler: スケジュールされたタスクを管理し、決まったタイミングで実行する。
- Schedule: Fluent API でタスクを定義するためのビルダー。
- ScheduledTask: スケジュールと設定を持つ、1 つひとつのタスク。
- Cron Expression: タスクをいつ実行するかを表す、標準的な cron の構文。
基本的な使い方
クイックスタート
import { Scheduler } from '@guren/core'
const scheduler = new Scheduler()
scheduler.schedule((schedule) => {
// 毎日午前3時にコールバックを実行
schedule.call(async () => {
await cleanupOldSessions()
}).daily().at('03:00').name('cleanup-sessions')
})
scheduler.start()
スケジューラーの実行
スケジューラーは、アプリケーションの起動処理の中で開始します。
// app.ts
import { Scheduler } from '@guren/core'
const scheduler = new Scheduler({
timezone: 'Asia/Tokyo',
checkInterval: 60000, // 60秒ごとにチェック
logger: console.log,
})
// スケジュールを定義
scheduler.schedule((schedule) => {
schedule.call(() => console.log('Hello!')).everyMinute()
})
// アプリ起動時に開始
scheduler.start()
// シャットダウン時に停止
process.on('SIGTERM', () => {
scheduler.stop()
})
scheduler.start() を呼ぶと、その場で一度 tick が走ります。そのあと tick が続くのは、アプリサーバーのように別の仕組みが動かし続けているプロセスの中だけです。scheduler.start() を呼ぶだけの単体スクリプトでは、その時点で実行時刻を迎えたタスクを動かしたら、次の tick を待たずにプロセスが終了します。
実行時刻を迎えたタスクをプロセスの外から実行したい場合は、cron から bunx guren schedule:run を呼び出してください。このコマンドは、スケジュールカーネルに登録したタスクを読み込んで実行します。詳しくはCLIから見えるようにするを参照してください。
サーバーレスランタイムでの実行
scheduler.start() は常駐するプロセスを必要としますが、Cloudflare Workers にも AWS Lambda にもそのようなプロセスはありません。これらの環境ではプラットフォーム側のスケジューラが tick の役割を担い、アプリはタスクを登録するだけです。
- Cloudflare Workers:
guren cloudflare:buildが生成するワーカーはscheduledハンドラを export しており、wrangler.jsoncのtriggers.cronsに書いたスケジュールでそのハンドラが呼ばれます。Cloudflare Workers へのデプロイを参照してください。 - AWS Lambda:
@guren/core/lambdaのcreateScheduleHandler(scheduler)を EventBridge ルールにつなぎます。サーバーレスを参照してください。
トリガーが発火するたびに、その時点で実行時刻を迎えているタスクだけが実行されます。そのため、プラットフォームのトリガーの頻度は、最も頻繁に動くタスクと同じか、それより細かくしてください。
また、preventOverlapping() はタスクに付くメモリ上のフラグなので、プロセスが常駐しないランタイムでは、発火と発火の間では効きません。runOnOneServer() は取得の記録をスケジューラの lock に置くので、ロックの保存先が発火のあとも残っていれば効きます。既定のプロセス内ロックは残りません。
schedule.command() は node:child_process でシェルを呼び出すため、Workers では動きません。Workers では schedule.call() か schedule.job() を使ってください。
スケジュールの定義
コールバック
scheduler.schedule((schedule) => {
schedule.call(async () => {
// タスクロジック
await sendDailyReports()
}).daily().at('09:00')
})
ジョブ
キューのジョブを、スケジュールに沿ってディスパッチします。
import { SendWeeklyDigestJob } from '@/app/Jobs/SendWeeklyDigestJob'
scheduler.schedule((schedule) => {
schedule.job(SendWeeklyDigestJob, { userId: 'all' })
.weekly()
.sundays()
.at('09:00')
})
シェルコマンド
scheduler.schedule((schedule) => {
schedule.command('pg_dump "$DATABASE_URL" --file=backup.sql')
.daily()
.at('02:00')
.name('database-backup')
})
実行頻度オプション
分単位
schedule.call(task).everyMinute() // 毎分
schedule.call(task).everyTwoMinutes() // 2分ごと
schedule.call(task).everyThreeMinutes() // 3分ごと
schedule.call(task).everyFourMinutes() // 4分ごと
schedule.call(task).everyFiveMinutes() // 5分ごと
schedule.call(task).everyTenMinutes() // 10分ごと
schedule.call(task).everyFifteenMinutes()// 15分ごと
schedule.call(task).everyThirtyMinutes() // 30分ごと
時間単位
schedule.call(task).hourly() // 毎時00分
schedule.call(task).hourlyAt(15) // 毎時15分
schedule.call(task).everyTwoHours() // 2時間ごと
schedule.call(task).everyThreeHours() // 3時間ごと
schedule.call(task).everyFourHours() // 4時間ごと
schedule.call(task).everySixHours() // 6時間ごと
日単位
schedule.call(task).daily() // 毎日0時
schedule.call(task).dailyAt('13:00') // 毎日13時
schedule.call(task).at('13:00') // dailyAtのエイリアス
schedule.call(task).twiceDaily(1, 13) // 1時と13時
週単位
schedule.call(task).weekly() // 毎週日曜0時
schedule.call(task).weeklyOn(1, '08:00') // 毎週月曜8時
// 曜日のショートカット
schedule.call(task).daily().sundays()
schedule.call(task).daily().mondays()
schedule.call(task).daily().tuesdays()
schedule.call(task).daily().wednesdays()
schedule.call(task).daily().thursdays()
schedule.call(task).daily().fridays()
schedule.call(task).daily().saturdays()
// 平日と週末
schedule.call(task).daily().weekdays() // 月曜〜金曜
schedule.call(task).daily().weekends() // 土曜〜日曜
月単位と年単位
schedule.call(task).monthly() // 毎月1日0時
schedule.call(task).monthlyOn(15, '09:00') // 毎月15日9時
schedule.call(task).lastDayOfMonth('18:00') // 月末18時
schedule.call(task).quarterly() // 1月、4月、7月、10月1日
schedule.call(task).yearly() // 1月1日0時
schedule.call(task).yearlyOn(6, 15, '12:00') // 6月15日12時
カスタムCron
// 標準cronフォーマット: 分 時 日 月 曜日
schedule.call(task).cron('0 */2 * * *') // 2時間ごと
schedule.call(task).cron('30 9 * * 1-5') // 平日9:30
schedule.call(task).cron('0 0 1,15 * *') // 1日と15日の0時
タスク設定
タスク名
schedule.call(sendReports)
.daily()
.name('send-daily-reports') // タスクの一意識別子
タイムゾーン
schedule.call(task)
.daily()
.at('09:00')
.tz('Asia/Tokyo') // 東京時間の9時に実行
// またはsetTimezoneを使用
schedule.call(task)
.daily()
.setTimezone('America/New_York')
重複実行の防止
前回の実行がまだ終わっていなければ、今回の実行をスキップします。
schedule.call(longRunningTask)
.everyMinute()
.preventOverlapping() // 前回の実行が終わっていなければスキップ
// 有効期限付き(10分後に自動アンロック)
schedule.call(task)
.everyMinute()
.preventOverlapping(600000) // ミリ秒で10分
この重複防止は、そのプロセスの中でタスクに付けたフラグで判定しています。有効期限を付けないと、固まったままの実行が 1 つあるだけで、プロセスを再起動するまで以降の実行がすべて止まります。有効期限を付ければ、指定したミリ秒が経った時点で次の実行が始まり、固まっていた実行があとから終わっても、その実行には影響しません。
単一サーバーでの実行
複数のサーバーで動かす構成では、どのサーバーでもスケジューラが tick するので、スケジュールごとに 1 回だけ動かしたいタスクには、サーバー間で共有するロックが必要です。スケジューラに SchedulerLock を渡し、タスクに印を付けてください。
import { createScheduler, createRedisClient } from '@guren/core'
import { RedisSchedulerLock } from '@guren/core/redis'
const scheduler = createScheduler({
lock: new RedisSchedulerLock(createRedisClient({ url: process.env.REDIS_URL })),
})
scheduler.schedule((schedule) => {
schedule.call(task)
.daily()
.name('daily-report')
.runOnOneServer()
})
取得の記録は、タスク名と実行時刻(分単位)をキーにして保存されます。そのため、タスクには .name() が必要です。記録は 1 時間保持され、実行が終わっても解放されません。解放してしまうと、時計が数秒遅れているサーバーが同じ分に達したときに、タスクがもう一度実行されてしまうからです。
自分の重複防止のフラグでタスクがまだ止まっているサーバーは、取得自体を試みません。取得に成功したあとで when() / skip() によって実行を見送ったサーバーは記録を手放すので、その分の実行は別のサーバーが引き受けられます。
MemorySchedulerLock は単一プロセス用のロックで、サーバーが 1 台の構成やテストで使います。lock を渡さずに作ったスケジューラも、このロックを使います。2 台目のサーバーからは何も守れないので、暗黙のロックで動く runOnOneServer() タスクが最初に現れた時点で、createScheduler({ lock }) と RedisSchedulerLock を案内する警告が表示されます。
.name() がないタスクや名前が空文字のタスクは、記録のキーにする名前がないため、登録した時点と start() の時点で拒否されます。
Redis 以外の保存先を使いたい場合は、SchedulerLock を自分で実装してください。acquire(key, ttlSeconds) では有効期限付きの set-if-absent をアトミックに行い、release(key) ではキーを削除します。
キーの先頭には、スケジューラの lockPrefix(既定は 'schedule:')が付きます。1 つのロックの保存先を 2 つのアプリで共有するときは、それぞれ別の prefix を設定してください。同じにしておくと、同名のタスクがあった場合に、本来 1 回ずつ動くはずの実行を 2 つのアプリで奪い合うことになります。
条件付き実行
// 条件がtrueの場合のみ実行
schedule.call(task)
.daily()
.when(() => process.env.NODE_ENV === 'production')
// 条件がtrueの場合はスキップ
schedule.call(task)
.daily()
.skip(() => isMaintenanceMode())
ライフサイクルフック
schedule.call(sendEmails)
.daily()
.at('09:00')
.before(() => console.log('メール送信開始...'))
.after(() => console.log('メール送信完了'))
.onSuccess(() => metrics.increment('emails.sent'))
.onFailure((error) => {
alerting.notify('メール送信失敗', error)
})
スケジューラーAPI
const scheduler = new Scheduler()
// タスクを定義
scheduler.schedule((schedule) => { ... })
// ビルド済みタスクを追加
scheduler.addTask(scheduledTask)
// 開始/停止
scheduler.start()
scheduler.stop()
// ステータス確認
scheduler.getIsRunning()
// タスクを取得
scheduler.getTasks() // 全タスク
scheduler.getDueTasks() // 現在実行予定のタスク
scheduler.getTask('task-name') // 名前でタスクを取得
scheduler.count() // タスク数
// タスク管理
scheduler.removeTask('task-name') // 名前で削除
scheduler.clear() // 全タスク削除
// 手動実行
await scheduler.runDueTasks() // 実行予定の全タスクを今すぐ実行
CLI統合
tick し続けるスケジューラーは、アプリケーションの中で動きます(前述のscheduler.start())。CLI が受け持つのはそれ以外の部分で、何が登録されているかの確認と、プロセスの外からの実行です。プロセスの外からの実行は、システムの cron やプラットフォームのトリガーから呼び出します。
# スケジュールされたタスクを一覧
bunx guren schedule:list
bunx guren schedule:list --json
# 実行時刻を迎えたタスクを実行
bunx guren schedule:run
# 特定のタスクを時刻に関係なく即座に実行
bunx guren schedule:run --task cleanup-sessions --force
CLIから見えるようにする
schedule:listとschedule:runはアプリケーションを boot しません。読み込むのはスケジュールカーネルだけで、app/Console/Kernel.ts(小文字の版とsrc/の下の版も含む)か、--kernelで渡したパスを探します。それ以外の場所で宣言したタスクは、scheduler.start()では問題なく動いていても、この 2 つのコマンドからは見えません。
認識されるエクスポートの形は 2 通りあり、それぞれに命名規約があります。
レジストラは、多くのアプリケーションですでに書かれている形です。アプリの実行時はプロバイダーが スケジューラーを作って渡し、CLI から呼ぶときは CLI が自前のスケジューラーを渡します。
// app/Console/Kernel.ts
import type { Scheduler } from '@guren/core'
export function registerSchedules(scheduler: Scheduler): void {
scheduler.schedule((schedule) => {
schedule.call(warmCache).hourly().name('warm-cache')
})
}
名前はregister…Schedules(registerSchedules、registerBillingSchedulesなど)にするか、デフォルトエクスポートにします。1 つのカーネルから複数エクスポートしても構いません。どれも同じスケジューラーを受け取ります。この命名規約があるので、CLI はファイルがたまたまエクスポートしているヘルパーまで呼んでしまうことがありません。規約に合わない名前のレジストラは、黙って無視されず、「認識できない」と報告されます。
カーネルファクトリは引数を取らず、組み立てたScheduleを返す関数です。
scheduleTasksKernel、schedule、defineScheduleという名前か、デフォルトエクスポートであれば認識されます。bunx guren add scheduleが生成するのはこの形です。
// app/Console/Kernel.ts
import { Schedule } from '@guren/core'
export function scheduleTasksKernel(): Schedule {
const schedule = new Schedule()
schedule.call(warmCache).hourly().name('warm-cache')
return schedule
}
ファクトリはタスクを宣言するだけで、それ自体は何も実行しません。宣言したタスクは、プロバイダーがバインドするスケジューラーに別途渡してください。
for (const task of scheduleTasksKernel().buildTasks()) scheduler.addTask(task)
アプリケーションのコードでは、レジストラの形をおすすめします。このガイドのほかの箇所で説明しているScheduler APIをそのまま使えて、つなぎのコードをもう一度書かなくてもタスクが実行中のスケジューラーに届くからです。
どちらの形でも、サービスはカーネルを組み立てるときではなく、タスクのコールバックの中で解決してください。CLI はアプリを boot せずにこのファイルを読むので、組み立ての時点でコンテナから取り出そうとしても、解決できるものがありません。
schedule.call(() => defaultContainer().make<SessionManager>('session').pruneExpired()).hourly()
カーネルがあるのにどちらの形にも当てはまらない場合や、読み込み中に例外を投げた場合は、そのことを報告して 0 以外の終了コードで終わります。まだ何もスケジュールしていないアプリとは、別の状態として扱われます。
テスト
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
import { Scheduler, Schedule } from '@guren/core'
describe('Scheduling', () => {
let scheduler: Scheduler
beforeEach(() => {
scheduler = new Scheduler()
})
afterEach(() => {
scheduler.stop()
})
test('日次タスクをスケジュールする', () => {
scheduler.schedule((schedule) => {
schedule.call(() => {}).daily().at('09:00').name('test-task')
})
expect(scheduler.count()).toBe(1)
expect(scheduler.getTask('test-task')).toBeDefined()
})
test('実行予定のタスクを識別する', () => {
const mockTask = mock(() => {})
scheduler.schedule((schedule) => {
schedule.call(mockTask).everyMinute()
})
const dueTasks = scheduler.getDueTasks(new Date())
expect(dueTasks.length).toBeGreaterThan(0)
})
test('実行予定のタスクを実行する', async () => {
let executed = false
scheduler.schedule((schedule) => {
schedule.call(() => { executed = true }).everyMinute()
})
await scheduler.runDueTasks()
expect(executed).toBe(true)
})
test('when条件を尊重する', async () => {
let executed = false
scheduler.schedule((schedule) => {
schedule.call(() => { executed = true })
.everyMinute()
.when(() => false) // 実行しない
})
await scheduler.runDueTasks()
expect(executed).toBe(false)
})
})
ベストプラクティス
タスクに名前を付ける: デバッグや管理がしやすくなるよう、常に
.name()を付ける。頻度は必要な分だけにする: 必要以上に頻繁にタスクを実行しない。
タイムゾーンを明示する: 実行時刻が重要なタスクでは、解釈の違いが出ないようタイムゾーンを設定する。
長いタスクは重複を防ぐ: 実行間隔より時間がかかりそうなタスクには
.preventOverlapping()を付ける。失敗にきちんと対処する:
.onFailure()でエラーをログに残し、アラートを送る。スケジュールをテストする: タスクのスケジュールが意図どおりかを確かめるテストを書く。
タスクの実行を監視する: 実行をログに残し、成功と失敗のメトリクスを追う。
重い処理はジョブに回す: 重い処理をスケジューラーで直接実行せず、ジョブをディスパッチする。