The Itinera bookRust edition

Part 2: Getting started, Chapter 15

Reacting to what a step did

What you want to do

The shop's accountants want an audit line for every charge that succeeded: what the order was charged, and the receipt. You want it written after every successful charge, without adding a word about auditing to the charge.

The code

The audit is a step policy: a type deriving Default, with an impl block marked #[step_policy(name = "audit")]. Its method marked #[on_step_success] is a hook, which the executor calls after a step it is attached to succeeds. The hook asks for the step's name, for the order's price from the data bag, and for the receipt the step contributed, and contributes the audit line under audit. It returns Self::carry_on(), leaving what happens next as it was.

Rust, from the program ch15_reacting_to_what_a_step_did
/// Writes an audit line for every step it is attached to that succeeds: what the order was charged,
/// and the receipt the step gave back.
#[derive(Default)]
struct Audit;

#[step_policy(name = "audit")]
impl Audit {
    #[on_step_success]
    fn charged(
        &self,
        #[step_name] step: StepName,
        #[from_workflow] price: i64,
        #[from_step] receipt: String,
        #[contributor] mut contributor: Contributor<'_>,
    ) -> Result<Option<OnSuccess>, Error> {
        contributor.contribute("audit", format!("{step} took {price}, receipt {receipt}"));
        Self::carry_on()
    }
}

The workflow attaches the policy to the charge, with policies = [Audit] on its line:

Rust, from the program ch15_reacting_to_what_a_step_did
/// The shop's workflow: it charges, repairing a charge that fails and auditing one that succeeds,
/// then ships, and shipping may be tried once more.
#[workflow(name = "orders", reporters = [Log, Desk])]
#[with_step(Charge, name = "charge", rescue = RenewCard, policies = [Audit])]
#[with_step(Ship, name = "ship", retry_budget = 1)]
struct Orders;

The rescue step RenewCard is chapter 14's, the shipping is chapter 12's and the desk is chapter 11's.

The log is chapter 14's, and now also says which policy's hook was called, and after which step:

Rust, from the program ch15_reacting_to_what_a_step_did
EventBody::HookCalled {
    hook: HookSource::Step {
        policy, hook, step, ..
    },
    ..
} => Some(format!("{policy}: {hook} of {}", step.step)),

A new customer, Kim, buys a gift card, which the shop sends by email. The charge now asks whether the order is a gift card, and if it is, gives back the card's code under code:

Rust, from the program ch15_reacting_to_what_a_step_did
/// Charges an order's price to its card through the payment client, and gives back its receipt,
/// and the gift card's code when the order is a gift card.
#[derive(Step)]
struct Charge {
    #[input]
    order: String,
    #[input]
    price: i64,
    #[input]
    card: String,
    #[input]
    gift_card: bool,
}

#[step]
impl Charge {
    fn run(
        self,
        #[service] payments: &dyn Payments,
        #[contributor] mut contributor: Contributor<'_>,
    ) -> Result<Outcome, Error> {
        if self.price <= 0 {
            return Self::fail("nothing-to-charge");
        }
        let Some(receipt) = payments.charge(&self.order, &self.card, self.price)? else {
            return Self::fail("card-expired");
        };
        contributor.contribute("receipt", receipt);
        if self.gift_card {
            contributor.contribute("code", format!("G-{}", self.order));
        }
        Self::succeed()
    }
}

The payment client it asks for is chapter 14's Payments.

The order's data now says whether it is a gift card, and the output holds the audit line:

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

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

The shop runs Ann's, Gil's and Jo's orders, then Kim's, reads back the audit line, and prints it:

