Running async journeys while others wait
What you want to do
The shop's bank and carriers answer over the network, and while one order waits for them, the program's thread waits too. You want the shop to take other orders forward in the meantime.
The code
The shop is chapter 25's, written in Itinera's other execution mode: asynchronous. The asynchronous mode needs the async feature of itinera, which you turn on in Cargo.toml with features = ["async"], and an async runtime to run the program's futures. The shop uses tokio, a crate you add to Cargo.toml too; Itinera works with any runtime.
The payment client and the carrier now answer asynchronously, so their methods are async fn, with chapter 12's errors. The order book answers from the shop's own records without waiting, so it stays as it was. In Rust, a trait with async fn methods can't be used as &dyn Payments, the way a step asks for a service, unless it is marked #[async_trait], from itinera::mode, and so is each implementation of it:
/// A payment client: it charges an order's amount to a card, and gives back a receipt, or nothing
/// when the card has expired, or the error of a network that failed; and it says which card
/// replaced one, if any did. Each answer comes over the network.
#[async_trait]
trait Payments: Send + Sync {
async fn charge(
&self,
order: &str,
card: &str,
amount: i64,
) -> Result<Option<String>, NetworkDown>;
async fn replacement(&self, card: &str) -> Option<String>;
}
/// A carrier: it books a parcel of this weight in kg and gives back its tracking number, or the
/// error of a booking system that failed, then says whether it collects the parcel. Each answer
/// comes over the network.
#[async_trait]
trait Carrier: Send + Sync {
async fn book(&self, order: &str, weight: i64) -> Result<String, BookingFailed>;
async fn collects(&self, weight: i64) -> bool;
}The faked bank and carriers await network before each answer. It hands the thread back once, as a real call does while it waits for the other side. The bank's implementation is marked #[async_trait] too:
/// The network the fakes answer over: it hands the thread back once before the answer comes, as a
/// real call does while it waits.
async fn network() {
tokio::task::yield_now().await;
}
/// The shop's bank, faked: a card is its holder's name and the year it expires, and the bank
/// declines a card that expired before this year. Its network fails on any amount over 100. It
/// prints what it does once it has answered.
struct Bank {
this_year: u32,
}
#[async_trait]
impl Payments for Bank {
async fn charge(
&self,
order: &str,
card: &str,
amount: i64,
) -> Result<Option<String>, NetworkDown> {
network().await;
if amount > 100 {
println!("the bank's network fails while charging order {order}");
return Err(NetworkDown);
}
if expiry(card).is_some_and(|year| year < self.this_year) {
println!("the bank declines card {card}: it has expired");
return Ok(None);
}
println!("the bank charges {amount} for order {order}");
Ok(Some(format!("R-{order}")))
}
/// A card that expired in the last three years was replaced by its holder's card that expires
/// in three years. An older one was not.
async fn replacement(&self, card: &str) -> Option<String> {
network().await;
if expiry(card)? + 3 < self.this_year {
println!("the bank says nothing replaced card {card}");
return None;
}
let (holder, _) = card.rsplit_once('-')?;
let replacement = format!("{holder}-{}", self.this_year + 3);
println!("the bank says card {card} was replaced by {replacement}");
Some(replacement)
}
}Each step's run becomes an async fn, even the pricing's, which has nothing to wait for:
#[step]
impl Price {
async fn run(self, #[contributor] mut contributor: Contributor<'_>) -> Result<Outcome, Error> {
contributor.contribute("amount", self.price - self.discount.unwrap_or(0));
Self::succeed()
}
}A step awaits the services it calls. The shipping's step reporter is now an AsyncStepReporter, whose info is awaited too:
#[step]
impl Ship {
async fn run(
self,
#[service] carrier: &dyn Carrier,
#[contributor] mut contributor: Contributor<'_>,
#[reporter] mut reporter: AsyncStepReporter<'_>,
) -> Result<Outcome, Error> {
if self.gift_card {
return Self::skip_because("nothing-to-ship");
}
if self.weight > 80 {
return Self::fail("over-parcel-limit");
}
let tracking = carrier.book(&self.order, self.weight).await?;
contributor.contribute("tracking", tracking);
if !carrier.collects(self.weight).await {
return Self::fail("too-heavy");
}
reporter
.info(format!("the parcel leaves with receipt {}", self.receipt))
.await?;
Self::succeed()
}
}Telling a customer and giving money back go over the network too, so the shop's roles become asynchronous the same way: their traits, and the workflow's implementations of them, are marked #[async_trait]. A role whose methods don't wait may stay synchronous, as a service may, like the order book: an asynchronous journey calls it just the same:
/// What the shop does for its policies: tell a customer something.
#[async_trait]
trait Messages: Sync {
async fn tell(&self, customer: &str, message: &str);
}
/// What the shop does for its policies: give an order's money back.
#[async_trait]
trait Refunds: Sync {
async fn refund(&self, receipt: &str, amount: i64);
}The workflow's implementations await network before they answer:
#[role]
#[async_trait]
impl Refunds for Orders {
async fn refund(&self, receipt: &str, amount: i64) {
network().await;
println!("the bank gives {amount} back on receipt {receipt}");
}
}
#[role]
#[async_trait]
impl Messages for Orders {
async fn tell(&self, customer: &str, message: &str) {
network().await;
println!("{} to {customer}: {message}", self.channel);
}
}Each hook becomes an async fn as well, and awaits the roles it calls, and so does refund, which calls one:
/// Deals with a shipping that fails for good. It gives up a parcel over the shop's limit rather
/// than try it again, since the limit is the same on every attempt, whichever carrier comes. And
/// whatever gave the shipping up, it refunds the order's charge.
#[derive(Default)]
struct FailedShipping;
#[step_policy(name = "failed_shipping")]
impl FailedShipping {
#[on_step_retry]
async fn before_retrying(
&self,
#[reason] reason: Option<Reason>,
#[from_workflow] receipt: String,
#[from_workflow] amount: i64,
#[role] refunds: &dyn Refunds,
#[contributor] contributor: Contributor<'_>,
) -> Result<Option<FailWorkflow>, Error> {
if reason.as_ref().map(Reason::code) == Some("over-parcel-limit") {
self.refund(refunds, contributor, &receipt, amount, "over-parcel-limit")
.await;
return Self::fail_workflow("no-carrier-can-take-it");
}
Self::carry_on()
}
#[on_step_failure]
async fn given_up(
&self,
#[cause] cause: StepFailureCause,
#[from_workflow] receipt: String,
#[from_workflow] amount: i64,
#[role] refunds: &dyn Refunds,
#[contributor] contributor: Contributor<'_>,
) -> Result<Option<FailWorkflow>, Error> {
self.refund(refunds, contributor, &receipt, amount, &cause.to_string())
.await;
Self::carry_on()
}
/// Gives the order's money back, and records why.
async fn refund(
&self,
refunds: &dyn Refunds,
mut contributor: Contributor<'_>,
receipt: &str,
amount: i64,
why: &str,
) {
refunds.refund(receipt, amount).await;
contributor.contribute("refund", format!("{amount} back on {receipt}: {why}"));
}
}main runs three of the day's orders, Ann's, Gil's and Jo's, at the same time. order makes each one's instance as chapter 25's main did, and journey runs it with an executor of its own, from local_executor_for!, as before. Parcel is chapter 21's. #[tokio::main] starts tokio's runtime and runs main on it, and tokio::join! awaits the three journeys together in main's own task, so they take turns on the one thread that runs it, and the output is the same on every run. The runtime has several threads by default; with one, as #[tokio::main(flavor = "current_thread")] asks, the shop runs just the same.
/// An instance of the shop's workflow for an order paid with this card.
fn order(
order: &str,
customer: &str,
price: i64,
discount: Option<i64>,
parcel: Parcel,
card: &str,
) -> Result<Instance<Orders, Asynchronous>, Box<dyn std::error::Error>> {
Ok(Orders::builder()
.channel("email")
.initial_data(NewOrder {
order: order.to_owned(),
customer: customer.to_owned(),
price,
discount,
parcel,
card: Some(card.to_owned()),
gift_card: false,
})
.create()?)
}
/// Runs an order's journey with an executor of its own.
async fn journey(instance: Instance<Orders, Asynchronous>) -> Result<JourneyResult, Refusal> {
local_executor_for!(Orders).run(instance).await
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (ann, gil, jo) = tokio::join!(
journey(order(
"A-1",
"Ann",
40,
Some(10),
Parcel {
weight: 2,
size: [30, 20, 10],
},
"ann-2028",
)?),
journey(order(
"A-7",
"Gil",
45,
None,
Parcel {
weight: 60,
size: [120, 80, 60],
},
"gil-2027",
)?),
journey(order(
"A-8",
"Jo",
30,
None,
Parcel {
weight: 1,
size: [20, 15, 5],
},
"jo-2024",
)?),
);
for result in [ann?, gil?, jo?] {
println!("order {} {}", result.journey_id, result.status.kind());
}
Ok(())
}It prints:
log orders A-1 #1 journey_started card, customer, discount, gift_card, order, parcel, price
log orders A-1 #2 attempt_started lookup, attempt 1
log orders A-1 #3 service_provided lookup gets order_book from ledger, a ch26_running_async_journeys_while_others_wait::Ledger
the order book has no order A-1 yet
log orders A-1 #4 step_succeeded lookup
log orders A-1 #5 contribution_committed completed
log orders A-1 #6 hook_called already_completed: on step success of lookup
log orders A-1 #7 attempt_started price, attempt 1
log orders A-1 #8 step_succeeded price
log orders A-1 #9 contribution_committed amount
log orders A-1 #10 attempt_started charge, attempt 1
log orders A-1 #11 service_provided charge gets payments from bank, a ch26_running_async_journeys_while_others_wait::Bank
log orders A-7 #1 journey_started card, customer, gift_card, order, parcel, price
log orders A-7 #2 attempt_started lookup, attempt 1
log orders A-7 #3 service_provided lookup gets order_book from ledger, a ch26_running_async_journeys_while_others_wait::Ledger
the order book has no order A-7 yet
log orders A-7 #4 step_succeeded lookup
log orders A-7 #5 contribution_committed completed
log orders A-7 #6 hook_called already_completed: on step success of lookup
log orders A-7 #7 attempt_started price, attempt 1
log orders A-7 #8 optional_input_absent discount
log orders A-7 #9 step_succeeded price
log orders A-7 #10 contribution_committed amount
log orders A-7 #11 attempt_started charge, attempt 1
log orders A-7 #12 service_provided charge gets payments from bank, a ch26_running_async_journeys_while_others_wait::Bank
log orders A-8 #1 journey_started card, customer, gift_card, order, parcel, price
log orders A-8 #2 attempt_started lookup, attempt 1
log orders A-8 #3 service_provided lookup gets order_book from ledger, a ch26_running_async_journeys_while_others_wait::Ledger
the order book has no order A-8 yet
log orders A-8 #4 step_succeeded lookup
log orders A-8 #5 contribution_committed completed
log orders A-8 #6 hook_called already_completed: on step success of lookup
log orders A-8 #7 attempt_started price, attempt 1
log orders A-8 #8 optional_input_absent discount
log orders A-8 #9 step_succeeded price
log orders A-8 #10 contribution_committed amount
log orders A-8 #11 attempt_started charge, attempt 1
log orders A-8 #12 service_provided charge gets payments from bank, a ch26_running_async_journeys_while_others_wait::Bank
the bank charges 45 for order A-7
log orders A-7 #13 step_succeeded charge
log orders A-7 #14 contribution_committed receipt
log orders A-7 #15 hook_called audit: on step success of charge
log orders A-7 #16 contribution_committed audit
log orders A-7 #17 attempt_started ship, attempt 1
log orders A-7 #18 input_adapter_supplied ship gets weight from parcels
log orders A-7 #19 service_provided ship gets carrier from carriers, a ch26_running_async_journeys_while_others_wait::MainCarrier
the bank declines card jo-2024: it has expired
log orders A-8 #13 step_failed charge: card-expired
log orders A-8 #14 step_rescuing charge: failure, rescued by charge_rescue
log orders A-8 #15 attempt_started charge_rescue, attempt 1
the bank charges 30 for order A-1
log orders A-1 #12 step_succeeded charge
log orders A-1 #13 contribution_committed receipt
log orders A-1 #14 hook_called audit: on step success of charge
log orders A-1 #15 contribution_committed audit
log orders A-1 #16 attempt_started ship, attempt 1
log orders A-1 #17 input_adapter_supplied ship gets weight from parcels
log orders A-1 #18 service_provided ship gets carrier from carriers, a ch26_running_async_journeys_while_others_wait::MainCarrier
the bank says card jo-2024 was replaced by jo-2029
log orders A-8 #16 step_succeeded charge_rescue
log orders A-8 #17 contribution_committed card
log orders A-8 #18 data_overwritten card
log orders A-8 #19 step_rescued charge by charge_rescue
log orders A-8 #20 attempt_started charge, attempt 2
the main carrier books order A-1
the main carrier books order A-7
log orders A-1 #19 step_info ship: the parcel leaves with receipt R-A-1
log orders A-1 #20 step_succeeded ship
log orders A-1 #21 contribution_committed tracking
log orders A-1 #22 optional_input_absent code
log orders A-7 #20 step_failed ship: too-heavy
log orders A-7 #21 hook_called failed_shipping: on step retry of ship
log orders A-7 #22 step_retrying ship
log orders A-7 #23 attempt_started ship, attempt 2
log orders A-7 #24 input_adapter_supplied ship gets weight from parcels
log orders A-7 #25 service_provided ship gets carrier from carriers, a ch26_running_async_journeys_while_others_wait::BackupCarrier
the bank charges 30 for order A-8
log orders A-8 #21 step_succeeded charge
log orders A-8 #22 contribution_committed receipt
log orders A-8 #23 hook_called audit: on step success of charge
log orders A-8 #24 contribution_committed audit
log orders A-8 #25 attempt_started ship, attempt 1
log orders A-8 #26 input_adapter_supplied ship gets weight from parcels
log orders A-8 #27 service_provided ship gets carrier from carriers, a ch26_running_async_journeys_while_others_wait::MainCarrier
the backup carrier books order A-7
the main carrier books order A-8
email to Ann: Order A-1 is on its way, tracking T-A-1.
log orders A-1 #23 hook_called notify: on workflow success
log orders A-1 #24 journey_succeeded
log orders A-8 #28 step_info ship: the parcel leaves with receipt R-A-8
log orders A-8 #29 step_succeeded ship
log orders A-8 #30 contribution_committed tracking
log orders A-8 #31 optional_input_absent code
log orders A-7 #26 step_info ship: the parcel leaves with receipt R-A-7
log orders A-7 #27 step_succeeded ship
log orders A-7 #28 contribution_committed tracking
log orders A-7 #29 optional_input_absent code
email to Gil: Order A-7 is on its way, tracking B-A-7.
log orders A-7 #30 hook_called notify: on workflow success
log orders A-7 #31 journey_succeeded
email to Jo: Order A-8 is on its way, tracking T-A-8.
log orders A-8 #32 hook_called notify: on workflow success
log orders A-8 #33 journey_succeeded
order A-1 succeeded
order A-7 succeeded
order A-8 succeededHow it works
async fnand#[async_trait]are all the shop needs. Its steps and hooks becomeasync fn, awaiting what they call, and the traits of the services and roles that wait take#[async_trait]. The rest follows from those: the shipping awaits its step reporter, the roles areSync, and an asynchronous executor runs the journeys. The workflow's declaration, its input adapter, its ID generator and its providers don't change, nor do the log and the desk.- A workflow is in one execution mode, taken from its steps. A step's
runis synchronous or asynchronous, and the workflow takes its mode from its steps, so every step of the shop is asynchronous, even the pricing, whoserunnever awaits anything. Its hooks are in the same mode. local_executor_for!picks the executor of the workflow's mode. For an asynchronous workflow it makes anAsyncLocalExecutorinstead of aLocalExecutor, so the code that makes the executor doesn't change. Itsrunreturns a future, which runs the journey as it is awaited, and the journeys end as they did in chapter 25: Gil's parcel goes with the backup carrier, and Jo's card is renewed.- A journey that waits lets the others go on. Ann's journey runs until its charge waits for the bank. Then Gil's starts, and runs until it waits for the bank too, then Jo's. As each answer comes, its journey goes on from where it waited, so the log interleaves the three journeys, in the order the answers came. Each line names its journey, and each journey's events are still numbered from
#1. - An executor runs one journey at a time, as chapter 24 showed, so each journey that runs at the same time as others gets an executor of its own.
- A role an asynchronous hook asks for is
Sync. An asynchronous hook's future may move to another thread between two waits, and it holds the role while it runs. Rust lets it move only if the role can be used from several threads, which is whatSyncsays, so the shop's traits are nowtrait Messages: Syncandtrait Refunds: Sync.
Ann's, Gil's and Jo's journeys now go on together, each while the others wait. But the only way to know that the shop still gives the same answers after a change is to run every order again, against the bank and the carriers, and read the log.
Outcomes
We have shown how the shop runs in the asynchronous execution mode, with journeys going on at the same time while others wait.
| The usual way | With Itinera |
|---|---|
| Making code asynchronous often means changing every function between the request and the call that waits. | The steps, the hooks, and the services and roles they await change, and the workflow takes its mode from its steps. |
| The log lines of requests handled at the same time are often hard to tell apart. | Every event names its journey and counts within it, however the journeys interleave. |