wakuwaku

cron ジョブ

ServiceBuilder から組み立てたローカルジョブを一定間隔で実行します。実行の間隔の取り方は三種類あり、エラーや panic が起きてもスケジュールは続きます。

マイクロサービスにおける「cron」処理の多くはスキャンです。期限切れセッションの削除、詰まった処理の再試行、検索インデックスの整合などです。CronJob はまさにそのためのもので、定数 INTERVAL を持つ CronTick の Processor です。INTERVAL は実行の間隔の取り方を表す CronJobInterval です。間隔はカレンダー式ではなくジョブ型の定数なので、「毎日 3:00 に」のような書き方はありません。

Spring でいえば、Bean のメソッドに付ける @Scheduled がいちばん近いもので、三種類の間隔はそのオプションに対応します。AfterFinish は fixedDelay、AtLeast は実行が重ならない fixedRate、AfterStart は @Async を付けた fixedRate です。違いの一つは 後述 します。サービスのレプリカはそれぞれがすべてのジョブを実行します。

ジョブを定義する

ジョブは依存を保持する構造体で、次の三つのトレイトを実装します。

  • ServiceCreation。サービスビルダー に登録された値から組み立てるためのものです。
  • Processor<CronTick>。Output = () で、Error は Display を実装している必要があります。これが一回の実行です。
  • CronJob。INTERVAL を追加します。

ジョブは専用の Tokio タスクで動くため、Send + Sync + 'static でもある必要があります。

src/jobs.rs
use std::time::Duration;

use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::{CronJob, CronJobInterval, CronTick, ServiceCreation};

pub struct ExpireSessions {
    store: SessionStore,
}

impl ServiceCreation for ExpireSessions {
    type Dep = SessionStore;

    fn create_service(store: SessionStore) -> Self {
        Self { store }
    }
}

impl Processor<CronTick> for ExpireSessions {
    type Output = ();
    type Error = StoreError;

    async fn process(&self, _tick: CronTick) -> Result<(), StoreError> {
        let deleted = self.store.delete_expired(1_000).await?;
        tracing::info!(deleted, "expired sessions removed");
        Ok(())
    }
}

impl CronJob for ExpireSessions {
    const INTERVAL: CronJobInterval = CronJobInterval::AtLeast(Duration::from_secs(60));
}

SessionStore と StoreError は、実際のデータアクセスの代わりのダミーです。ストアは clone が安価です(ジョブを組み立てるときにサービスビルダーから clone されます)。エラーに必要なのは Display だけです。失敗した実行はそれでログに出力されるためです。

src/store.rs
/// データベースプールの代わり: clone は安価で、すべての clone がデータを共有する。
#[derive(Clone, Default)]
pub struct SessionStore {
    expired: Arc<AtomicU64>,
}

impl SessionStore {
    pub async fn delete_expired(&self, limit: u64) -> Result<u64, StoreError> {
        self.expired.fetch_add(1, Ordering::Relaxed);
        Ok(limit.min(1))
    }
}

#[derive(Debug)]
pub struct StoreError(String);

impl fmt::Display for StoreError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "session store: {}", self.0)
    }
}

データ層を使うなら、Db ハンドルはそのまま Dep にできます。クエリのエラーもすでに Display を実装しています。データアクセス を参照してください。

ティック

各実行は CronTick を受け取ります。

フィールド型意味
runu64ランタイム開始以降、このジョブがそれまでに実行された回数。最初の実行では 0。
fired_atSystemTime実行を開始した時点の実時間。

run は実際に行われた実行を数えます。実行の間隔は 間隔の種類 と各実行にかかる時間で決まり、AfterStart はスキップせざるを得なかったグリッド点を数えないので、run は「開始からの経過時間 ÷ 間隔」ではありません。「N 回に一回は全件処理する」といった用途に向いています。

impl Processor<CronTick> for ReindexSearch {
    type Output = ();
    type Error = StoreError;

    async fn process(&self, tick: CronTick) -> Result<(), StoreError> {
        // 24 回に一回(と初回)は全件を作り直し、
        // それ以外は変更分だけを取り込む
        let reindexed = if tick.run % 24 == 0 {
            self.index.reindex_all().await?
        } else {
            self.index.reindex_changed().await?
        };
        tracing::info!(run = tick.run, fired_at = ?tick.fired_at, reindexed, "search index refreshed");
        Ok(())
    }
}

impl CronJob for ReindexSearch {
    const INTERVAL: CronJobInterval = CronJobInterval::AfterFinish(Duration::from_secs(60 * 60));
}

登録と起動

