wakuwaku

Lifecycle

Compose cron jobs, Iggy consumers and gRPC servers from one service builder, start them in a safe order, and shut them down gracefully.

A wakuwaku service is an ordinary Rust binary. There is no framework-owned main and no hidden container: you connect to your infrastructure, register dependencies in a ServiceBuilder, hand the builder to each register center in turn, and finally wait for a shutdown signal. This page walks through a complete main that runs all three kinds of components, and explains the order of every step.

wakuwaku architecture: one ServiceBuilder feeds the cron, gRPC and Iggy register centers; below, the crate layeringRUNTIME WIRINGServiceBuildershared dependenciesDB pool (sqlx)Redis connectionArc<IggyClient>config, …CronRegisterCenter.cron_job()CronRuntime.start()Tokio task per jobGrpcServiceRegisterCenter.grpc_service()tonic Router.into_router_fn()served by tonicIggyConsumerRegisterCenter.iggy_consumer()ConsumerRuntime.start(client, config)consumer tasksclone Dep → create_service, or compose_*Components usewakuwaku::entitysqlx · Redis · Meilisearchwakuwaku::iggyPublisher for eventswakuwaku::proofProven<T, P> for inputCRATE LAYERINGYour service — depends only on wakuwakuwakuwakuServiceBuilder · cron_job · grpc_service · iggy_consumer · compose_* · depswakuwaku-entity→ wakuwaku::entitycapability-typed data accesswakuwaku-iggy→ wakuwaku::iggytyped events on Apache Iggywakuwaku-proof→ wakuwaku::proofproof-carrying validationkanau · Processorthird-party: tokio · tonic · sqlx · redis · meilisearch-sdk · iggy
Every component is built from dependencies registered in one ServiceBuilder: a single cloned dependency, or a compose_* closure over the whole builder. The sub-crates are re-exported by wakuwaku.

The example service

The service manages accounts:

  • a gRPC service Accounts answers GetBalance from an AccountStore;
  • an event handler sends a welcome mail through a Mailer for every UserRegistered event on Iggy;
  • a cron job deletes expired sessions from a SessionStore once a minute.

AccountStore, SessionStore, and Mailer are in-memory stand-ins so that the example runs anywhere; in a real service they would be a Db handle, a Redis-backed store, and an SMTP client. Nothing else in the code would change.

Project setup

[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 re-exports tonic, but the code generated by tonic-prost-build names tonic, tonic_prost, and prost directly, so they are listed as dependencies too (the versions match the ones wakuwaku uses). See gRPC for details. The kanau and serde lines enable the JSON derives for the event body; see Defining events.

The components

Each component is a plain struct built from one dependency with ServiceCreation. They are explained in depth on their own pages (Cron jobs, Consuming events, gRPC); here they only need to exist.

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");
}

The rest of this page explains the numbered steps.

Order of construction

The builder is moved through the register centers: every registration method takes it by value and every finishing method returns it, so the type system enforces that each step sees the dependencies registered before it. Within that constraint, the order above is deliberate.

StepWhat happensCan fail?
0. LoggingInstall a tracing subscriber.No
1. InfrastructureConnect the Iggy client, bind the gRPC port.Yes, returns an error
2. DependenciesServiceBuilder::new(..).push(..)No
3. ConsumersHandlers are created; start validates topics and spawns one task per partition.Yes, returns an error
4. gRPCServices are created and wrapped in their servers; nothing listens yet.No
5. Cron jobsJobs are created; start spawns one task per job, and the first run of each job happens immediately.No (panics outside a Tokio runtime)
6. ServeAccept requests until the shutdown signal.Yes, after the components started
7. DrainStop consumers and cron jobs.No

The rule behind it: do everything that can fail before starting anything that does work. Binding the port with TcpIncoming::bind in step 1, rather than letting serve bind it in step 6, means a port conflict stops the process before the first cron run or the first consumed event. Consumers start before the cron jobs because their start is the other fallible step.

Components are created at their registration call (create_service runs inside iggy_consumer, grpc_service, and cron_job), and each one receives its own clone of its Dep. After the last register center you still own the builder (_services above), so code that runs later can provide dependencies from it.

Startup failures

Errors in steps 1 and 3 are returned from main with ?; the process exits with the error and nothing has run. The consumer's start checks the setup before it spawns any task and returns a wakuwaku::iggy::error::Error:

