wakuwaku

ライフサイクル

一つのサービスビルダーから cron ジョブ、Iggy コンシューマー、gRPC サーバーを組み立て、安全な順序で起動し、グレースフルに停止します。

wakuwaku のサービスは普通の Rust バイナリです。フレームワークが握る main も、隠れたコンテナもありません。インフラに接続し、ServiceBuilder に依存を登録し、ビルダーを各レジスタセンターに順に渡し、最後に停止シグナルを待ちます。このページでは三種類のコンポーネントをすべて動かす完全な main を示し、各ステップの順序の理由を説明します。

wakuwaku のアーキテクチャ: 1 つの ServiceBuilder が cron・gRPC・Iggy の各レジスタセンターに依存を供給し、その下にクレートの階層がある実行時の組み立てServiceBuilder共有する依存DB プール (sqlx)Redis 接続Arc<IggyClient>設定 などCronRegisterCenter.cron_job()CronRuntime.start()ジョブごとの Tokio タスクGrpcServiceRegisterCenter.grpc_service()tonic Router.into_router_fn()tonic で提供IggyConsumerRegisterCenter.iggy_consumer()ConsumerRuntime.start(client, config)コンシューマタスクDep を clone → create_service、または compose_*コンポーネントが使うwakuwaku::entitysqlx · Redis · Meilisearchwakuwaku::iggyPublisher でイベント発行wakuwaku::proofProven<T, P> で入力を検証クレートの階層あなたのサービス — 依存するのは wakuwaku だけwakuwakuServiceBuilder · cron_job · grpc_service · iggy_consumer · compose_* · depswakuwaku-entity→ wakuwaku::entity権限を型で表すデータアクセスwakuwaku-iggy→ wakuwaku::iggyApache Iggy 上の型付きイベントwakuwaku-proof→ wakuwaku::proof証明を伴う検証kanau · Processorサードパーティ: tokio · tonic · sqlx · redis · meilisearch-sdk · iggy
すべてのコンポーネントは 1 つの ServiceBuilder に登録された依存から作られます。1 つの依存を clone するか、ビルダー全体を借用する compose_* クロージャを使います。サブクレートは wakuwaku から再エクスポートされます。

サンプルサービス

口座を管理するサービスです。

  • gRPC サービス Accounts が、AccountStore を使って GetBalance に応答します。
  • イベントハンドラ が、Iggy 上の UserRegistered イベントごとに Mailer でウェルカムメールを送ります。
  • cron ジョブ が、1 分ごとに SessionStore から期限切れのセッションを削除します。

AccountStore、SessionStore、Mailer は、どこでも動かせるようにしたインメモリの代用品です。実際のサービスでは Db ハンドル、Redis ベースのストア、SMTP クライアントになりますが、それ以外のコードは変わりません。

プロジェクトの構成

[package]
name = "accounts"
version = "0.1.0"
edition = "2024"

[dependencies]
wakuwaku = { version = "1.0.0-rc3", features = ["iggy"] }
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Event bodies as JSON (see Defining events).
kanau = { version = "0.6", features = ["serde_json"] }
serde = { version = "1", features = ["derive"] }
# Code generated by tonic-prost-build refers to these crates by name.
tonic = "0.14"
tonic-prost = "0.14"
prost = "0.14"

[build-dependencies]
tonic-prost-build = "0.14"

[dev-dependencies]
# Paused clock for tests (see Testing).
tokio = { version = "1", features = ["full", "test-util"] }

wakuwaku は tonic を再エクスポートしていますが、tonic-prost-build が生成するコードは tonic、tonic_prost、prost を直接名前で参照するため、これらも依存に加えています (バージョンは wakuwaku が使うものと一致します)。詳しくは gRPC を参照してください。kanau と serde の行は、イベント本体の JSON 用 derive を有効にするためのものです。イベントの定義 を参照してください。

コンポーネント

各コンポーネントは、ServiceCreation で一つの依存から作られる普通の構造体です。詳しくはそれぞれのページ (cron ジョブ、イベントの消費、gRPC) で説明しています。ここでは存在しさえすれば十分です。

src/components.rs
use std::collections::HashMap;
use std::hash::DefaultHasher;
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};