ServiceBuilder::cron_job::<Job, Position>() はジョブの Dep を探して clone し、create_service でジョブを組み立て、ジョブのリストを始めます。ジョブごとに一回ずつチェーンして呼び、最後に start します。

src/main.rs
let (services, cron) = ServiceBuilder::new(SessionStore::default())
    .push(SearchIndex::default())
    .cron_job::<ExpireSessions, _>()
    .cron_job::<ReindexSearch, _>()
    .start();

// `services` は返ってくる: ここから gRPC サーバーやコンシューマーを登録できる
let _store: &SessionStore = services.provide::<SessionStore, _>();

let _ = tokio::signal::ctrl_c().await;
// 実行中の処理を終わらせてから、すべてのジョブを止める
cron.shutdown().await;
  • Position は、Dep 型が複数回登録されていない限り _ で構いません(複数回登録している場合は Here / There を使います。サービスビルダー を参照)。登録されていない Dep はコンパイルエラーになります。
  • 複数の登録値が必要なジョブは、代わりに compose_cron_job で登録します。複数のサービスから組み立てるジョブ を参照してください。
  • start は ジョブごとに一つの Tokio タスク を起動し、サービスビルダーと CronRuntime を返します。Tokio ランタイムの中で呼ぶ必要があり、外で呼ぶと panic します。
  • ジョブは、gRPC サーバーやサービスビルダー自体と同じく型レベルのリストに保持されます。Box 化も動的ディスパッチもありません。

スケジュール

同じ cron ジョブを 3 種類の CronJobInterval で動かした場合の比較(時間軸は間隔 d 単位)各行は同じジョブ: 初回の実行は 2.5·d、以降の実行は 0.25·dAfterFinish(d)前回の終了から d 後重ならないdd初回 · 2.5·dAtLeast(d)開始の間隔は d 以上重ならないmax(d, 2.5·d)dd初回 · 2.5·dAfterStart(d)格子 start + k·d重なりうるddddd初回 · 2.5·dErr → ログに記録、スケジュールは継続d より長い → 終了後すぐ次を開始d と 2d の実行が初回の実行と重なる時間startd2d3d4d5d実行Err やパニック: tracing でログに記録し、スケジュールは継続
同じジョブを 3 種類の INTERVAL で動かした場合です。AfterFinish は各実行の終了から d 待ち、AtLeast は開始の間隔を d 以上に保ちつつ、d より長い実行の後はすぐに次を始めます。AfterStart は実行の長さにかかわらず格子どおりに始めるので、d と 2d の実行は初回の実行と並行に動きます。どの種類でも最初の実行は start の時点です。

どのジョブも、最初の実行は start の時点です。 start を呼んだ時点であり、一間隔待ってからではありません。その後の実行がいつ始まるかは、INTERVAL のバリアントとその長さ d で決まります。

INTERVAL次の実行が始まるのは同じジョブの実行の重なりSpring
AfterFinish(d)前の実行が 終わって から d 後なしfixedDelay
AtLeast(d)前の実行が 始まって から d 後。前の実行がそれより遅く終わった場合は終わった直後なし重ならない fixedRate
AfterStart(d)前の実行の状態にかかわらず、グリッド start + k × d 上あり@Async を付けた fixedRate

三つの違いが表れるのは、実行に時間がかかったときだけです。図では、最初の実行に 2.5·d、それ以降の実行にそれぞれ 0.25·d かかっています。

INTERVAL実行の開始時刻
AfterFinish(d)0、3.5·d、4.75·d、6·d、…実行の間には必ず d の休みが入るので、開始時刻の間隔は d + 実行時間です。
AtLeast(d)0、2.5·d、3.5·d、4.5·d、…超過した実行の直後に次が始まり、その後は d ごとに一回に戻ります。
AfterStart(d)0、d、2·d、3·d、…d と 2·d の実行は、最初の実行がまだ続いているうちに始まります。

ジョブ同士は独立しています。ReindexSearch が遅くても ExpireSessions が遅れることはありません。

間隔の選び方

AtLeast(d): d ごとに多くても一回、同時には一つだけ。 「だいたい d ごとに」動かしたいスキャンなら、ふつうはこれを選びます。時間を超過した実行は、未処理の回を溜め込む代わりに次の実行を遅らせます。次の実行は超過した実行が終わった直後に始まり、その後はジョブが元のペースに戻ります。連続する開始時刻は単調時計で少なくとも d 離れるので、その間に実時間の時計が調整されない限り、fired_at も少なくとも d 離れます。

