Finishing a journey early
What you want to do
Now and then the website sends an order a second time: Mia's order A-13, paid and shipped yesterday, arrives again today. You want the shop to see that the order is already done, and end its journey there, as succeeded, without charging Mia or booking her parcel a second time.
The code
The shop keeps an order book, which says whether an order was already completed. It is a service, like the bank:
/// The shop's order book: it says whether an order was already completed.
trait OrderBook: Send + Sync {
fn completed(&self, order: &str) -> bool;
}The workflow's new provider, ledger, makes the order book. In this program it is faked, and holds the one order completed before today, Mia's:
/// The order book, which has the orders completed before today.
#[provider(key = "order_book")]
fn ledger(&self) -> Result<Provided<dyn OrderBook>, Error> {
Ok(provide!(Ledger {
completed: vec!["A-13"],
}))
}A new step, the lookup, asks the order book about the order and gives back what it says, under completed. It decides nothing about what happens next:
/// Looks the order up in the order book, and gives back whether it was already completed.
#[derive(Step)]
struct Lookup {
#[input]
order: String,
}
#[step]
impl Lookup {
fn run(
self,
#[service] order_book: &dyn OrderBook,
#[contributor] mut contributor: Contributor<'_>,
) -> Result<Outcome, Error> {
contributor.contribute("completed", order_book.completed(&self.order));
Self::succeed()
}
}A step policy, already_completed, reads that answer. Its on step success asks for completed from the step, as the audit asks for the receipt in chapter 15. For an order already completed, it returns Self::finish_workflow(), which is the lifecycle FinishWorkflow. Otherwise it carries on:
/// Finishes the journey of an order that was already completed: there is nothing left to do for
/// it.
#[derive(Default)]
struct AlreadyCompleted;
#[step_policy(name = "already_completed")]
impl AlreadyCompleted {
#[on_step_success]
fn looked_up(&self, #[from_step] completed: bool) -> Result<Option<OnSuccess>, Error> {
if completed {
return Self::finish_workflow();
}
Self::carry_on()
}
}The workflow runs the lookup first, with the policy attached to it:
/// The shop's workflow: it looks the order up, and finishes an order already completed. Otherwise
/// it works out what the order costs, then charges it, repairing a charge that fails and auditing
/// one that succeeds, then ships, and shipping may be tried once more.
/// Once a journey has ended, the customer is told, through the channel the shop configures.
#[workflow(name = "orders", reporters = [Log, Desk], policies = [Notify])]
#[with_step(Lookup, name = "lookup", policies = [AlreadyCompleted])]
#[with_step(Price, name = "price")]
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit, Outage])]
#[with_step(Ship, name = "ship", retry_budget = 1, policies = [HeavyParcels])]
struct Orders {
channel: &'static str,
}The other steps, policies and reporters it names are as earlier chapters show them: the pricing, the charge and the audit in chapter 20, RenewCard in chapter 14, the shipping, outage and heavy_parcels in chapter 17, notify in chapter 19 and the desk in chapter 11.
The log is chapter 21's, and now also says which policy finished a journey:
EventBody::JourneySucceeded {
decided_by: Some(policy),
..
} => Some(format!("FinishWorkflow from {policy}")),The rest is chapter 21's shop, and Order, the output, is chapter 19's. main runs two orders, Ann's and Mia's:
fn main() -> Result<(), Box<dyn std::error::Error>> {
// The order, the customer, the price, the discount, the parcel's weight in kg and its size in
// cm, the card, and whether the order is a gift card.
let orders = [
(
"A-1",
"Ann",
40,
Some(10),
2,
[30, 20, 10],
"ann-2028",
false,
),
("A-13", "Mia", 50, None, 4, [40, 30, 30], "mia-2029", false),
];
for (order, customer, price, discount, weight, size, card, gift_card) in orders {
println!("{customer}'s order:");
let instance = Orders::builder()
.channel("email")
.initial_data(NewOrder {
order: order.to_owned(),
customer: customer.to_owned(),
price,
discount,
parcel: Parcel { weight, size },
card: card.to_owned(),
gift_card,
})
.create()?;
let result = local_executor_for!(Orders).run(instance)?;
let data: Order = result.output()?;
let receipt = data.receipt.as_deref().unwrap_or("none");
let tracking = data.tracking.as_deref().unwrap_or("none");
println!(
"the journey {}, with receipt {receipt} and tracking {tracking}",
result.status.kind(),
);
println!("audit: {}", data.audit.as_deref().unwrap_or("none"));
}
Ok(())
}It prints:
Ann's order:
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 ch22_finishing_a_journey_early::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 ch22_finishing_a_journey_early::Bank
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 ch22_finishing_a_journey_early::MainCarrier
the main carrier books order A-1
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
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
the journey succeeded, with receipt R-A-1 and tracking T-A-1
audit: charge took 30, receipt R-A-1
Mia's order:
log orders A-13 #1 journey_started card, customer, gift_card, order, parcel, price
log orders A-13 #2 attempt_started lookup, attempt 1
log orders A-13 #3 service_provided lookup gets order_book from ledger, a ch22_finishing_a_journey_early::Ledger
the order book has order A-13 completed already
log orders A-13 #4 step_succeeded lookup
log orders A-13 #5 contribution_committed completed
log orders A-13 #6 hook_called already_completed: on step success of lookup
log orders A-13 #7 optional_input_absent tracking
log orders A-13 #8 optional_input_absent code
email to Mia: Order A-13 is complete.
log orders A-13 #9 hook_called notify: on workflow success
log orders A-13 #10 journey_succeeded FinishWorkflow from already_completed
the journey succeeded, with receipt none and tracking none
audit: noneHow it works
FinishWorkflowends the journey at once, as succeeded. It is the second lifecycle, afterFailWorkflowin chapter 17. Mia's lookup succeeded and contributedcompleted, soalready_completedfinished the journey. Her price, charge and shipping never ran: her log has no attempt of theirs, and the bank and the carriers were never asked, so Mia is charged only once.- The journey says who finished it.
journey_succeedednames the policy whose hook returnedFinishWorkflow, and the log prints it:FinishWorkflow from already_completed. Ann'sjourney_succeedednames no policy, because her journey ended by default, after its last step. - Only
on step successmay finish a journey, and its type says so.Self::finish_workflow()returns anOption<OnSuccess>, which is whaton step successreturns, and never anOption<FailWorkflow>, which is whaton step retryandon step abnormal terminationreturn. So a step that hasn't succeeded can never finish its journey. - What the step contributed is kept. The hook runs after the lookup's contributions are committed, so
completedis in Mia's data bag, as in Ann's. - The workflow's own hooks still run. A journey finished early has succeeded, so
notify'son workflow successtells Mia. Her data bag holds no tracking number and no gift card's code, so she readsOrder A-13 is complete.The receipt and the tracking of yesterday's journey belong to that journey, somainreads none of them in this one. - The step reports what it found, and the policy decides what follows. The lookup only says whether the order was completed. Ending the journey there is a decision about the flow, so it lives in a policy. The price, the charge and the shipping could each skip an order already completed, but each of them would then need to know about the order book, and a step added later would need to know too. A decision about the whole journey belongs in a policy, not in every step it would stop.
The shop no longer charges an order twice. But it still keeps the money of an order it charges and never ships, such as Ned's in chapter 17.
Outcomes
We have shown how a step policy finishes a journey early, as succeeded, once nothing is left to do.
| The usual way | With Itinera |
|---|---|
A process that stops early often does so through a return deep in the code, and nothing records which check stopped it. |
on step success returns FinishWorkflow, and journey_succeeded names the policy that finished the journey. |
| Skipping work for a request already handled often means a check at the top of each function that does the work. | One policy, attached to one lookup, ends the journey, and the steps it stops know nothing about it. |