use kanau::{JsonMessageDe, JsonMessageSer};
use serde::{Deserialize, Serialize};
use wakuwaku::deps::kanau::processor::Processor;
use wakuwaku::deps::tonic::{self, Request, Response, Status};
use wakuwaku::iggy::error::HandleError;
use wakuwaku::iggy::{
    Event, EventAtomicOrdering, EventBody, EventHandler, EventTypeTag, IggySuperPartitionName,
    NonAssociative, PartitionKey,
};
use wakuwaku::{CronJob, CronJobInterval, CronTick, ServiceCreation};

use crate::proto::accounts_server::Accounts;
use crate::proto::{Balance, GetBalanceRequest};

// ---- Shared dependencies: cheap to clone, every clone shares the same state.

/// Account balances. Stands in for a database handle.
#[derive(Clone, Default)]
pub struct AccountStore {
    balances: Arc<Mutex<HashMap<u64, i64>>>,
}

impl AccountStore {
    pub fn deposit(&self, account: u64, cents: i64) {
        *self.balances.lock().unwrap().entry(account).or_default() += cents;
    }

    pub fn balance(&self, account: u64) -> Option<i64> {
        self.balances.lock().unwrap().get(&account).copied()
    }
}

/// Login sessions and when they expire.
#[derive(Clone, Default)]
pub struct SessionStore {
    expires_at: Arc<Mutex<HashMap<String, SystemTime>>>,
}

impl SessionStore {
    pub fn insert(&self, token: &str, expires_at: SystemTime) {
        self.expires_at.lock().unwrap().insert(token.to_owned(), expires_at);
    }

    pub fn contains(&self, token: &str) -> bool {
        self.expires_at.lock().unwrap().contains_key(token)
    }

    /// Remove every session that expired before `now`; return how many.
    pub fn remove_expired(&self, now: SystemTime) -> usize {
        let mut sessions = self.expires_at.lock().unwrap();
        let before = sessions.len();
        sessions.retain(|_, expires_at| *expires_at > now);
        before - sessions.len()
    }
}

/// Outgoing mail. Stands in for an SMTP client.
#[derive(Clone, Default)]
pub struct Mailer {
    sent: Arc<Mutex<Vec<String>>>,
}

impl Mailer {
    pub fn send(&self, to: &str) {
        self.sent.lock().unwrap().push(to.to_owned());
    }

    pub fn sent(&self) -> Vec<String> {
        self.sent.lock().unwrap().clone()
    }
}

// ---- Cron job: expire sessions every minute.

pub struct ExpireSessions {
    sessions: SessionStore,
}

impl ServiceCreation for ExpireSessions {
    type Dep = SessionStore;
    fn create_service(sessions: SessionStore) -> Self {
        Self { sessions }
    }
}

impl Processor<CronTick> for ExpireSessions {
    type Output = ();
    type Error = std::convert::Infallible;
    async fn process(&self, tick: CronTick) -> Result<(), Self::Error> {
        let removed = self.sessions.remove_expired(tick.fired_at);
        tracing::info!(removed, "expired sessions");
        Ok(())
    }
}

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

// ---- Event handler: welcome mail for every new user.

#[derive(Hash)]
pub struct UserId(pub u64);

impl PartitionKey for UserId {
    type PartitionMerge = DefaultHasher;
    const SUPER_PARTITION_NAME: IggySuperPartitionName = IggySuperPartitionName("users");
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonMessageSer, JsonMessageDe)]
pub struct UserRegistered {
    pub user_id: u64,
    pub email: String,
}

impl EventBody for UserRegistered {}

impl Event for UserRegistered {
    const TYPE_TAG: EventTypeTag = EventTypeTag::new(1);
    type Key = UserId;
    type Algebra = NonAssociative;
    fn key(&self) -> UserId {
        UserId(self.user_id)
    }
    fn atomic_ordering(&self) -> EventAtomicOrdering {
        EventAtomicOrdering::Relaxed
    }
}

pub struct SendWelcomeMail {
    mailer: Mailer,
}

impl ServiceCreation for SendWelcomeMail {
    type Dep = Mailer;
    fn create_service(mailer: Mailer) -> Self {
        Self { mailer }
    }
}