VariantCause
InvalidConfigConsumerConfig has no partitions, no retry delays, or poll_count == 0
MixedTopicsthe registered events do not share one main topic
DuplicateEventTypetwo handlers are registered for the same EventTypeTag
TopicNotFoundthe main topic does not exist in the stream
PartitionNotFounda configured partition does not exist in the topic
PartitionCountMismatchthe retry topic retry_<topic> exists with a different partition count
Iggyan Iggy call failed

If the retry topic does not exist yet, start creates it with the main topic's partition count. Iggy partition ids are zero-based, which is why the example consumes vec![0].

Two failures are not errors but panics, both programming mistakes caught at the first start:

  • Registering two gRPC servers with the same service name panics when the router function (add_grpc) adds them.
  • Starting cron jobs outside a Tokio runtime panics (see below).

An early return stops what already started

If a step fails after earlier steps started components, ? drops the ConsumerRuntime or CronRuntime on the way out, and dropping either one aborts its tasks, including work in progress. That is safe, but abrupt; it is one more reason to run every fallible step first.

Graceful shutdown

Step 6 runs until the shutdown signal. shutdown_signal completes on Ctrl-C and, on Unix, on SIGTERM, which is what container orchestrators send. The order then is: stop taking new work at the edge, then stop the background work.

  1. gRPC. When the signal completes, serve_with_incoming_shutdown stops accepting connections, asks the open ones to shut down gracefully, and returns once all of them have closed, so requests in flight complete. A long-lived streaming RPC keeps its connection open until it ends; if you have those, bound the wait (for example with tokio::time::timeout). Use serve_with_shutdown(addr, signal) instead if you do not bind the port yourself.
  2. Consumers. ConsumerRuntime::shutdown drains gracefully: it stops polling, lets handler calls already started complete, writes their failures to the retry partition, and stores offsets. Waits for new work (polling, idling) are cancelled. Retries not run yet are left as after a crash: with the default DeliveryMode NoEventLose, the next consumer with the same name reads them again, so handlers must tolerate seeing an event twice; with NoDoubleConsume, those already read from the retry partition are lost. ConsumerRuntime::abort cancels every partition task at once instead, cutting a handler at its next .await.
  3. Cron jobs. CronRuntime::shutdown stops scheduling new runs and waits for runs in progress to finish, including every overlapping run of an AfterStart job. CronRuntime::abort cancels runs in progress at their next .await and waits for them to stop.

Dropping either runtime aborts its work without waiting. shutdown waits as long as the work takes, including a consumer retrying Iggy requests while the server is unreachable, so bound it with tokio::time::timeout. When the timeout fires, the shutdown future is dropped together with the runtime it owns, which aborts what is left:

// 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 keeps the result of serving in served and only applies ? after the drain, so consumers and cron jobs are stopped cleanly even when the gRPC server failed.

Grace periods

Kubernetes sends SIGTERM, waits for the pod's termination grace period (30 seconds by default), then kills the process. Make sure your slowest cron run and your longest RPC fit into that period, or raise it.

The Tokio runtime

wakuwaku runs on Tokio and does not create a runtime for you; #[tokio::main] does. Everything that spawns tasks must be called inside it:

  • CronRegisterCenterBuilder::start (and CronRegisterCenter::start) panic outside a runtime with "there is no reactor running, must be called from the context of a Tokio 1.x runtime";
  • the consumer's start and the gRPC server are async, so they need a runtime to be awaited at all.

Components require Send futures, so the default multi-threaded runtime works. Registering components (create_service, into_router_fn, into_parts) does not need a runtime, which is what makes components easy to construct in plain unit tests.

Logging and tracing

The framework reports through tracing. Without a subscriber those reports are discarded, so install one before anything else, as step 0 does. What you will see:

  • every cron run executes inside a cron_job span with the fields job (the type name) and run (the run number); a run that returns an error is logged as cron job failed, a run that panics as cron job panicked, and the job keeps its schedule;
  • the Iggy consumer logs handler failures with their retry decisions, records it had to drop (unknown type tag, undecodable body), and retries that gave up, with fields such as partition, key, and tag.

With EnvFilter the level is chosen at run time, for example RUST_LOG=info,wakuwaku_iggy=debug. Your components use the same tracing macros (the ExpireSessions job logs how many sessions it removed), so their events appear inside the framework's spans.

Running several replicas

Every replica runs every cron job. Make jobs idempotent or partition their work; see Cron jobs.

On this page