Rust, from the program ch15_reacting_to_what_a_step_did
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // The order, the customer, the price, the parcel's weight in kg, the card, and whether the
    // order is a gift card.
    let orders = [
        ("A-1", "Ann", 40, 2, "ann-2028", false),
        ("A-7", "Gil", 45, 60, "gil-2027", false),
        ("A-8", "Jo", 30, 1, "jo-2024", false),
        ("A-9", "Kim", 25, 0, "kim-2027", true),
    ];
    for (order, customer, price, weight, card, gift_card) in orders {
        println!("{customer}'s order:");
        let instance = Orders::builder()
            .initial_data(NewOrder {
                order: order.to_owned(),
                price,
                weight,
                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 ch15_reacting_to_what_a_step_did prints, recorded in ch15_reacting_to_what_a_step_did.txt
Ann's order:
log orders A-1 #1 journey_started card, gift_card, order, price, weight
log orders A-1 #2 attempt_started charge, attempt 1
log orders A-1 #3 service_provided charge gets payments from bank, a ch15_reacting_to_what_a_step_did::Bank
the bank charges 40 for order A-1
log orders A-1 #4 step_succeeded charge
log orders A-1 #5 contribution_committed receipt
log orders A-1 #6 hook_called audit: on step success of charge
log orders A-1 #7 contribution_committed audit
log orders A-1 #8 attempt_started ship, attempt 1
log orders A-1 #9 service_provided ship gets carrier from carriers, a ch15_reacting_to_what_a_step_did::MainCarrier
the main carrier books order A-1
log orders A-1 #10 step_info ship: the parcel leaves with receipt R-A-1
log orders A-1 #11 step_succeeded ship
log orders A-1 #12 contribution_committed tracking
log orders A-1 #13 journey_succeeded
the journey succeeded, with receipt R-A-1 and tracking T-A-1
audit: charge took 40, receipt R-A-1
Gil's order:
log orders A-7 #1 journey_started card, gift_card, order, price, weight
log orders A-7 #2 attempt_started charge, attempt 1
log orders A-7 #3 service_provided charge gets payments from bank, a ch15_reacting_to_what_a_step_did::Bank
the bank charges 45 for order A-7
log orders A-7 #4 step_succeeded charge
log orders A-7 #5 contribution_committed receipt
log orders A-7 #6 hook_called audit: on step success of charge
log orders A-7 #7 contribution_committed audit
log orders A-7 #8 attempt_started ship, attempt 1
log orders A-7 #9 service_provided ship gets carrier from carriers, a ch15_reacting_to_what_a_step_did::MainCarrier
the main carrier books order A-7
log orders A-7 #10 step_failed ship: too-heavy
log orders A-7 #11 step_retrying ship
log orders A-7 #12 attempt_started ship, attempt 2
log orders A-7 #13 service_provided ship gets carrier from carriers, a ch15_reacting_to_what_a_step_did::BackupCarrier
the backup carrier books order A-7
log orders A-7 #14 step_info ship: the parcel leaves with receipt R-A-7
log orders A-7 #15 step_succeeded ship
log orders A-7 #16 contribution_committed tracking
log orders A-7 #17 journey_succeeded
the journey succeeded, with receipt R-A-7 and tracking B-A-7
audit: charge took 45, receipt R-A-7
Jo's order:
log orders A-8 #1 journey_started card, gift_card, order, price, weight
log orders A-8 #2 attempt_started charge, attempt 1
log orders A-8 #3 service_provided charge gets payments from bank, a ch15_reacting_to_what_a_step_did::Bank
the bank declines card jo-2024: it has expired
log orders A-8 #4 step_failed charge: card-expired
log orders A-8 #5 step_rescuing charge: failure, rescued by charge_rescue
log orders A-8 #6 attempt_started charge_rescue, attempt 1
the bank says card jo-2024 was replaced by jo-2029
log orders A-8 #7 step_succeeded charge_rescue
log orders A-8 #8 contribution_committed card
log orders A-8 #9 data_overwritten card
log orders A-8 #10 step_rescued charge by charge_rescue
log orders A-8 #11 attempt_started charge, attempt 2
the bank charges 30 for order A-8
log orders A-8 #12 step_succeeded charge
log orders A-8 #13 contribution_committed receipt
log orders A-8 #14 hook_called audit: on step success of charge
log orders A-8 #15 contribution_committed audit
log orders A-8 #16 attempt_started ship, attempt 1
log orders A-8 #17 service_provided ship gets carrier from carriers, a ch15_reacting_to_what_a_step_did::MainCarrier
the main carrier books order A-8
log orders A-8 #18 step_info ship: the parcel leaves with receipt R-A-8
log orders A-8 #19 step_succeeded ship
log orders A-8 #20 contribution_committed tracking
log orders A-8 #21 journey_succeeded
the journey succeeded, with receipt R-A-8 and tracking T-A-8
audit: charge took 30, receipt R-A-8
Kim's order:
log orders A-9 #1 journey_started card, gift_card, order, price, weight
log orders A-9 #2 attempt_started charge, attempt 1
log orders A-9 #3 service_provided charge gets payments from bank, a ch15_reacting_to_what_a_step_did::Bank
the bank charges 25 for order A-9
log orders A-9 #4 step_succeeded charge
log orders A-9 #5 contribution_committed receipt
log orders A-9 #6 contribution_committed code
log orders A-9 #7 hook_called audit: on step success of charge
log orders A-9 #8 contribution_committed audit
log orders A-9 #9 attempt_started ship, attempt 1
log orders A-9 #10 service_provided ship gets carrier from carriers, a ch15_reacting_to_what_a_step_did::MainCarrier
the main carrier books order A-9
log orders A-9 #11 step_info ship: the parcel leaves with receipt R-A-9
log orders A-9 #12 step_succeeded ship
log orders A-9 #13 contribution_committed tracking
log orders A-9 #14 journey_succeeded
the journey succeeded, with receipt R-A-9 and tracking T-A-9
audit: charge took 25, receipt R-A-9

How it works

  1. A step policy holds what happens around a step. It defines hooks, and the workflow attaches it to steps, so the charge never knows it is audited. A step never knows which policies are attached to it; the workflow attaches them. The policy names no step of its own, so it can be attached to any step of any workflow; a hook that needs the step's name asks for it, with #[step_name].
  2. The executor calls a hook after an outcome. #[on_step_success] marks the hook called each time a step the policy is attached to succeeds, after its contributions are committed; the audit reads the receipt from what the charge contributed. The executor records the call as hook_called, naming the policy and the hook. Jo's charge fails on its first attempt, so the hook is called only after attempt 2, once the charge succeeded with the new card.
  3. A hook asks for what it needs, and gets only that. Each parameter after &self is a request. #[from_step] asks for what the step contributed, by the parameter's name, here receipt; #[from_workflow] asks for data from the data bag, here price; #[contributor] asks for a contributor. A hook never gets the data bag itself.
  4. A hook's contributions are committed when it returns. The audit line is committed right after the hook, as contribution_committed audit, and the shop reads it back with the journey's output.
  5. A hook decides nothing it isn't asked to. Its Ok holds an Option, whose Some would be a decision about what happens next. Self::carry_on() is None: the hook leaves what happens next as it would be without it, and after a success the next step runs. The audit only writes down what happened.
  6. A policy is built afresh for every attempt. The executor builds it with Default, and nothing is kept from one call to the next, which is why a hook takes &self: everything it needs comes through what it asks for.

Now look at Kim's journey. Her charge succeeded, and gave back her gift card's code, so there is nothing left to do for her order. But the workflow still runs the shipping, and the main carrier books a parcel of 0 kg.

Outcomes

We have shown how a step policy reacts to what a step did, without the step knowing.

The usual way With Itinera
Auditing a call is usually a line added after it, wherever it is made, so the code that charges also knows about the audit. The audit is a policy attached to the charge in the workflow, and the charge has no line about it.
What runs after a call succeeds is often spread across the callers. Every hook call is an event, naming the policy and the hook, after the outcome it reacts to.