Migrating from 0.3
Move a service from wakuwaku 0.3 to 1.0.0-rc3, with before-and-after code for each replaced module.
wakuwaku 0.3 was a set of utilities: a RabbitMQ integration, Redis and sqlx helpers, a resource pool, and a service builder. 1.0.0-rc3 keeps the service builder, but it is now a framework. Data access, events, and validation are separate crates, re-exported behind feature flags, and RabbitMQ is replaced by Apache Iggy.
This page goes through the changes one module at a time. Each section shows code written against 0.3.1 and the equivalent for 1.0.0-rc3. Both sides were compiled against their respective versions.
Summary
| 0.3 | 1.0.0-rc3 |
|---|---|
services::builder::ServiceBuilder, services::ServiceCreation | unchanged, also re-exported as wakuwaku::ServiceBuilder, wakuwaku::ServiceCreation |
services::grpc_service, ServiceBuilder::grpc_service | unchanged |
integration::amqp, services::amqp_consumer, ServiceBuilder::amqp_consumer | wakuwaku::iggy, ServiceBuilder::iggy_consumer |
interval_job::IntervalJobExecutionSignal | CronJob, ServiceBuilder::cron_job |
integration::redis | wakuwaku::entity::redis |
integration::sqlx | wakuwaku::entity::sqlx |
pool | removed |
integration::surreal | removed |
wakuwaku::Error | removed; each crate has its own error type |
feature tonic | feature grpc |
Update Cargo.toml
The feature flags changed:
| 0.3 feature | 1.0.0-rc3 |
|---|---|
amqprs (default) | removed. Use iggy for events (a different broker; see below). |
redis (default) | redis |
uuid (default) | removed. It converted uuid::Uuid into a RedisKey. Implement Name for your id types instead. |
tracing (default) | removed. wakuwaku always logs through tracing. |
tracing-otel | removed. The monotonic_counter.* metric events are gone. |
sqlx | sqlx-pg. MySQL is new: sqlx-mysql. |
surreal | removed |
tonic (implicit) | grpc, now enabled by default |
| none | new: proof, iggy, entity, meilisearch |
In 0.3 you depended on kanau, sqlx, and tonic yourself. In 1.0.0-rc3, import them through wakuwaku::deps so their versions match wakuwaku's. The exceptions are code generators and derive macros that name a crate by path; see Installation.
[dependencies]
wakuwaku = { version = "0.3", features = ["sqlx", "tonic"] }
kanau = { version = "0.6", features = ["message"] }
sqlx = { version = "0.9", features = ["postgres", "runtime-tokio"] }
tonic = "0.14"Service builder and gRPC
ServiceBuilder, ServiceCreation, the Here / There positions, ServiceBuilder::grpc_service, and GrpcServiceRegisterCenter work as before. Only the imports change: tonic comes from wakuwaku::deps::tonic, and the two core types are also available at the crate root.
use tonic::service::Routes;
use tonic::transport::Server;
use wakuwaku::services::ServiceCreation;
use wakuwaku::services::builder::ServiceBuilder;The old paths wakuwaku::services::builder::ServiceBuilder and wakuwaku::services::ServiceCreation still work.
RabbitMQ consumers become Iggy event handlers
RabbitMQ support is gone. Events now go through Apache Iggy, with typed events, per-key ordering, and batching. This is not a drop-in replacement. Messages still queued in RabbitMQ are not moved to Iggy, so drain the queues (or replay their contents into Iggy) before you switch.
use kanau::processor::Processor;
use kanau::{JsonMessageDe, JsonMessageSer};
use serde::{Deserialize, Serialize};
use wakuwaku::integration::amqp::{
AmqpExchangeType, AmqpMessageProcessor, AmqpMessageSend, AmqpPool, AmqpRouting,
};
use wakuwaku::services::ServiceCreation;
use wakuwaku::services::builder::ServiceBuilder;
#[derive(Serialize, Deserialize, JsonMessageSer, JsonMessageDe)]
struct OrderPlaced {
order_id: u64,
}
impl AmqpRouting for OrderPlaced {
const EXCHANGE: &'static str = "orders";
const EXCHANGE_TYPE: AmqpExchangeType = AmqpExchangeType::Topic;
const ROUTING_KEY: &'static str = "order.placed";
}
impl AmqpMessageSend for OrderPlaced {}
#[derive(Clone)]
struct Mailer;
struct SendReceipt {
mailer: Mailer,
}
impl ServiceCreation for SendReceipt {
type Dep = Mailer;
fn create_service(mailer: Mailer) -> Self {
Self { mailer }
}
}
impl Processor<OrderPlaced> for SendReceipt {
type Output = ();
type Error = wakuwaku::Error;
async fn process(&self, event: OrderPlaced) -> Result<(), wakuwaku::Error> {
println!("receipt for order {}", event.order_id);
Ok(())
}
}
impl AmqpMessageProcessor<OrderPlaced> for SendReceipt {
const QUEUE: &'static str = "send-receipt";
}
async fn run(pool: &AmqpPool) -> Result<(), wakuwaku::Error> {
let (_services, consumers) = ServiceBuilder::new(Mailer)
.amqp_consumer::<SendReceipt, _, _>()
.setup(pool)
.await?;
OrderPlaced { order_id: 7 }.send(pool).await?;
consumers.closed().await;
Ok(())
}How the 0.3 pieces map:
| 0.3 | 1.0.0-rc3 |
|---|---|
AmqpRouting::EXCHANGE | the Iggy stream passed to Publisher::new and ConsumerConfig::new |
AmqpRouting::ROUTING_KEY | Event::TYPE_TAG plus the key type's SUPER_PARTITION_NAME (the topic) |
AmqpMessageSend::send | Publisher::publish |
AmqpMessageProcessor<E> (a Processor<E>) | EventHandler<E>, which receives a slice of events (MAX_BATCH at most) |
AmqpMessageProcessor::QUEUE | the consumer name in ConsumerConfig::new |
RETRY_POLICY, <queue>.retry | ConsumerConfig::retry_delays, the retry_<topic> topic |
FailureAction, <queue>.dlq | the ErrorClass of the returned HandleError; there is no dead-letter topic |
ServiceBuilder::amqp_consumer(..).setup(&pool) | ServiceBuilder::iggy_consumer(..).start(client, config) |
dropping AmqpConsumersRuntime | ConsumerRuntime::shutdown().await |
Things to watch for:
- Create the stream and topics first.
startcreates only the retry topicretry_<topic>. The main topic, named after the key'sSUPER_PARTITION_NAME, and the configured partitions must already exist. - Partition ids are zero-based. The example consumes partition
0. - Retries are per key. A
Retryableerror moves the key's unfinished events to the retry topic, and its later events wait behind them. If the last retry fails, the failed runs are dropped. AnUnrecoverableerror drops the chunk and logs it. 0.3 parked such messages in a dead-letter queue instead.
Events covers defining events, publishing, consuming, ordering, and error handling. Defining events explains the JSON derives used for the body.
Interval jobs become cron jobs
In 0.3, IntervalJobExecutionSignal described when a signal message was due. You had to call time_pool from a loop of your own, publish the signal to RabbitMQ, and handle it in a consumer. In 1.0.0-rc3, a CronJob runs the work itself, inside the process, on the schedule its INTERVAL declares:
use std::task::Poll;
use kanau::{JsonMessageDe, JsonMessageSer};
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
use wakuwaku::integration::amqp::{AmqpExchangeType, AmqpMessageSend, AmqpRouting};
use wakuwaku::interval_job::IntervalJobExecutionSignal;
#[derive(Serialize, Deserialize, JsonMessageSer, JsonMessageDe)]
struct ExpireSessionsSignal {
unix_time: i64,
}
impl AmqpRouting for ExpireSessionsSignal {
const EXCHANGE: &'static str = "jobs";
const EXCHANGE_TYPE: AmqpExchangeType = AmqpExchangeType::Direct;
const ROUTING_KEY: &'static str = "expire-sessions";
}
impl AmqpMessageSend for ExpireSessionsSignal {}
impl IntervalJobExecutionSignal for ExpireSessionsSignal {
fn tick(now: OffsetDateTime) -> Self {
Self { unix_time: now.unix_timestamp() }
}
fn time_pool(now: OffsetDateTime, last_time: OffsetDateTime) -> Poll<Self> {
if now - last_time >= Duration::minutes(1) {
Poll::Ready(Self::tick(now))
} else {
Poll::Pending
}
}
}The work that used to live in the signal's consumer moves into process. Every replica that starts the job runs it, so make the job idempotent or take a Redis lock inside the run. See Cron jobs.
Redis key-value helpers become entity::redis
The KeyValue, KeyValueRead, and KeyValueWrite traits are replaced by a Redis data source and typed collections. A cache with a TTL becomes a Cache constant, and every operation is a query that runs through a Db handle:
use std::time::Duration;
use kanau::{JsonMessageDe, JsonMessageSer};
use serde::{Deserialize, Serialize};
use wakuwaku::integration::redis::{KeyValue, KeyValueRead, KeyValueWrite, RedisConnection};
#[derive(Clone, Serialize, Deserialize, JsonMessageSer, JsonMessageDe)]
struct Profile {
name: String,
}
struct ProfileEntry {
user: u64,
profile: Profile,
}
impl KeyValue for ProfileEntry {
type Key = String;
type Value = Profile;
fn key(&self) -> String {
format!("profile:{}", self.user)
}
fn value(&self) -> Profile {
self.profile.clone()
}
fn into_value(self) -> Profile {
self.profile
}
fn new(key: String, profile: Profile) -> Self {
let user = key.trim_start_matches("profile:").parse().unwrap_or_default();
Self { user, profile }
}
}
impl KeyValueRead for ProfileEntry {}
impl KeyValueWrite for ProfileEntry {}
async fn run(conn: &mut RedisConnection) -> Result<(), wakuwaku::Error> {
let entry = ProfileEntry { user: 7, profile: Profile { name: "Haruki".into() } };
entry.write_with_ttl(conn, Duration::from_secs(300)).await?;
let profile: Option<Profile> = ProfileEntry::read(conn, "profile:7".into()).await?;
Ok(())
}RedisConnectionbecomesDb<RedisSource, C>. The capabilityCdecides which queries may run. UseReadWritefor code that writes andReadOnlyfor code that should only read.- The key layout changed. Collections store their items under
@@wakuwaku-entity:<kind>:<namespace>:<name>, so values written by 0.3 are not found. For caches this only means a cold start. - Values that are not caches (no TTL, or your own key layout) are written as your own queries on
RedisSource. See Redis. - Errors are
redis::RedisErrorinstead ofwakuwaku::Error.
DatabaseProcessor becomes SqlxSource and Db
0.3 wrapped a PgPool in DatabaseProcessor, and any SQL could run on db.db(). In 1.0.0-rc3 every query is a value that declares what it reads and writes, and it runs through a Db whose capability must cover that:
use wakuwaku::integration::sqlx::DatabaseProcessor;
async fn count_users(db: &DatabaseProcessor) -> Result<i64, wakuwaku::Error> {
let (count,): (i64,) = sqlx::query_as("SELECT count(*) FROM users")
.fetch_one(db.db())
.await?;
Ok(count)
}- The pool stays private inside
SqlxSource, so the capability cannot be bypassed. Run migrations on the pool before wrapping it. - A
Db<PgSource, ReadOnly>cannot run a query declared as a write. That is a compile error. - MySQL is supported through
sqlx-mysqlandMySqlSource. - A transaction is a flow: a state type whose steps run queries inside one open transaction, while the library owns BEGIN, COMMIT, and ROLLBACK. See Transactions.
Queries and sqlx cover this in detail.
wakuwaku::Error is removed
0.3 had one error enum for everything, including a From<wakuwaku::Error> for tonic::Status conversion. In 1.0.0-rc3, each part uses the error type of the layer it wraps: sqlx::Error for sqlx sources, RedisError for Redis, HandleError for event handlers, and ValidateError for validation. A query declares its own Error on its Execute impl, so a query that can refuse (a lost optimistic lock, a broken business rule) returns that refusal as its error. Define your service's own error type and its mapping to Status:
use tonic::Status;
fn find_user(id: u64) -> Result<String, wakuwaku::Error> {
Err(wakuwaku::Error::NotFound)
}
fn get_user(id: u64) -> Result<String, Status> {
// `wakuwaku::Error` converted into `tonic::Status` for you.
Ok(find_user(id)?)
}Removed without replacement
pool: the generic resource pool. The clients wakuwaku works with bring their own pooling or sharing: sqlx pools, Redis multiplexed connections (clones share one socket), and anArc<IggyClient>.integration::surreal: SurrealDB support was dropped.