The Itinera bookRust edition

Part 2: Getting started, Chapter 6

Passing data on

What you want to do

When the bank charges an order, it gives a receipt. You want the charge to give that receipt back, so that the shop has it once the journey ends, and so that the parcel can carry it when the shop ships the order.

The code

The charge now asks for the order's number as well as its price. The bank, still faked, gives a receipt, and the charge hands it on through a contributor, a parameter of run marked #[contributor].

Rust, from the program ch06_passing_data_on
/// Charges an order's price, and gives back the bank's receipt.
#[derive(Step)]
struct Charge {
    #[input]
    order: String,
    #[input]
    price: i64,
}

#[step]
impl Charge {
    fn run(self, #[contributor] mut contributor: Contributor<'_>) -> Result<Outcome, Error> {
        if self.price <= 0 {
            return Self::fail("nothing-to-charge");
        }
        let receipt = bank_charges(&self.order, self.price);
        contributor.contribute("receipt", receipt);
        Self::succeed()
    }
}

Shipping is a second step. It books the carrier, which gives a tracking number at once, and hands that on the same way. The carrier, faked too, weighs the parcel only when it comes to collect it, and takes it only up to 20 kg. The receipt is one of the shipping's inputs, asked for like the order's number.

Rust, from the program ch06_passing_data_on
/// Ships an order, with its receipt in the parcel, and gives back its tracking number.
#[derive(Step)]
struct Ship {
    #[input]
    order: String,
    #[input]
    weight: i64,
    #[input]
    receipt: String,
}

#[step]
impl Ship {
    fn run(self, #[contributor] mut contributor: Contributor<'_>) -> Result<Outcome, Error> {
        let tracking = carrier_books(&self.order);
        contributor.contribute("tracking", tracking);
        if !carrier_collects(self.weight) {
            return Self::fail("too-heavy");
        }
        println!("the parcel leaves with receipt {}", self.receipt);
        Self::succeed()
    }
}

The workflow lists the two steps, one #[with_step(...)] line each:

Rust, from the program ch06_passing_data_on
/// The shop's workflow: it charges, then ships.
#[workflow(name = "orders")]
#[with_step(Charge, name = "charge")]
#[with_step(Ship, name = "ship")]
struct Orders;

Each order now carries its number and its parcel's weight as well as its price:

Rust, from the program ch06_passing_data_on
/// What the shop knows about an order when its journey starts.
#[derive(InitialData)]
struct NewOrder {
    order: String,
    price: i64,
    weight: i64,
}

Once a journey has ended, the shop reads back the receipt and the tracking number into a struct of its own. A tracking number may be missing, so its field is an Option<String>.

Rust, from the program ch06_passing_data_on
/// What the shop reads back once the journey has ended.
#[derive(Output)]
struct Order {
    receipt: String,
    tracking: Option<String>,
}

Two orders come in: Ann's, whose parcel weighs 2 kg, and Gil's, whose parcel weighs 60 kg.

Rust, from the program ch06_passing_data_on
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The order, the customer, the price and the parcel's weight in kg.
    for (order, customer, price, weight) in [("A-1", "Ann", 40, 2), ("A-7", "Gil", 45, 60)] {
        println!("{customer}'s order:");
        let instance = Orders::builder()
            .initial_data(NewOrder {
                order: order.to_owned(),
                price,
                weight,
            })
            .create()?;
        let result = local_executor_for!(Orders).run(instance)?;
        let data: Order = result.output()?;
        let tracking = data.tracking.as_deref().unwrap_or("none");
        println!(
            "the journey {}, with receipt {} and tracking {tracking}",
            result.status.kind(),
            data.receipt,
        );
    }
    Ok(())
}

It prints:

What ch06_passing_data_on prints, recorded in ch06_passing_data_on.txt
Ann's order:
the bank charges 40 for order A-1
the carrier books order A-1
the parcel leaves with receipt R-A-1
the journey succeeded, with receipt R-A-1 and tracking T-A-1
Gil's order:
the bank charges 45 for order A-7
the carrier books order A-7
the journey failed, with receipt R-A-7 and tracking none

How it works

  1. A step gives back what it did by contributing it. The contributor's contribute method takes a name and a value: here the receipt, under receipt. What a step gives this way is a contribution, and its names need no declaring in advance. A step's Contributor<'_> belongs to one attempt, one run of the step: the '_ ties the contributor to that attempt, and it serves no other.
  2. The journey's data is its data bag. A journey holds its data in its data bag: values under names. It starts as the initial data, and a step's contributions are committed to it when its attempt succeeds. Only values go in: data that could be serialized, such as numbers, strings, and structs that derive Clone and Serde's Serialize and Deserialize.
  3. A later step reads a contribution as an ordinary input. The shipping asks for receipt with an #[input] field, just as it asks for the order's number, which came from the initial data. It doesn't know the charge made the receipt, and the charge doesn't know anyone reads it. A step never calls another step: it reads what an earlier one contributed, by name.
  4. The steps run in the order the workflow lists them. The charge's line comes first, so the charge runs first, and once it has succeeded, the shipping runs. Nothing reorders them: to read what the shop does with an order, you read the workflow's list, top to bottom.
  5. A failed attempt's contributions are never committed. For Gil's order, the carrier booked the parcel and gave its tracking number, which the shipping contributed. Then the carrier refused to collect a parcel of 60 kg, and the shipping failed with too-heavy. A step's contributions are committed to the data bag only when its attempt succeeds, so Gil's tracking number never reached it, and nothing in the shop can believe his parcel left.
  6. The data bag is the journey's output. When a journey ends, its data bag, as it stands then, is its output, whether the journey succeeded or failed. #[derive(Output)] lets result.output() read it into Order, each field from the name it is called by. An Option field is None when its name is absent, as tracking is after Gil's journey. It returns a Result, since the data bag might lack what a field needs, and ? passes such an error on. Ann's output holds her receipt and her tracking number. Gil's holds his receipt, since his charge succeeded, and no tracking number.

So the charge gives back its receipt, and the shipping uses it, without either knowing about the other. But each still reaches the outside world on its own: the charge calls the shop's one bank, and the shipping its one carrier, by name, inside its run. A step written that way can only ever use that bank and that carrier. To charge through another bank, or a pretend one, you would have to change the step.

Outcomes

We have shown how a step gives back what it did, and how a later step and the shop read it.

The usual way With Itinera
A function usually returns what it did to its caller, which hands it on to the next function itself. A step contributes what it did, and a later step or the shop reads it by name, with no step calling another.
When a function fails halfway, what it already wrote often stays written, and undoing it is up to the code. A failed attempt's contributions are never committed, so the data bag holds only what steps that succeeded gave.