AfterFinish(d): 実行と実行の間に必ず休みを入れる。 二つの開始時刻の間隔は d + 実行時間なので、スケジュールは各実行の長さの分だけずれていきます。その代わり、ジョブが負荷をかける相手(データベースやレート制限のある API)は、実行がどれだけ長くかかっても、その後に必ず d の休みを得られます。溜まった作業をバッチで消化する処理や、負荷に応じて実行時間が伸びる処理に向いています。

AfterStart(d): 固定のグリッド。実行が重なることがある。 各グリッド点で、前の実行が終わったかどうかにかかわらず、専用のタスクで実行を始めます。排他よりタイミングが重要な場合、たとえば一回の実行が遅れても後ろにずれてはいけないサンプリングやハートビートに向いています。次の点に注意してください。

  • ジョブは自分自身と並行して動きます。process は &self を受け取るので、実行間で共有する状態には同期が必要で、処理自体も二つの実行が同時に動くことに耐えなければなりません。
  • 実行が d より長くかかり続けると、実行は溜まり続けます。同時に動く数に上限はありません。
  • ランタイムがブロックされていた(たとえばワーカースレッド上のブロッキングなコードによって)ために逃したグリッド点は、まとめて実行されずにスキップされ、run にも数えられません。
  • fired_at には起床の遅れが含まれるので、連続する二つの fired_at の間隔が d よりわずかに 短く なることがあります。これは レプリカ間のフェンス で問題になります。

エラーと panic

Err を返した実行は、tracing で error レベルとしてログに出力されます(cron job failed とエラーの Display 出力)。panic した実行も捕捉され、同様にログに出力されます(cron job panicked と panic メッセージ)。どちらの場合もジョブのスケジュールは保たれ、次の実行は、実行が成功した場合と同じく間隔の定めるとおりに始まります。

各実行は job(ジョブの型名)と run のフィールドを持つ cron_job という tracing スパンの中で動くので、ジョブが出力したログはどの実行のものか追跡できます。これらのログを見るには、main で tracing のサブスクライバーを設定してください。

panic = "abort"

panic の捕捉はアンワインドに依存しています。リリースプロファイルで panic = "abort" を設定していると、panic したジョブはプロセス全体を停止させます。

間隔 0 はビルドできない

長さ 0 の間隔は、三つのどのバリアントでもスケジュールが成り立たないため、そのようなジョブの登録はコンパイル時に拒否されます。ここで Busy は通常どおり ServiceCreation と Processor<CronTick> を実装しています。

impl CronJob for Busy {
    const INTERVAL: CronJobInterval = CronJobInterval::AtLeast(Duration::ZERO); // no schedule
}

#[tokio::main]
async fn main() {
    let (_services, cron) = ServiceBuilder::new(Config)
        .cron_job::<Busy, _>() // `cargo build` fails: INTERVAL is zero
        .start();
    cron.shutdown().await;
}

cargo build は次のエラーで止まります(抜粋)。

error[E0080]: evaluation panicked: CronJob::INTERVAL must not be zero
   --> wakuwaku/src/services/cron_job.rs:300:17
    |
300 |         const { assert_interval::<Job>() };
    |                 ^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `wakuwaku::services::cron_job::CronRegisterCenter::<Busy>::new::{constant#0}` failed inside this call
...
note: the above error was encountered while instantiating `fn CronRegisterCenter::<Busy>::new`

エラーが指すのは自分の cron_job の呼び出しではなく wakuwaku のソースです。どのジョブを直せばよいかは CronRegisterCenter::<Busy> のジョブ型でわかります。

cargo check では検出されない

この検査は、コード生成の際に登録がインスタンス化されるときに評価される const アサーションです。cargo build と cargo run では報告されますが、cargo check、cargo clippy、エディターでは報告されません。cargo test も、テストが実際にその登録に到達しない限り報告しません(テストハーネスは main を呼ばないので、そこにある登録はインスタンス化されません)。CI で check、clippy、テストしか実行していないなら、cargo build のステップを追加してください。

複数のレプリカで動かす

ジョブは ローカルで 動きます。ジョブを起動したプロセスはすべてそれを実行します。サービスのレプリカが三つあれば各実行は三回行われ、各レプリカのスケジュールはそのレプリカが起動した時点から始まるため、三つの実行の時刻も揃いません。

ジョブはこれを前提に設計します。おすすめ順に挙げます。

  1. 冪等にする。 「期限切れのセッションを削除する」は三回実行しても安全です。二回目と三回目は何もすることがありません。
  2. 作業を分割する。 各レプリカがキーの自分の担当分だけをスキャンするようにします(たとえば ID のハッシュをレプリカ数で割った余りで分ける)。
  3. 実行の中でロックを取る。 たとえば redis feature の Redis Lock を使います(Redis と ロック を参照)。