impl EventHandler<UserRegistered> for SendWelcomeMail {
    const MAX_BATCH: NonZeroUsize = NonZeroUsize::new(32).unwrap();
    async fn handle(&self, run: &[UserRegistered]) -> Result<(), HandleError> {
        for event in run {
            self.mailer.send(&event.email);
        }
        Ok(())
    }
}

// ---- gRPC service: read balances.

pub struct AccountsService {
    store: AccountStore,
}

impl ServiceCreation for AccountsService {
    type Dep = AccountStore;
    fn create_service(store: AccountStore) -> Self {
        Self { store }
    }
}

#[tonic::async_trait]
impl Accounts for AccountsService {
    async fn get_balance(
        &self,
        request: Request<GetBalanceRequest>,
    ) -> Result<Response<Balance>, Status> {
        let account_id = request.into_inner().account_id;
        let cents = self
            .store
            .balance(account_id)
            .ok_or_else(|| Status::not_found("no such account"))?;
        Ok(Response::new(Balance { account_id, cents }))
    }
}

main

src/main.rs
mod components;

pub mod proto {
    wakuwaku::deps::tonic::include_proto!("accounts.v1");
}

use std::sync::Arc;

use tracing_subscriber::EnvFilter;
use wakuwaku::ServiceBuilder;
use wakuwaku::deps::iggy::prelude::{Client, IggyClient};
use wakuwaku::deps::tonic::service::Routes;
use wakuwaku::deps::tonic::transport::Server;
use wakuwaku::deps::tonic::transport::server::TcpIncoming;
use wakuwaku::iggy::ConsumerConfig;

use components::{
    AccountStore, AccountsService, ExpireSessions, Mailer, SendWelcomeMail, SessionStore,
};
use proto::accounts_server::AccountsServer;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 0. Logging first, so every later step can report.
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
        .init();

    // 1. Connect to infrastructure and bind the port. Any error here stops
    //    startup before a single component runs.
    let iggy = Arc::new(IggyClient::from_connection_string(
        "iggy://iggy:iggy@localhost:8090",
    )?);
    iggy.connect().await?;
    let incoming = TcpIncoming::bind("[::]:50051".parse()?)?;

    // 2. Register the shared dependencies.
    let services = ServiceBuilder::new(AccountStore::default())
        .push(SessionStore::default())
        .push(Mailer::default());

    // 3. Event consumers. `start` validates the topics and can fail.
    let (services, consumers) = services
        .iggy_consumer::<SendWelcomeMail, _, _>()
        .start(iggy.clone(), ConsumerConfig::new("shop", "accounts", vec![0]))
        .await?;

    // 4. gRPC servers. Built now, served in step 6.
    let (services, add_grpc) = services
        .grpc_service::<AccountsServer<_>, AccountsService, _>(AccountsServer::new)
        .into_router_fn();

    // 5. Cron jobs. The first run of every job happens right away.
    let (_services, cron) = services.cron_job::<ExpireSessions, _>().start();

    // 6. Serve until SIGTERM / Ctrl-C. In-flight requests finish first.
    let router = add_grpc(Server::builder().add_routes(Routes::default()));
    let served = router
        .serve_with_incoming_shutdown(incoming, shutdown_signal())
        .await;

    // 7. Drain the rest, then report how serving ended.
    consumers.shutdown().await;
    cron.shutdown().await;
    served?;
    tracing::info!("stopped");
    Ok(())
}

async fn shutdown_signal() {
    let ctrl_c = async {
        tokio::signal::ctrl_c().await.expect("install Ctrl-C handler");
    };
    #[cfg(unix)]
    let terminate = async {
        tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
            .expect("install SIGTERM handler")
            .recv()
            .await;
    };
    #[cfg(not(unix))]
    let terminate = std::future::pending::<()>();

    tokio::select! {
        () = ctrl_c => {}
        () = terminate => {}
    }
    tracing::info!("shutdown signal received");
}

以下、番号付きの各ステップを説明します。

組み立ての順序

