The Itinera bookRust edition

Part 3: Controlling the flow, Chapter 22

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:

Rust, from the program ch22_finishing_a_journey_early
/// 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:

Rust, from the program ch22_finishing_a_journey_early
/// 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:

Rust, from the program ch22_finishing_a_journey_early
/// 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:

Rust, from the program ch22_finishing_a_journey_early
/// 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:

Rust, from the program ch22_finishing_a_journey_early
/// 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:

Rust, from the program ch22_finishing_a_journey_early
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:

Rust, from the program ch22_finishing_a_journey_early
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:

What ch22_finishing_a_journey_early prints, recorded in ch22_finishing_a_journey_early.txt
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: none

How it works

  1. FinishWorkflow ends the journey at once, as succeeded. It is the second lifecycle, after FailWorkflow in chapter 17. Mia's lookup succeeded and contributed completed, so already_completed finished 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.
  2. The journey says who finished it. journey_succeeded names the policy whose hook returned FinishWorkflow, and the log prints it: FinishWorkflow from already_completed. Ann's journey_succeeded names no policy, because her journey ended by default, after its last step.
  3. Only on step success may finish a journey, and its type says so. Self::finish_workflow() returns an Option<OnSuccess>, which is what on step success returns, and never an Option<FailWorkflow>, which is what on step retry and on step abnormal termination return. So a step that hasn't succeeded can never finish its journey.
  4. What the step contributed is kept. The hook runs after the lookup's contributions are committed, so completed is in Mia's data bag, as in Ann's.
  5. The workflow's own hooks still run. A journey finished early has succeeded, so notify's on workflow success tells Mia. Her data bag holds no tracking number and no gift card's code, so she reads Order A-13 is complete. The receipt and the tracking of yesterday's journey belong to that journey, so main reads none of them in this one.
  6. 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.