ロックを使うと、すべてのレプリカが取得を試み、成功するのは多くても一つだけです。ほかのレプリカはその回の実行をスキップします。

src/jobs/digest.rs
use std::time::Duration;

use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::deps::redis::RedisError;
use wakuwaku::entity::redis::RedisSource;
use wakuwaku::entity::redis::collection::lock::Lock;
use wakuwaku::entity::{Db, ReadWrite};
use wakuwaku::{CronJob, CronJobInterval, CronTick, ServiceCreation};

/// ジョブ名ごとに一つのロック。リースは一回の実行より長くする。
const CRON_LOCKS: Lock<str> = Lock::new("cron", Duration::from_secs(5 * 60));

pub struct SendDailyDigest {
    db: Db<RedisSource, ReadWrite>,
}

impl ServiceCreation for SendDailyDigest {
    type Dep = Db<RedisSource, ReadWrite>;

    fn create_service(db: Db<RedisSource, ReadWrite>) -> Self {
        Self { db }
    }
}

impl Processor<CronTick> for SendDailyDigest {
    type Output = ();
    type Error = RedisError;

    async fn process(&self, _tick: CronTick) -> Result<(), RedisError> {
        // すべてのレプリカが試し、トークンを得るのは多くても一つ
        let Some(token) = self.db.process(CRON_LOCKS.try_lock("send-daily-digest")).await? else {
            return Ok(()); // 別のレプリカがちょうど実行中
        };

        // ... ダイジェストを作って送る。長い処理なら `token.extend()` を呼ぶ ...

        self.db.process(token.release()).await?;
        Ok(())
    }
}

impl CronJob for SendDailyDigest {
    const INTERVAL: CronJobInterval = CronJobInterval::AtLeast(Duration::from_secs(60 * 60));
}

取得は決して待ちません。try_lock はロックが空いていれば Some(token) を、そうでなければ None を出力します。保持者がクラッシュしたときにロックを解放するのはリース(ロックの TTL)なので、リースより長くかかる可能性のある処理では token.extend() を呼ぶ必要があります。

ロックが防ぐのは重複実行ではなく同時実行

実行の最後にロックを解放する方式で保証されるのは、二つのレプリカがジョブを 同時に 実行しないことだけです。レプリカ同士のスケジュールはずれているので、数秒後に別のレプリカが空いたロックを取り、同じ間隔のうちにもう一度実行するかもしれません。

レプリカ全体で一間隔に一回

全レプリカを通じて各回を d ごとに一回程度に抑えたいなら、実行が共有ストレージでその回を 確保 するようにし、fired_at によるフェンスをかけます。実行が先へ進めるのは、自分の fired_at をアトミックに記録でき、かつ前回記録された値が少なくとも d 前である場合だけです。ジョブごとに一行を持つ SQL なら次のようになります。

-- $1 = ジョブ名、$2 = ティックの fired_at
-- この UPDATE が行を変更した場合だけ実行を進める
-- (各ジョブの行は古い last_fired_at で用意しておく)
UPDATE cron_claims
SET    last_fired_at = $2
WHERE  job = $1
  AND  last_fired_at <= $2 - INTERVAL '1 hour';

Redis のロックも同じ役割を果たせます。ロックを解放せず、リースを d より少し短く設定して自然に切れるのを待ちます。こうするとロック自体が「最近だれかが実行した」という記録になります。その場合、処理はリースより十分短い時間で終わる必要があります。

フェンスをかけるジョブには AfterFinish か AtLeast を使います。これらでは一つのレプリカ上の連続する fired_at が少なくとも d 離れるので、レプリカが自分の次の実行をフェンスで締め出すことはありません。AfterStart の実行は d よりわずかに短い間隔で始まることがあり、その場合フェンスは進めるべき実行を拒否して、一回分をまるごと飛ばしてしまいます。

複数のサービスから組み立てるジョブ

cron_job がジョブの組み立てに使う登録値は 一つ、その Dep だけです。複数の登録値が必要なジョブや、手作業で組み立てるジョブは compose_cron_job で登録します。クロージャはサービスビルダーを借用し、必要なものを provide して、ジョブを返します(クロージャでコンポーネントを組み立てる を参照)。このジョブには ServiceCreation の実装は不要で、サービスとしても登録されません。cron_job と compose_cron_job は自由に組み合わせてチェーンできます。