ビルダーはレジスタセンターの間を ムーブ しながら進みます。登録メソッドはビルダーを値で受け取り、終了メソッドはそれを返すので、各ステップがそれ以前に登録された依存を参照できることは型システムが保証します。その制約の範囲で、上の順序には意図があります。

ステップ何が起きるか失敗しうるか
0. ログtracing のサブスクライバーを設定するいいえ
1. インフラIggy クライアントを接続し、gRPC のポートをバインドするはい。エラーを返す
2. 依存ServiceBuilder::new(..).push(..)いいえ
3. コンシューマーハンドラを作る。start がトピックを検証し、パーティションごとにタスクを起動するはい。エラーを返す
4. gRPCサービスを作ってサーバーで包む。まだ何も待ち受けないいいえ
5. cron ジョブジョブを作る。start がジョブごとにタスクを起動し、各ジョブの初回実行は即座に行われるいいえ (Tokio ランタイムの外ではパニック)
6. サーブ停止シグナルまでリクエストを受け付けるはい。コンポーネント起動後
7. 後片付けコンシューマーと cron ジョブを止めるいいえ

背後にある原則は 仕事をするものを起動する前に、失敗しうることをすべて済ませる です。ステップ 6 の serve にバインドさせるのではなく、ステップ 1 で TcpIncoming::bind によりポートをバインドしておけば、ポートの競合は最初の cron 実行や最初のイベント消費より前にプロセスを止めます。コンシューマーを cron ジョブより先に起動するのは、その start がもう一つの失敗しうるステップだからです。

コンポーネントは登録メソッドの呼び出し時に作られ (create_service は iggy_consumer、grpc_service、cron_job の中で実行されます)、それぞれが自分用の Dep の clone を受け取ります。最後のレジスタセンターを通った後もビルダー (上の _services) は手元に残るので、後で実行するコードはそこから依存を provide できます。

起動時の失敗

ステップ 1 と 3 のエラーは ? で main から返され、何も実行されないままプロセスはエラーで終了します。コンシューマーの start は、タスクを起動する前に構成を検査し、wakuwaku::iggy::error::Error を返します。

バリアント原因
InvalidConfigConsumerConfig のパーティションやリトライ間隔が空、または poll_count == 0
MixedTopics登録したイベントが一つのメイントピックを共有していない
DuplicateEventType同じ EventTypeTag に二つのハンドラが登録されている
TopicNotFoundメイントピックがストリームに存在しない
PartitionNotFound設定したパーティションがトピックに存在しない
PartitionCountMismatchリトライトピック retry_<topic> が異なるパーティション数で存在する
IggyIggy の呼び出しが失敗した

リトライトピックがまだ存在しなければ、start がメイントピックと同じパーティション数で作成します。Iggy のパーティション ID は 0 始まりなので、例では vec![0] を消費しています。

次の二つはエラーではなくパニックになります。どちらもプログラミングの誤りで、最初の起動で見つかります。

  • 同じサービス名の gRPC サーバーを二つ登録すると、ルーター関数 (add_grpc) がそれらを追加する時点でパニックします。
  • Tokio ランタイムの外で cron ジョブを起動するとパニックします (後述)。

途中で return すると、起動済みのものは中断されます

先に起動したコンポーネントがある状態で後のステップが失敗すると、? で抜ける際に ConsumerRuntime や CronRuntime が drop されます。どちらも drop されると処理中の仕事を含めてタスクを 中断 (abort) します。安全ではありますが唐突なので、失敗しうるステップを先に済ませる理由の一つになります。

グレースフルシャットダウン