/// 二つの登録値が必要なので、compose で組み立てる。
pub struct ReconcileIndex {
    store: SessionStore,
    index: SearchIndex,
}
src/main.rs
let (services, cron) = ServiceBuilder::new(SessionStore::default())
    .push(SearchIndex::default())
    .cron_job::<ExpireSessions, _>()
    .compose_cron_job(|s| ReconcileIndex {
        store: s.provide::<SessionStore, _>().clone(),
        index: s.provide::<SearchIndex, _>().clone(),
    })
    .start();

compose で組み立てたジョブも Processor<CronTick> と CronJob は実装します。省けるのは ServiceCreation だけです。ジョブを後で起動したいときは、チェーンを start の代わりに into_parts で締めます。サービスビルダーと、まだ起動していない CronRegisterCenter が返ります。CronRegisterCenter はサービスビルダーなしでも使えます。

use wakuwaku::services::cron_job::CronRegisterCenter;

let store = SessionStore::default();
let cron = CronRegisterCenter::new(ExpireSessions::create_service(store.clone()))
    .push(ReconcileIndex { store, index: SearchIndex::default() })
    .start();
cron.shutdown().await;

new と push のどちらにも 間隔 0 の検査 が適用されます。

停止する

CronRuntime にはジョブを止める方法が三つあります。

何が起きるか
cron.shutdown().awaitグレースフル。 新しい実行は始まらず、次の実行を待っているジョブはすぐに止まります。実行中の処理は、重なって動いている AfterStart の実行も含めてすべて 最後まで終わります。すべてのジョブが止まると戻ります。
cron.abort().await実行中の処理は次の .await で キャンセル されます。副作用の途中で止まることもあります。すべてのジョブが止まると戻ります。
cron を drop するabort と同じですが、待ちません。

バッチ処理の途中で打ち切られないよう、shutdown を使いましょう。shutdown は実行にかかるだけ待つので、処理がハングすると永遠に戻りません。問題になる場合はタイムアウトで上限を設けます。

if tokio::time::timeout(Duration::from_secs(30), cron.shutdown()).await.is_err() {
    // タイムアウトすると `CronRuntime` が drop され、止まらない実行は中断される
    tracing::warn!("cron jobs did not stop within 30s; aborted");
}

gRPC も公開するサービスでは、ジョブを起動し、start が返したサービスビルダーからサーバーを組み立て、サーバーが処理を終えてからジョブを止めます。

src/main.rs
let (services, cron) = ServiceBuilder::new(AccountStore::default())
    .push(SessionStore::default())
    .cron_job::<ExpireSessions, _>()
    .start();

let (_services, register) = services
    .grpc_service::<AccountsServer<_>, AccountsService, _>(AccountsServer::new)
    .into_router_fn();

let router = Server::builder().add_routes(Routes::default());
register(router)
    .serve_with_shutdown("[::1]:50051".parse()?, async {
        let _ = tokio::signal::ctrl_c().await;
    })
    .await?;

// サーバーは処理を終えた。次にジョブの実行中の処理を終わらせる
cron.shutdown().await;

サーバー側は gRPC サービス を、サービス全体での順序は ライフサイクル を参照してください。

ジョブをテストする

ジョブはただの値なので、ユニットテストではジョブを組み立て、手作りの CronTick で process を呼びます。ランタイムも、間隔が過ぎるのを待つ必要もありません。

src/jobs.rs
#[cfg(test)]
mod tests {
    use std::time::SystemTime;

    use super::*;

    #[tokio::test]
    async fn expire_sessions_deletes_from_the_store() {
        let store = SessionStore::default();
        let job = ExpireSessions::create_service(store.clone());

        let tick = CronTick { run: 0, fired_at: SystemTime::now() };
        job.process(tick).await.unwrap();

        assert_eq!(store.expired.load(Ordering::Relaxed), 1);
    }
}

スケジュール自体をテストしたい場合は、Tokio の一時停止したクロックの下で本物のランタイムを起動します。この状態では sleep が仮想時間を瞬時に進めます。[dev-dependencies] で Tokio の test-util feature が必要です(tokio = { version = "1", features = ["test-util"] })。

#[tokio::test(start_paused = true)]
async fn the_first_run_happens_at_start() {
    let store = SessionStore::default();
    let (_services, cron) = ServiceBuilder::new(store.clone())
        .cron_job::<ExpireSessions, _>()
        .start();

    // 60 秒の間隔の半分: 最初の実行だけが済んでいる
    tokio::time::sleep(Duration::from_secs(30)).await;
    cron.shutdown().await;

    assert_eq!(store.expired.load(Ordering::Relaxed), 1);
}

ほとんどのジョブには一つ目のテストで十分です。スケジュールは wakuwaku の責任で、wakuwaku 側でテストされています。全体的な方針は テスト を参照してください。

目次