ステップ 6 は停止シグナルまで続きます。shutdown_signal は Ctrl-C と、Unix ではコンテナオーケストレーターが送る SIGTERM で完了します。その後は、まず入口で新しい仕事の受け付けを止め、次にバックグラウンドの仕事を止めます。

  1. gRPC。 シグナルが完了すると、serve_with_incoming_shutdown は新しい接続の受け付けを止め、開いている接続にグレースフルな終了を求め、すべての接続が閉じたら戻ります。処理中のリクエストは完了します。長時間続くストリーミング RPC は終わるまで接続を開いたままにするので、そうした RPC がある場合は tokio::time::timeout などで待ち時間に上限を設けてください。ポートを自分でバインドしない場合は、代わりに serve_with_shutdown(addr, signal) を使います。
  2. コンシューマー。 ConsumerRuntime::shutdown はグレースフルに後片付けします。ポーリングを止め、すでに始まったハンドラ呼び出しを完了させ、その失敗をリトライパーティションへ書き込み、オフセットを保存します。新しい仕事の待機 (ポーリング、アイドル) はキャンセルされます。まだ実行していないリトライはクラッシュ時と同じ扱いです。デフォルトの DeliveryMode である NoEventLose では、同じ名前の次のコンシューマーがそれらを読み直すため、ハンドラは同じイベントを二度受け取っても問題ないようにしておく必要があります。NoDoubleConsume では、リトライパーティションから読み出し済みのものは失われます。ConsumerRuntime::abort は代わりにすべてのパーティションタスクを即座にキャンセルし、ハンドラは次の .await で打ち切られます。
  3. cron ジョブ。 CronRuntime::shutdown は新しい実行のスケジュールを止め、実行中のものが終わるのを 待ちます。AfterStart のジョブで重なって実行中のものもすべて待ちます。CronRuntime::abort は実行中のものを次の .await でキャンセルし、停止するまで待ちます。

どちらのランタイムも、drop すると待たずに中断します。shutdown は仕事が終わるまで待ち続けます (Iggy に届かない間、コンシューマーがリクエストをリトライし続ける場合も含みます)。そのため tokio::time::timeout で上限を設けてください。タイムアウトすると shutdown の Future がそれが所有するランタイムごと drop され、残りの仕事は中断されます。

// Give the background work at most 20 seconds; past that, cancel it.
if tokio::time::timeout(Duration::from_secs(20), consumers.shutdown()).await.is_err() {
    tracing::warn!("consumers did not drain in time");
}

main はサーブの結果を served に保持し、後片付けが終わってから ? を適用します。そのため、gRPC サーバーが失敗した場合でもコンシューマーと cron ジョブはきれいに停止します。

猶予期間

Kubernetes は SIGTERM を送った後、Pod の終了猶予期間 (デフォルトは 30 秒) を待ってからプロセスを強制終了します。最も遅い cron 実行と最も長い RPC がその期間に収まるようにするか、猶予期間を延ばしてください。

Tokio ランタイム

wakuwaku は Tokio 上で動き、ランタイムを自分では作りません。作るのは #[tokio::main] です。タスクを起動するものはすべて、その中で呼び出す必要があります。

  • CronRegisterCenterBuilder::start (と CronRegisterCenter::start) は、ランタイムの外では "there is no reactor running, must be called from the context of a Tokio 1.x runtime" で パニック します。
  • コンシューマーの start と gRPC サーバーは async なので、そもそも await するのにランタイムが必要です。

コンポーネントは Send な Future を要求するので、デフォルトのマルチスレッドランタイムで動きます。コンポーネントの登録 (create_service、into_router_fn、into_parts) にはランタイムが不要です。だからこそ、普通のユニットテストで簡単にコンポーネントを作れます。

ログとトレーシング

フレームワークは tracing で報告します。サブスクライバーがなければ報告は捨てられるので、ステップ 0 のように何よりも先に設定してください。出力される内容は次のとおりです。

  • cron の各実行は、フィールド job (型名) と run (実行回数) を持つ cron_job スパンの中で行われます。エラーを返した実行は cron job failed、パニックした実行は cron job panicked として記録され、ジョブはスケジュールを維持します。
  • Iggy コンシューマーは、ハンドラの失敗とリトライの判断、破棄したレコード (未知の型タグ、デコードできない本体)、諦めたリトライを、partition、key、tag などのフィールド付きで記録します。

EnvFilter を使えば、レベルを実行時に選べます (例: RUST_LOG=info,wakuwaku_iggy=debug)。コンポーネント側も同じ tracing のマクロを使うので (ExpireSessions ジョブは削除したセッション数を記録します)、そのイベントはフレームワークのスパンの中に現れます。

複数レプリカで動かす場合

すべてのレプリカがすべての cron ジョブを実行します。ジョブを冪等にするか、仕事を分割してください。cron ジョブ を参照してください。

目次